Proyectos de Subversion Moodle

Rev

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