MDL-44992 Conditional availability tab focus bug
[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      * Called to initialise the system when the page loads. This method will
69      * also call the init method for each plugin.
70      *
71      * @method init
72      */
73     init : function(pluginParams) {
74         // Init all plugins.
75         for(var plugin in pluginParams) {
76             var params = pluginParams[plugin];
77             var pluginClass = M[params[0]].form;
78             pluginClass.init.apply(pluginClass, params);
79         }
81         // Get the availability field, hide it, and replace with the main div.
82         this.field = Y.one('#id_availabilityconditionsjson');
83         this.field.setAttribute('aria-hidden', 'true');
84         // The fcontainer class here is inappropriate, but is necessary
85         // because otherwise it is impossible to make Behat work correctly on
86         // these controls as Behat incorrectly decides they're a moodleform
87         // textarea. IMO Behat should not know about moodleforms at all and
88         // should look purely at HTML elements on the page, but until it is
89         // fixed to do this or fixed in some other way to only detect moodleform
90         // elements that specifically match what those elements should look like,
91         // then there is no good solution.
92         this.mainDiv = Y.Node.create('<div class="availability-field fcontainer"></div>');
93         this.field.insert(this.mainDiv, 'after');
95         // Get top-level tree as JSON.
96         var value = this.field.get('value');
97         if (value === '') {
98             this.rootList = new M.core_availability.List(null, true);
99         } else {
100             var data = Y.JSON.parse(value);
101             this.rootList = new M.core_availability.List(data, true);
102         }
103         this.mainDiv.appendChild(this.rootList.node);
105         // Update JSON value after loading (to reflect any changes that need
106         // to be made to make it valid).
107         this.update();
108         this.rootList.renumber();
110         // Mark main area as dynamically updated.
111         this.mainDiv.setAttribute('aria-live', 'polite');
113         // Listen for form submission - to avoid having our made-up fields
114         // submitted, we need to disable them all before submit.
115         this.field.ancestor('form').on('submit', function() {
116             this.mainDiv.all('input,textarea,select').set('disabled', true);
117         }, this);
118     },
120     /**
121      * Called at any time to update the hidden field value.
122      *
123      * This should be called whenever any value changes in the form settings.
124      *
125      * @method update
126      */
127     update : function() {
128         // Convert tree to value.
129         var jsValue = this.rootList.getValue();
131         // Store any errors (for form reporting) in 'errors' value if present.
132         var errors = [];
133         this.rootList.fillErrors(errors);
134         if (errors.length !== 0) {
135             jsValue.errors = errors;
136         }
138         // Set into hidden form field, JS-encoded.
139         this.field.set('value', Y.JSON.stringify(jsValue));
140     }
141 };
144 /**
145  * Base object for plugins. Plugins should use Y.Object to extend this class.
146  *
147  * @class M.core_availability.plugin
148  * @static
149  */
150 M.core_availability.plugin = {
151     /**
152      * True if users are allowed to add items of this plugin at the moment.
153      *
154      * @property allowAdd
155      * @type Boolean
156      */
157     allowAdd : false,
159     /**
160      * Called (from PHP) to initialise the plugin. Should usually not be
161      * overridden by child plugin.
162      *
163      * @method init
164      * @param {String} component Component name e.g. 'availability_date'
165      */
166     init : function(component, allowAdd, params) {
167         var name = component.replace(/^availability_/, '');
168         this.allowAdd = allowAdd;
169         M.core_availability.form.plugins[name] = this;
170         this.initInner.apply(this, params);
171     },
173     /**
174      * Init method for plugin to override. (Default does nothing.)
175      *
176      * This method will receive any parameters defined in frontend.php
177      * get_javascript_init_params.
178      *
179      * @method initInner
180      * @protected
181      */
182     initInner : function() {
183     },
185     /**
186      * Gets a YUI node representing the controls for this plugin on the form.
187      *
188      * Must be implemented by sub-object; default throws an exception.
189      *
190      * @method getNode
191      * @return {Y.Node} YUI node
192      */
193     getNode : function() {
194         throw 'getNode not implemented';
195     },
197     /**
198      * Fills in the value from this plugin's controls into a value object,
199      * which will later be converted to JSON and stored in the form field.
200      *
201      * Must be implemented by sub-object; default throws an exception.
202      *
203      * @method fillValue
204      * @param {Object} value Value object (to be written to)
205      * @param {Y.Node} node YUI node (same one returned from getNode)
206      */
207     fillValue : function() {
208         throw 'fillValue not implemented';
209     },
211     /**
212      * Fills in any errors from this plugin's controls. If there are any
213      * errors, push them into the supplied array.
214      *
215      * Errors are Moodle language strings in format component:string, e.g.
216      * 'availability_date:error_date_past_end_of_world'.
217      *
218      * The default implementation does nothing.
219      *
220      * @method fillErrors
221      * @param {Array} errors Array of errors (push new errors here)
222      * @param {Y.Node} node YUI node (same one returned from getNode)
223      */
224     fillErrors : function() {
225     },
227     /**
228      * Focuses the first thing in the plugin after it has been added.
229      *
230      * The default implementation uses a simple algorithm to identify the
231      * first focusable input/select and then focuses it.
232      */
233     focusAfterAdd : function(node) {
234         var target = node.one('input:not([disabled]),select:not([disabled])');
235         target.focus();
236     }
237 };
240 /**
241  * Maintains a list of children and settings for how they are combined.
242  *
243  * @class M.core_availability.List
244  * @constructor
245  * @param {Object} json Decoded JSON value
246  * @param {Boolean} [false] root True if this is root level list
247  * @param {Boolean} [false] root True if parent is root level list
248  */
249 M.core_availability.List = function(json, root, parentRoot) {
250     // Set default value for children. (You can't do this in the prototype
251     // definition, or it ends up sharing the same array between all of them.)
252     this.children = [];
254     if (root !== undefined) {
255         this.root = root;
256     }
257     var strings = M.str.availability;
258     // Create DIV structure (without kids).
259     this.node = Y.Node.create('<div class="availability-list"><h3 class="accesshide"></h3>' +
260             '<div class="availability-inner">' +
261             '<div class="availability-header">' + strings.listheader_sign_before +
262             ' <label><span class="accesshide">' + strings.label_sign +
263             ' </span><select class="availability-neg" title="' + strings.label_sign + '">' +
264             '<option value="">' + strings.listheader_sign_pos + '</option>' +
265             '<option value="!">' + strings.listheader_sign_neg + '</option></select></label> ' +
266             '<span class="availability-single">' + strings.listheader_single + '</span>' +
267             '<span class="availability-multi">' + strings.listheader_multi_before +
268             ' <label><span class="accesshide">' + strings.label_multi + ' </span>' +
269             '<select class="availability-op" title="' + strings.label_multi + '"><option value="&">' +
270             strings.listheader_multi_and + '</option>' +
271             '<option value="|">' + strings.listheader_multi_or + '</option></select></label> ' +
272             strings.listheader_multi_after + '</span></div>' +
273             '<div class="availability-children"></div>' +
274             '<div class="availability-none">' + M.str.moodle.none + '</div>' +
275             '<div class="availability-button"></div></div></div>');
276     if (!root) {
277         this.node.addClass('availability-childlist');
278     }
279     this.inner = this.node.one('> .availability-inner');
281     var shown = true;
282     if (root) {
283         // If it's the root, add an eye icon as first thing in header.
284         if (json && json.show !== undefined) {
285             shown = json.show;
286         }
287         this.eyeIcon = new M.core_availability.EyeIcon(false, shown);
288         this.node.one('.availability-header').get('firstChild').insert(
289                 this.eyeIcon.span, 'before');
290     } else if (parentRoot) {
291         // When the parent is root, add an eye icon before the main list div.
292         if (json && json.showc !== undefined) {
293             shown = json.showc;
294         }
295         this.eyeIcon = new M.core_availability.EyeIcon(false, shown);
296         this.inner.insert(this.eyeIcon.span, 'before');
297     }
299     if (!root) {
300         // If it's not the root, add a delete button to the 'none' option.
301         // You can only delete lists when they have no children so this will
302         // automatically appear at the correct time.
303         var deleteIcon = new M.core_availability.DeleteIcon(this);
304         var noneNode = this.node.one('.availability-none');
305         noneNode.appendChild(document.createTextNode(' '));
306         noneNode.appendChild(deleteIcon.span);
308         // Also if it's not the root, none is actually invalid, so add a label.
309         noneNode.appendChild(Y.Node.create('<span class="label label-warning">' +
310                 M.str.availability.invalid + '</span>'));
311     }
313     // Create the button and add it.
314     var button = Y.Node.create('<button type="button" class="btn btn-default">' +
315             M.str.availability.addrestriction + '</button>');
316     button.on("click", function() { this.clickAdd(); }, this);
317     this.node.one('div.availability-button').appendChild(button);
319     if (json) {
320         // Set operator from JSON data.
321         switch (json.op) {
322             case '&' :
323             case '|' :
324                 this.node.one('.availability-neg').set('value', '');
325                 break;
326             case '!&' :
327             case '!|' :
328                 this.node.one('.availability-neg').set('value', '!');
329                 break;
330         }
331         switch (json.op) {
332             case '&' :
333             case '!&' :
334                 this.node.one('.availability-op').set('value', '&');
335                 break;
336             case '|' :
337             case '!|' :
338                 this.node.one('.availability-op').set('value', '|');
339                 break;
340         }
342         // Construct children.
343         for (var i = 0; i < json.c.length; i++) {
344             var child = json.c[i];
345             if (this.root && json && json.showc !== undefined) {
346                 child.showc = json.showc[i];
347             }
348             var newItem;
349             if (child.type !== undefined) {
350                 // Plugin type.
351                 newItem = new M.core_availability.Item(child, this.root);
352             } else {
353                 // List type.
354                 newItem = new M.core_availability.List(child, false, this.root);
355             }
356             this.addChild(newItem);
357         }
358     }
360     // Add update listeners to the dropdowns.
361     this.node.one('.availability-neg').on('change', function() {
362         // Update hidden field and HTML.
363         M.core_availability.form.update();
364         this.updateHtml();
365     }, this);
366     this.node.one('.availability-op').on('change', function() {
367         // Update hidden field.
368         M.core_availability.form.update();
369         this.updateHtml();
370     }, this);
372     // Update HTML to hide unnecessary parts.
373     this.updateHtml();
374 };
376 /**
377  * Adds a child to the end of the list (in HTML and stored data).
378  *
379  * @method addChild
380  * @private
381  * @param {M.core_availability.Item|M.core_availability.List} newItem Child to add
382  */
383 M.core_availability.List.prototype.addChild = function(newItem) {
384     if (this.children.length > 0) {
385         // Create connecting label (text will be filled in later by updateHtml).
386         this.inner.one('.availability-children').appendChild(Y.Node.create(
387                 '<div class="availability-connector">' +
388                 '<span class="label"></span>' +
389                 '</div>'));
390     }
391     // Add item to array and to HTML.
392     this.children.push(newItem);
393     this.inner.one('.availability-children').appendChild(newItem.node);
394 };
396 /**
397  * Focuses something after a new list is added.
398  *
399  * @method focusAfterAdd
400  */
401 M.core_availability.List.prototype.focusAfterAdd = function() {
402     this.inner.one('button').focus();
403 };
405 /**
406  * Checks whether this list uses the individual show icons or the single one.
407  *
408  * (Basically, AND and the equivalent NOT OR list can have individual show icons
409  * so that you hide the activity entirely if a user fails one condition, but
410  * may display it with information about the condition if they fail a different
411  * one. That isn't possible with OR and NOT AND because for those types, there
412  * is not really a concept of which single condition caused the user to fail
413  * it.)
414  *
415  * Method can only be called on the root list.
416  *
417  * @method isIndividualShowIcons
418  * @return {Boolean} True if using the individual icons
419  */
420 M.core_availability.List.prototype.isIndividualShowIcons = function() {
421     if (!this.root) {
422         throw 'Can only call this on root list';
423     }
424     var neg = this.node.one('.availability-neg').get('value') === '!';
425     var isor = this.node.one('.availability-op').get('value') === '|';
426     return (!neg && !isor) || (neg && isor);
427 };
429 /**
430  * Renumbers the list and all children.
431  *
432  * @method renumber
433  * @param {String} parentNumber Number to use in heading for this list
434  */
435 M.core_availability.List.prototype.renumber = function(parentNumber) {
436     // Update heading for list.
437     var headingParams = { count: this.children.length };
438     var prefix;
439     if (parentNumber === undefined) {
440         headingParams.number = '';
441         prefix = '';
442     } else {
443         headingParams.number = parentNumber + ':';
444         prefix = parentNumber + '.';
445     }
446     var heading = M.util.get_string('setheading', 'availability', headingParams);
447     this.node.one('> h3').set('innerHTML', heading);
449     // Do children.
450     for (var i = 0; i < this.children.length; i++) {
451         var child = this.children[i];
452         child.renumber(prefix + (i + 1));
453     }
454 };
456 /**
457  * Updates HTML for the list based on the current values, for example showing
458  * the 'None' text if there are no children.
459  *
460  * @method updateHtml
461  */
462 M.core_availability.List.prototype.updateHtml = function() {
463     // Control children appearing or not appearing.
464     if (this.children.length > 0) {
465         this.inner.one('> .availability-children').removeAttribute('aria-hidden');
466         this.inner.one('> .availability-none').setAttribute('aria-hidden', 'true');
467         this.inner.one('> .availability-header').removeAttribute('aria-hidden');
468         if (this.children.length > 1) {
469             this.inner.one('.availability-single').setAttribute('aria-hidden', 'true');
470             this.inner.one('.availability-multi').removeAttribute('aria-hidden');
471         } else {
472             this.inner.one('.availability-single').removeAttribute('aria-hidden');
473             this.inner.one('.availability-multi').setAttribute('aria-hidden', 'true');
474         }
475     } else {
476         this.inner.one('> .availability-children').setAttribute('aria-hidden', 'true');
477         this.inner.one('> .availability-none').removeAttribute('aria-hidden');
478         this.inner.one('> .availability-header').setAttribute('aria-hidden', 'true');
479     }
481     // For root list, control eye icons.
482     if (this.root) {
483         var showEyes = this.isIndividualShowIcons();
485         // Individual icons.
486         for (var i = 0; i < this.children.length; i++) {
487             var child = this.children[i];
488             if (showEyes) {
489                 child.eyeIcon.span.removeAttribute('aria-hidden');
490             } else {
491                 child.eyeIcon.span.setAttribute('aria-hidden', 'true');
492             }
493         }
495         // Single icon is the inverse.
496         if (showEyes) {
497             this.eyeIcon.span.setAttribute('aria-hidden', 'true');
498         } else {
499             this.eyeIcon.span.removeAttribute('aria-hidden');
500         }
501     }
503     // Update connector text.
504     var connectorText;
505     if (this.inner.one('.availability-op').get('value') === '&') {
506         connectorText = M.str.availability.and;
507     } else {
508         connectorText = M.str.availability.or;
509     }
510     this.inner.all('.availability-connector span.label').each(function(span) {
511         span.set('innerHTML', connectorText);
512     });
513 };
515 /**
516  * Deletes a descendant item (Item or List). Called when the user clicks a
517  * delete icon.
518  *
519  * This is a recursive function.
520  *
521  * @method deleteDescendant
522  * @param {M.core_availability.Item|M.core_availability.List} descendant Item to delete
523  * @return {Boolean} True if it was deleted
524  */
525 M.core_availability.List.prototype.deleteDescendant = function(descendant) {
526     // Loop through children.
527     for (var i = 0; i < this.children.length; i++) {
528         var child = this.children[i];
529         if (child === descendant) {
530             // Remove from internal array.
531             this.children.splice(i, 1);
532             var target = child.node;
533             // Remove one of the connector nodes around target (if any left).
534             if (this.children.length > 0) {
535                 if (target.previous('.availability-connector')) {
536                     target.previous('.availability-connector').remove();
537                 } else {
538                     target.next('.availability-connector').remove();
539                 }
540             }
541             // Remove target itself.
542             this.inner.one('> .availability-children').removeChild(target);
543             // Update the form and the list HTML.
544             M.core_availability.form.update();
545             this.updateHtml();
546             // Focus add button for this list.
547             this.inner.one('> .availability-button').one('button').focus();
548             return true;
549         } else if (child instanceof M.core_availability.List) {
550             // Recursive call.
551             var found = child.deleteDescendant(descendant);
552             if (found) {
553                 return true;
554             }
555         }
556     }
558     return false;
559 };
561 /**
562  * Shows the 'add restriction' dialogue box.
563  *
564  * @method clickAdd
565  */
566 M.core_availability.List.prototype.clickAdd = function() {
567     var content = Y.Node.create('<div>' +
568             '<ul class="list-unstyled"></ul>' +
569             '<div class="availability-buttons">' +
570             '<button type="button" class="btn btn-default">' + M.str.moodle.cancel +
571             '</button></div></div>');
572     var cancel = content.one('button');
574     // Make a list of all the dialog options.
575     var dialogRef = { dialog: null };
576     var ul = content.one('ul');
577     var li, id, button, label;
578     for (var type in M.core_availability.form.plugins) {
579         // Plugins might decide not to display their add button.
580         if (!M.core_availability.form.plugins[type].allowAdd) {
581             continue;
582         }
583         // Add entry for plugin.
584         li = Y.Node.create('<li class="clearfix"></li>');
585         id = 'availability_addrestriction_' + type;
586         var pluginStrings = M.str['availability_' + type];
587         button = Y.Node.create('<button type="button" class="btn btn-default"' +
588                 'id="' + id + '">' + pluginStrings.title + '</button>');
589         button.on('click', this.getAddHandler(type, dialogRef), this);
590         li.appendChild(button);
591         label = Y.Node.create('<label for="' + id + '">' +
592                 pluginStrings.description + '</label>');
593         li.appendChild(label);
594         ul.appendChild(li);
595     }
596     // Extra entry for lists.
597     li = Y.Node.create('<li class="clearfix"></li>');
598     id = 'availability_addrestriction_list_';
599     button = Y.Node.create('<button type="button" class="btn btn-default"' +
600             'id="' + id + '">' + M.str.availability.condition_group + '</button>');
601     button.on('click', this.getAddHandler(null, dialogRef), this);
602     li.appendChild(button);
603     label = Y.Node.create('<label for="' + id + '">' +
604             M.str.availability.condition_group_info + '</label>');
605     li.appendChild(label);
606     ul.appendChild(li);
608     var config = {
609         headerContent : M.str.availability.addrestriction,
610         bodyContent : content,
611         additionalBaseClass : 'availability-dialogue',
612         draggable : true,
613         modal : true,
614         closeButton : false,
615         width : '450px'
616     };
617     dialogRef.dialog = new M.core.dialogue(config);
618     dialogRef.dialog.show();
619     cancel.on('click', function() {
620         dialogRef.dialog.destroy();
621         // Focus the button they clicked originally.
622         this.inner.one('> .availability-button').one('button').focus();
623     }, this);
624 };
626 /**
627  * Gets an add handler function used by the dialogue to add a particular item.
628  *
629  * @method getAddHandler
630  * @param {String|Null} type Type name of plugin or null to add lists
631  * @param {Object} dialogRef Reference to object that contains dialog
632  * @param {M.core.dialogue} dialogRef.dialog Dialog object
633  * @return {Function} Add handler function to call when adding that thing
634  */
635 M.core_availability.List.prototype.getAddHandler = function(type, dialogRef) {
636     return function() {
637         if (type) {
638             // Create an Item object to represent the child.
639             newItem = new M.core_availability.Item({ type: type, creating: true }, this.root);
640         } else {
641             // Create a new List object to represent the child.
642             newItem = new M.core_availability.List({ c: [], showc: true }, false, this.root);
643         }
644         // Add to list.
645         this.addChild(newItem);
646         // Update the form and list HTML.
647         M.core_availability.form.update();
648         M.core_availability.form.rootList.renumber();
649         this.updateHtml();
650         // Hide dialog.
651         dialogRef.dialog.destroy();
652         newItem.focusAfterAdd();
653     };
654 };
656 /**
657  * Gets the value of the list ready to convert to JSON and fill form field.
658  *
659  * @method getValue
660  * @return {Object} Value of list suitable for use in JSON
661  */
662 M.core_availability.List.prototype.getValue = function() {
663     // Work out operator from selects.
664     var value = {};
665     value.op = this.node.one('.availability-neg').get('value') +
666             this.node.one('.availability-op').get('value');
668     // Work out children from list.
669     value.c = [];
670     var i;
671     for (i = 0; i < this.children.length; i++) {
672         value.c.push(this.children[i].getValue());
673     }
675     // Work out show/showc for root level.
676     if (this.root) {
677         if (this.isIndividualShowIcons()) {
678             value.showc = [];
679             for (i = 0; i < this.children.length; i++) {
680                 value.showc.push(!this.children[i].eyeIcon.isHidden());
681             }
682         } else {
683             value.show = !this.eyeIcon.isHidden();
684         }
685     }
686     return value;
687 };
689 /**
690  * Checks whether this list has any errors (incorrect user input). If so,
691  * an error string identifier in the form langfile:langstring should be pushed
692  * into the errors array.
693  *
694  * @method fillErrors
695  * @param {Array} errors Array of errors so far
696  */
697 M.core_availability.List.prototype.fillErrors = function(errors) {
698     // List with no items is an error (except root).
699     if (this.children.length === 0 && !this.root) {
700         errors.push('availability:error_list_nochildren');
701     }
702     // Pass to children.
703     for (var i = 0; i < this.children.length; i++) {
704         this.children[i].fillErrors(errors);
705     }
706 };
708 /**
709  * Eye icon for this list (null if none).
710  *
711  * @property eyeIcon
712  * @type M.core_availability.EyeIcon
713  */
714 M.core_availability.List.prototype.eyeIcon = null;
716 /**
717  * True if list is special root level list.
718  *
719  * @property root
720  * @type Boolean
721  */
722 M.core_availability.List.prototype.root = false;
724 /**
725  * Array containing children (Lists or Items).
726  *
727  * @property children
728  * @type M.core_availability.List[]|M.core_availability.Item[]
729  */
730 M.core_availability.List.prototype.children = null;
732 /**
733  * HTML outer node for list.
734  *
735  * @property node
736  * @type Y.Node
737  */
738 M.core_availability.List.prototype.node = null;
740 /**
741  * HTML node for inner div that actually is the displayed list.
742  *
743  * @property node
744  * @type Y.Node
745  */
746 M.core_availability.List.prototype.inner = null;
749 /**
750  * Represents a single condition.
751  *
752  * @class M.core_availability.Item
753  * @constructor
754  * @param {Object} json Decoded JSON value
755  * @param {Boolean} root True if this item is a child of the root list.
756  */
757 M.core_availability.Item = function(json, root) {
758     this.pluginType = json.type;
759     if (M.core_availability.form.plugins[json.type] === undefined) {
760         // Handle undefined plugins.
761         this.plugin = null;
762         this.pluginNode = Y.Node.create('<div class="availability-warning">' +
763                 M.str.availability.missingplugin + '</div>');
764     } else {
765         // Plugin is known.
766         this.plugin = M.core_availability.form.plugins[json.type];
767         this.pluginNode = this.plugin.getNode(json);
769         // Add a class with the plugin Frankenstyle name to make CSS easier in plugin.
770         this.pluginNode.addClass('availability_' + json.type);
771     }
773     this.node = Y.Node.create('<div class="availability-item"><h3 class="accesshide"></h3></div>');
775     // Add eye icon if required. This icon is added for root items, but may be
776     // hidden depending on the selected list operator.
777     if (root) {
778         var shown = true;
779         if(json.showc !== undefined) {
780             shown = json.showc;
781         }
782         this.eyeIcon = new M.core_availability.EyeIcon(true, shown);
783         this.node.appendChild(this.eyeIcon.span);
784     }
786     // Add plugin controls.
787     this.pluginNode.addClass('availability-plugincontrols');
788     this.node.appendChild(this.pluginNode);
790     // Add delete button for node.
791     var deleteIcon = new M.core_availability.DeleteIcon(this);
792     this.node.appendChild(deleteIcon.span);
794     // Add the invalid marker (empty).
795     this.node.appendChild(document.createTextNode(' '));
796     this.node.appendChild(Y.Node.create('<span class="label label-warning"/>'));
797 };
799 /**
800  * Obtains the value of this condition, which will be serialized into JSON
801  * format and stored in the form.
802  *
803  * @method getValue
804  * @return {Object} JavaScript object containing value of this item
805  */
806 M.core_availability.Item.prototype.getValue = function() {
807     value = { 'type' : this.pluginType };
808     if (this.plugin) {
809         this.plugin.fillValue(value, this.pluginNode);
810     }
811     return value;
812 };
814 /**
815  * Checks whether this condition has any errors (incorrect user input). If so,
816  * an error string identifier in the form langfile:langstring should be pushed
817  * into the errors array.
818  *
819  * @method fillErrors
820  * @param {Array} errors Array of errors so far
821  */
822 M.core_availability.Item.prototype.fillErrors = function(errors) {
823     var before = errors.length;
824     if (this.plugin) {
825         // Pass to plugin.
826         this.plugin.fillErrors(errors, this.pluginNode);
827     } else {
828         // Unknown plugin is an error
829         errors.push('core_availability:item_unknowntype');
830     }
831     // If any errors were added, add the marker to this item.
832     var errorLabel = this.node.one('> .label-warning');
833     if (errors.length !== before && !errorLabel.get('firstChild')) {
834         errorLabel.appendChild(document.createTextNode(M.str.availability.invalid));
835     } else if (errors.length === before && errorLabel.get('firstChild')) {
836         errorLabel.get('firstChild').remove();
837     }
838 };
840 /**
841  * Renumbers the item.
842  *
843  * @method renumber
844  * @param {String} number Number to use in heading for this item
845  */
846 M.core_availability.Item.prototype.renumber = function(number) {
847     // Update heading for item.
848     var headingParams = { number: number };
849     if (this.plugin) {
850         headingParams.type = M.str['availability_' + this.pluginType].title;
851     } else {
852         headingParams.type = '[' + this.pluginType + ']';
853     }
854     headingParams.number = number + ':';
855     var heading = M.util.get_string('itemheading', 'availability', headingParams);
856     this.node.one('> h3').set('innerHTML', heading);
857 };
859 /**
860  * Focuses something after a new item is added.
861  *
862  * @method focusAfterAdd
863  */
864 M.core_availability.Item.prototype.focusAfterAdd = function() {
865     this.plugin.focusAfterAdd(this.pluginNode);
866 };
868 /**
869  * Name of plugin.
870  *
871  * @property pluginType
872  * @type String
873  */
874 M.core_availability.Item.prototype.pluginType = null;
876 /**
877  * Object representing plugin form controls.
878  *
879  * @property plugin
880  * @type Object
881  */
882 M.core_availability.Item.prototype.plugin = null;
884 /**
885  * Eye icon for item.
886  *
887  * @property eyeIcon
888  * @type M.core_availability.EyeIcon
889  */
890 M.core_availability.Item.prototype.eyeIcon = null;
892 /**
893  * HTML node for item.
894  *
895  * @property node
896  * @type Y.Node
897  */
898 M.core_availability.Item.prototype.node = null;
900 /**
901  * Inner part of node that is owned by plugin.
902  *
903  * @property pluginNode
904  * @type Y.Node
905  */
906 M.core_availability.Item.prototype.pluginNode = null;
909 /**
910  * Eye icon (to control show/hide of the activity if the user fails a condition).
911  *
912  * There are individual eye icons (show/hide control for a single condition) and
913  * 'all' eye icons (show/hide control that applies to the entire item, whatever
914  * reason it fails for). This is necessary because the individual conditions
915  * don't make sense for OR and AND NOT lists.
916  *
917  * @class M.core_availability.EyeIcon
918  * @constructor
919  * @param {Boolean} individual True if the icon is controlling a single condition
920  * @param {Boolean} shown True if icon is initially in shown state
921  */
922 M.core_availability.EyeIcon = function(individual, shown) {
923     this.individual = individual;
924     this.span = Y.Node.create('<span class="availability-eye">');
925     var iconBase = M.cfg.wwwroot + '/theme/image.php/' + M.cfg.theme + '/core/' + M.cfg.themerev;
926     var hideButton = Y.Node.create('<img tabindex="0" role="button"/>');
928     // Set up button text and icon.
929     var suffix = individual ? '_individual' : '_all';
930     var setHidden = function() {
931         hideButton.set('src', iconBase + '/t/show');
932         hideButton.set('alt', M.str.availability['hidden' + suffix]);
933         hideButton.set('title', M.str.availability['hidden' + suffix] + ' \u2022 ' +
934                 M.str.availability.show_verb);
935     };
936     var setShown = function() {
937         hideButton.set('src', iconBase + '/t/hide');
938         hideButton.set('alt', M.str.availability['shown' + suffix]);
939         hideButton.set('title', M.str.availability['shown' + suffix] + ' \u2022 ' +
940                 M.str.availability.hide_verb);
941     };
942     if(shown) {
943         setShown();
944     } else {
945         setHidden();
946     }
948     // Update when button is clicked.
949     this.span.appendChild(hideButton);
950     var click = function() {
951         if (this.isHidden()) {
952             setShown();
953         } else {
954             setHidden();
955         }
956         M.core_availability.form.update();
957     };
958     hideButton.on('click', click, this);
959     hideButton.on('key', click, 'up:enter', this);
960 };
962 /**
963  * True if this eye icon is an individual one (see above).
964  *
965  * @property individual
966  * @type Boolean
967  */
968 M.core_availability.EyeIcon.prototype.individual = false;
970 /**
971  * YUI node for the span that contains this icon.
972  *
973  * @property span
974  * @type Y.Node
975  */
976 M.core_availability.EyeIcon.prototype.span = null;
978 /**
979  * Checks the current state of the icon.
980  *
981  * @method isHidden
982  * @return {Boolean} True if this icon is set to 'hidden'
983  */
984 M.core_availability.EyeIcon.prototype.isHidden = function() {
985     var suffix = this.individual ? '_individual' : '_all';
986     var compare = M.str.availability['hidden' + suffix];
987     return this.span.one('img').get('alt') === compare;
988 };
991 /**
992  * Delete icon (to delete an Item or List).
993  *
994  * @class M.core_availability.DeleteIcon
995  * @constructor
996  * @param {M.core_availability.Item|M.core_availability.List} toDelete Thing to delete
997  */
998 M.core_availability.DeleteIcon = function(toDelete) {
999     this.span = Y.Node.create('<span class="availability-delete">');
1000     var deleteButton = Y.Node.create('<img src="' +
1001             M.cfg.wwwroot + '/theme/image.php/' + M.cfg.theme + '/core/' + M.cfg.themerev +
1002             '/t/delete" alt="' +
1003             M.str.moodle['delete'] + '" title="' + M.str.moodle['delete'] +
1004             '" tabindex="0" role="button"/>');
1005     this.span.appendChild(deleteButton);
1006     var click = function() {
1007         M.core_availability.form.rootList.deleteDescendant(toDelete);
1008         M.core_availability.form.rootList.renumber();
1009     };
1010     deleteButton.on('click', click, this);
1011     deleteButton.on('key', click, 'up:enter', this);
1012 };
1014 /**
1015  * YUI node for the span that contains this icon.
1016  *
1017  * @property span
1018  * @type Y.Node
1019  */
1020 M.core_availability.DeleteIcon.prototype.span = null;