Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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 || {};
19
 
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: {},
34
 
35
    /**
36
     * Availability field (textarea).
37
     *
38
     * @property field
39
     * @type Y.Node
40
     */
41
    field: null,
42
 
43
    /**
44
     * Main div that replaces the availability field.
45
     *
46
     * @property mainDiv
47
     * @type Y.Node
48
     */
49
    mainDiv: null,
50
 
51
    /**
52
     * Object that represents the root of the tree.
53
     *
54
     * @property rootList
55
     * @type M.core_availability.List
56
     */
57
    rootList: null,
58
 
59
    /**
60
     * Counter used when creating anything that needs an id.
61
     *
62
     * @property idCounter
63
     * @type Number
64
     */
65
    idCounter: 0,
66
 
67
    /**
68
     * The 'Restrict by group' button if present.
69
     *
70
     * @property restrictByGroup
71
     * @type Y.Node
72
     */
73
    restrictByGroup: null,
74
 
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
        }
88
 
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');
102
 
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);
116
 
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();
121
 
122
        // Mark main area as dynamically updated.
123
        this.mainDiv.setAttribute('aria-live', 'polite');
124
 
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);
130
 
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
 
147
        // Everything is ready. Make sure the div is visible and hide the loading indicator.
148
        this.parent = Y.one('#fitem_id_availabilityconditionsjson');
149
        this.parent.removeClass('d-none');
150
        document.getElementById('availabilityconditions-loading').remove();
151
    },
152
 
153
    /**
154
     * Called at any time to update the hidden field value.
155
     *
156
     * This should be called whenever any value changes in the form settings.
157
     *
158
     * @method update
159
     */
160
    update: function() {
161
        // Convert tree to value.
162
        var jsValue = this.rootList.getValue();
163
 
164
        // Store any errors (for form reporting) in 'errors' value if present.
165
        var errors = [];
166
        this.rootList.fillErrors(errors);
167
        if (errors.length !== 0) {
168
            jsValue.errors = errors;
169
        }
170
 
171
        // Set into hidden form field, JS-encoded.
172
        this.field.set('value', Y.JSON.stringify(jsValue));
173
 
174
        // Also update the restrict by group button if present.
175
        this.updateRestrictByGroup();
176
    },
177
 
178
    /**
179
     * Updates the status of the 'restrict by group' button (enables or disables
180
     * it) based on current availability restrictions and group/grouping settings.
181
     */
182
    updateRestrictByGroup: function() {
183
        if (!this.restrictByGroup) {
184
            return;
185
        }
186
 
187
        // If the root list is anything other than the default 'and' type, disable.
188
        if (this.rootList.getValue().op !== '&') {
189
            this.restrictByGroup.set('disabled', true);
190
            return;
191
        }
192
 
193
        // If there's already a group restriction, disable it.
194
        var alreadyGot = this.rootList.hasItemOfType('group') ||
195
                this.rootList.hasItemOfType('grouping');
196
        if (alreadyGot) {
197
            this.restrictByGroup.set('disabled', true);
198
            return;
199
        }
200
 
201
        // If the groupmode and grouping id aren't set, disable it.
202
        var groupmode = Y.one('#id_groupmode');
203
        var groupingid = Y.one('#id_groupingid');
204
        var groupavailability = Number(this.restrictByGroup.getData('groupavailability')) === 1;
205
        var groupingavailability = Number(this.restrictByGroup.getData('groupingavailability')) === 1;
206
 
207
        if ((!groupmode || Number(groupmode.get('value')) === 0 || !groupavailability) &&
208
                (!groupingid || Number(groupingid.get('value')) === 0 || !groupingavailability)) {
209
            this.restrictByGroup.set('disabled', true);
210
            return;
211
        }
212
 
213
        this.restrictByGroup.set('disabled', false);
214
    },
215
 
216
    /**
217
     * Called when the user clicks on the 'restrict by group' button. This is
218
     * a special case that adds a group or grouping restriction.
219
     *
220
     * By default this restriction is not shown which makes it similar to the
221
     *
222
     * @param e Button click event
223
     */
224
    addRestrictByGroup: function(e) {
225
        // If you don't prevent default, it submits the form for some reason.
226
        e.preventDefault();
227
 
228
        // Add the condition.
229
        var groupmode = Y.one('#id_groupmode');
230
        var groupingid = Y.one('#id_groupingid');
231
        var groupavailability = Number(this.restrictByGroup.getData('groupavailability')) === 1;
232
        var groupingavailability = Number(this.restrictByGroup.getData('groupingavailability')) === 1;
233
 
234
        var newChild;
235
        if (groupingid && Number(groupingid.get('value')) !== 0 && groupingavailability) {
236
            // Add a grouping restriction if one is specified.
237
            newChild = new M.core_availability.Item(
238
                    {type: 'grouping', id: Number(groupingid.get('value'))}, true);
239
        } else if (groupmode && groupavailability) {
240
            // Otherwise just add a group restriction.
241
            newChild = new M.core_availability.Item({type: 'group'}, true);
242
        }
243
 
244
        // Refresh HTML.
245
        if (newChild !== null) {
246
            this.rootList.addChild(newChild);
247
            this.update();
248
            this.rootList.renumber();
249
            this.rootList.updateHtml();
250
        }
251
    }
252
};
253
 
254
 
255
/**
256
 * Base object for plugins. Plugins should use Y.Object to extend this class.
257
 *
258
 * @class M.core_availability.plugin
259
 * @static
260
 */
261
M.core_availability.plugin = {
262
    /**
263
     * True if users are allowed to add items of this plugin at the moment.
264
     *
265
     * @property allowAdd
266
     * @type Boolean
267
     */
268
    allowAdd: false,
269
 
270
    /**
271
     * Called (from PHP) to initialise the plugin. Should usually not be
272
     * overridden by child plugin.
273
     *
274
     * @method init
1441 ariadna 275
     * @param {String} component Component name e.g. 'availability_date'.
276
     * @param {boolean} allowAdd Indicates whether adding new instances of the plugin is permitted.
277
     * @param {Object} params Additional parameters.
278
     * @param {boolean} displayMode Whether the eye icon is show or hide. True for "Hide", false for "Show".
1 efrain 279
     */
1441 ariadna 280
    init: function(component, allowAdd, params, displayMode) {
1 efrain 281
        var name = component.replace(/^availability_/, '');
282
        this.allowAdd = allowAdd;
283
        M.core_availability.form.plugins[name] = this;
284
        this.initInner.apply(this, params);
1441 ariadna 285
        this.displayMode = displayMode;
1 efrain 286
    },
287
 
288
    /**
289
     * Init method for plugin to override. (Default does nothing.)
290
     *
291
     * This method will receive any parameters defined in frontend.php
292
     * get_javascript_init_params.
293
     *
294
     * @method initInner
295
     * @protected
296
     */
297
    initInner: function() {
298
        // Can be overriden.
299
    },
300
 
301
    /**
302
     * Gets a YUI node representing the controls for this plugin on the form.
303
     *
304
     * Must be implemented by sub-object; default throws an exception.
305
     *
306
     * @method getNode
307
     * @return {Y.Node} YUI node
308
     */
309
    getNode: function() {
310
        throw 'getNode not implemented';
311
    },
312
 
313
    /**
314
     * Fills in the value from this plugin's controls into a value object,
315
     * which will later be converted to JSON and stored in the form field.
316
     *
317
     * Must be implemented by sub-object; default throws an exception.
318
     *
319
     * @method fillValue
320
     * @param {Object} value Value object (to be written to)
321
     * @param {Y.Node} node YUI node (same one returned from getNode)
322
     */
323
    fillValue: function() {
324
        throw 'fillValue not implemented';
325
    },
326
 
327
    /**
328
     * Fills in any errors from this plugin's controls. If there are any
329
     * errors, push them into the supplied array.
330
     *
331
     * Errors are Moodle language strings in format component:string, e.g.
332
     * 'availability_date:error_date_past_end_of_world'.
333
     *
334
     * The default implementation does nothing.
335
     *
336
     * @method fillErrors
337
     * @param {Array} errors Array of errors (push new errors here)
338
     * @param {Y.Node} node YUI node (same one returned from getNode)
339
     */
340
    fillErrors: function() {
341
        // Can be overriden.
342
    },
343
 
344
    /**
345
     * Focuses the first thing in the plugin after it has been added.
346
     *
347
     * The default implementation uses a simple algorithm to identify the
348
     * first focusable input/select and then focuses it.
349
     */
350
    focusAfterAdd: function(node) {
351
        var target = node.one('input:not([disabled]),select:not([disabled])');
352
        target.focus();
353
    }
354
};
355
 
356
 
357
/**
358
 * Maintains a list of children and settings for how they are combined.
359
 *
360
 * @class M.core_availability.List
361
 * @constructor
362
 * @param {Object} json Decoded JSON value
363
 * @param {Boolean} [false] root True if this is root level list
364
 * @param {Boolean} [false] root True if parent is root level list
365
 */
366
M.core_availability.List = function(json, root, parentRoot) {
367
    // Set default value for children. (You can't do this in the prototype
368
    // definition, or it ends up sharing the same array between all of them.)
369
    this.children = [];
370
 
371
    if (root !== undefined) {
372
        this.root = root;
373
    }
374
    // Create DIV structure (without kids).
375
    this.node = Y.Node.create('<div class="availability-list"><h3 class="accesshide"></h3>' +
376
            '<div class="availability-inner">' +
377
            '<div class="availability-header mb-1"><span>' +
378
            M.util.get_string('listheader_sign_before', 'availability') + '</span>' +
379
            ' <label><span class="accesshide">' + M.util.get_string('label_sign', 'availability') +
1441 ariadna 380
            ' </span><select class="availability-neg form-select mx-1"' +
1 efrain 381
            ' title="' + M.util.get_string('label_sign', 'availability') + '">' +
382
            '<option value="">' + M.util.get_string('listheader_sign_pos', 'availability') + '</option>' +
383
            '<option value="!">' + M.util.get_string('listheader_sign_neg', 'availability') + '</option></select></label> ' +
384
            '<span class="availability-single">' + M.util.get_string('listheader_single', 'availability') + '</span>' +
385
            '<span class="availability-multi">' + M.util.get_string('listheader_multi_before', 'availability') +
386
            ' <label><span class="accesshide">' + M.util.get_string('label_multi', 'availability') + ' </span>' +
1441 ariadna 387
            '<select class="availability-op form-select mx-1"' +
1 efrain 388
            ' title="' + M.util.get_string('label_multi', 'availability') + '"><option value="&">' +
389
            M.util.get_string('listheader_multi_and', 'availability') + '</option>' +
390
            '<option value="|">' + M.util.get_string('listheader_multi_or', 'availability') + '</option></select></label> ' +
391
            M.util.get_string('listheader_multi_after', 'availability') + '</span></div>' +
392
            '<div class="availability-children"></div>' +
393
            '<div class="availability-none"><span class="px-3">' + M.util.get_string('none', 'moodle') + '</span></div>' +
394
            '<div class="clearfix mt-1"></div>' +
395
            '<div class="availability-button"></div></div><div class="clearfix"></div></div>');
396
    if (!root) {
397
        this.node.addClass('availability-childlist d-sm-flex align-items-center');
398
    }
399
    this.inner = this.node.one('> .availability-inner');
400
 
401
    var shown = true;
402
    if (root) {
403
        // If it's the root, add an eye icon as first thing in header.
404
        if (json && json.show !== undefined) {
405
            shown = json.show;
406
        }
407
        this.eyeIcon = new M.core_availability.EyeIcon(false, shown);
408
        this.node.one('.availability-header').get('firstChild').insert(
409
                this.eyeIcon.span, 'before');
410
        this.node.one('.availability-header').get('firstChild').insert(
411
            this.eyeIcon.disabledSpan, 'before');
412
 
413
        this.on('availability:privateRuleSet', function(e) {
414
            e.target.getDOMNode().dataset.private = true;
415
            this.updatePrivateStatus();
416
        });
417
        this.on('availability:privateRuleUnset', function(e) {
418
            delete e.target.getDOMNode().dataset.private;
419
            this.updatePrivateStatus();
420
        });
421
    } else if (parentRoot) {
422
        // When the parent is root, add an eye icon before the main list div.
423
        if (json && json.showc !== undefined) {
424
            shown = json.showc;
425
        }
426
        this.eyeIcon = new M.core_availability.EyeIcon(false, shown);
427
        this.inner.insert(this.eyeIcon.span, 'before');
428
        this.inner.insert(this.eyeIcon.disabledSpan, 'before');
429
    }
430
 
431
    if (!root) {
432
        // If it's not the root, add a delete button to the 'none' option.
433
        // You can only delete lists when they have no children so this will
434
        // automatically appear at the correct time.
435
        var deleteIcon = new M.core_availability.DeleteIcon(this);
436
        var noneNode = this.node.one('.availability-none');
437
        noneNode.appendChild(document.createTextNode(' '));
438
        noneNode.appendChild(deleteIcon.span);
439
 
440
        // Also if it's not the root, none is actually invalid, so add a label.
441
        noneNode.appendChild(Y.Node.create('<span class="mt-1 badge bg-warning text-dark">' +
442
                M.util.get_string('invalid', 'availability') + '</span>'));
443
    }
444
 
445
    // Create the button and add it.
446
    var button = Y.Node.create('<button type="button" class="btn btn-secondary mt-1">' +
447
            M.util.get_string('addrestriction', 'availability') + '</button>');
448
    button.on("click", function() {
449
        this.clickAdd();
450
    }, this);
451
    this.node.one('div.availability-button').appendChild(button);
452
 
453
    if (json) {
454
        // Set operator from JSON data.
455
        switch (json.op) {
456
            case '&' :
457
            case '|' :
458
                this.node.one('.availability-neg').set('value', '');
459
                break;
460
            case '!&' :
461
            case '!|' :
462
                this.node.one('.availability-neg').set('value', '!');
463
                break;
464
        }
465
        switch (json.op) {
466
            case '&' :
467
            case '!&' :
468
                this.node.one('.availability-op').set('value', '&');
469
                break;
470
            case '|' :
471
            case '!|' :
472
                this.node.one('.availability-op').set('value', '|');
473
                break;
474
        }
475
 
476
        // Construct children.
477
        for (var i = 0; i < json.c.length; i++) {
478
            var child = json.c[i];
479
            if (this.root && json && json.showc !== undefined) {
480
                child.showc = json.showc[i];
481
            }
482
            var newItem;
483
            if (child.type !== undefined) {
484
                // Plugin type.
485
                newItem = new M.core_availability.Item(child, this.root);
486
            } else {
487
                // List type.
488
                newItem = new M.core_availability.List(child, false, this.root);
489
            }
490
            this.addChild(newItem);
491
        }
492
    }
493
 
494
    // Add update listeners to the dropdowns.
495
    this.node.one('.availability-neg').on('change', function() {
496
        // Update hidden field and HTML.
497
        M.util.js_pending('availability-neg-change');
498
        M.core_availability.form.update();
499
        this.updateHtml();
500
        M.util.js_complete('availability-neg-change');
501
    }, this);
502
    this.node.one('.availability-op').on('change', function() {
503
        // Update hidden field.
504
        M.util.js_pending('availability-op-change');
505
        M.core_availability.form.update();
506
        this.updateHtml();
507
        M.util.js_complete('availability-op-change');
508
    }, this);
509
 
510
    // Update HTML to hide unnecessary parts.
511
    this.updateHtml();
512
};
513
Y.augment(M.core_availability.List, Y.EventTarget, true, null, {emitFacade: true});
514
 
515
/**
516
 * Adds a child to the end of the list (in HTML and stored data).
517
 *
518
 * @method addChild
519
 * @private
520
 * @param {M.core_availability.Item|M.core_availability.List} newItem Child to add
521
 */
522
M.core_availability.List.prototype.addChild = function(newItem) {
523
    if (this.children.length > 0) {
524
        // Create connecting label (text will be filled in later by updateHtml).
525
        this.inner.one('.availability-children').appendChild(Y.Node.create(
526
                '<div class="availability-connector">' +
527
                '<span class="label"></span>' +
528
                '</div>'));
529
    }
530
    // Add item to array and to HTML.
531
    this.children.push(newItem);
532
    // Allow events from child Items and Lists to bubble up to this list.
533
    newItem.addTarget(this);
534
    this.inner.one('.availability-children').appendChild(newItem.node);
535
};
536
 
537
/**
538
 * Focuses something after a new list is added.
539
 *
540
 * @method focusAfterAdd
541
 */
542
M.core_availability.List.prototype.focusAfterAdd = function() {
543
    this.inner.one('button').focus();
544
};
545
 
546
/**
547
 * Checks whether this list uses the individual show icons or the single one.
548
 *
549
 * (Basically, AND and the equivalent NOT OR list can have individual show icons
550
 * so that you hide the activity entirely if a user fails one condition, but
551
 * may display it with information about the condition if they fail a different
552
 * one. That isn't possible with OR and NOT AND because for those types, there
553
 * is not really a concept of which single condition caused the user to fail
554
 * it.)
555
 *
556
 * Method can only be called on the root list.
557
 *
558
 * @method isIndividualShowIcons
559
 * @return {Boolean} True if using the individual icons
560
 */
561
M.core_availability.List.prototype.isIndividualShowIcons = function() {
562
    if (!this.root) {
563
        throw 'Can only call this on root list';
564
    }
565
    var neg = this.node.one('.availability-neg').get('value') === '!';
566
    var isor = this.node.one('.availability-op').get('value') === '|';
567
    return (!neg && !isor) || (neg && isor);
568
};
569
 
570
/**
571
 * Renumbers the list and all children.
572
 *
573
 * @method renumber
574
 * @param {String} parentNumber Number to use in heading for this list
575
 */
576
M.core_availability.List.prototype.renumber = function(parentNumber) {
577
    // Update heading for list.
578
    var headingParams = {count: this.children.length};
579
    var prefix;
580
    if (parentNumber === undefined) {
581
        headingParams.number = '';
582
        prefix = '';
583
    } else {
584
        headingParams.number = parentNumber + ':';
585
        prefix = parentNumber + '.';
586
    }
587
    var heading = M.util.get_string('setheading', 'availability', headingParams);
588
    this.node.one('> h3').set('innerHTML', heading);
589
    this.node.one('> h3').getDOMNode().dataset.restrictionOrder = parentNumber ? parentNumber : 'root';
590
    // Do children.
591
    for (var i = 0; i < this.children.length; i++) {
592
        var child = this.children[i];
593
        child.renumber(prefix + (i + 1));
594
    }
595
};
596
 
597
/**
598
 * Updates HTML for the list based on the current values, for example showing
599
 * the 'None' text if there are no children.
600
 *
601
 * @method updateHtml
602
 */
603
M.core_availability.List.prototype.updateHtml = function() {
604
    // Control children appearing or not appearing.
605
    if (this.children.length > 0) {
606
        this.inner.one('> .availability-children').removeAttribute('aria-hidden');
607
        this.inner.one('> .availability-none').setAttribute('aria-hidden', 'true');
608
        this.inner.one('> .availability-header').removeAttribute('aria-hidden');
609
        if (this.children.length > 1) {
610
            this.inner.one('.availability-single').setAttribute('aria-hidden', 'true');
611
            this.inner.one('.availability-multi').removeAttribute('aria-hidden');
612
        } else {
613
            this.inner.one('.availability-single').removeAttribute('aria-hidden');
614
            this.inner.one('.availability-multi').setAttribute('aria-hidden', 'true');
615
        }
616
    } else {
617
        this.inner.one('> .availability-children').setAttribute('aria-hidden', 'true');
618
        this.inner.one('> .availability-none').removeAttribute('aria-hidden');
619
        this.inner.one('> .availability-header').setAttribute('aria-hidden', 'true');
620
    }
621
 
622
    // For root list, control eye icons.
623
    if (this.root) {
624
        var showEyes = this.isIndividualShowIcons();
625
 
626
        // Individual icons.
627
        for (var i = 0; i < this.children.length; i++) {
628
            var child = this.children[i];
629
            if (showEyes) {
630
                child.eyeIcon.span.removeAttribute('aria-hidden');
631
                child.eyeIcon.disabledSpan.removeAttribute('aria-hidden');
632
            } else {
633
                child.eyeIcon.span.setAttribute('aria-hidden', 'true');
634
                child.eyeIcon.disabledSpan.setAttribute('aria-hidden', 'true');
635
            }
636
        }
637
 
638
        // Single icon is the inverse.
639
        if (showEyes) {
640
            this.eyeIcon.span.setAttribute('aria-hidden', 'true');
641
            this.eyeIcon.disabledSpan.setAttribute('aria-hidden', 'true');
642
        } else {
643
            this.eyeIcon.span.removeAttribute('aria-hidden');
644
            this.eyeIcon.disabledSpan.removeAttribute('aria-hidden');
645
        }
646
        this.updatePrivateStatus();
647
    }
648
 
649
    // Update connector text.
650
    var connectorText;
651
    if (this.inner.one('.availability-op').get('value') === '&') {
652
        connectorText = M.util.get_string('and', 'availability');
653
    } else {
654
        connectorText = M.util.get_string('or', 'availability');
655
    }
656
    this.inner.all('> .availability-children > .availability-connector span.label').each(function(span) {
657
        span.set('innerHTML', connectorText);
658
    });
659
};
660
 
661
/**
662
 * Deletes a descendant item (Item or List). Called when the user clicks a
663
 * delete icon.
664
 *
665
 * This is a recursive function.
666
 *
667
 * @method deleteDescendant
668
 * @param {M.core_availability.Item|M.core_availability.List} descendant Item to delete
669
 * @return {Boolean} True if it was deleted
670
 */
671
M.core_availability.List.prototype.deleteDescendant = function(descendant) {
672
    // Loop through children.
673
    for (var i = 0; i < this.children.length; i++) {
674
        var child = this.children[i];
675
        if (child === descendant) {
676
            // Remove from internal array.
677
            this.children.splice(i, 1);
678
            var target = child.node;
679
            // Remove one of the connector nodes around target (if any left).
680
            if (this.children.length > 0) {
681
                if (target.previous('.availability-connector')) {
682
                    target.previous('.availability-connector').remove();
683
                } else {
684
                    target.next('.availability-connector').remove();
685
                }
686
            }
687
            // Remove target itself.
688
            this.inner.one('> .availability-children').removeChild(target);
689
            // Update the form and the list HTML.
690
            M.core_availability.form.update();
691
            this.updateHtml();
692
            // Focus add button for this list.
693
            this.inner.one('> .availability-button').one('button').focus();
694
            return true;
695
        } else if (child instanceof M.core_availability.List) {
696
            // Recursive call.
697
            var found = child.deleteDescendant(descendant);
698
            if (found) {
699
                return true;
700
            }
701
        }
702
    }
703
 
704
    return false;
705
};
706
 
707
/**
708
 * Shows the 'add restriction' dialogue box.
709
 *
710
 * @method clickAdd
711
 */
712
M.core_availability.List.prototype.clickAdd = function() {
713
    var content = Y.Node.create('<div>' +
714
            '<ul class="list-unstyled container-fluid"></ul>' +
715
            '<div class="availability-buttons mdl-align">' +
716
            '<button type="button" class="btn btn-secondary">' + M.util.get_string('cancel', 'moodle') +
717
            '</button></div></div>');
718
    var cancel = content.one('button');
719
 
720
    // Make a list of all the dialog options.
721
    var dialogRef = {dialog: null};
722
    var ul = content.one('ul');
723
    var li, id, button, label;
724
    for (var type in M.core_availability.form.plugins) {
725
        // Plugins might decide not to display their add button.
726
        if (!M.core_availability.form.plugins[type].allowAdd) {
727
            continue;
728
        }
729
        // Add entry for plugin.
730
        li = Y.Node.create('<li class="clearfix row"></li>');
731
        id = 'availability_addrestriction_' + type;
732
        button = Y.Node.create('<div class="col-6"><button type="button" class="btn btn-secondary w-100"' +
733
                'id="' + id + '">' + M.util.get_string('title', 'availability_' + type) + '</button></div>');
734
        button.on('click', this.getAddHandler(type, dialogRef), this);
735
        li.appendChild(button);
736
        label = Y.Node.create('<div class="col-6"><label for="' + id + '">' +
737
                M.util.get_string('description', 'availability_' + type) + '</label></div>');
738
        li.appendChild(label);
739
        ul.appendChild(li);
740
    }
741
    // Extra entry for lists.
742
    li = Y.Node.create('<li class="clearfix row"></li>');
743
    id = 'availability_addrestriction_list_';
744
    button = Y.Node.create('<div class="col-6"><button type="button" class="btn btn-secondary w-100"' +
745
            'id="' + id + '">' + M.util.get_string('condition_group', 'availability') + '</button></div>');
746
    button.on('click', this.getAddHandler(null, dialogRef), this);
747
    li.appendChild(button);
748
    label = Y.Node.create('<div class="col-6"><label for="' + id + '">' +
749
            M.util.get_string('condition_group_info', 'availability') + '</label></div>');
750
    li.appendChild(label);
751
    ul.appendChild(li);
752
 
753
    var config = {
754
        headerContent: M.util.get_string('addrestriction', 'availability'),
755
        bodyContent: content,
756
        additionalBaseClass: 'availability-dialogue',
757
        draggable: true,
758
        modal: true,
759
        closeButton: false,
760
        width: '450px'
761
    };
762
    dialogRef.dialog = new M.core.dialogue(config);
763
    dialogRef.dialog.show();
764
    cancel.on('click', function() {
1441 ariadna 765
        dialogRef.dialog.hide();
1 efrain 766
        // Focus the button they clicked originally.
767
        this.inner.one('> .availability-button').one('button').focus();
768
    }, this);
769
};
770
 
771
/**
772
 * Gets an add handler function used by the dialogue to add a particular item.
773
 *
774
 * @method getAddHandler
775
 * @param {String|Null} type Type name of plugin or null to add lists
776
 * @param {Object} dialogRef Reference to object that contains dialog
777
 * @param {M.core.dialogue} dialogRef.dialog Dialog object
778
 * @return {Function} Add handler function to call when adding that thing
779
 */
780
M.core_availability.List.prototype.getAddHandler = function(type, dialogRef) {
781
    return function() {
782
        var newItem;
1441 ariadna 783
        var displayMode = true;
784
        // Check if we have changed the eye icon in the manage restriction to hidden.
785
        if (type && M.core_availability.form.plugins[type].displayMode) {
786
            displayMode = false;
787
        }
1 efrain 788
        if (type) {
789
            // Create an Item object to represent the child.
1441 ariadna 790
            newItem = new M.core_availability.Item({type: type, creating: true, showc: displayMode}, this.root);
1 efrain 791
        } else {
792
            // Create a new List object to represent the child.
1441 ariadna 793
            newItem = new M.core_availability.List({c: [], showc: displayMode}, false, this.root);
1 efrain 794
        }
795
        // Add to list.
796
        this.addChild(newItem);
797
        // Update the form and list HTML.
798
        M.core_availability.form.update();
799
        M.core_availability.form.rootList.renumber();
800
        this.updateHtml();
801
        // Hide dialog.
1441 ariadna 802
        dialogRef.dialog.hide();
1 efrain 803
        newItem.focusAfterAdd();
804
    };
805
};
806
 
807
/**
808
 * Gets the value of the list ready to convert to JSON and fill form field.
809
 *
810
 * @method getValue
811
 * @return {Object} Value of list suitable for use in JSON
812
 */
813
M.core_availability.List.prototype.getValue = function() {
814
    // Work out operator from selects.
815
    var value = {};
816
    value.op = this.node.one('.availability-neg').get('value') +
817
            this.node.one('.availability-op').get('value');
818
 
819
    // Work out children from list.
820
    value.c = [];
821
    var i;
822
    for (i = 0; i < this.children.length; i++) {
823
        value.c.push(this.children[i].getValue());
824
    }
825
 
826
    // Work out show/showc for root level.
827
    if (this.root) {
828
        if (this.isIndividualShowIcons()) {
829
            value.showc = [];
830
            for (i = 0; i < this.children.length; i++) {
831
                var eyeIcon = this.children[i].eyeIcon;
832
                value.showc.push(!eyeIcon.isHidden() && !eyeIcon.isDisabled());
833
            }
834
        } else {
835
            value.show = !this.eyeIcon.isHidden() && !this.eyeIcon.isDisabled();
836
        }
837
    }
838
    return value;
839
};
840
 
841
/**
842
 * Checks whether this list has any errors (incorrect user input). If so,
843
 * an error string identifier in the form langfile:langstring should be pushed
844
 * into the errors array.
845
 *
846
 * @method fillErrors
847
 * @param {Array} errors Array of errors so far
848
 */
849
M.core_availability.List.prototype.fillErrors = function(errors) {
850
    // List with no items is an error (except root).
851
    if (this.children.length === 0 && !this.root) {
852
        errors.push('availability:error_list_nochildren');
853
    }
854
    // Pass to children.
855
    for (var i = 0; i < this.children.length; i++) {
856
        this.children[i].fillErrors(errors);
857
    }
858
};
859
 
860
/**
861
 * Checks whether the list contains any items of the given type name.
862
 *
863
 * @method hasItemOfType
864
 * @param {String} pluginType Required plugin type (name)
865
 * @return {Boolean} True if there is one
866
 */
867
M.core_availability.List.prototype.hasItemOfType = function(pluginType) {
868
    // Check each item.
869
    for (var i = 0; i < this.children.length; i++) {
870
        var child = this.children[i];
871
        if (child instanceof M.core_availability.List) {
872
            // Recursive call.
873
            if (child.hasItemOfType(pluginType)) {
874
                return true;
875
            }
876
        } else {
877
            if (child.pluginType === pluginType) {
878
                return true;
879
            }
880
        }
881
    }
882
    return false;
883
};
884
 
885
M.core_availability.List.prototype.getEyeIcons = function() {
886
    // Check each item.
887
    var eyeIcons = [];
888
    eyeIcons.push(this.eyeIcon);
889
    for (var i = 0; i < this.children.length; i++) {
890
        var child = this.children[i];
891
        if (child.eyeIcon !== null) {
892
            eyeIcons.push(child.eyeIcon);
893
        }
894
        if (child instanceof M.core_availability.List) {
895
            eyeIcons.concat(child.getEyeIcons());
896
        }
897
    }
898
    return eyeIcons;
899
};
900
 
901
/**
902
 * Find all eye icons in the list and children, and disable or enable them if needed.
903
 */
904
M.core_availability.List.prototype.updatePrivateStatus = function() {
905
    if (!this.root) {
906
        throw new Error('Can only call this on root list');
907
    }
908
    var shouldDisable = !this.node.all('[data-private]').isEmpty();
909
    var eyeIcons = this.getEyeIcons();
910
    for (var i = 0, j = eyeIcons.length; i < j; i++) {
911
        if (shouldDisable) {
912
            eyeIcons[i].setDisabled();
913
        } else {
914
            eyeIcons[i].setEnabled();
915
        }
916
    }
917
};
918
 
919
/**
920
 * Eye icon for this list (null if none).
921
 *
922
 * @property eyeIcon
923
 * @type M.core_availability.EyeIcon
924
 */
925
M.core_availability.List.prototype.eyeIcon = null;
926
 
927
/**
928
 * True if list is special root level list.
929
 *
930
 * @property root
931
 * @type Boolean
932
 */
933
M.core_availability.List.prototype.root = false;
934
 
935
/**
936
 * Array containing children (Lists or Items).
937
 *
938
 * @property children
939
 * @type M.core_availability.List[]|M.core_availability.Item[]
940
 */
941
M.core_availability.List.prototype.children = null;
942
 
943
/**
944
 * HTML outer node for list.
945
 *
946
 * @property node
947
 * @type Y.Node
948
 */
949
M.core_availability.List.prototype.node = null;
950
 
951
/**
952
 * HTML node for inner div that actually is the displayed list.
953
 *
954
 * @property node
955
 * @type Y.Node
956
 */
957
M.core_availability.List.prototype.inner = null;
958
 
959
 
960
/**
961
 * Represents a single condition.
962
 *
963
 * @class M.core_availability.Item
964
 * @constructor
965
 * @param {Object} json Decoded JSON value
966
 * @param {Boolean} root True if this item is a child of the root list.
967
 */
968
M.core_availability.Item = function(json, root) {
969
    this.pluginType = json.type;
970
    if (M.core_availability.form.plugins[json.type] === undefined) {
971
        // Handle undefined plugins.
972
        this.plugin = null;
973
        this.pluginNode = Y.Node.create('<div class="availability-warning">' +
974
                M.util.get_string('missingplugin', 'availability') + '</div>');
975
    } else {
976
        // Plugin is known.
977
        this.plugin = M.core_availability.form.plugins[json.type];
978
        this.pluginNode = this.plugin.getNode(json);
979
 
980
        // Add a class with the plugin Frankenstyle name to make CSS easier in plugin.
981
        this.pluginNode.addClass('availability_' + json.type);
982
    }
983
 
984
    // Allow events from pluginNode to bubble up to the Item.
985
    Y.augment(this.pluginNode, Y.EventTarget, true, null, {emitFacade: true});
986
    this.pluginNode.addTarget(this);
987
 
988
    this.node = Y.Node.create('<div class="availability-item d-sm-flex align-items-center"><h3 class="accesshide"></h3></div>');
989
 
990
    // Add eye icon if required. This icon is added for root items, but may be
991
    // hidden depending on the selected list operator.
992
    if (root) {
993
        var shown = true;
994
        if (json.showc !== undefined) {
995
            shown = json.showc;
996
        }
997
        this.eyeIcon = new M.core_availability.EyeIcon(true, shown);
998
        this.node.appendChild(this.eyeIcon.span);
999
        this.node.appendChild(this.eyeIcon.disabledSpan);
1000
    }
1001
 
1002
    // Add plugin controls.
1003
    this.pluginNode.addClass('availability-plugincontrols');
1004
    this.node.appendChild(this.pluginNode);
1005
 
1006
    // Add delete button for node.
1007
    var deleteIcon = new M.core_availability.DeleteIcon(this);
1008
    this.node.appendChild(deleteIcon.span);
1009
 
1010
    // Add the invalid marker (empty).
1011
    this.node.appendChild(document.createTextNode(' '));
1012
    this.node.appendChild(Y.Node.create('<span class="badge bg-warning text-dark"/>'));
1013
};
1014
Y.augment(M.core_availability.Item, Y.EventTarget, true, null, {emitFacade: true});
1015
 
1016
/**
1017
 * Obtains the value of this condition, which will be serialized into JSON
1018
 * format and stored in the form.
1019
 *
1020
 * @method getValue
1021
 * @return {Object} JavaScript object containing value of this item
1022
 */
1023
M.core_availability.Item.prototype.getValue = function() {
1024
    var value = {'type': this.pluginType};
1025
    if (this.plugin) {
1026
        this.plugin.fillValue(value, this.pluginNode);
1027
    }
1028
    return value;
1029
};
1030
 
1031
/**
1032
 * Checks whether this condition has any errors (incorrect user input). If so,
1033
 * an error string identifier in the form langfile:langstring should be pushed
1034
 * into the errors array.
1035
 *
1036
 * @method fillErrors
1037
 * @param {Array} errors Array of errors so far
1038
 */
1039
M.core_availability.Item.prototype.fillErrors = function(errors) {
1040
    var before = errors.length;
1041
    if (this.plugin) {
1042
        // Pass to plugin.
1043
        this.plugin.fillErrors(errors, this.pluginNode);
1044
    } else {
1045
        // Unknown plugin is an error
1046
        errors.push('core_availability:item_unknowntype');
1047
    }
1048
    // If any errors were added, add the marker to this item.
1049
    var errorLabel = this.node.one('> .bg-warning');
1050
    if (errors.length !== before && !errorLabel.get('firstChild')) {
1051
        var errorString = '';
1052
        // Fetch the last error code from the array of errors and split using the ':' delimiter.
1053
        var langString = errors[errors.length - 1].split(':');
1054
        var component = langString[0];
1055
        var identifier = langString[1];
1056
        // If get_string can't find the string, it will return the string in this format.
1057
        var undefinedString = '[[' + identifier + ',' + component + ']]';
1058
        // Get the lang string.
1059
        errorString = M.util.get_string(identifier, component);
1060
        if (errorString === undefinedString) {
1061
            // Use a generic invalid input message when the error lang string cannot be loaded.
1062
            errorString = M.util.get_string('invalid', 'availability');
1063
        }
1064
        // Show the error string.
1065
        errorLabel.appendChild(document.createTextNode(errorString));
1066
    } else if (errors.length === before && errorLabel.get('firstChild')) {
1067
        errorLabel.get('firstChild').remove();
1068
    }
1069
};
1070
 
1071
/**
1072
 * Renumbers the item.
1073
 *
1074
 * @method renumber
1075
 * @param {String} number Number to use in heading for this item
1076
 */
1077
M.core_availability.Item.prototype.renumber = function(number) {
1078
    // Update heading for item.
1079
    var headingParams = {number: number};
1080
    if (this.plugin) {
1081
        headingParams.type = M.util.get_string('title', 'availability_' + this.pluginType);
1082
    } else {
1083
        headingParams.type = '[' + this.pluginType + ']';
1084
    }
1085
    headingParams.number = number + ':';
1086
    var heading = M.util.get_string('itemheading', 'availability', headingParams);
1087
    this.node.one('> h3').set('innerHTML', heading);
1088
    this.node.one('> h3').getDOMNode().dataset.restrictionOrder = number ? number : 'root';
1089
};
1090
 
1091
/**
1092
 * Focuses something after a new item is added.
1093
 *
1094
 * @method focusAfterAdd
1095
 */
1096
M.core_availability.Item.prototype.focusAfterAdd = function() {
1097
    this.plugin.focusAfterAdd(this.pluginNode);
1098
};
1099
 
1100
/**
1101
 * Name of plugin.
1102
 *
1103
 * @property pluginType
1104
 * @type String
1105
 */
1106
M.core_availability.Item.prototype.pluginType = null;
1107
 
1108
/**
1109
 * Object representing plugin form controls.
1110
 *
1111
 * @property plugin
1112
 * @type Object
1113
 */
1114
M.core_availability.Item.prototype.plugin = null;
1115
 
1116
/**
1117
 * Eye icon for item.
1118
 *
1119
 * @property eyeIcon
1120
 * @type M.core_availability.EyeIcon
1121
 */
1122
M.core_availability.Item.prototype.eyeIcon = null;
1123
 
1124
/**
1125
 * HTML node for item.
1126
 *
1127
 * @property node
1128
 * @type Y.Node
1129
 */
1130
M.core_availability.Item.prototype.node = null;
1131
 
1132
/**
1133
 * Inner part of node that is owned by plugin.
1134
 *
1135
 * @property pluginNode
1136
 * @type Y.Node
1137
 */
1138
M.core_availability.Item.prototype.pluginNode = null;
1139
 
1140
 
1141
/**
1142
 * Eye icon (to control show/hide of the activity if the user fails a condition).
1143
 *
1144
 * There are individual eye icons (show/hide control for a single condition) and
1145
 * 'all' eye icons (show/hide control that applies to the entire item, whatever
1146
 * reason it fails for). This is necessary because the individual conditions
1147
 * don't make sense for OR and AND NOT lists.
1148
 *
1149
 * @class M.core_availability.EyeIcon
1150
 * @constructor
1151
 * @param {Boolean} individual True if the icon is controlling a single condition
1152
 * @param {Boolean} shown True if icon is initially in shown state
1153
 */
1154
M.core_availability.EyeIcon = function(individual, shown) {
1155
    this.individual = individual;
1156
    this.span = Y.Node.create('<a class="availability-eye col-form-label" href="#" role="button">');
1157
    var icon = Y.Node.create('<img />');
1158
    this.span.appendChild(icon);
1159
 
1160
    // Set up button text and icon.
1161
    var suffix = individual ? '_individual' : '_all',
1162
        setHidden = function() {
1163
            var hiddenStr = M.util.get_string('hidden' + suffix, 'availability');
1164
            icon.set('src', M.util.image_url('i/show', 'core'));
1165
            icon.set('alt', hiddenStr);
1166
            this.span.set('title', hiddenStr + ' \u2022 ' +
1167
                    M.util.get_string('show_verb', 'availability'));
1168
        },
1169
        setShown = function() {
1170
            var shownStr = M.util.get_string('shown' + suffix, 'availability');
1171
            icon.set('src', M.util.image_url('i/hide', 'core'));
1172
            icon.set('alt', shownStr);
1173
            this.span.set('title', shownStr + ' \u2022 ' +
1174
                    M.util.get_string('hide_verb', 'availability'));
1175
        };
1176
    if (shown) {
1177
        setShown.call(this);
1178
    } else {
1179
        setHidden.call(this);
1180
    }
1181
 
1182
    // Update when button is clicked.
1183
    var click = function(e) {
1184
        e.preventDefault();
1185
        if (this.isHidden()) {
1186
            setShown.call(this);
1187
        } else {
1188
            setHidden.call(this);
1189
        }
1190
        M.core_availability.form.update();
1191
    };
1192
    this.span.on('click', click, this);
1193
    this.span.on('key', click, 'up:32', this);
1194
    this.span.on('key', function(e) {
1195
        e.preventDefault();
1196
    }, 'down:32', this);
1197
 
1198
    this.disabledSpan = Y.Node.create('<span class="availability-eye-disabled col-form-label" href="#">');
1199
    var disabledIcon = Y.Node.create('<img />');
1200
    var disabledStr = M.util.get_string('hidden' + suffix, 'availability');
1201
    disabledIcon.set('src', M.util.image_url('i/show', 'core'));
1202
    disabledIcon.set('alt', disabledStr);
1203
    this.disabledSpan.set('title', disabledStr + ' \u2022 ' +
1204
        M.util.get_string('disabled_verb', 'availability'));
1205
    this.disabledSpan.appendChild(disabledIcon);
1206
    this.disabledSpan.hide();
1207
};
1208
 
1209
/**
1210
 * True if this eye icon is an individual one (see above).
1211
 *
1212
 * @property individual
1213
 * @type Boolean
1214
 */
1215
M.core_availability.EyeIcon.prototype.individual = false;
1216
 
1217
/**
1218
 * YUI node for the span that contains this icon.
1219
 *
1220
 * @property span
1221
 * @type Y.Node
1222
 */
1223
M.core_availability.EyeIcon.prototype.span = null;
1224
 
1225
/**
1226
 * YUI node for the span that contains the "disabled" state of the icon.
1227
 *
1228
 * @property span
1229
 * @type Y.Node
1230
 */
1231
M.core_availability.EyeIcon.prototype.disabledSpan = null;
1232
 
1233
/**
1234
 * Checks the current state of the icon.
1235
 *
1236
 * @method isHidden
1237
 * @return {Boolean} True if this icon is set to 'hidden'
1238
 */
1239
M.core_availability.EyeIcon.prototype.isHidden = function() {
1240
    var suffix = this.individual ? '_individual' : '_all',
1241
        compare = M.util.get_string('hidden' + suffix, 'availability');
1242
    return this.span.one('img').get('alt') === compare;
1243
};
1244
 
1245
/**
1246
 * Checks whether the eye icon is disabled, and a dummy "hidden" icon displayed instead.
1247
 *
1248
 * @method isDisabled
1249
 * @return {Boolean} True if this icon is disabled
1250
 */
1251
M.core_availability.EyeIcon.prototype.isDisabled = function() {
1252
    return this.span.hasAttribute('hidden');
1253
};
1254
 
1255
/**
1256
 * Locks the state of the icon.
1257
 *
1258
 * @method setLocked
1259
 */
1260
M.core_availability.EyeIcon.prototype.setDisabled = function() {
1261
    if (!this.isDisabled()) {
1262
        this.span.hide();
1263
        this.disabledSpan.show();
1264
    }
1265
};
1266
 
1267
/**
1268
 * Unlocks the icon so it can be changed.
1269
 *
1270
 * @method setUnlocked
1271
 */
1272
M.core_availability.EyeIcon.prototype.setEnabled = function() {
1273
    if (this.isDisabled()) {
1274
        this.span.show();
1275
        this.disabledSpan.hide();
1276
    }
1277
};
1278
 
1279
/**
1280
 * Delete icon (to delete an Item or List).
1281
 *
1282
 * @class M.core_availability.DeleteIcon
1283
 * @constructor
1284
 * @param {M.core_availability.Item|M.core_availability.List} toDelete Thing to delete
1285
 */
1286
M.core_availability.DeleteIcon = function(toDelete) {
1287
    this.span = Y.Node.create('<a class="d-inline-block col-form-label availability-delete px-3" href="#" title="' +
1288
            M.util.get_string('delete', 'moodle') + '" role="button">');
1289
    var img = Y.Node.create('<img src="' + M.util.image_url('t/delete', 'core') +
1290
            '" alt="' + M.util.get_string('delete', 'moodle') + '" />');
1291
    this.span.appendChild(img);
1292
    var click = function(e) {
1293
        e.preventDefault();
1294
        M.core_availability.form.rootList.deleteDescendant(toDelete);
1295
        M.core_availability.form.rootList.renumber();
1296
    };
1297
    this.span.on('click', click, this);
1298
    this.span.on('key', click, 'up:32', this);
1299
    this.span.on('key', function(e) {
1300
        e.preventDefault();
1301
    }, 'down:32', this);
1302
};
1303
 
1304
/**
1305
 * YUI node for the span that contains this icon.
1306
 *
1307
 * @property span
1308
 * @type Y.Node
1309
 */
1310
M.core_availability.DeleteIcon.prototype.span = null;