MDL-64506 templates: Move BS2 btns' to BS4 btns'
[moodle.git] / availability / yui / src / form / js / form.js
1 /**
2  * Provides interface for users to edit availability settings on the
3  * module/section editing form.
4  *
5  * The system works using this JavaScript plus form.js files inside each
6  * condition plugin.
7  *
8  * The overall concept is that data is held in a textarea in the form in JSON
9  * format. This JavaScript converts the textarea into a set of controls
10  * generated here and by the relevant plugins.
11  *
12  * (Almost) all data is held directly by the state of the HTML controls, and
13  * can be updated to the form field by calling the 'update' method, which
14  * this code and the plugins call if any HTML control changes.
15  *
16  * @module moodle-core_availability-form
17  */
18 M.core_availability = M.core_availability || {};
20 /**
21  * Core static functions for availability settings in editing form.
22  *
23  * @class M.core_availability.form
24  * @static
25  */
26 M.core_availability.form = {
27     /**
28      * Object containing installed plugins. They are indexed by plugin name.
29      *
30      * @property plugins
31      * @type Object
32      */
33     plugins: {},
35     /**
36      * Availability field (textarea).
37      *
38      * @property field
39      * @type Y.Node
40      */
41     field: null,
43     /**
44      * Main div that replaces the availability field.
45      *
46      * @property mainDiv
47      * @type Y.Node
48      */
49     mainDiv: null,
51     /**
52      * Object that represents the root of the tree.
53      *
54      * @property rootList
55      * @type M.core_availability.List
56      */
57     rootList: null,
59     /**
60      * Counter used when creating anything that needs an id.
61      *
62      * @property idCounter
63      * @type Number
64      */
65     idCounter: 0,
67     /**
68      * The 'Restrict by group' button if present.
69      *
70      * @property restrictByGroup
71      * @type Y.Node
72      */
73     restrictByGroup: null,
75     /**
76      * Called to initialise the system when the page loads. This method will
77      * also call the init method for each plugin.
78      *
79      * @method init
80      */
81     init: function(pluginParams) {
82         // Init all plugins.
83         for (var plugin in pluginParams) {
84             var params = pluginParams[plugin];
85             var pluginClass = M[params[0]].form;
86             pluginClass.init.apply(pluginClass, params);
87         }
89         // Get the availability field, hide it, and replace with the main div.
90         this.field = Y.one('#id_availabilityconditionsjson');
91         this.field.setAttribute('aria-hidden', 'true');
92         // The fcontainer class here is inappropriate, but is necessary
93         // because otherwise it is impossible to make Behat work correctly on
94         // these controls as Behat incorrectly decides they're a moodleform
95         // textarea. IMO Behat should not know about moodleforms at all and
96         // should look purely at HTML elements on the page, but until it is
97         // fixed to do this or fixed in some other way to only detect moodleform
98         // elements that specifically match what those elements should look like,
99         // then there is no good solution.
100         this.mainDiv = Y.Node.create('<div class="availability-field fcontainer"></div>');
101         this.field.insert(this.mainDiv, 'after');
103         // Get top-level tree as JSON.
104         var value = this.field.get('value');
105         var data = null;
106         if (value !== '') {
107             try {
108                 data = Y.JSON.parse(value);
109             } catch (x) {
110                 // If the JSON data is not valid, treat it as empty.
111                 this.field.set('value', '');
112             }
113         }
114         this.rootList = new M.core_availability.List(data, true);
115         this.mainDiv.appendChild(this.rootList.node);
117         // Update JSON value after loading (to reflect any changes that need
118         // to be made to make it valid).
119         this.update();
120         this.rootList.renumber();
122         // Mark main area as dynamically updated.
123         this.mainDiv.setAttribute('aria-live', 'polite');
125         // Listen for form submission - to avoid having our made-up fields
126         // submitted, we need to disable them all before submit.
127         this.field.ancestor('form').on('submit', function() {
128             this.mainDiv.all('input,textarea,select').set('disabled', true);
129         }, this);
131         // If the form has group mode and/or grouping options, there is a
132         // 'add restriction' button there.
133         this.restrictByGroup = Y.one('#restrictbygroup');
134         if (this.restrictByGroup) {
135             this.restrictByGroup.on('click', this.addRestrictByGroup, this);
136             var groupmode = Y.one('#id_groupmode');
137             var groupingid = Y.one('#id_groupingid');
138             if (groupmode) {
139                 groupmode.on('change', this.updateRestrictByGroup, this);
140             }
141             if (groupingid) {
142                 groupingid.on('change', this.updateRestrictByGroup, this);
143             }
144             this.updateRestrictByGroup();
145         }
146     },
148     /**
149      * Called at any time to update the hidden field value.
150      *
151      * This should be called whenever any value changes in the form settings.
152      *
153      * @method update
154      */
155     update: function() {
156         // Convert tree to value.
157         var jsValue = this.rootList.getValue();
159         // Store any errors (for form reporting) in 'errors' value if present.
160         var errors = [];
161         this.rootList.fillErrors(errors);
162         if (errors.length !== 0) {
163             jsValue.errors = errors;
164         }
166         // Set into hidden form field, JS-encoded.
167         this.field.set('value', Y.JSON.stringify(jsValue));
169         // Also update the restrict by group button if present.
170         this.updateRestrictByGroup();
171     },
173     /**
174      * Updates the status of the 'restrict by group' button (enables or disables
175      * it) based on current availability restrictions and group/grouping settings.
176      */
177     updateRestrictByGroup: function() {
178         if (!this.restrictByGroup) {
179             return;
180         }
182         // If the root list is anything other than the default 'and' type, disable.
183         if (this.rootList.getValue().op !== '&') {
184             this.restrictByGroup.set('disabled', true);
185             return;
186         }
188         // If there's already a group restriction, disable it.
189         var alreadyGot = this.rootList.hasItemOfType('group') ||
190                 this.rootList.hasItemOfType('grouping');
191         if (alreadyGot) {
192             this.restrictByGroup.set('disabled', true);
193             return;
194         }
196         // If the groupmode and grouping id aren't set, disable it.
197         var groupmode = Y.one('#id_groupmode');
198         var groupingid = Y.one('#id_groupingid');
199         if ((!groupmode || Number(groupmode.get('value')) === 0) &&
200                 (!groupingid || Number(groupingid.get('value')) === 0)) {
201             this.restrictByGroup.set('disabled', true);
202             return;
203         }
205         this.restrictByGroup.set('disabled', false);
206     },
208     /**
209      * Called when the user clicks on the 'restrict by group' button. This is
210      * a special case that adds a group or grouping restriction.
211      *
212      * By default this restriction is not shown which makes it similar to the
213      *
214      * @param e Button click event
215      */
216     addRestrictByGroup: function(e) {
217         // If you don't prevent default, it submits the form for some reason.
218         e.preventDefault();
220         // Add the condition.
221         var groupingid = Y.one('#id_groupingid');
222         var newChild;
223         if (groupingid && Number(groupingid.get('value')) !== 0) {
224             // Add a grouping restriction if one is specified.
225             newChild = new M.core_availability.Item(
226                     {type: 'grouping', id: Number(groupingid.get('value'))}, true);
227         } else {
228             // Otherwise just add a group restriction.
229             newChild = new M.core_availability.Item({type: 'group'}, true);
230         }
232         // Refresh HTML.
233         this.rootList.addChild(newChild);
234         this.update();
235         this.rootList.renumber();
236         this.rootList.updateHtml();
237     }
238 };
241 /**
242  * Base object for plugins. Plugins should use Y.Object to extend this class.
243  *
244  * @class M.core_availability.plugin
245  * @static
246  */
247 M.core_availability.plugin = {
248     /**
249      * True if users are allowed to add items of this plugin at the moment.
250      *
251      * @property allowAdd
252      * @type Boolean
253      */
254     allowAdd: false,
256     /**
257      * Called (from PHP) to initialise the plugin. Should usually not be
258      * overridden by child plugin.
259      *
260      * @method init
261      * @param {String} component Component name e.g. 'availability_date'
262      */
263     init: function(component, allowAdd, params) {
264         var name = component.replace(/^availability_/, '');
265         this.allowAdd = allowAdd;
266         M.core_availability.form.plugins[name] = this;
267         this.initInner.apply(this, params);
268     },
270     /**
271      * Init method for plugin to override. (Default does nothing.)
272      *
273      * This method will receive any parameters defined in frontend.php
274      * get_javascript_init_params.
275      *
276      * @method initInner
277      * @protected
278      */
279     initInner: function() {
280         // Can be overriden.
281     },
283     /**
284      * Gets a YUI node representing the controls for this plugin on the form.
285      *
286      * Must be implemented by sub-object; default throws an exception.
287      *
288      * @method getNode
289      * @return {Y.Node} YUI node
290      */
291     getNode: function() {
292         throw 'getNode not implemented';
293     },
295     /**
296      * Fills in the value from this plugin's controls into a value object,
297      * which will later be converted to JSON and stored in the form field.
298      *
299      * Must be implemented by sub-object; default throws an exception.
300      *
301      * @method fillValue
302      * @param {Object} value Value object (to be written to)
303      * @param {Y.Node} node YUI node (same one returned from getNode)
304      */
305     fillValue: function() {
306         throw 'fillValue not implemented';
307     },
309     /**
310      * Fills in any errors from this plugin's controls. If there are any
311      * errors, push them into the supplied array.
312      *
313      * Errors are Moodle language strings in format component:string, e.g.
314      * 'availability_date:error_date_past_end_of_world'.
315      *
316      * The default implementation does nothing.
317      *
318      * @method fillErrors
319      * @param {Array} errors Array of errors (push new errors here)
320      * @param {Y.Node} node YUI node (same one returned from getNode)
321      */
322     fillErrors: function() {
323         // Can be overriden.
324     },
326     /**
327      * Focuses the first thing in the plugin after it has been added.
328      *
329      * The default implementation uses a simple algorithm to identify the
330      * first focusable input/select and then focuses it.
331      */
332     focusAfterAdd: function(node) {
333         var target = node.one('input:not([disabled]),select:not([disabled])');
334         target.focus();
335     }
336 };
339 /**
340  * Maintains a list of children and settings for how they are combined.
341  *
342  * @class M.core_availability.List
343  * @constructor
344  * @param {Object} json Decoded JSON value
345  * @param {Boolean} [false] root True if this is root level list
346  * @param {Boolean} [false] root True if parent is root level list
347  */
348 M.core_availability.List = function(json, root, parentRoot) {
349     // Set default value for children. (You can't do this in the prototype
350     // definition, or it ends up sharing the same array between all of them.)
351     this.children = [];
353     if (root !== undefined) {
354         this.root = root;
355     }
356     // Create DIV structure (without kids).
357     this.node = Y.Node.create('<div class="availability-list"><h3 class="accesshide"></h3>' +
358             '<div class="availability-inner">' +
359             '<div class="availability-header m-b-1"><span>' +
360             M.util.get_string('listheader_sign_before', 'availability') + '</span>' +
361             ' <label><span class="accesshide">' + M.util.get_string('label_sign', 'availability') +
362             ' </span><select class="availability-neg custom-select m-x-1"' +
363             ' title="' + M.util.get_string('label_sign', 'availability') + '">' +
364             '<option value="">' + M.util.get_string('listheader_sign_pos', 'availability') + '</option>' +
365             '<option value="!">' + M.util.get_string('listheader_sign_neg', 'availability') + '</option></select></label> ' +
366             '<span class="availability-single">' + M.util.get_string('listheader_single', 'availability') + '</span>' +
367             '<span class="availability-multi">' + M.util.get_string('listheader_multi_before', 'availability') +
368             ' <label><span class="accesshide">' + M.util.get_string('label_multi', 'availability') + ' </span>' +
369             '<select class="availability-op custom-select m-x-1"' +
370             ' title="' + M.util.get_string('label_multi', 'availability') + '"><option value="&">' +
371             M.util.get_string('listheader_multi_and', 'availability') + '</option>' +
372             '<option value="|">' + M.util.get_string('listheader_multi_or', 'availability') + '</option></select></label> ' +
373             M.util.get_string('listheader_multi_after', 'availability') + '</span></div>' +
374             '<div class="availability-children"></div>' +
375             '<div class="availability-none"><span class="p-x-1">' + M.util.get_string('none', 'moodle') + '</span></div>' +
376             '<div class="clearfix m-t-1"></div>' +
377             '<div class="availability-button"></div></div><div class="clearfix"></div></div>');
378     if (!root) {
379         this.node.addClass('availability-childlist d-sm-flex align-items-center');
380     }
381     this.inner = this.node.one('> .availability-inner');
383     var shown = true;
384     if (root) {
385         // If it's the root, add an eye icon as first thing in header.
386         if (json && json.show !== undefined) {
387             shown = json.show;
388         }
389         this.eyeIcon = new M.core_availability.EyeIcon(false, shown);
390         this.node.one('.availability-header').get('firstChild').insert(
391                 this.eyeIcon.span, 'before');
392     } else if (parentRoot) {
393         // When the parent is root, add an eye icon before the main list div.
394         if (json && json.showc !== undefined) {
395             shown = json.showc;
396         }
397         this.eyeIcon = new M.core_availability.EyeIcon(false, shown);
398         this.inner.insert(this.eyeIcon.span, 'before');
399     }
401     if (!root) {
402         // If it's not the root, add a delete button to the 'none' option.
403         // You can only delete lists when they have no children so this will
404         // automatically appear at the correct time.
405         var deleteIcon = new M.core_availability.DeleteIcon(this);
406         var noneNode = this.node.one('.availability-none');
407         noneNode.appendChild(document.createTextNode(' '));
408         noneNode.appendChild(deleteIcon.span);
410         // Also if it's not the root, none is actually invalid, so add a label.
411         noneNode.appendChild(Y.Node.create('<span class="m-t-1 label label-warning">' +
412                 M.util.get_string('invalid', 'availability') + '</span>'));
413     }
415     // Create the button and add it.
416     var button = Y.Node.create('<button type="button" class="btn btn-secondary m-t-1">' +
417             M.util.get_string('addrestriction', 'availability') + '</button>');
418     button.on("click", function() {
419         this.clickAdd();
420     }, this);
421     this.node.one('div.availability-button').appendChild(button);
423     if (json) {
424         // Set operator from JSON data.
425         switch (json.op) {
426             case '&' :
427             case '|' :
428                 this.node.one('.availability-neg').set('value', '');
429                 break;
430             case '!&' :
431             case '!|' :
432                 this.node.one('.availability-neg').set('value', '!');
433                 break;
434         }
435         switch (json.op) {
436             case '&' :
437             case '!&' :
438                 this.node.one('.availability-op').set('value', '&');
439                 break;
440             case '|' :
441             case '!|' :
442                 this.node.one('.availability-op').set('value', '|');
443                 break;
444         }
446         // Construct children.
447         for (var i = 0; i < json.c.length; i++) {
448             var child = json.c[i];
449             if (this.root && json && json.showc !== undefined) {
450                 child.showc = json.showc[i];
451             }
452             var newItem;
453             if (child.type !== undefined) {
454                 // Plugin type.
455                 newItem = new M.core_availability.Item(child, this.root);
456             } else {
457                 // List type.
458                 newItem = new M.core_availability.List(child, false, this.root);
459             }
460             this.addChild(newItem);
461         }
462     }
464     // Add update listeners to the dropdowns.
465     this.node.one('.availability-neg').on('change', function() {
466         // Update hidden field and HTML.
467         M.core_availability.form.update();
468         this.updateHtml();
469     }, this);
470     this.node.one('.availability-op').on('change', function() {
471         // Update hidden field.
472         M.core_availability.form.update();
473         this.updateHtml();
474     }, this);
476     // Update HTML to hide unnecessary parts.
477     this.updateHtml();
478 };
480 /**
481  * Adds a child to the end of the list (in HTML and stored data).
482  *
483  * @method addChild
484  * @private
485  * @param {M.core_availability.Item|M.core_availability.List} newItem Child to add
486  */
487 M.core_availability.List.prototype.addChild = function(newItem) {
488     if (this.children.length > 0) {
489         // Create connecting label (text will be filled in later by updateHtml).
490         this.inner.one('.availability-children').appendChild(Y.Node.create(
491                 '<div class="availability-connector">' +
492                 '<span class="label"></span>' +
493                 '</div>'));
494     }
495     // Add item to array and to HTML.
496     this.children.push(newItem);
497     this.inner.one('.availability-children').appendChild(newItem.node);
498 };
500 /**
501  * Focuses something after a new list is added.
502  *
503  * @method focusAfterAdd
504  */
505 M.core_availability.List.prototype.focusAfterAdd = function() {
506     this.inner.one('button').focus();
507 };
509 /**
510  * Checks whether this list uses the individual show icons or the single one.
511  *
512  * (Basically, AND and the equivalent NOT OR list can have individual show icons
513  * so that you hide the activity entirely if a user fails one condition, but
514  * may display it with information about the condition if they fail a different
515  * one. That isn't possible with OR and NOT AND because for those types, there
516  * is not really a concept of which single condition caused the user to fail
517  * it.)
518  *
519  * Method can only be called on the root list.
520  *
521  * @method isIndividualShowIcons
522  * @return {Boolean} True if using the individual icons
523  */
524 M.core_availability.List.prototype.isIndividualShowIcons = function() {
525     if (!this.root) {
526         throw 'Can only call this on root list';
527     }
528     var neg = this.node.one('.availability-neg').get('value') === '!';
529     var isor = this.node.one('.availability-op').get('value') === '|';
530     return (!neg && !isor) || (neg && isor);
531 };
533 /**
534  * Renumbers the list and all children.
535  *
536  * @method renumber
537  * @param {String} parentNumber Number to use in heading for this list
538  */
539 M.core_availability.List.prototype.renumber = function(parentNumber) {
540     // Update heading for list.
541     var headingParams = {count: this.children.length};
542     var prefix;
543     if (parentNumber === undefined) {
544         headingParams.number = '';
545         prefix = '';
546     } else {
547         headingParams.number = parentNumber + ':';
548         prefix = parentNumber + '.';
549     }
550     var heading = M.util.get_string('setheading', 'availability', headingParams);
551     this.node.one('> h3').set('innerHTML', heading);
553     // Do children.
554     for (var i = 0; i < this.children.length; i++) {
555         var child = this.children[i];
556         child.renumber(prefix + (i + 1));
557     }
558 };
560 /**
561  * Updates HTML for the list based on the current values, for example showing
562  * the 'None' text if there are no children.
563  *
564  * @method updateHtml
565  */
566 M.core_availability.List.prototype.updateHtml = function() {
567     // Control children appearing or not appearing.
568     if (this.children.length > 0) {
569         this.inner.one('> .availability-children').removeAttribute('aria-hidden');
570         this.inner.one('> .availability-none').setAttribute('aria-hidden', 'true');
571         this.inner.one('> .availability-header').removeAttribute('aria-hidden');
572         if (this.children.length > 1) {
573             this.inner.one('.availability-single').setAttribute('aria-hidden', 'true');
574             this.inner.one('.availability-multi').removeAttribute('aria-hidden');
575         } else {
576             this.inner.one('.availability-single').removeAttribute('aria-hidden');
577             this.inner.one('.availability-multi').setAttribute('aria-hidden', 'true');
578         }
579     } else {
580         this.inner.one('> .availability-children').setAttribute('aria-hidden', 'true');
581         this.inner.one('> .availability-none').removeAttribute('aria-hidden');
582         this.inner.one('> .availability-header').setAttribute('aria-hidden', 'true');
583     }
585     // For root list, control eye icons.
586     if (this.root) {
587         var showEyes = this.isIndividualShowIcons();
589         // Individual icons.
590         for (var i = 0; i < this.children.length; i++) {
591             var child = this.children[i];
592             if (showEyes) {
593                 child.eyeIcon.span.removeAttribute('aria-hidden');
594             } else {
595                 child.eyeIcon.span.setAttribute('aria-hidden', 'true');
596             }
597         }
599         // Single icon is the inverse.
600         if (showEyes) {
601             this.eyeIcon.span.setAttribute('aria-hidden', 'true');
602         } else {
603             this.eyeIcon.span.removeAttribute('aria-hidden');
604         }
605     }
607     // Update connector text.
608     var connectorText;
609     if (this.inner.one('.availability-op').get('value') === '&') {
610         connectorText = M.util.get_string('and', 'availability');
611     } else {
612         connectorText = M.util.get_string('or', 'availability');
613     }
614     this.inner.all('> .availability-children > .availability-connector span.label').each(function(span) {
615         span.set('innerHTML', connectorText);
616     });
617 };
619 /**
620  * Deletes a descendant item (Item or List). Called when the user clicks a
621  * delete icon.
622  *
623  * This is a recursive function.
624  *
625  * @method deleteDescendant
626  * @param {M.core_availability.Item|M.core_availability.List} descendant Item to delete
627  * @return {Boolean} True if it was deleted
628  */
629 M.core_availability.List.prototype.deleteDescendant = function(descendant) {
630     // Loop through children.
631     for (var i = 0; i < this.children.length; i++) {
632         var child = this.children[i];
633         if (child === descendant) {
634             // Remove from internal array.
635             this.children.splice(i, 1);
636             var target = child.node;
637             // Remove one of the connector nodes around target (if any left).
638             if (this.children.length > 0) {
639                 if (target.previous('.availability-connector')) {
640                     target.previous('.availability-connector').remove();
641                 } else {
642                     target.next('.availability-connector').remove();
643                 }
644             }
645             // Remove target itself.
646             this.inner.one('> .availability-children').removeChild(target);
647             // Update the form and the list HTML.
648             M.core_availability.form.update();
649             this.updateHtml();
650             // Focus add button for this list.
651             this.inner.one('> .availability-button').one('button').focus();
652             return true;
653         } else if (child instanceof M.core_availability.List) {
654             // Recursive call.
655             var found = child.deleteDescendant(descendant);
656             if (found) {
657                 return true;
658             }
659         }
660     }
662     return false;
663 };
665 /**
666  * Shows the 'add restriction' dialogue box.
667  *
668  * @method clickAdd
669  */
670 M.core_availability.List.prototype.clickAdd = function() {
671     var content = Y.Node.create('<div>' +
672             '<ul class="list-unstyled container-fluid"></ul>' +
673             '<div class="availability-buttons mdl-align">' +
674             '<button type="button" class="btn btn-secondary">' + M.util.get_string('cancel', 'moodle') +
675             '</button></div></div>');
676     var cancel = content.one('button');
678     // Make a list of all the dialog options.
679     var dialogRef = {dialog: null};
680     var ul = content.one('ul');
681     var li, id, button, label;
682     for (var type in M.core_availability.form.plugins) {
683         // Plugins might decide not to display their add button.
684         if (!M.core_availability.form.plugins[type].allowAdd) {
685             continue;
686         }
687         // Add entry for plugin.
688         li = Y.Node.create('<li class="clearfix row"></li>');
689         id = 'availability_addrestriction_' + type;
690         button = Y.Node.create('<div class="col-6"><button type="button" class="btn btn-secondary w-100"' +
691                 'id="' + id + '">' + M.util.get_string('title', 'availability_' + type) + '</button></div>');
692         button.on('click', this.getAddHandler(type, dialogRef), this);
693         li.appendChild(button);
694         label = Y.Node.create('<div class="col-6"><label for="' + id + '">' +
695                 M.util.get_string('description', 'availability_' + type) + '</label></div>');
696         li.appendChild(label);
697         ul.appendChild(li);
698     }
699     // Extra entry for lists.
700     li = Y.Node.create('<li class="clearfix row"></li>');
701     id = 'availability_addrestriction_list_';
702     button = Y.Node.create('<div class="col-6"><button type="button" class="btn btn-secondary w-100"' +
703             'id="' + id + '">' + M.util.get_string('condition_group', 'availability') + '</button></div>');
704     button.on('click', this.getAddHandler(null, dialogRef), this);
705     li.appendChild(button);
706     label = Y.Node.create('<div class="col-6"><label for="' + id + '">' +
707             M.util.get_string('condition_group_info', 'availability') + '</label></div>');
708     li.appendChild(label);
709     ul.appendChild(li);
711     var config = {
712         headerContent: M.util.get_string('addrestriction', 'availability'),
713         bodyContent: content,
714         additionalBaseClass: 'availability-dialogue',
715         draggable: true,
716         modal: true,
717         closeButton: false,
718         width: '450px'
719     };
720     dialogRef.dialog = new M.core.dialogue(config);
721     dialogRef.dialog.show();
722     cancel.on('click', function() {
723         dialogRef.dialog.destroy();
724         // Focus the button they clicked originally.
725         this.inner.one('> .availability-button').one('button').focus();
726     }, this);
727 };
729 /**
730  * Gets an add handler function used by the dialogue to add a particular item.
731  *
732  * @method getAddHandler
733  * @param {String|Null} type Type name of plugin or null to add lists
734  * @param {Object} dialogRef Reference to object that contains dialog
735  * @param {M.core.dialogue} dialogRef.dialog Dialog object
736  * @return {Function} Add handler function to call when adding that thing
737  */
738 M.core_availability.List.prototype.getAddHandler = function(type, dialogRef) {
739     return function() {
740         var newItem;
741         if (type) {
742             // Create an Item object to represent the child.
743             newItem = new M.core_availability.Item({type: type, creating: true}, this.root);
744         } else {
745             // Create a new List object to represent the child.
746             newItem = new M.core_availability.List({c: [], showc: true}, false, this.root);
747         }
748         // Add to list.
749         this.addChild(newItem);
750         // Update the form and list HTML.
751         M.core_availability.form.update();
752         M.core_availability.form.rootList.renumber();
753         this.updateHtml();
754         // Hide dialog.
755         dialogRef.dialog.destroy();
756         newItem.focusAfterAdd();
757     };
758 };
760 /**
761  * Gets the value of the list ready to convert to JSON and fill form field.
762  *
763  * @method getValue
764  * @return {Object} Value of list suitable for use in JSON
765  */
766 M.core_availability.List.prototype.getValue = function() {
767     // Work out operator from selects.
768     var value = {};
769     value.op = this.node.one('.availability-neg').get('value') +
770             this.node.one('.availability-op').get('value');
772     // Work out children from list.
773     value.c = [];
774     var i;
775     for (i = 0; i < this.children.length; i++) {
776         value.c.push(this.children[i].getValue());
777     }
779     // Work out show/showc for root level.
780     if (this.root) {
781         if (this.isIndividualShowIcons()) {
782             value.showc = [];
783             for (i = 0; i < this.children.length; i++) {
784                 value.showc.push(!this.children[i].eyeIcon.isHidden());
785             }
786         } else {
787             value.show = !this.eyeIcon.isHidden();
788         }
789     }
790     return value;
791 };
793 /**
794  * Checks whether this list has any errors (incorrect user input). If so,
795  * an error string identifier in the form langfile:langstring should be pushed
796  * into the errors array.
797  *
798  * @method fillErrors
799  * @param {Array} errors Array of errors so far
800  */
801 M.core_availability.List.prototype.fillErrors = function(errors) {
802     // List with no items is an error (except root).
803     if (this.children.length === 0 && !this.root) {
804         errors.push('availability:error_list_nochildren');
805     }
806     // Pass to children.
807     for (var i = 0; i < this.children.length; i++) {
808         this.children[i].fillErrors(errors);
809     }
810 };
812 /**
813  * Checks whether the list contains any items of the given type name.
814  *
815  * @method hasItemOfType
816  * @param {String} pluginType Required plugin type (name)
817  * @return {Boolean} True if there is one
818  */
819 M.core_availability.List.prototype.hasItemOfType = function(pluginType) {
820     // Check each item.
821     for (var i = 0; i < this.children.length; i++) {
822         var child = this.children[i];
823         if (child instanceof M.core_availability.List) {
824             // Recursive call.
825             if (child.hasItemOfType(pluginType)) {
826                 return true;
827             }
828         } else {
829             if (child.pluginType === pluginType) {
830                 return true;
831             }
832         }
833     }
834     return false;
835 };
837 /**
838  * Eye icon for this list (null if none).
839  *
840  * @property eyeIcon
841  * @type M.core_availability.EyeIcon
842  */
843 M.core_availability.List.prototype.eyeIcon = null;
845 /**
846  * True if list is special root level list.
847  *
848  * @property root
849  * @type Boolean
850  */
851 M.core_availability.List.prototype.root = false;
853 /**
854  * Array containing children (Lists or Items).
855  *
856  * @property children
857  * @type M.core_availability.List[]|M.core_availability.Item[]
858  */
859 M.core_availability.List.prototype.children = null;
861 /**
862  * HTML outer node for list.
863  *
864  * @property node
865  * @type Y.Node
866  */
867 M.core_availability.List.prototype.node = null;
869 /**
870  * HTML node for inner div that actually is the displayed list.
871  *
872  * @property node
873  * @type Y.Node
874  */
875 M.core_availability.List.prototype.inner = null;
878 /**
879  * Represents a single condition.
880  *
881  * @class M.core_availability.Item
882  * @constructor
883  * @param {Object} json Decoded JSON value
884  * @param {Boolean} root True if this item is a child of the root list.
885  */
886 M.core_availability.Item = function(json, root) {
887     this.pluginType = json.type;
888     if (M.core_availability.form.plugins[json.type] === undefined) {
889         // Handle undefined plugins.
890         this.plugin = null;
891         this.pluginNode = Y.Node.create('<div class="availability-warning">' +
892                 M.util.get_string('missingplugin', 'availability') + '</div>');
893     } else {
894         // Plugin is known.
895         this.plugin = M.core_availability.form.plugins[json.type];
896         this.pluginNode = this.plugin.getNode(json);
898         // Add a class with the plugin Frankenstyle name to make CSS easier in plugin.
899         this.pluginNode.addClass('availability_' + json.type);
900     }
902     this.node = Y.Node.create('<div class="availability-item d-sm-flex align-items-center"><h3 class="accesshide"></h3></div>');
904     // Add eye icon if required. This icon is added for root items, but may be
905     // hidden depending on the selected list operator.
906     if (root) {
907         var shown = true;
908         if (json.showc !== undefined) {
909             shown = json.showc;
910         }
911         this.eyeIcon = new M.core_availability.EyeIcon(true, shown);
912         this.node.appendChild(this.eyeIcon.span);
913     }
915     // Add plugin controls.
916     this.pluginNode.addClass('availability-plugincontrols');
917     this.node.appendChild(this.pluginNode);
919     // Add delete button for node.
920     var deleteIcon = new M.core_availability.DeleteIcon(this);
921     this.node.appendChild(deleteIcon.span);
923     // Add the invalid marker (empty).
924     this.node.appendChild(document.createTextNode(' '));
925     this.node.appendChild(Y.Node.create('<span class="label label-warning"/>'));
926 };
928 /**
929  * Obtains the value of this condition, which will be serialized into JSON
930  * format and stored in the form.
931  *
932  * @method getValue
933  * @return {Object} JavaScript object containing value of this item
934  */
935 M.core_availability.Item.prototype.getValue = function() {
936     var value = {'type': this.pluginType};
937     if (this.plugin) {
938         this.plugin.fillValue(value, this.pluginNode);
939     }
940     return value;
941 };
943 /**
944  * Checks whether this condition has any errors (incorrect user input). If so,
945  * an error string identifier in the form langfile:langstring should be pushed
946  * into the errors array.
947  *
948  * @method fillErrors
949  * @param {Array} errors Array of errors so far
950  */
951 M.core_availability.Item.prototype.fillErrors = function(errors) {
952     var before = errors.length;
953     if (this.plugin) {
954         // Pass to plugin.
955         this.plugin.fillErrors(errors, this.pluginNode);
956     } else {
957         // Unknown plugin is an error
958         errors.push('core_availability:item_unknowntype');
959     }
960     // If any errors were added, add the marker to this item.
961     var errorLabel = this.node.one('> .label-warning');
962     if (errors.length !== before && !errorLabel.get('firstChild')) {
963         errorLabel.appendChild(document.createTextNode(M.util.get_string('invalid', 'availability')));
964     } else if (errors.length === before && errorLabel.get('firstChild')) {
965         errorLabel.get('firstChild').remove();
966     }
967 };
969 /**
970  * Renumbers the item.
971  *
972  * @method renumber
973  * @param {String} number Number to use in heading for this item
974  */
975 M.core_availability.Item.prototype.renumber = function(number) {
976     // Update heading for item.
977     var headingParams = {number: number};
978     if (this.plugin) {
979         headingParams.type = M.util.get_string('title', 'availability_' + this.pluginType);
980     } else {
981         headingParams.type = '[' + this.pluginType + ']';
982     }
983     headingParams.number = number + ':';
984     var heading = M.util.get_string('itemheading', 'availability', headingParams);
985     this.node.one('> h3').set('innerHTML', heading);
986 };
988 /**
989  * Focuses something after a new item is added.
990  *
991  * @method focusAfterAdd
992  */
993 M.core_availability.Item.prototype.focusAfterAdd = function() {
994     this.plugin.focusAfterAdd(this.pluginNode);
995 };
997 /**
998  * Name of plugin.
999  *
1000  * @property pluginType
1001  * @type String
1002  */
1003 M.core_availability.Item.prototype.pluginType = null;
1005 /**
1006  * Object representing plugin form controls.
1007  *
1008  * @property plugin
1009  * @type Object
1010  */
1011 M.core_availability.Item.prototype.plugin = null;
1013 /**
1014  * Eye icon for item.
1015  *
1016  * @property eyeIcon
1017  * @type M.core_availability.EyeIcon
1018  */
1019 M.core_availability.Item.prototype.eyeIcon = null;
1021 /**
1022  * HTML node for item.
1023  *
1024  * @property node
1025  * @type Y.Node
1026  */
1027 M.core_availability.Item.prototype.node = null;
1029 /**
1030  * Inner part of node that is owned by plugin.
1031  *
1032  * @property pluginNode
1033  * @type Y.Node
1034  */
1035 M.core_availability.Item.prototype.pluginNode = null;
1038 /**
1039  * Eye icon (to control show/hide of the activity if the user fails a condition).
1040  *
1041  * There are individual eye icons (show/hide control for a single condition) and
1042  * 'all' eye icons (show/hide control that applies to the entire item, whatever
1043  * reason it fails for). This is necessary because the individual conditions
1044  * don't make sense for OR and AND NOT lists.
1045  *
1046  * @class M.core_availability.EyeIcon
1047  * @constructor
1048  * @param {Boolean} individual True if the icon is controlling a single condition
1049  * @param {Boolean} shown True if icon is initially in shown state
1050  */
1051 M.core_availability.EyeIcon = function(individual, shown) {
1052     this.individual = individual;
1053     this.span = Y.Node.create('<a class="availability-eye col-form-label" href="#" role="button">');
1054     var icon = Y.Node.create('<img />');
1055     this.span.appendChild(icon);
1057     // Set up button text and icon.
1058     var suffix = individual ? '_individual' : '_all',
1059         setHidden = function() {
1060             var hiddenStr = M.util.get_string('hidden' + suffix, 'availability');
1061             icon.set('src', M.util.image_url('i/show', 'core'));
1062             icon.set('alt', hiddenStr);
1063             this.span.set('title', hiddenStr + ' \u2022 ' +
1064                     M.util.get_string('show_verb', 'availability'));
1065         },
1066         setShown = function() {
1067             var shownStr = M.util.get_string('shown' + suffix, 'availability');
1068             icon.set('src', M.util.image_url('i/hide', 'core'));
1069             icon.set('alt', shownStr);
1070             this.span.set('title', shownStr + ' \u2022 ' +
1071                     M.util.get_string('hide_verb', 'availability'));
1072         };
1073     if (shown) {
1074         setShown.call(this);
1075     } else {
1076         setHidden.call(this);
1077     }
1079     // Update when button is clicked.
1080     var click = function(e) {
1081         e.preventDefault();
1082         if (this.isHidden()) {
1083             setShown.call(this);
1084         } else {
1085             setHidden.call(this);
1086         }
1087         M.core_availability.form.update();
1088     };
1089     this.span.on('click', click, this);
1090     this.span.on('key', click, 'up:32', this);
1091     this.span.on('key', function(e) {
1092         e.preventDefault();
1093     }, 'down:32', this);
1094 };
1096 /**
1097  * True if this eye icon is an individual one (see above).
1098  *
1099  * @property individual
1100  * @type Boolean
1101  */
1102 M.core_availability.EyeIcon.prototype.individual = false;
1104 /**
1105  * YUI node for the span that contains this icon.
1106  *
1107  * @property span
1108  * @type Y.Node
1109  */
1110 M.core_availability.EyeIcon.prototype.span = null;
1112 /**
1113  * Checks the current state of the icon.
1114  *
1115  * @method isHidden
1116  * @return {Boolean} True if this icon is set to 'hidden'
1117  */
1118 M.core_availability.EyeIcon.prototype.isHidden = function() {
1119     var suffix = this.individual ? '_individual' : '_all',
1120         compare = M.util.get_string('hidden' + suffix, 'availability');
1121     return this.span.one('img').get('alt') === compare;
1122 };
1125 /**
1126  * Delete icon (to delete an Item or List).
1127  *
1128  * @class M.core_availability.DeleteIcon
1129  * @constructor
1130  * @param {M.core_availability.Item|M.core_availability.List} toDelete Thing to delete
1131  */
1132 M.core_availability.DeleteIcon = function(toDelete) {
1133     this.span = Y.Node.create('<a class="d-inline-block col-form-label availability-delete p-x-1" href="#" title="' +
1134             M.util.get_string('delete', 'moodle') + '" role="button">');
1135     var img = Y.Node.create('<img src="' + M.util.image_url('t/delete', 'core') +
1136             '" alt="' + M.util.get_string('delete', 'moodle') + '" />');
1137     this.span.appendChild(img);
1138     var click = function(e) {
1139         e.preventDefault();
1140         M.core_availability.form.rootList.deleteDescendant(toDelete);
1141         M.core_availability.form.rootList.renumber();
1142     };
1143     this.span.on('click', click, this);
1144     this.span.on('key', click, 'up:32', this);
1145     this.span.on('key', function(e) {
1146         e.preventDefault();
1147     }, 'down:32', this);
1148 };
1150 /**
1151  * YUI node for the span that contains this icon.
1152  *
1153  * @property span
1154  * @type Y.Node
1155  */
1156 M.core_availability.DeleteIcon.prototype.span = null;