Merge branch 'MDL-47628-master' of https://github.com/sammarshallou/moodle
[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     },
282     /**
283      * Gets a YUI node representing the controls for this plugin on the form.
284      *
285      * Must be implemented by sub-object; default throws an exception.
286      *
287      * @method getNode
288      * @return {Y.Node} YUI node
289      */
290     getNode : function() {
291         throw 'getNode not implemented';
292     },
294     /**
295      * Fills in the value from this plugin's controls into a value object,
296      * which will later be converted to JSON and stored in the form field.
297      *
298      * Must be implemented by sub-object; default throws an exception.
299      *
300      * @method fillValue
301      * @param {Object} value Value object (to be written to)
302      * @param {Y.Node} node YUI node (same one returned from getNode)
303      */
304     fillValue : function() {
305         throw 'fillValue not implemented';
306     },
308     /**
309      * Fills in any errors from this plugin's controls. If there are any
310      * errors, push them into the supplied array.
311      *
312      * Errors are Moodle language strings in format component:string, e.g.
313      * 'availability_date:error_date_past_end_of_world'.
314      *
315      * The default implementation does nothing.
316      *
317      * @method fillErrors
318      * @param {Array} errors Array of errors (push new errors here)
319      * @param {Y.Node} node YUI node (same one returned from getNode)
320      */
321     fillErrors : function() {
322     },
324     /**
325      * Focuses the first thing in the plugin after it has been added.
326      *
327      * The default implementation uses a simple algorithm to identify the
328      * first focusable input/select and then focuses it.
329      */
330     focusAfterAdd : function(node) {
331         var target = node.one('input:not([disabled]),select:not([disabled])');
332         target.focus();
333     }
334 };
337 /**
338  * Maintains a list of children and settings for how they are combined.
339  *
340  * @class M.core_availability.List
341  * @constructor
342  * @param {Object} json Decoded JSON value
343  * @param {Boolean} [false] root True if this is root level list
344  * @param {Boolean} [false] root True if parent is root level list
345  */
346 M.core_availability.List = function(json, root, parentRoot) {
347     // Set default value for children. (You can't do this in the prototype
348     // definition, or it ends up sharing the same array between all of them.)
349     this.children = [];
351     if (root !== undefined) {
352         this.root = root;
353     }
354     // Create DIV structure (without kids).
355     this.node = Y.Node.create('<div class="availability-list"><h3 class="accesshide"></h3>' +
356             '<div class="availability-inner">' +
357             '<div class="availability-header">' + M.util.get_string('listheader_sign_before', 'availability') +
358             ' <label><span class="accesshide">' + M.util.get_string('label_sign', 'availability') +
359             ' </span><select class="availability-neg" title="' + M.util.get_string('label_sign', 'availability') + '">' +
360             '<option value="">' + M.util.get_string('listheader_sign_pos', 'availability') + '</option>' +
361             '<option value="!">' + M.util.get_string('listheader_sign_neg', 'availability') + '</option></select></label> ' +
362             '<span class="availability-single">' + M.util.get_string('listheader_single', 'availability') + '</span>' +
363             '<span class="availability-multi">' + M.util.get_string('listheader_multi_before', 'availability') +
364             ' <label><span class="accesshide">' + M.util.get_string('label_multi', 'availability') + ' </span>' +
365             '<select class="availability-op" title="' + M.util.get_string('label_multi', 'availability') + '"><option value="&">' +
366             M.util.get_string('listheader_multi_and', 'availability') + '</option>' +
367             '<option value="|">' + M.util.get_string('listheader_multi_or', 'availability') + '</option></select></label> ' +
368             M.util.get_string('listheader_multi_after', 'availability') + '</span></div>' +
369             '<div class="availability-children"></div>' +
370             '<div class="availability-none">' + M.util.get_string('none', 'moodle') + '</div>' +
371             '<div class="availability-button"></div></div></div>');
372     if (!root) {
373         this.node.addClass('availability-childlist');
374     }
375     this.inner = this.node.one('> .availability-inner');
377     var shown = true;
378     if (root) {
379         // If it's the root, add an eye icon as first thing in header.
380         if (json && json.show !== undefined) {
381             shown = json.show;
382         }
383         this.eyeIcon = new M.core_availability.EyeIcon(false, shown);
384         this.node.one('.availability-header').get('firstChild').insert(
385                 this.eyeIcon.span, 'before');
386     } else if (parentRoot) {
387         // When the parent is root, add an eye icon before the main list div.
388         if (json && json.showc !== undefined) {
389             shown = json.showc;
390         }
391         this.eyeIcon = new M.core_availability.EyeIcon(false, shown);
392         this.inner.insert(this.eyeIcon.span, 'before');
393     }
395     if (!root) {
396         // If it's not the root, add a delete button to the 'none' option.
397         // You can only delete lists when they have no children so this will
398         // automatically appear at the correct time.
399         var deleteIcon = new M.core_availability.DeleteIcon(this);
400         var noneNode = this.node.one('.availability-none');
401         noneNode.appendChild(document.createTextNode(' '));
402         noneNode.appendChild(deleteIcon.span);
404         // Also if it's not the root, none is actually invalid, so add a label.
405         noneNode.appendChild(Y.Node.create('<span class="label label-warning">' +
406                 M.util.get_string('invalid', 'availability') + '</span>'));
407     }
409     // Create the button and add it.
410     var button = Y.Node.create('<button type="button" class="btn btn-default">' +
411             M.util.get_string('addrestriction', 'availability') + '</button>');
412     button.on("click", function() { this.clickAdd(); }, this);
413     this.node.one('div.availability-button').appendChild(button);
415     if (json) {
416         // Set operator from JSON data.
417         switch (json.op) {
418             case '&' :
419             case '|' :
420                 this.node.one('.availability-neg').set('value', '');
421                 break;
422             case '!&' :
423             case '!|' :
424                 this.node.one('.availability-neg').set('value', '!');
425                 break;
426         }
427         switch (json.op) {
428             case '&' :
429             case '!&' :
430                 this.node.one('.availability-op').set('value', '&');
431                 break;
432             case '|' :
433             case '!|' :
434                 this.node.one('.availability-op').set('value', '|');
435                 break;
436         }
438         // Construct children.
439         for (var i = 0; i < json.c.length; i++) {
440             var child = json.c[i];
441             if (this.root && json && json.showc !== undefined) {
442                 child.showc = json.showc[i];
443             }
444             var newItem;
445             if (child.type !== undefined) {
446                 // Plugin type.
447                 newItem = new M.core_availability.Item(child, this.root);
448             } else {
449                 // List type.
450                 newItem = new M.core_availability.List(child, false, this.root);
451             }
452             this.addChild(newItem);
453         }
454     }
456     // Add update listeners to the dropdowns.
457     this.node.one('.availability-neg').on('change', function() {
458         // Update hidden field and HTML.
459         M.core_availability.form.update();
460         this.updateHtml();
461     }, this);
462     this.node.one('.availability-op').on('change', function() {
463         // Update hidden field.
464         M.core_availability.form.update();
465         this.updateHtml();
466     }, this);
468     // Update HTML to hide unnecessary parts.
469     this.updateHtml();
470 };
472 /**
473  * Adds a child to the end of the list (in HTML and stored data).
474  *
475  * @method addChild
476  * @private
477  * @param {M.core_availability.Item|M.core_availability.List} newItem Child to add
478  */
479 M.core_availability.List.prototype.addChild = function(newItem) {
480     if (this.children.length > 0) {
481         // Create connecting label (text will be filled in later by updateHtml).
482         this.inner.one('.availability-children').appendChild(Y.Node.create(
483                 '<div class="availability-connector">' +
484                 '<span class="label"></span>' +
485                 '</div>'));
486     }
487     // Add item to array and to HTML.
488     this.children.push(newItem);
489     this.inner.one('.availability-children').appendChild(newItem.node);
490 };
492 /**
493  * Focuses something after a new list is added.
494  *
495  * @method focusAfterAdd
496  */
497 M.core_availability.List.prototype.focusAfterAdd = function() {
498     this.inner.one('button').focus();
499 };
501 /**
502  * Checks whether this list uses the individual show icons or the single one.
503  *
504  * (Basically, AND and the equivalent NOT OR list can have individual show icons
505  * so that you hide the activity entirely if a user fails one condition, but
506  * may display it with information about the condition if they fail a different
507  * one. That isn't possible with OR and NOT AND because for those types, there
508  * is not really a concept of which single condition caused the user to fail
509  * it.)
510  *
511  * Method can only be called on the root list.
512  *
513  * @method isIndividualShowIcons
514  * @return {Boolean} True if using the individual icons
515  */
516 M.core_availability.List.prototype.isIndividualShowIcons = function() {
517     if (!this.root) {
518         throw 'Can only call this on root list';
519     }
520     var neg = this.node.one('.availability-neg').get('value') === '!';
521     var isor = this.node.one('.availability-op').get('value') === '|';
522     return (!neg && !isor) || (neg && isor);
523 };
525 /**
526  * Renumbers the list and all children.
527  *
528  * @method renumber
529  * @param {String} parentNumber Number to use in heading for this list
530  */
531 M.core_availability.List.prototype.renumber = function(parentNumber) {
532     // Update heading for list.
533     var headingParams = { count: this.children.length };
534     var prefix;
535     if (parentNumber === undefined) {
536         headingParams.number = '';
537         prefix = '';
538     } else {
539         headingParams.number = parentNumber + ':';
540         prefix = parentNumber + '.';
541     }
542     var heading = M.util.get_string('setheading', 'availability', headingParams);
543     this.node.one('> h3').set('innerHTML', heading);
545     // Do children.
546     for (var i = 0; i < this.children.length; i++) {
547         var child = this.children[i];
548         child.renumber(prefix + (i + 1));
549     }
550 };
552 /**
553  * Updates HTML for the list based on the current values, for example showing
554  * the 'None' text if there are no children.
555  *
556  * @method updateHtml
557  */
558 M.core_availability.List.prototype.updateHtml = function() {
559     // Control children appearing or not appearing.
560     if (this.children.length > 0) {
561         this.inner.one('> .availability-children').removeAttribute('aria-hidden');
562         this.inner.one('> .availability-none').setAttribute('aria-hidden', 'true');
563         this.inner.one('> .availability-header').removeAttribute('aria-hidden');
564         if (this.children.length > 1) {
565             this.inner.one('.availability-single').setAttribute('aria-hidden', 'true');
566             this.inner.one('.availability-multi').removeAttribute('aria-hidden');
567         } else {
568             this.inner.one('.availability-single').removeAttribute('aria-hidden');
569             this.inner.one('.availability-multi').setAttribute('aria-hidden', 'true');
570         }
571     } else {
572         this.inner.one('> .availability-children').setAttribute('aria-hidden', 'true');
573         this.inner.one('> .availability-none').removeAttribute('aria-hidden');
574         this.inner.one('> .availability-header').setAttribute('aria-hidden', 'true');
575     }
577     // For root list, control eye icons.
578     if (this.root) {
579         var showEyes = this.isIndividualShowIcons();
581         // Individual icons.
582         for (var i = 0; i < this.children.length; i++) {
583             var child = this.children[i];
584             if (showEyes) {
585                 child.eyeIcon.span.removeAttribute('aria-hidden');
586             } else {
587                 child.eyeIcon.span.setAttribute('aria-hidden', 'true');
588             }
589         }
591         // Single icon is the inverse.
592         if (showEyes) {
593             this.eyeIcon.span.setAttribute('aria-hidden', 'true');
594         } else {
595             this.eyeIcon.span.removeAttribute('aria-hidden');
596         }
597     }
599     // Update connector text.
600     var connectorText;
601     if (this.inner.one('.availability-op').get('value') === '&') {
602         connectorText = M.util.get_string('and', 'availability');
603     } else {
604         connectorText = M.util.get_string('or', 'availability');
605     }
606     this.inner.all('> .availability-children > .availability-connector span.label').each(function(span) {
607         span.set('innerHTML', connectorText);
608     });
609 };
611 /**
612  * Deletes a descendant item (Item or List). Called when the user clicks a
613  * delete icon.
614  *
615  * This is a recursive function.
616  *
617  * @method deleteDescendant
618  * @param {M.core_availability.Item|M.core_availability.List} descendant Item to delete
619  * @return {Boolean} True if it was deleted
620  */
621 M.core_availability.List.prototype.deleteDescendant = function(descendant) {
622     // Loop through children.
623     for (var i = 0; i < this.children.length; i++) {
624         var child = this.children[i];
625         if (child === descendant) {
626             // Remove from internal array.
627             this.children.splice(i, 1);
628             var target = child.node;
629             // Remove one of the connector nodes around target (if any left).
630             if (this.children.length > 0) {
631                 if (target.previous('.availability-connector')) {
632                     target.previous('.availability-connector').remove();
633                 } else {
634                     target.next('.availability-connector').remove();
635                 }
636             }
637             // Remove target itself.
638             this.inner.one('> .availability-children').removeChild(target);
639             // Update the form and the list HTML.
640             M.core_availability.form.update();
641             this.updateHtml();
642             // Focus add button for this list.
643             this.inner.one('> .availability-button').one('button').focus();
644             return true;
645         } else if (child instanceof M.core_availability.List) {
646             // Recursive call.
647             var found = child.deleteDescendant(descendant);
648             if (found) {
649                 return true;
650             }
651         }
652     }
654     return false;
655 };
657 /**
658  * Shows the 'add restriction' dialogue box.
659  *
660  * @method clickAdd
661  */
662 M.core_availability.List.prototype.clickAdd = function() {
663     var content = Y.Node.create('<div>' +
664             '<ul class="list-unstyled"></ul>' +
665             '<div class="availability-buttons mdl-align">' +
666             '<button type="button" class="btn btn-default">' + M.util.get_string('cancel', 'moodle') +
667             '</button></div></div>');
668     var cancel = content.one('button');
670     // Make a list of all the dialog options.
671     var dialogRef = { dialog: null };
672     var ul = content.one('ul');
673     var li, id, button, label;
674     for (var type in M.core_availability.form.plugins) {
675         // Plugins might decide not to display their add button.
676         if (!M.core_availability.form.plugins[type].allowAdd) {
677             continue;
678         }
679         // Add entry for plugin.
680         li = Y.Node.create('<li class="clearfix"></li>');
681         id = 'availability_addrestriction_' + type;
682         button = Y.Node.create('<button type="button" class="btn btn-default"' +
683                 'id="' + id + '">' + M.util.get_string('title', 'availability_' + type) + '</button>');
684         button.on('click', this.getAddHandler(type, dialogRef), this);
685         li.appendChild(button);
686         label = Y.Node.create('<label for="' + id + '">' +
687                 M.util.get_string('description', 'availability_' + type) + '</label>');
688         li.appendChild(label);
689         ul.appendChild(li);
690     }
691     // Extra entry for lists.
692     li = Y.Node.create('<li class="clearfix"></li>');
693     id = 'availability_addrestriction_list_';
694     button = Y.Node.create('<button type="button" class="btn btn-default"' +
695             'id="' + id + '">' + M.util.get_string('condition_group', 'availability') + '</button>');
696     button.on('click', this.getAddHandler(null, dialogRef), this);
697     li.appendChild(button);
698     label = Y.Node.create('<label for="' + id + '">' +
699             M.util.get_string('condition_group_info', 'availability') + '</label>');
700     li.appendChild(label);
701     ul.appendChild(li);
703     var config = {
704         headerContent : M.util.get_string('addrestriction', 'availability'),
705         bodyContent : content,
706         additionalBaseClass : 'availability-dialogue',
707         draggable : true,
708         modal : true,
709         closeButton : false,
710         width : '450px'
711     };
712     dialogRef.dialog = new M.core.dialogue(config);
713     dialogRef.dialog.show();
714     cancel.on('click', function() {
715         dialogRef.dialog.destroy();
716         // Focus the button they clicked originally.
717         this.inner.one('> .availability-button').one('button').focus();
718     }, this);
719 };
721 /**
722  * Gets an add handler function used by the dialogue to add a particular item.
723  *
724  * @method getAddHandler
725  * @param {String|Null} type Type name of plugin or null to add lists
726  * @param {Object} dialogRef Reference to object that contains dialog
727  * @param {M.core.dialogue} dialogRef.dialog Dialog object
728  * @return {Function} Add handler function to call when adding that thing
729  */
730 M.core_availability.List.prototype.getAddHandler = function(type, dialogRef) {
731     return function() {
732         if (type) {
733             // Create an Item object to represent the child.
734             newItem = new M.core_availability.Item({ type: type, creating: true }, this.root);
735         } else {
736             // Create a new List object to represent the child.
737             newItem = new M.core_availability.List({ c: [], showc: true }, false, this.root);
738         }
739         // Add to list.
740         this.addChild(newItem);
741         // Update the form and list HTML.
742         M.core_availability.form.update();
743         M.core_availability.form.rootList.renumber();
744         this.updateHtml();
745         // Hide dialog.
746         dialogRef.dialog.destroy();
747         newItem.focusAfterAdd();
748     };
749 };
751 /**
752  * Gets the value of the list ready to convert to JSON and fill form field.
753  *
754  * @method getValue
755  * @return {Object} Value of list suitable for use in JSON
756  */
757 M.core_availability.List.prototype.getValue = function() {
758     // Work out operator from selects.
759     var value = {};
760     value.op = this.node.one('.availability-neg').get('value') +
761             this.node.one('.availability-op').get('value');
763     // Work out children from list.
764     value.c = [];
765     var i;
766     for (i = 0; i < this.children.length; i++) {
767         value.c.push(this.children[i].getValue());
768     }
770     // Work out show/showc for root level.
771     if (this.root) {
772         if (this.isIndividualShowIcons()) {
773             value.showc = [];
774             for (i = 0; i < this.children.length; i++) {
775                 value.showc.push(!this.children[i].eyeIcon.isHidden());
776             }
777         } else {
778             value.show = !this.eyeIcon.isHidden();
779         }
780     }
781     return value;
782 };
784 /**
785  * Checks whether this list has any errors (incorrect user input). If so,
786  * an error string identifier in the form langfile:langstring should be pushed
787  * into the errors array.
788  *
789  * @method fillErrors
790  * @param {Array} errors Array of errors so far
791  */
792 M.core_availability.List.prototype.fillErrors = function(errors) {
793     // List with no items is an error (except root).
794     if (this.children.length === 0 && !this.root) {
795         errors.push('availability:error_list_nochildren');
796     }
797     // Pass to children.
798     for (var i = 0; i < this.children.length; i++) {
799         this.children[i].fillErrors(errors);
800     }
801 };
803 /**
804  * Checks whether the list contains any items of the given type name.
805  *
806  * @method hasItemOfType
807  * @param {String} pluginType Required plugin type (name)
808  * @return {Boolean} True if there is one
809  */
810 M.core_availability.List.prototype.hasItemOfType = function(pluginType) {
811     // Check each item.
812     for (var i = 0; i < this.children.length; i++) {
813         var child = this.children[i];
814         if (child instanceof M.core_availability.List) {
815             // Recursive call.
816             if (child.hasItemOfType(pluginType)) {
817                 return true;
818             }
819         } else {
820             if (child.pluginType === pluginType) {
821                 return true;
822             }
823         }
824     }
825     return false;
826 };
828 /**
829  * Eye icon for this list (null if none).
830  *
831  * @property eyeIcon
832  * @type M.core_availability.EyeIcon
833  */
834 M.core_availability.List.prototype.eyeIcon = null;
836 /**
837  * True if list is special root level list.
838  *
839  * @property root
840  * @type Boolean
841  */
842 M.core_availability.List.prototype.root = false;
844 /**
845  * Array containing children (Lists or Items).
846  *
847  * @property children
848  * @type M.core_availability.List[]|M.core_availability.Item[]
849  */
850 M.core_availability.List.prototype.children = null;
852 /**
853  * HTML outer node for list.
854  *
855  * @property node
856  * @type Y.Node
857  */
858 M.core_availability.List.prototype.node = null;
860 /**
861  * HTML node for inner div that actually is the displayed list.
862  *
863  * @property node
864  * @type Y.Node
865  */
866 M.core_availability.List.prototype.inner = null;
869 /**
870  * Represents a single condition.
871  *
872  * @class M.core_availability.Item
873  * @constructor
874  * @param {Object} json Decoded JSON value
875  * @param {Boolean} root True if this item is a child of the root list.
876  */
877 M.core_availability.Item = function(json, root) {
878     this.pluginType = json.type;
879     if (M.core_availability.form.plugins[json.type] === undefined) {
880         // Handle undefined plugins.
881         this.plugin = null;
882         this.pluginNode = Y.Node.create('<div class="availability-warning">' +
883                 M.util.get_string('missingplugin', 'availability') + '</div>');
884     } else {
885         // Plugin is known.
886         this.plugin = M.core_availability.form.plugins[json.type];
887         this.pluginNode = this.plugin.getNode(json);
889         // Add a class with the plugin Frankenstyle name to make CSS easier in plugin.
890         this.pluginNode.addClass('availability_' + json.type);
891     }
893     this.node = Y.Node.create('<div class="availability-item"><h3 class="accesshide"></h3></div>');
895     // Add eye icon if required. This icon is added for root items, but may be
896     // hidden depending on the selected list operator.
897     if (root) {
898         var shown = true;
899         if(json.showc !== undefined) {
900             shown = json.showc;
901         }
902         this.eyeIcon = new M.core_availability.EyeIcon(true, shown);
903         this.node.appendChild(this.eyeIcon.span);
904     }
906     // Add plugin controls.
907     this.pluginNode.addClass('availability-plugincontrols');
908     this.node.appendChild(this.pluginNode);
910     // Add delete button for node.
911     var deleteIcon = new M.core_availability.DeleteIcon(this);
912     this.node.appendChild(deleteIcon.span);
914     // Add the invalid marker (empty).
915     this.node.appendChild(document.createTextNode(' '));
916     this.node.appendChild(Y.Node.create('<span class="label label-warning"/>'));
917 };
919 /**
920  * Obtains the value of this condition, which will be serialized into JSON
921  * format and stored in the form.
922  *
923  * @method getValue
924  * @return {Object} JavaScript object containing value of this item
925  */
926 M.core_availability.Item.prototype.getValue = function() {
927     value = { 'type' : this.pluginType };
928     if (this.plugin) {
929         this.plugin.fillValue(value, this.pluginNode);
930     }
931     return value;
932 };
934 /**
935  * Checks whether this condition has any errors (incorrect user input). If so,
936  * an error string identifier in the form langfile:langstring should be pushed
937  * into the errors array.
938  *
939  * @method fillErrors
940  * @param {Array} errors Array of errors so far
941  */
942 M.core_availability.Item.prototype.fillErrors = function(errors) {
943     var before = errors.length;
944     if (this.plugin) {
945         // Pass to plugin.
946         this.plugin.fillErrors(errors, this.pluginNode);
947     } else {
948         // Unknown plugin is an error
949         errors.push('core_availability:item_unknowntype');
950     }
951     // If any errors were added, add the marker to this item.
952     var errorLabel = this.node.one('> .label-warning');
953     if (errors.length !== before && !errorLabel.get('firstChild')) {
954         errorLabel.appendChild(document.createTextNode(M.util.get_string('invalid', 'availability')));
955     } else if (errors.length === before && errorLabel.get('firstChild')) {
956         errorLabel.get('firstChild').remove();
957     }
958 };
960 /**
961  * Renumbers the item.
962  *
963  * @method renumber
964  * @param {String} number Number to use in heading for this item
965  */
966 M.core_availability.Item.prototype.renumber = function(number) {
967     // Update heading for item.
968     var headingParams = { number: number };
969     if (this.plugin) {
970         headingParams.type = M.util.get_string('title', 'availability_' + this.pluginType);
971     } else {
972         headingParams.type = '[' + this.pluginType + ']';
973     }
974     headingParams.number = number + ':';
975     var heading = M.util.get_string('itemheading', 'availability', headingParams);
976     this.node.one('> h3').set('innerHTML', heading);
977 };
979 /**
980  * Focuses something after a new item is added.
981  *
982  * @method focusAfterAdd
983  */
984 M.core_availability.Item.prototype.focusAfterAdd = function() {
985     this.plugin.focusAfterAdd(this.pluginNode);
986 };
988 /**
989  * Name of plugin.
990  *
991  * @property pluginType
992  * @type String
993  */
994 M.core_availability.Item.prototype.pluginType = null;
996 /**
997  * Object representing plugin form controls.
998  *
999  * @property plugin
1000  * @type Object
1001  */
1002 M.core_availability.Item.prototype.plugin = null;
1004 /**
1005  * Eye icon for item.
1006  *
1007  * @property eyeIcon
1008  * @type M.core_availability.EyeIcon
1009  */
1010 M.core_availability.Item.prototype.eyeIcon = null;
1012 /**
1013  * HTML node for item.
1014  *
1015  * @property node
1016  * @type Y.Node
1017  */
1018 M.core_availability.Item.prototype.node = null;
1020 /**
1021  * Inner part of node that is owned by plugin.
1022  *
1023  * @property pluginNode
1024  * @type Y.Node
1025  */
1026 M.core_availability.Item.prototype.pluginNode = null;
1029 /**
1030  * Eye icon (to control show/hide of the activity if the user fails a condition).
1031  *
1032  * There are individual eye icons (show/hide control for a single condition) and
1033  * 'all' eye icons (show/hide control that applies to the entire item, whatever
1034  * reason it fails for). This is necessary because the individual conditions
1035  * don't make sense for OR and AND NOT lists.
1036  *
1037  * @class M.core_availability.EyeIcon
1038  * @constructor
1039  * @param {Boolean} individual True if the icon is controlling a single condition
1040  * @param {Boolean} shown True if icon is initially in shown state
1041  */
1042 M.core_availability.EyeIcon = function(individual, shown) {
1043     this.individual = individual;
1044     this.span = Y.Node.create('<a class="availability-eye" href="#" role="button">');
1045     var icon = Y.Node.create('<img />');
1046     this.span.appendChild(icon);
1048     // Set up button text and icon.
1049     var suffix = individual ? '_individual' : '_all',
1050         setHidden = function() {
1051             var hiddenStr = M.util.get_string('hidden' + suffix, 'availability');
1052             icon.set('src', M.util.image_url('i/show', 'core'));
1053             icon.set('alt', hiddenStr);
1054             this.span.set('title', hiddenStr + ' \u2022 ' +
1055                     M.util.get_string('show_verb', 'availability'));
1056         },
1057         setShown = function() {
1058             var shownStr = M.util.get_string('shown' + suffix, 'availability');
1059             icon.set('src', M.util.image_url('i/hide', 'core'));
1060             icon.set('alt', shownStr);
1061             this.span.set('title', shownStr + ' \u2022 ' +
1062                     M.util.get_string('hide_verb', 'availability'));
1063         };
1064     if(shown) {
1065         setShown.call(this);
1066     } else {
1067         setHidden.call(this);
1068     }
1070     // Update when button is clicked.
1071     var click = function(e) {
1072         e.preventDefault();
1073         if (this.isHidden()) {
1074             setShown.call(this);
1075         } else {
1076             setHidden.call(this);
1077         }
1078         M.core_availability.form.update();
1079     };
1080     this.span.on('click', click, this);
1081     this.span.on('key', click, 'up:32', this);
1082     this.span.on('key', function(e) { e.preventDefault(); }, 'down:32', this);
1083 };
1085 /**
1086  * True if this eye icon is an individual one (see above).
1087  *
1088  * @property individual
1089  * @type Boolean
1090  */
1091 M.core_availability.EyeIcon.prototype.individual = false;
1093 /**
1094  * YUI node for the span that contains this icon.
1095  *
1096  * @property span
1097  * @type Y.Node
1098  */
1099 M.core_availability.EyeIcon.prototype.span = null;
1101 /**
1102  * Checks the current state of the icon.
1103  *
1104  * @method isHidden
1105  * @return {Boolean} True if this icon is set to 'hidden'
1106  */
1107 M.core_availability.EyeIcon.prototype.isHidden = function() {
1108     var suffix = this.individual ? '_individual' : '_all',
1109         compare = M.util.get_string('hidden' + suffix, 'availability');
1110     return this.span.one('img').get('alt') === compare;
1111 };
1114 /**
1115  * Delete icon (to delete an Item or List).
1116  *
1117  * @class M.core_availability.DeleteIcon
1118  * @constructor
1119  * @param {M.core_availability.Item|M.core_availability.List} toDelete Thing to delete
1120  */
1121 M.core_availability.DeleteIcon = function(toDelete) {
1122     this.span = Y.Node.create('<a class="availability-delete" href="#" title="' +
1123             M.util.get_string('delete', 'moodle') + '" role="button">');
1124     var img = Y.Node.create('<img src="' + M.util.image_url('t/delete', 'core') +
1125             '" alt="' + M.util.get_string('delete', 'moodle') + '" />');
1126     this.span.appendChild(img);
1127     var click = function(e) {
1128         e.preventDefault();
1129         M.core_availability.form.rootList.deleteDescendant(toDelete);
1130         M.core_availability.form.rootList.renumber();
1131     };
1132     this.span.on('click', click, this);
1133     this.span.on('key', click, 'up:32', this);
1134     this.span.on('key', function(e) { e.preventDefault(); }, 'down:32', this);
1135 };
1137 /**
1138  * YUI node for the span that contains this icon.
1139  *
1140  * @property span
1141  * @type Y.Node
1142  */
1143 M.core_availability.DeleteIcon.prototype.span = null;