MDL-55258 behat: Set focus on button before clicking it
[moodle.git] / lib / amd / src / templates.js
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/>.
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  */
26 define(['core/mustache',
27          'jquery',
28          'core/ajax',
29          'core/str',
30          'core/notification',
31          'core/url',
32          'core/config',
33          'core/localstorage',
34          'core/event',
35          'core/yui',
36          'core/log'
37        ],
38        function(mustache, $, ajax, str, notification, coreurl, config, storage, event, Y, Log) {
40     // Private variables and functions.
42     /** @var {string[]} templateCache - Cache of already loaded templates */
43     var templateCache = {};
45     /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
46     var requiredStrings = [];
48     /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
49     var requiredJS = [];
51     /** @var {Number} uniqid Incrementing value that is changed for every call to render */
52     var uniqid = 1;
54     /** @var {String} themeName for the current render */
55     var currentThemeName = '';
57     /**
58      * Load a template from the cache or local storage or ajax request.
59      *
60      * @method getTemplate
61      * @private
62      * @param {string} templateName - should consist of the component and the name of the template like this:
63      *                              core/menu (lib/templates/menu.mustache) or
64      *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
65      * @param {Boolean} async If false - this function will not return until the promises are resolved.
66      * @return {Promise} JQuery promise object resolved when the template has been fetched.
67      */
68     var getTemplate = function(templateName, async) {
69         var deferred = $.Deferred();
70         var parts = templateName.split('/');
71         var component = parts.shift();
72         var name = parts.shift();
74         var searchKey = currentThemeName + '/' + templateName;
76         // First try request variables.
77         if (searchKey in templateCache) {
78             deferred.resolve(templateCache[searchKey]);
79             return deferred.promise();
80         }
82         // Now try local storage.
83         var cached = storage.get('core_template/' + searchKey);
85         if (cached) {
86             deferred.resolve(cached);
87             templateCache[searchKey] = cached;
88             return deferred.promise();
89         }
91         // Oh well - load via ajax.
92         var promises = ajax.call([{
93             methodname: 'core_output_load_template',
94             args: {
95                 component: component,
96                 template: name,
97                 themename: currentThemeName
98             }
99         }], async, false);
101         promises[0].done(
102             function(templateSource) {
103                 storage.set('core_template/' + searchKey, templateSource);
104                 templateCache[searchKey] = templateSource;
105                 deferred.resolve(templateSource);
106             }
107         ).fail(
108             function(ex) {
109                 deferred.reject(ex);
110             }
111         );
112         return deferred.promise();
113     };
115     /**
116      * Load a partial from the cache or ajax.
117      *
118      * @method partialHelper
119      * @private
120      * @param {string} name The partial name to load.
121      * @return {string}
122      */
123     var partialHelper = function(name) {
124         var template = '';
126         getTemplate(name, false).done(
127             function(source) {
128                 template = source;
129             }
130         ).fail(notification.exception);
132         return template;
133     };
135     /**
136      * Render image icons.
137      *
138      * @method pixHelper
139      * @private
140      * @param {string} sectionText The text to parse arguments from.
141      * @param {function} helper Used to render the alt attribute of the text.
142      * @return {string}
143      */
144     var pixHelper = function(sectionText, helper) {
145         var parts = sectionText.split(',');
146         var key = '';
147         var component = '';
148         var text = '';
149         var result;
151         if (parts.length > 0) {
152             key = parts.shift().trim();
153         }
154         if (parts.length > 0) {
155             component = parts.shift().trim();
156         }
157         if (parts.length > 0) {
158             text = parts.join(',').trim();
159         }
160         var url = coreurl.imageUrl(key, component);
162         var templatecontext = {
163             attributes: [
164                 {name: 'src', value: url},
165                 {name: 'alt', value: helper(text)},
166                 {name: 'class', value: 'smallicon'}
167             ]
168         };
169         // We forced loading of this early, so it will be in the cache.
170         var template = templateCache[currentThemeName + '/core/pix_icon'];
171         result = mustache.render(template, templatecontext, partialHelper);
172         return result.trim();
173     };
175     /**
176      * Render blocks of javascript and save them in an array.
177      *
178      * @method jsHelper
179      * @private
180      * @param {string} sectionText The text to save as a js block.
181      * @param {function} helper Used to render the block.
182      * @return {string}
183      */
184     var jsHelper = function(sectionText, helper) {
185         requiredJS.push(helper(sectionText, this));
186         return '';
187     };
189     /**
190      * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
191      * into a get_string call.
192      *
193      * @method stringHelper
194      * @private
195      * @param {string} sectionText The text to parse the arguments from.
196      * @param {function} helper Used to render subsections of the text.
197      * @return {string}
198      */
199     var stringHelper = function(sectionText, helper) {
200         var parts = sectionText.split(',');
201         var key = '';
202         var component = '';
203         var param = '';
204         if (parts.length > 0) {
205             key = parts.shift().trim();
206         }
207         if (parts.length > 0) {
208             component = parts.shift().trim();
209         }
210         if (parts.length > 0) {
211             param = parts.join(',').trim();
212         }
214         if (param !== '') {
215             // Allow variable expansion in the param part only.
216             param = helper(param, this);
217         }
218         // Allow json formatted $a arguments.
219         if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {
220             param = JSON.parse(param);
221         }
223         var index = requiredStrings.length;
224         requiredStrings.push({key: key, component: component, param: param});
225         return '{{_s' + index + '}}';
226     };
228     /**
229      * Quote helper used to wrap content in quotes, and escape all quotes present in the content.
230      *
231      * @method quoteHelper
232      * @private
233      * @param {string} sectionText The text to parse the arguments from.
234      * @param {function} helper Used to render subsections of the text.
235      * @return {string}
236      */
237     var quoteHelper = function(sectionText, helper) {
238         var content = helper(sectionText.trim(), this);
240         // Escape the {{ and the ".
241         // This involves wrapping {{, and }} in change delimeter tags.
242         content = content
243             .replace('"', '\\"')
244             .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
245             ;
246         return '"' + content + '"';
247     };
249     /**
250      * Add some common helper functions to all context objects passed to templates.
251      * These helpers match exactly the helpers available in php.
252      *
253      * @method addHelpers
254      * @private
255      * @param {Object} context Simple types used as the context for the template.
256      * @param {String} themeName We set this multiple times, because there are async calls.
257      */
258     var addHelpers = function(context, themeName) {
259         currentThemeName = themeName;
260         requiredStrings = [];
261         requiredJS = [];
262         context.uniqid = uniqid++;
263         context.str = function() {
264           return stringHelper;
265         };
266         context.pix = function() {
267           return pixHelper;
268         };
269         context.js = function() {
270           return jsHelper;
271         };
272         context.quote = function() {
273           return quoteHelper;
274         };
275         context.globals = {config: config};
276         context.currentTheme = themeName;
277     };
279     /**
280      * Get all the JS blocks from the last rendered template.
281      *
282      * @method getJS
283      * @private
284      * @param {string[]} strings Replacement strings.
285      * @return {string}
286      */
287     var getJS = function(strings) {
288         var js = '';
289         if (requiredJS.length > 0) {
290             js = requiredJS.join(";\n");
291         }
293         // Re-render to get the final strings.
294         return treatStringsInContent(js, strings);
295     };
297     /**
298      * Treat strings in content.
299      *
300      * The purpose of this method is to replace the placeholders found in a string
301      * with the their respective translated strings.
302      *
303      * Previously we were relying on String.replace() but the complexity increased with
304      * the numbers of strings to replace. Now we manually walk the string and stop at each
305      * placeholder we find, only then we replace it. Most of the time we will
306      * replace all the placeholders in a single run, at times we will need a few
307      * more runs when placeholders are replaced with strings that contain placeholders
308      * themselves.
309      *
310      * @param {String} content The content in which string placeholders are to be found.
311      * @param {Array} strings The strings to replace with.
312      * @return {String} The treated content.
313      */
314     var treatStringsInContent = function(content, strings) {
315         var pattern = /{{_s\d+}}/,
316             treated,
317             index,
318             strIndex,
319             walker,
320             char,
321             strFinal;
323         do {
324             treated = '';
325             index = content.search(pattern);
326             while (index > -1) {
328                 // Copy the part prior to the placeholder to the treated string.
329                 treated += content.substring(0, index);
330                 content = content.substr(index);
331                 strIndex = '';
332                 walker = 4;  // 4 is the length of '{{_s'.
334                 // Walk the characters to manually extract the index of the string from the placeholder.
335                 char = content.substr(walker, 1);
336                 do {
337                     strIndex += char;
338                     walker++;
339                     char = content.substr(walker, 1);
340                 } while (char != '}');
342                 // Get the string, add it to the treated result, and remove the placeholder from the content to treat.
343                 strFinal = strings[parseInt(strIndex, 10)];
344                 if (typeof strFinal === 'undefined') {
345                     Log.debug('Could not find string for pattern {{_s' + strIndex + '}}.');
346                     strFinal = '';
347                 }
348                 treated += strFinal;
349                 content = content.substr(6 + strIndex.length);  // 6 is the length of the placeholder without the index: '{{_s}}'.
351                 // Find the next placeholder.
352                 index = content.search(pattern);
353             }
355             // The content becomes the treated part with the rest of the content.
356             content = treated + content;
358             // Check if we need to walk the content again, in case strings contained placeholders.
359             index = content.search(pattern);
361         } while (index > -1);
363         return content;
364     };
366     /**
367      * Render a template and then call the callback with the result.
368      *
369      * @method doRender
370      * @private
371      * @param {string} templateSource The mustache template to render.
372      * @param {Object} context Simple types used as the context for the template.
373      * @param {String} themeName Name of the current theme.
374      * @return {Promise} object
375      */
376     var doRender = function(templateSource, context, themeName) {
377         var deferred = $.Deferred();
379         currentThemeName = themeName;
381         // Make sure we fetch this first.
382         var loadPixTemplate = getTemplate('core/pix_icon', true);
384         loadPixTemplate.done(
385             function() {
386                 addHelpers(context, themeName);
387                 var result = '';
388                 try {
389                     result = mustache.render(templateSource, context, partialHelper);
390                 } catch (ex) {
391                     deferred.reject(ex);
392                 }
394                 if (requiredStrings.length > 0) {
395                     str.get_strings(requiredStrings)
396                     .then(function(strings) {
398                         // Why do we not do another call the render here?
399                         //
400                         // Because that would expose DOS holes. E.g.
401                         // I create an assignment called "{{fish" which
402                         // would get inserted in the template in the first pass
403                         // and cause the template to die on the second pass (unbalanced).
405                         result = treatStringsInContent(result, strings);
406                         deferred.resolve(result, getJS(strings));
407                     })
408                     .fail(deferred.reject);
409                 } else {
410                     deferred.resolve(result.trim(), getJS([]));
411                 }
412             }
413         ).fail(deferred.reject);
414         return deferred.promise();
415     };
417     /**
418      * Execute a block of JS returned from a template.
419      * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
420      *
421      * @method runTemplateJS
422      * @param {string} source - A block of javascript.
423      */
424     var runTemplateJS = function(source) {
425         if (source.trim() !== '') {
426             var newscript = $('<script>').attr('type', 'text/javascript').html(source);
427             $('head').append(newscript);
428         }
429     };
431     /**
432      * Do some DOM replacement and trigger correct events and fire javascript.
433      *
434      * @method domReplace
435      * @private
436      * @param {JQuery} element - Element or selector to replace.
437      * @param {String} newHTML - HTML to insert / replace.
438      * @param {String} newJS - Javascript to run after the insertion.
439      * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
440      */
441     var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
442         var replaceNode = $(element);
443         if (replaceNode.length) {
444             // First create the dom nodes so we have a reference to them.
445             var newNodes = $(newHTML);
446             var yuiNodes = null;
447             // Do the replacement in the page.
448             if (replaceChildNodes) {
449                 // Cleanup any YUI event listeners attached to any of these nodes.
450                 yuiNodes = new Y.NodeList(replaceNode.children().get());
451                 yuiNodes.destroy(true);
453                 // JQuery will cleanup after itself.
454                 replaceNode.empty();
455                 replaceNode.append(newNodes);
456             } else {
457                 // Cleanup any YUI event listeners attached to any of these nodes.
458                 yuiNodes = new Y.NodeList(replaceNode.get());
459                 yuiNodes.destroy(true);
461                 // JQuery will cleanup after itself.
462                 replaceNode.replaceWith(newNodes);
463             }
464             // Run any javascript associated with the new HTML.
465             runTemplateJS(newJS);
466             // Notify all filters about the new content.
467             event.notifyFilterContentUpdated(newNodes);
468         }
469     };
472     return /** @alias module:core/templates */ {
473         // Public variables and functions.
474         /**
475          * Load a template and call doRender on it.
476          *
477          * @method render
478          * @private
479          * @param {string} templateName - should consist of the component and the name of the template like this:
480          *                              core/menu (lib/templates/menu.mustache) or
481          *                              tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
482          * @param {Object} context - Could be array, string or simple value for the context of the template.
483          * @param {string} themeName - Name of the current theme.
484          * @return {Promise} JQuery promise object resolved when the template has been rendered.
485          */
486         render: function(templateName, context, themeName) {
487             var deferred = $.Deferred();
489             if (typeof (themeName) === "undefined") {
490                 // System context by default.
491                 themeName = config.theme;
492             }
494             currentThemeName = themeName;
496             var loadTemplate = getTemplate(templateName, true);
498             loadTemplate.done(
499                 function(templateSource) {
500                     var renderPromise = doRender(templateSource, context, themeName);
502                     renderPromise.done(
503                         function(result, js) {
504                             deferred.resolve(result, js);
505                         }
506                     ).fail(
507                         function(ex) {
508                             deferred.reject(ex);
509                         }
510                     );
511                 }
512             ).fail(
513                 function(ex) {
514                     deferred.reject(ex);
515                 }
516             );
517             return deferred.promise();
518         },
520         /**
521          * Execute a block of JS returned from a template.
522          * Call this AFTER adding the template HTML into the DOM so the nodes can be found.
523          *
524          * @method runTemplateJS
525          * @param {string} source - A block of javascript.
526          */
527         runTemplateJS: runTemplateJS,
529         /**
530          * Replace a node in the page with some new HTML and run the JS.
531          *
532          * @method replaceNodeContents
533          * @param {JQuery} element - Element or selector to replace.
534          * @param {String} newHTML - HTML to insert / replace.
535          * @param {String} newJS - Javascript to run after the insertion.
536          */
537         replaceNodeContents: function(element, newHTML, newJS) {
538             domReplace(element, newHTML, newJS, true);
539         },
541         /**
542          * Insert a node in the page with some new HTML and run the JS.
543          *
544          * @method replaceNode
545          * @param {JQuery} element - Element or selector to replace.
546          * @param {String} newHTML - HTML to insert / replace.
547          * @param {String} newJS - Javascript to run after the insertion.
548          */
549         replaceNode: function(element, newHTML, newJS) {
550             domReplace(element, newHTML, newJS, false);
551         }
552     };
553 });