MDL-57009 js: Skip ajax request for same string
[moodle.git] / lib / amd / src / templates.js
CommitLineData
9bdcf579
DW
1// This file is part of Moodle - http://moodle.org/
2//
3// Moodle is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version.
7//
8// Moodle is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
15
16/**
17 * Template renderer for Moodle. Load and render Moodle templates with Mustache.
18 *
19 * @module core/templates
20 * @package core
21 * @class templates
22 * @copyright 2015 Damyon Wiese <damyon@moodle.com>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 * @since 2.9
25 */
35be5826 26define(['core/mustache',
9bdcf579
DW
27 'jquery',
28 'core/ajax',
29 'core/str',
30 'core/notification',
31 'core/url',
4b9e5326 32 'core/config',
28de7771 33 'core/localstorage',
1fca8a7b 34 'core/event',
29879f8f
FM
35 'core/yui',
36 'core/log'
9bdcf579 37 ],
29879f8f 38 function(mustache, $, ajax, str, notification, coreurl, config, storage, event, Y, Log) {
9bdcf579 39
8d00afb1
DW
40 // Module variables.
41 /** @var {Number} uniqInstances Count of times this constructor has been called. */
42 var uniqInstances = 0;
9bdcf579
DW
43
44 /** @var {string[]} templateCache - Cache of already loaded templates */
45 var templateCache = {};
46
8d00afb1
DW
47 /**
48 * Constructor
49 *
50 * Each call to templates.render gets it's own instance of this class.
51 */
52 var Renderer = function() {
53 this.requiredStrings = [];
54 this.requiredJS = [];
55 this.currentThemeName = '';
56 };
57 // Class variables and functions.
58
9bdcf579 59 /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
8d00afb1 60 Renderer.prototype.requiredStrings = null;
9bdcf579
DW
61
62 /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
8d00afb1 63 Renderer.prototype.requiredJS = null;
9bdcf579
DW
64
65 /** @var {String} themeName for the current render */
8d00afb1 66 Renderer.prototype.currentThemeName = '';
9bdcf579 67
f992dcf6
DP
68 /**
69 * Load a template from the cache or local storage or ajax request.
70 *
71 * @method getTemplate
72 * @private
73 * @param {string} templateName - should consist of the component and the name of the template like this:
74 * core/menu (lib/templates/menu.mustache) or
75 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
c96f55e6 76 * @param {Boolean} async If false - this function will not return until the promises are resolved.
f992dcf6
DP
77 * @return {Promise} JQuery promise object resolved when the template has been fetched.
78 */
8d00afb1 79 Renderer.prototype.getTemplate = function(templateName, async) {
f992dcf6
DP
80 var deferred = $.Deferred();
81 var parts = templateName.split('/');
82 var component = parts.shift();
83 var name = parts.shift();
84
8d00afb1 85 var searchKey = this.currentThemeName + '/' + templateName;
f992dcf6
DP
86
87 // First try request variables.
88 if (searchKey in templateCache) {
89 deferred.resolve(templateCache[searchKey]);
90 return deferred.promise();
91 }
92
93 // Now try local storage.
94 var cached = storage.get('core_template/' + searchKey);
95
96 if (cached) {
97 deferred.resolve(cached);
98 templateCache[searchKey] = cached;
99 return deferred.promise();
100 }
101
102 // Oh well - load via ajax.
103 var promises = ajax.call([{
104 methodname: 'core_output_load_template',
35be5826 105 args: {
f992dcf6
DP
106 component: component,
107 template: name,
8d00afb1 108 themename: this.currentThemeName
f992dcf6
DP
109 }
110 }], async, false);
111
112 promises[0].done(
35be5826 113 function(templateSource) {
f992dcf6
DP
114 storage.set('core_template/' + searchKey, templateSource);
115 templateCache[searchKey] = templateSource;
116 deferred.resolve(templateSource);
117 }
118 ).fail(
35be5826 119 function(ex) {
f992dcf6
DP
120 deferred.reject(ex);
121 }
122 );
123 return deferred.promise();
124 };
125
126 /**
127 * Load a partial from the cache or ajax.
128 *
129 * @method partialHelper
130 * @private
131 * @param {string} name The partial name to load.
132 * @return {string}
133 */
8d00afb1 134 Renderer.prototype.partialHelper = function(name) {
f992dcf6
DP
135 var template = '';
136
8d00afb1 137 this.getTemplate(name, false).done(
f992dcf6
DP
138 function(source) {
139 template = source;
140 }
141 ).fail(notification.exception);
142
143 return template;
144 };
145
9bdcf579
DW
146 /**
147 * Render image icons.
148 *
149 * @method pixHelper
150 * @private
8d00afb1 151 * @param {object} context The mustache context
9bdcf579 152 * @param {string} sectionText The text to parse arguments from.
e37d53da 153 * @param {function} helper Used to render the alt attribute of the text.
9bdcf579
DW
154 * @return {string}
155 */
8d00afb1 156 Renderer.prototype.pixHelper = function(context, sectionText, helper) {
9bdcf579
DW
157 var parts = sectionText.split(',');
158 var key = '';
159 var component = '';
160 var text = '';
161 var result;
162
163 if (parts.length > 0) {
164 key = parts.shift().trim();
165 }
166 if (parts.length > 0) {
167 component = parts.shift().trim();
168 }
169 if (parts.length > 0) {
170 text = parts.join(',').trim();
171 }
172 var url = coreurl.imageUrl(key, component);
173
174 var templatecontext = {
175 attributes: [
9f5f3dcc
DP
176 {name: 'src', value: url},
177 {name: 'alt', value: helper(text)},
8354d12d 178 {name: 'title', value: helper(text)},
9f5f3dcc 179 {name: 'class', value: 'smallicon'}
9bdcf579
DW
180 ]
181 };
182 // We forced loading of this early, so it will be in the cache.
8d00afb1
DW
183 var template = templateCache[this.currentThemeName + '/core/pix_icon'];
184 result = mustache.render(template, templatecontext, this.partialHelper.bind(this));
9bdcf579
DW
185 return result.trim();
186 };
187
9bdcf579
DW
188 /**
189 * Render blocks of javascript and save them in an array.
190 *
191 * @method jsHelper
192 * @private
8d00afb1 193 * @param {object} context The current mustache context.
9bdcf579
DW
194 * @param {string} sectionText The text to save as a js block.
195 * @param {function} helper Used to render the block.
196 * @return {string}
197 */
8d00afb1
DW
198 Renderer.prototype.jsHelper = function(context, sectionText, helper) {
199 this.requiredJS.push(helper(sectionText, context));
9bdcf579
DW
200 return '';
201 };
202
203 /**
204 * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
205 * into a get_string call.
206 *
207 * @method stringHelper
208 * @private
8d00afb1 209 * @param {object} context The current mustache context.
9bdcf579
DW
210 * @param {string} sectionText The text to parse the arguments from.
211 * @param {function} helper Used to render subsections of the text.
212 * @return {string}
213 */
8d00afb1 214 Renderer.prototype.stringHelper = function(context, sectionText, helper) {
9bdcf579
DW
215 var parts = sectionText.split(',');
216 var key = '';
217 var component = '';
218 var param = '';
219 if (parts.length > 0) {
220 key = parts.shift().trim();
221 }
222 if (parts.length > 0) {
223 component = parts.shift().trim();
224 }
225 if (parts.length > 0) {
226 param = parts.join(',').trim();
227 }
228
229 if (param !== '') {
230 // Allow variable expansion in the param part only.
8d00afb1 231 param = helper(param, context);
9bdcf579
DW
232 }
233 // Allow json formatted $a arguments.
234 if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
235 param = JSON.parse(param);
236 }
237
8d00afb1
DW
238 var index = this.requiredStrings.length;
239 this.requiredStrings.push({key: key, component: component, param: param});
a89cf237
FM
240
241 // The placeholder must not use {{}} as those can be misinterpreted by the engine.
242 return '[[_s' + index + ']]';
9bdcf579
DW
243 };
244
0b4bff8c
AN
245 /**
246 * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
247 *
248 * @method quoteHelper
249 * @private
8d00afb1 250 * @param {object} context The current mustache context.
0b4bff8c
AN
251 * @param {string} sectionText The text to parse the arguments from.
252 * @param {function} helper Used to render subsections of the text.
253 * @return {string}
254 */
8d00afb1
DW
255 Renderer.prototype.quoteHelper = function(context, sectionText, helper) {
256 var content = helper(sectionText.trim(), context);
0b4bff8c
AN
257
258 // Escape the {{ and the ".
259 // This involves wrapping {{, and }} in change delimeter tags.
260 content = content
261 .replace('"', '\\"')
262 .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
263 ;
264 return '"' + content + '"';
265 };
266
9bdcf579
DW
267 /**
268 * Add some common helper functions to all context objects passed to templates.
269 * These helpers match exactly the helpers available in php.
270 *
271 * @method addHelpers
272 * @private
273 * @param {Object} context Simple types used as the context for the template.
274 * @param {String} themeName We set this multiple times, because there are async calls.
275 */
8d00afb1
DW
276 Renderer.prototype.addHelpers = function(context, themeName) {
277 this.currentThemeName = themeName;
278 this.requiredStrings = [];
279 this.requiredJS = [];
280 context.uniqid = (uniqInstances++);
c96f55e6 281 context.str = function() {
8d00afb1
DW
282 return this.stringHelper.bind(this, context);
283 }.bind(this);
c96f55e6 284 context.pix = function() {
8d00afb1
DW
285 return this.pixHelper.bind(this, context);
286 }.bind(this);
c96f55e6 287 context.js = function() {
8d00afb1
DW
288 return this.jsHelper.bind(this, context);
289 }.bind(this);
c96f55e6 290 context.quote = function() {
8d00afb1
DW
291 return this.quoteHelper.bind(this, context);
292 }.bind(this);
9f5f3dcc 293 context.globals = {config: config};
9bdcf579
DW
294 context.currentTheme = themeName;
295 };
296
297 /**
298 * Get all the JS blocks from the last rendered template.
299 *
300 * @method getJS
301 * @private
302 * @param {string[]} strings Replacement strings.
303 * @return {string}
304 */
8d00afb1 305 Renderer.prototype.getJS = function(strings) {
9bdcf579 306 var js = '';
8d00afb1
DW
307 if (this.requiredJS.length > 0) {
308 js = this.requiredJS.join(";\n");
9bdcf579
DW
309 }
310
9bdcf579 311 // Re-render to get the final strings.
8d00afb1 312 return this.treatStringsInContent(js, strings);
29879f8f
FM
313 };
314
315 /**
316 * Treat strings in content.
317 *
318 * The purpose of this method is to replace the placeholders found in a string
319 * with the their respective translated strings.
320 *
321 * Previously we were relying on String.replace() but the complexity increased with
322 * the numbers of strings to replace. Now we manually walk the string and stop at each
323 * placeholder we find, only then we replace it. Most of the time we will
324 * replace all the placeholders in a single run, at times we will need a few
325 * more runs when placeholders are replaced with strings that contain placeholders
326 * themselves.
327 *
328 * @param {String} content The content in which string placeholders are to be found.
329 * @param {Array} strings The strings to replace with.
330 * @return {String} The treated content.
331 */
8d00afb1 332 Renderer.prototype.treatStringsInContent = function(content, strings) {
a89cf237 333 var pattern = /\[\[_s\d+\]\]/,
29879f8f
FM
334 treated,
335 index,
336 strIndex,
337 walker,
338 char,
339 strFinal;
340
341 do {
342 treated = '';
343 index = content.search(pattern);
344 while (index > -1) {
345
346 // Copy the part prior to the placeholder to the treated string.
347 treated += content.substring(0, index);
348 content = content.substr(index);
349 strIndex = '';
a89cf237 350 walker = 4; // 4 is the length of '[[_s'.
29879f8f
FM
351
352 // Walk the characters to manually extract the index of the string from the placeholder.
353 char = content.substr(walker, 1);
354 do {
355 strIndex += char;
356 walker++;
357 char = content.substr(walker, 1);
a89cf237 358 } while (char != ']');
29879f8f
FM
359
360 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
361 strFinal = strings[parseInt(strIndex, 10)];
362 if (typeof strFinal === 'undefined') {
a89cf237 363 Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');
29879f8f
FM
364 strFinal = '';
365 }
366 treated += strFinal;
a89cf237 367 content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.
29879f8f
FM
368
369 // Find the next placeholder.
370 index = content.search(pattern);
371 }
372
373 // The content becomes the treated part with the rest of the content.
374 content = treated + content;
375
376 // Check if we need to walk the content again, in case strings contained placeholders.
377 index = content.search(pattern);
378
379 } while (index > -1);
380
381 return content;
9bdcf579
DW
382 };
383
384 /**
385 * Render a template and then call the callback with the result.
386 *
387 * @method doRender
388 * @private
389 * @param {string} templateSource The mustache template to render.
390 * @param {Object} context Simple types used as the context for the template.
391 * @param {String} themeName Name of the current theme.
392 * @return {Promise} object
393 */
8d00afb1 394 Renderer.prototype.doRender = function(templateSource, context, themeName) {
9bdcf579
DW
395 var deferred = $.Deferred();
396
8d00afb1 397 this.currentThemeName = themeName;
9bdcf579
DW
398
399 // Make sure we fetch this first.
8d00afb1 400 var loadPixTemplate = this.getTemplate('core/pix_icon', true);
9bdcf579
DW
401
402 loadPixTemplate.done(
403 function() {
8d00afb1 404 this.addHelpers(context, themeName);
9bdcf579
DW
405 var result = '';
406 try {
8d00afb1 407 result = mustache.render(templateSource, context, this.partialHelper.bind(this));
9bdcf579
DW
408 } catch (ex) {
409 deferred.reject(ex);
410 }
411
8d00afb1
DW
412 if (this.requiredStrings.length > 0) {
413 str.get_strings(this.requiredStrings)
29879f8f
FM
414 .then(function(strings) {
415
416 // Why do we not do another call the render here?
417 //
418 // Because that would expose DOS holes. E.g.
419 // I create an assignment called "{{fish" which
420 // would get inserted in the template in the first pass
421 // and cause the template to die on the second pass (unbalanced).
422
8d00afb1
DW
423 result = this.treatStringsInContent(result, strings);
424 deferred.resolve(result, this.getJS(strings));
425 }.bind(this))
29879f8f 426 .fail(deferred.reject);
9bdcf579 427 } else {
8d00afb1 428 deferred.resolve(result.trim(), this.getJS([]));
9bdcf579 429 }
8d00afb1 430 }.bind(this)
29879f8f 431 ).fail(deferred.reject);
9bdcf579
DW
432 return deferred.promise();
433 };
434
28de7771
DW
435 /**
436 * Execute a block of JS returned from a template.
437 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
438 *
439 * @method runTemplateJS
440 * @param {string} source - A block of javascript.
441 */
442 var runTemplateJS = function(source) {
443 if (source.trim() !== '') {
35be5826 444 var newscript = $('<script>').attr('type', 'text/javascript').html(source);
28de7771
DW
445 $('head').append(newscript);
446 }
447 };
448
449 /**
450 * Do some DOM replacement and trigger correct events and fire javascript.
451 *
452 * @method domReplace
453 * @private
454 * @param {JQuery} element - Element or selector to replace.
455 * @param {String} newHTML - HTML to insert / replace.
456 * @param {String} newJS - Javascript to run after the insertion.
457 * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
458 */
459 var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
460 var replaceNode = $(element);
461 if (replaceNode.length) {
462 // First create the dom nodes so we have a reference to them.
463 var newNodes = $(newHTML);
1fca8a7b 464 var yuiNodes = null;
28de7771
DW
465 // Do the replacement in the page.
466 if (replaceChildNodes) {
1fca8a7b
DW
467 // Cleanup any YUI event listeners attached to any of these nodes.
468 yuiNodes = new Y.NodeList(replaceNode.children().get());
469 yuiNodes.destroy(true);
470
471 // JQuery will cleanup after itself.
28de7771
DW
472 replaceNode.empty();
473 replaceNode.append(newNodes);
474 } else {
1fca8a7b
DW
475 // Cleanup any YUI event listeners attached to any of these nodes.
476 yuiNodes = new Y.NodeList(replaceNode.get());
477 yuiNodes.destroy(true);
478
479 // JQuery will cleanup after itself.
28de7771
DW
480 replaceNode.replaceWith(newNodes);
481 }
482 // Run any javascript associated with the new HTML.
483 runTemplateJS(newJS);
484 // Notify all filters about the new content.
485 event.notifyFilterContentUpdated(newNodes);
486 }
487 };
488
8d00afb1
DW
489 /**
490 * Load a template and call doRender on it.
491 *
492 * @method render
493 * @private
494 * @param {string} templateName - should consist of the component and the name of the template like this:
495 * core/menu (lib/templates/menu.mustache) or
496 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
497 * @param {Object} context - Could be array, string or simple value for the context of the template.
498 * @param {string} themeName - Name of the current theme.
499 * @return {Promise} JQuery promise object resolved when the template has been rendered.
500 */
501 Renderer.prototype.render = function(templateName, context, themeName) {
502 var deferred = $.Deferred();
503
504 if (typeof (themeName) === "undefined") {
505 // System context by default.
506 themeName = config.theme;
507 }
508
509 this.currentThemeName = themeName;
510
511 var loadTemplate = this.getTemplate(templateName, true);
512
513 loadTemplate.done(
514 function(templateSource) {
515 var renderPromise = this.doRender(templateSource, context, themeName);
516
517 renderPromise.done(
518 function(result, js) {
519 deferred.resolve(result, js);
520 }
521 ).fail(
522 function(ex) {
523 deferred.reject(ex);
524 }
525 );
526 }.bind(this)
527 ).fail(
528 function(ex) {
529 deferred.reject(ex);
530 }
531 );
532 return deferred.promise();
533 };
534
90525930
MN
535 /**
536 * Prepend some HTML to a node and trigger events and fire javascript.
537 *
538 * @method domPrepend
539 * @private
540 * @param {jQuery|String} element - Element or selector to prepend HTML to
541 * @param {String} html - HTML to prepend
542 * @param {String} js - Javascript to run after we prepend the html
543 */
544 var domPrepend = function(element, html, js) {
545 var node = $(element);
546 if (node.length) {
547 // Prepend the html.
548 node.prepend(html);
549 // Run any javascript associated with the new HTML.
550 runTemplateJS(js);
551 // Notify all filters about the new content.
552 event.notifyFilterContentUpdated(node);
553 }
554 };
28de7771 555
f7775c9a
MN
556 /**
557 * Append some HTML to a node and trigger events and fire javascript.
558 *
559 * @method domAppend
560 * @private
561 * @param {jQuery|String} element - Element or selector to append HTML to
562 * @param {String} html - HTML to append
563 * @param {String} js - Javascript to run after we append the html
564 */
565 var domAppend = function(element, html, js) {
566 var node = $(element);
567 if (node.length) {
568 // Append the html.
569 node.append(html);
570 // Run any javascript associated with the new HTML.
571 runTemplateJS(js);
572 // Notify all filters about the new content.
573 event.notifyFilterContentUpdated(node);
574 }
575 };
576
9bdcf579
DW
577 return /** @alias module:core/templates */ {
578 // Public variables and functions.
579 /**
8d00afb1
DW
580 * Every call to render creates a new instance of the class and calls render on it. This
581 * means each render call has it's own class variables.
9bdcf579
DW
582 *
583 * @method render
584 * @private
585 * @param {string} templateName - should consist of the component and the name of the template like this:
586 * core/menu (lib/templates/menu.mustache) or
587 * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
588 * @param {Object} context - Could be array, string or simple value for the context of the template.
589 * @param {string} themeName - Name of the current theme.
590 * @return {Promise} JQuery promise object resolved when the template has been rendered.
591 */
592 render: function(templateName, context, themeName) {
8d00afb1
DW
593 var renderer = new Renderer();
594 return renderer.render(templateName, context, themeName);
9bdcf579
DW
595 },
596
597 /**
598 * Execute a block of JS returned from a template.
599 * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
600 *
601 * @method runTemplateJS
9bdcf579
DW
602 * @param {string} source - A block of javascript.
603 */
28de7771
DW
604 runTemplateJS: runTemplateJS,
605
606 /**
607 * Replace a node in the page with some new HTML and run the JS.
608 *
609 * @method replaceNodeContents
c96f55e6
DP
610 * @param {JQuery} element - Element or selector to replace.
611 * @param {String} newHTML - HTML to insert / replace.
612 * @param {String} newJS - Javascript to run after the insertion.
28de7771
DW
613 */
614 replaceNodeContents: function(element, newHTML, newJS) {
c96f55e6 615 domReplace(element, newHTML, newJS, true);
28de7771
DW
616 },
617
618 /**
619 * Insert a node in the page with some new HTML and run the JS.
620 *
621 * @method replaceNode
c96f55e6
DP
622 * @param {JQuery} element - Element or selector to replace.
623 * @param {String} newHTML - HTML to insert / replace.
624 * @param {String} newJS - Javascript to run after the insertion.
28de7771
DW
625 */
626 replaceNode: function(element, newHTML, newJS) {
c96f55e6 627 domReplace(element, newHTML, newJS, false);
f7775c9a
MN
628 },
629
90525930
MN
630 /**
631 * Prepend some HTML to a node and trigger events and fire javascript.
632 *
633 * @method prependNodeContents
634 * @param {jQuery|String} element - Element or selector to prepend HTML to
635 * @param {String} html - HTML to prepend
636 * @param {String} js - Javascript to run after we prepend the html
637 */
638 prependNodeContents: function(element, html, js) {
639 domPrepend(element, html, js);
640 },
641
f7775c9a
MN
642 /**
643 * Append some HTML to a node and trigger events and fire javascript.
644 *
645 * @method appendNodeContents
646 * @param {jQuery|String} element - Element or selector to append HTML to
647 * @param {String} html - HTML to append
648 * @param {String} js - Javascript to run after we append the html
649 */
650 appendNodeContents: function(element, html, js) {
651 domAppend(element, html, js);
9bdcf579
DW
652 }
653 };
654});