Commit 22ebd710 authored by S Anand's avatar S Anand
Browse files

ENH: .template() supports sub-templates

parent 9900de27
# $.template
`$.template()` renders HTML templates with JavaScript mixed inside them.
`$(selector).template()` renders HTML templates with JavaScript mixed inside them.
Example:
......@@ -21,7 +21,7 @@ This displays `Your platform is ...` and shows the userAgent just below the scri
Note: the `<template>` tag is not supported by Internet Explorer. Use
`<script type="text/html">` for IE compatibility.
The template can use all global variables. You can pass additional variables
Templates can access any global variable. You can pass additional variables
using as `.template({var1: value, var2: value, ...})`. For example:
<!-- render:html -->
......@@ -32,12 +32,55 @@ using as `.template({var1: value, var2: value, ...})`. For example:
<% }) %>
</template>
<script>
$('script.example').template({list: ['a', 'b', 'c']})
$('.example').template({list: ['a', 'b', 'c']})
</script>
```
To re-render the template, run `.template(data)` again with different data.
## $.template subtemplates
You can use sub-templates as follows:
<!-- render:html -->
```html
<template class="row" data-target="false">
<li class="<%- classes %>"><a href="<%- link %>"><%- text %></a></li>
</template>
<template class="main-template" data-template-item=".row">
<ul>
<%= item({classes: "active", link: '..', text: 'Parent'}) %>
<%= item({classes: "", link: '.', text: 'Current'}) %>
</ul>
</template>
<script>
$('.main-template').template()
</script>
```
`data-target="false"` ensures that the template `.row` is not rendered.
(This is typically used by sub-templates.)
`data-template-item=".row"` creates a function `item()` inside `.main-template`.
Calling `item()` renders `.roww` as a sub-template.
**Notes**:
- The sub-template `name` in `data-template-<name>` can only contain
lowercase letters, numbers and underscore.
- Sub-templates may themselves depend on, and call, other sub-templates.
- Sub-templates require a [Promise polyfill](https://www.npmjs.com/package/es6-promise) for IE.
Sub-templates can be from an [external source](#template-external-source) as
well. For example, this sub-template is loaded from `heading.html`:
```html
<template class="tmpl-head" src="heading.html" data-target="false"></template>
<template class="main-template" data-template-header=".tmpl-head">
<%= header({title: "Page title"}) %>
</template>
```
## $.template animation
......@@ -58,7 +101,7 @@ For example, this shows a circle in SVG bouncing around smoothly.
<script src="../../ui/morphdom/dist/morphdom-umd.min.js"></script>
<script type="text/html" data-engine="vdom" class="bouncing-ball">
<svg width="500" height="50">
<circle cx="<%= x %>" cy="<%= y %>" r="5" fill="red"/>
<circle cx="<%= x %>" cy="<%= y %>" r="5" fill="red"></circle>
</svg>
</script>
<script>
......@@ -73,7 +116,7 @@ For example, this shows a circle in SVG bouncing around smoothly.
You can also specify a `data-engine` via an option. For example:
```js
$('script.animate').template(data, {engine: 'vdom'})
$('.animate').template(data, {engine: 'vdom'})
```
......@@ -91,7 +134,7 @@ once and apply them across the body. For example:
The same template is rendered in <%- heading %>
</script>
<script>
$('script.targeted')
$('.targeted')
.template({heading: 'panel 1'}, {target: '.panel1'})
.template({heading: 'panel 2'}, {target: '.panel2'})
</script>
......@@ -117,7 +160,7 @@ is called, it appends rather than replaces. For example:
<li>New item #<%- n %> appended</li>
</script>
<script>
$('script.list')
$('.list')
.template({n: 1})
.template({n: 2})
</script>
......@@ -133,7 +176,7 @@ append to an [existing target](#template-targets). For example:
<!-- Every time .template() is called, the result is added as a list item here -->
</ul>
<script>
$('script.list')
$('.list')
.template({n: 1}, {append: true, target: '.existing-list'})
.template({n: 2}, {append: true, target: '.existing-list'})
</script>
......@@ -147,7 +190,7 @@ Template containers can have an `src=` attribute that loads the template from a
```html
<script type="text/html" src="template.html" class="source"></script>
<script>
$('script.source').template()
$('.source').template()
</script>
```
......@@ -166,7 +209,7 @@ For example:
Data is <%= data %>
</script>
<script>
$('script.missing').template({data: [1, 2, 3]})
$('.missing').template({data: [1, 2, 3]})
</script>
```
......@@ -195,7 +238,7 @@ You can also use the `selector: ...` option. For example:
<script type="text/html" class="try no-render">This will not render</script>
<script type="text/html" class="try render">This will render</script>
<script>
$('script.try').template({}, {selector: '.render', target: '.selector-target'})
$('.try').template({}, {selector: '.render', target: '.selector-target'})
</script>
```
......@@ -211,12 +254,12 @@ relevant.
```js
$.getJSON('data').then(function (data) {
// When we get data, show the dashboard, dispose any previous error
$('script.dashboard').template({ data: data })
$('script.error').template('dispose')
$('.dashboard').template({ data: data })
$('.error').template('dispose')
}).fail(function (xhr, error, msg) {
// If there's an error, dispose any previous dashboard, show the error
$('script.dashboard').template('dispose')
$('script.error').template({ error: msg })
$('.dashboard').template('dispose')
$('.error').template({ error: msg })
})
```
......@@ -239,7 +282,7 @@ For example:
<pre>Event e.templatedata = <span class="data">filled by event handler</span></pre>
</script>
<script>
$('script.event')
$('.event')
.on('template', function(e) { // When the template is rendered,
e.target.find('.data') // find the .data class inside target nodes
.html(JSON.stringify(e.templatedata)) // and enter the template data
......
import { findall } from './_util.js'
var _renderer = 'g1.template.render'
var _compiled = 'g1.template.compiled'
var _prev_created = 'g1.template.prev_created'
function subtemplates($main) {
// Takes a main template as a jQuery node with data-template-<name>="selector" attributes.
// Return an object of {name: selector}
return _.chain($main.data())
.pickBy(function (val, key) { return key.match(/^template/) })
.mapKeys(function (val, key) { return key.replace(/^template/, '').toLowerCase() })
.value()
}
export function template(data, options) {
options = options || {}
var selector = options.selector || this.data('selector') || 'script[type="text/html"],template'
var self = this
var selector = options.selector || self.data('selector') || 'script[type="text/html"],template'
// Pre-create the template rendering function
// Store this in .data('template.function')
findall(this, selector).each(function () {
findall(self, selector).each(function () {
var $this = $(this)
// If we want to dispose the last target, just dispose it.
if (data === 'dispose') {
......@@ -20,34 +35,59 @@ export function template(data, options) {
})
}
var renderer = $this.data(_renderer)
// If there's no template function cached, cache it
if (!renderer) {
var html = $this.html()
// Contents of script are regular strings. Contents of template are escaped
if (!$this.is('script'))
html = _.unescape(html)
var src = $this.attr('src')
if (src) {
// If the AJAX load succeeds, render the loaded template
// Else render the contents, with an additional xhr variable
$.get(src)
.done(function (contents) { make_template($this, contents, data, options) })
.fail(function (xhr) {
data.xhr = xhr
make_template($this, html, data, options)
})
} else
// If no src= is specified, just render the contents
make_template($this, html, data, options)
} else
// If the renderer is already present, just use it
// If the renderer is already present, just use it. Else compile it
if (renderer)
renderer(data, options)
// If there are subtemplate dependencies, compile them
else if (_.size(subtemplates($this)))
dependent_templates($this, self, data, options)
// If there aren't, then compile immediately.
else
make_template($this, data, options)
})
return this
}
var _renderer = 'template.render'
var _prev_created = 'template.prev_created'
function dependent_templates(selector, self, data, options) {
var uncompiled_selectors = _.pickBy(subtemplates(selector), function (sel) {
return !$(sel).data(_compiled)
})
return Promise.all(
_.map(uncompiled_selectors, function (sel) { return dependent_templates($(sel), self, data, options) })
).then(function () {
return Promise.all(
_.union(
_.map(uncompiled_selectors, function (sel) { return make_template($(sel), data, options) }),
make_template(selector, data, options)
)
)
})
.catch(function (error) { console.error(error) }) // eslint-disable-line no-console
}
function make_template($this, data, options) {
// Compile templates. If the template has src="", load it and then compile it.
// The result is in $this.data(_compiled) (via make_template_sync).
var html = $this.html()
// Contents of script are regular strings. Contents of template are escaped
if (!$this.is('script'))
html = _.unescape(html)
var src = $this.attr('src')
if (src) {
// If the AJAX load succeeds, render the loaded template
// Else render the contents, with an additional xhr variable
return $.get(src).done(function (html) {
make_template_sync($this, html, data, options)
}).fail(function (xhr) {
data.xhr = xhr
make_template_sync($this, html, data, options)
})
}
// If no src= is specified, just render the contents
else make_template_sync($this, html, data, options)
}
// Bind a template renderer to the node $this.data('template.render')
// This renderer function accepts (data, options) and creates
......@@ -56,11 +96,21 @@ var _prev_created = 'template.prev_created'
// - stores the target node in $this.data('template.target')
// - triggers a template event (with .templatedata, .target)
// - returns the target node
function make_template($this, html, data, default_options) {
function make_template_sync($this, html, data, default_options) {
var compiled_template = _.template(html)
var $created
// $this.data(_compiled) has the compiled template. This adds sub-template
// variables from data-template-* attributes.
$this.data(_compiled, function (subtemplate_data) {
subtemplate_data = _.extend(subtemplate_data || {}, _.mapValues(subtemplates($this), function (selector) {
return $(selector).data(_compiled)
}))
return compiled_template(subtemplate_data)
})
function renderer(data, options) {
html = compiled_template(data)
html = $this.data(_compiled)(data)
// Get options. DOM data-* over-rides JS options
var append = $this.data('append') || (options ? options.append : default_options.append)
var target = $this.data('target') || (options ? options.target : default_options.target)
......@@ -69,7 +119,7 @@ function make_template($this, html, data, default_options) {
engine = template.engines[engine] || template.engines['default']
// If we're appending the contents, just add the text
if (append) {
$created = $($.parseHTML(html))
$created = $($.parseHTML(html.trim()))
// If we're appending to a target node, just append to it.
if (target)
$(target).append($created)
......@@ -89,7 +139,7 @@ function make_template($this, html, data, default_options) {
return $created
}
$this.data(_renderer, renderer)
return renderer(data)
return $this.data('target') !== false ? renderer(data) : null
}
......@@ -107,7 +157,7 @@ template.engines = {}
template.engines['default'] = template.engines['jquery'] = function ($this, target, html) {
// Parse the template output and create a node collection
// $.parseHTML ensures that "hello" is parsed as HTML, not a selector
var $target = $($.parseHTML(html))
var $target = $($.parseHTML(html.trim()))
// If target exists, replace the HTML. Otherwise, create new nodes before the template.
if (target)
$(target).html($target)
......
<div class="result">
<%= 'obj:' + JSON.stringify(obj) + ', shuny:'+ JSON.stringify(shuny) %>
</div>
<div class="sub-template">
<%= sub2async({life: life/2, shuny: 0 }) %>
</div>
<div class="result">
<%= 'Subtemplate2.html ::: obj:' + JSON.stringify(obj) + ', shuny:'+ JSON.stringify(shuny) %>
</div>
<!DOCTYPE html>
<html>
<head>
<title>template tests</title>
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<script src="../node_modules/lodash/lodash.min.js"></script>
<script src="../node_modules/lodash/lodash.js"></script>
<script src="../node_modules/morphdom/dist/morphdom-umd.min.js"></script>
<script src="../dist/g1.js"></script>
<script src="tape.js"></script>
<script src="tape-stream.js"></script>
<style>
circle, rect { transition: all 0.5s ease; }
circle,
rect {
transition: all 0.5s ease;
}
</style>
</head>
<body>
<h1>Lodash templates</h1>
<script type="text/html" id="t1">Your platform is <%= navigator.userAgent %></script>
<template id="t1">Your platform is <%= navigator.userAgent %></template>
<script>
function strip(text) {
return text.replace(/^\s+/, '').replace(/\s+$/, '')
......@@ -23,7 +28,7 @@
var node = target.get(0)
return node ? strip(node.textContent) : ''
}
tape('$().template() renders plain text with variables', function(t) {
tape('$().template() renders plain text with variables', function (t) {
$('#t1')
.one('template.g1', function (e) {
t.equal(e.target.length, 1)
......@@ -35,16 +40,16 @@
t.equal(text(e.target), 'Your platform is abc')
t.end()
})
.template({navigator: {userAgent: 'abc'}})
.template({ navigator: { userAgent: 'abc' } })
})
</script>
<div id="t2">
<script type="text/html">
<template>
<% list.forEach(function(item) { %>
<div><%= item %></div>
<div><%= item %></div>
<% }) %>
</script>
</template>
</div>
<script>
tape('$().template(data) passes data to the template', function (t) {
......@@ -57,7 +62,7 @@
t.equal($divs.length, list.length, 'Correct number of nodes are created')
var text = $divs.map(function () { return this.innerHTML }).get().join(' ')
t.equal(text, list.join(' '), 'Template content is correct')
t.deepEqual(e.templatedata, {list: list})
t.deepEqual(e.templatedata, { list: list })
$divs.attr('class', 'my-unique-item')
t.equal($('.my-unique-item').length, 3, 'Repeated calls over-write the same node')
}).template({ list: list })
......@@ -65,11 +70,11 @@
})
</script>
<script type="text/html" id="t3" src="sample-template.html"></script>
<template id="t3" src="sample-template.html"></template>
<script>
tape('$().template() renders src= via AJAX load', function(t) {
tape('$().template() renders src= via AJAX load', function (t) {
var data = { data: ['x', 'y'] }
$('#t3').one('template', function(e) {
$('#t3').one('template', function (e) {
t.equal(text(e.target), data.data.join(' '))
t.deepEqual(e.templatedata, data)
t.end()
......@@ -77,13 +82,13 @@
})
</script>
<script type="text/html" id="t4" src="nonexistent.html">
<template id="t4" src="nonexistent.html">
<%= xhr.status %>: Not found. <%= data.join(' ') %>
</script>
</template>
<script>
tape('$().template() renders contents if src= returns an error', function (t) {
var data = { data: ['x', 'y'] }
$('#t4').one('template', function(e) {
$('#t4').one('template', function (e) {
t.equal(text(e.target), '404: Not found. x y')
t.deepEqual(e.templatedata.data, data.data)
t.equal(e.templatedata.xhr.status, 404)
......@@ -92,34 +97,34 @@
})
</script>
<script type="text/html" id="t5">
<template id="t5">
<%= heading %>
</script>
</template>
<div class="dashboard1">has <em>old content</em></div>
<div class="dashboard2">has <em>old content</em></div>
<script>
tape('$().template(data, target) renders template to any DOM element', function(t) {
tape('$().template(data, target) renders template to any DOM element', function (t) {
$('#t5')
.one('template', function(e) {
.one('template', function (e) {
t.equal(text(e.target), 'Dashboard 1', 'renders with data')
t.equal(strip($('.dashboard1').html()), 'Dashboard 1', 'renders to target')
})
.template({ heading: 'Dashboard 1' }, {target: '.dashboard1'})
.template({ heading: 'Dashboard 1' }, { target: '.dashboard1' })
.one('template', function (e) {
t.equal(text(e.target), 'Dashboard 2')
t.equal(strip($('.dashboard2').html()), 'Dashboard 2', 'renders to target')
t.end()
})
.template({ heading: 'Dashboard 2' }, {target: '.dashboard2'})
.template({ heading: 'Dashboard 2' }, { target: '.dashboard2' })
})
</script>
<section id="root1">
<script type="text/html"><%= 1 + 1 %></script>
<script type="text/html"><%= 2 + 2 %></script>
<template><%= 1 + 1 %></template>
<template><%= 2 + 2 %></template>
</section>
<script>
tape('$().template() renders templates of all script[type="text/html"] sub-elements', function(t) {
tape('$().template() renders templates of all template,script[type="text/html"] sub-elements', function (t) {
var results = ['2', '4']
t.plan(results.length)
var index = 0
......@@ -129,9 +134,13 @@
})
</script>
<section id="root2" data-selector="script.template">
<script type="text/html" class="template"><div class="result"><%= 2 + 2 %></div></script>
<script type="text/html"><div class="result"><%= 1 + 1 %></div></script>
<section id="root2" data-selector="template.template">
<template class="template">
<div class="result"><%= 2 + 2 %></div>
</template>
<template>
<div class="result"><%= 1 + 1 %></div>
</template>
</section>
<script>
tape('$().template() accepts data-selector to select sub-elements', function (t) {
......@@ -144,8 +153,12 @@
</script>
<section id="root3">
<script type="text/html" class="template"><div class="result"><%= 2 + 2 %></div></script>
<script type="text/html"><div class="result"><%= 1 + 1 %></div></script>
<template class="template">
<div class="result"><%= 2 + 2 %></div>
</template>
<template>
<div class="result"><%= 1 + 1 %></div>
</template>
</section>
<script>
tape('$().template() accepts selector: to select sub-elements', function (t) {
......@@ -153,13 +166,13 @@
$('#root3').on('template', function (e) {
t.equal(e.target.filter('.result').text(), '4')
t.end()
}).template({}, {selector: '.template'})
}).template({}, { selector: '.template' })
})
</script>
<!-- Renders to target DOM element -->
<script class="target-dom" data-target=".target1" type="text/html">1 + 1 = <%= 1 + 1 %></script>
<script class="target-dom" type="text/html">2 + 2 = <%= 2 + 2 %></script>
<template class="target-dom" data-target=".target1">1 + 1 = <%= 1 + 1 %></template>
<template class="target-dom">2 + 2 = <%= 2 + 2 %></template>
<div class="target1"></div>
<div class="target2"></div>
<script>
......@@ -167,7 +180,7 @@
t.plan(2)
var count = 0
// Wait for both templates to be done. Then check if the text has changed
$('script.target-dom').on('template', function (e) {
$('.target-dom').on('template', function (e) {
count++
if (count == 2) {
t.equal($('.target1').text(), '1 + 1 = 2')
......@@ -178,15 +191,17 @@
</script>
<!-- Appends with data-append even if { append: false } -->
<script class="append-dom" data-append="true" type="text/html"><i class="new-dom"><%= n %></i><i class="new-dom"><%= n %></i></script>
<script class="append-dom" data-append="true" data-target=".append-dom-target" type="text/html"><i class="new-dom-target"><%= n %></i><i class="new-dom-target"><%= n %></i></script>
<template class="append-dom" data-append="true"><i class="new-dom"><%= n %></i><i
class="new-dom"><%= n %></i></template>
<template class="append-dom" data-append="true" data-target=".append-dom-target"><i
class="new-dom-target"><%= n %></i><i class="new-dom-target"><%= n %></i></template>
<div class="append-dom-target"></div>
<script>
tape('$().template() with data-append="true" appends data to target', function (t) {
var count = 3
t.plan(count * 4 + 2) // 2 tests at the end, plus 2 .on('template') tests x 2 DOM elements
_.range(count).forEach(function (i) {
$('script.append-dom')
$('.append-dom')
.one('template', function (e) {
t.equals(+text(e.target), i)
t.deepEqual(e.templatedata, { n: i })
......@@ -202,15 +217,16 @@
</script>
<!-- Appends with { append: true } -->
<script class="append-js" type="text/html"><i class="new-js"><%= n %></i><i class="new-js"><%= n %></i></script>
<script class="append-js" data-target=".append-js-target" type="text/html"><i class="new-js-target"><%= n %></i><i class="new-js-target"><%= n %></i></script>
<template class="append-js"><i class="new-js"><%= n %></i><i class="new-js"><%= n %></i></template>
<template class="append-js" data-target=".append-js-target"><i class="new-js-target"><%= n %></i><i
class="new-js-target"><%= n %></i></template>
<div class="append-js-target"></div>
<script>
tape('$().template() with {append:true} option appends data to target', function (t) {
var count = 3
t.plan(count * 4 + 2) // 2 tests at the end, plus 1 .on('template') tests x 2 DOM elements
_.range(count).forEach(function (i) {
$('script.append-js')
$('.append-js')
.one('template', function (e) {
t.equals(+text(e.target), i)
t.deepEqual(e.templatedata, { n: i })
......@@ -227,30 +243,32 @@
<!-- Disposes output -->
<div class="dispose-root">
<script type="text/html" class="dashboard"><p class="dashboard"><%- text %></p></script>
<template class="dashboard">
<p class="dashboard"><%- text %></p>
</template>
<div class="dispose-target">Old content deleted</div>
</div>
<script>
tape('$().template("dispose") disposes rendered templates', function (t) {
t.plan(7)
// Disposing an un-created template does not raise an error
$('.dispose-root script.dashboard').template('dispose')
$('.dispose-root .dashboard').template('dispose')
// Create the template
$('.dispose-root').one('template', function (e) {
t.equal($('.dispose-root p.dashboard').length, 1)
}).template({text: 'Dashboard'})
}).template({ text: 'Dashboard' })
// Dispose it. It should not exist. It should trigger a template events
$('.dispose-root script.dashboard').one('template', function (e) {
$('.dispose-root .dashboard').one('template', function (e) {
t.equal($('.dispose-root p.dashboard').length, 0)
t.equal(e.templatedata, 'dispose')
t.ok(e.target.is('p.dashboard'))
}).template('dispose')
// If data-target is specified, the contents are removed but not the target
$('.dispose-root script.dashboard').one('template', function (e) {
$('.dispose-root .dashboard').one('template', function (e) {
t.ok(e.target.parent().is('.dispose-target'))