Merge branch 'MDL-48771-squashed' of git://github.com/timhunt/moodle
[moodle.git] / mod / quiz / yui / src / toolboxes / js / resource.js
1 /* global TOOLBOX, BODY, SELECTOR */
3 /**
4  * Resource and activity toolbox class.
5  *
6  * This class is responsible for managing AJAX interactions with activities and resources
7  * when viewing a quiz in editing mode.
8  *
9  * @module mod_quiz-resource-toolbox
10  * @namespace M.mod_quiz.resource_toolbox
11  */
13 /**
14  * Resource and activity toolbox class.
15  *
16  * This is a class extending TOOLBOX containing code specific to resources
17  *
18  * This class is responsible for managing AJAX interactions with activities and resources
19  * when viewing a quiz in editing mode.
20  *
21  * @class resources
22  * @constructor
23  * @extends M.course.toolboxes.toolbox
24  */
25 var RESOURCETOOLBOX = function() {
26     RESOURCETOOLBOX.superclass.constructor.apply(this, arguments);
27 };
29 Y.extend(RESOURCETOOLBOX, TOOLBOX, {
30     /**
31      * An Array of events added when editing a max mark field.
32      * These should all be detached when editing is complete.
33      *
34      * @property editmaxmarkevents
35      * @protected
36      * @type Array
37      * @protected
38      */
39     editmaxmarkevents: [],
41     /**
42      *
43      */
44     NODE_PAGE: 1,
45     NODE_SLOT: 2,
46     NODE_JOIN: 3,
48     /**
49      * Initialize the resource toolbox
50      *
51      * For each activity the commands are updated and a reference to the activity is attached.
52      * This way it doesn't matter where the commands are going to called from they have a reference to the
53      * activity that they relate to.
54      * This is essential as some of the actions are displayed in an actionmenu which removes them from the
55      * page flow.
56      *
57      * This function also creates a single event delegate to manage all AJAX actions for all activities on
58      * the page.
59      *
60      * @method initializer
61      * @protected
62      */
63     initializer: function() {
64         M.mod_quiz.quizbase.register_module(this);
65         Y.delegate('click', this.handle_data_action, BODY, SELECTOR.ACTIVITYACTION, this);
66         Y.delegate('click', this.handle_data_action, BODY, SELECTOR.DEPENDENCY_LINK, this);
67         this.initialise_select_multiple();
68     },
70     /**
71      * Initialize the select multiple options
72      *
73      * Add actions to the buttons that enable multiple slots to be selected and managed at once.
74      *
75      * @method initialise_select_multiple
76      * @protected
77      */
78     initialise_select_multiple: function() {
79         // Click select multiple button to show the select all options.
80         Y.one(SELECTOR.SELECTMULTIPLEBUTTON).on('click', function(e) {
81             e.preventDefault();
82             Y.one('body').addClass(CSS.SELECTMULTIPLE);
83         });
85         // Click cancel button to show the select all options.
86         Y.one(SELECTOR.SELECTMULTIPLECANCELBUTTON).on('click', function(e) {
87             e.preventDefault();
88             Y.one('body').removeClass(CSS.SELECTMULTIPLE);
89         });
91         // Click select all link to check all the checkboxes.
92         Y.one(SELECTOR.SELECTALL).on('click', function(e) {
93             e.preventDefault();
94             Y.all(SELECTOR.SELECTMULTIPLECHECKBOX).set('checked', 'checked');
95         });
97         // Click deselect all link to show the select all checkboxes.
98         Y.one(SELECTOR.DESELECTALL).on('click', function(e) {
99             e.preventDefault();
100             Y.all(SELECTOR.SELECTMULTIPLECHECKBOX).set('checked', '');
101         });
103         // Disable delete multiple button by default.
104         Y.one(SELECTOR.SELECTMULTIPLEDELETEBUTTON).setAttribute('disabled', 'disabled');
106         // Assign the delete method to the delete multiple button.
107         Y.delegate('click', this.delete_multiple_with_confirmation, BODY, SELECTOR.SELECTMULTIPLEDELETEBUTTON, this);
109         // Enable the delete all button only when at least one slot is selected.
110         Y.delegate('click', this.toggle_select_all_buttons_enabled, BODY, SELECTOR.SELECTMULTIPLECHECKBOX, this);
111         Y.delegate('click', this.toggle_select_all_buttons_enabled, BODY, SELECTOR.SELECTALL, this);
112         Y.delegate('click', this.toggle_select_all_buttons_enabled, BODY, SELECTOR.DESELECTALL, this);
113     },
115     /**
116      * Handles the delegation event. When this is fired someone has triggered an action.
117      *
118      * Note not all actions will result in an AJAX enhancement.
119      *
120      * @protected
121      * @method handle_data_action
122      * @param {EventFacade} ev The event that was triggered.
123      * @returns {boolean}
124      */
125     handle_data_action: function(ev) {
126         // We need to get the anchor element that triggered this event.
127         var node = ev.target;
128         if (!node.test('a')) {
129             node = node.ancestor(SELECTOR.ACTIVITYACTION);
130         }
132         // From the anchor we can get both the activity (added during initialisation) and the action being
133         // performed (added by the UI as a data attribute).
134         var action = node.getData('action'),
135             activity = node.ancestor(SELECTOR.ACTIVITYLI);
137         if (!node.test('a') || !action || !activity) {
138             // It wasn't a valid action node.
139             return;
140         }
142         // Switch based upon the action and do the desired thing.
143         switch (action) {
144             case 'editmaxmark':
145                 // The user wishes to edit the maxmark of the resource.
146                 this.edit_maxmark(ev, node, activity, action);
147                 break;
148             case 'delete':
149                 // The user is deleting the activity.
150                 this.delete_with_confirmation(ev, node, activity, action);
151                 break;
152             case 'addpagebreak':
153             case 'removepagebreak':
154                 // The user is adding or removing a page break.
155                 this.update_page_break(ev, node, activity, action);
156                 break;
157             case 'adddependency':
158             case 'removedependency':
159                 // The user is adding or removing a dependency between questions.
160                 this.update_dependency(ev, node, activity, action);
161                 break;
162             default:
163                 // Nothing to do here!
164                 break;
165         }
166     },
168     /**
169      * Add a loading icon to the specified activity.
170      *
171      * The icon is added within the action area.
172      *
173      * @method add_spinner
174      * @param {Node} activity The activity to add a loading icon to
175      * @return {Node|null} The newly created icon, or null if the action area was not found.
176      */
177     add_spinner: function(activity) {
178         var actionarea = activity.one(SELECTOR.ACTIONAREA);
179         if (actionarea) {
180             return M.util.add_spinner(Y, actionarea);
181         }
182         return null;
183     },
185     /**
186      * If a select multiple checkbox is checked enable the buttons in the select multiple
187      * toolbar otherwise disable it.
188      *
189      * @method toggle_select_all_buttons_enabled
190      */
191     toggle_select_all_buttons_enabled: function() {
192         var checked = Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked');
193         var deletebutton = Y.one(SELECTOR.SELECTMULTIPLEDELETEBUTTON);
194         if (checked && !checked.isEmpty()) {
195             deletebutton.removeAttribute('disabled');
196         } else {
197             deletebutton.setAttribute('disabled', 'disabled');
198         }
199     },
201     /**
202      * Deletes the given activity or resource after confirmation.
203      *
204      * @protected
205      * @method delete_with_confirmation
206      * @param {EventFacade} ev The event that was fired.
207      * @param {Node} button The button that triggered this action.
208      * @param {Node} activity The activity node that this action will be performed on.
209      * @chainable
210      */
211     delete_with_confirmation: function(ev, button, activity) {
212         // Prevent the default button action.
213         ev.preventDefault();
215         // Get the element we're working on.
216         var element = activity,
217             // Create confirm string (different if element has or does not have name)
218             confirmstring = '',
219             qtypename = M.util.get_string('pluginname',
220                         'qtype_' + element.getAttribute('class').match(/qtype_([^\s]*)/)[1]);
221         confirmstring = M.util.get_string('confirmremovequestion', 'quiz', qtypename);
223         // Create the confirmation dialogue.
224         var confirm = new M.core.confirm({
225             question: confirmstring,
226             modal: true
227         });
229         // If it is confirmed.
230         confirm.on('complete-yes', function() {
231             var spinner = this.add_spinner(element);
232             var data = {
233                 'class': 'resource',
234                 'action': 'DELETE',
235                 'id': Y.Moodle.mod_quiz.util.slot.getId(element)
236             };
237             this.send_request(data, spinner, function(response) {
238                 if (response.deleted) {
239                     // Actually remove the element.
240                     Y.Moodle.mod_quiz.util.slot.remove(element);
241                     this.reorganise_edit_page();
242                     if (M.core.actionmenu && M.core.actionmenu.instance) {
243                         M.core.actionmenu.instance.hideMenu(ev);
244                     }
245                 }
246             });
248         }, this);
249     },
251     /**
252      * Deletes the given activities or resources after confirmation.
253      *
254      * @protected
255      * @method delete_multiple_with_confirmation
256      * @param {EventFacade} ev The event that was fired.
257      * @chainable
258      */
259     delete_multiple_with_confirmation: function(ev) {
260         ev.preventDefault();
262         var ids = '';
263         var slots = [];
264         Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked').each(function(node) {
265             var slot = Y.Moodle.mod_quiz.util.slot.getSlotFromComponent(node);
266             ids += ids === '' ? '' : ',';
267             ids += Y.Moodle.mod_quiz.util.slot.getId(slot);
268             slots.push(slot);
269         });
270         var element = Y.one('div.mod-quiz-edit-content');
272         // Do nothing if no slots are selected.
273         if (!slots || !slots.length) {
274             return;
275         }
277         // Create the confirmation dialogue.
278         var confirm = new M.core.confirm({
279             question: M.util.get_string('areyousureremoveselected', 'quiz'),
280             modal: true
281         });
283         // If it is confirmed.
284         confirm.on('complete-yes', function() {
285             var spinner = this.add_spinner(element);
286             var data = {
287                 'class': 'resource',
288                 field: 'deletemultiple',
289                 ids: ids
290             };
291             // Delete items on server.
292             this.send_request(data, spinner, function(response) {
293                 // Delete locally if deleted on server.
294                 if (response.deleted) {
295                     // Actually remove the element.
296                     Y.all(SELECTOR.SELECTMULTIPLECHECKBOX + ':checked').each(function(node) {
297                         Y.Moodle.mod_quiz.util.slot.remove(node.ancestor('li.activity'));
298                     });
299                     // Update the page numbers and sections.
300                     this.reorganise_edit_page();
302                     // Remove the select multiple options.
303                     Y.one('body').removeClass(CSS.SELECTMULTIPLE);
304                 }
305             });
307         }, this);
308     },
310     /**
311      * Edit the maxmark for the resource
312      *
313      * @protected
314      * @method edit_maxmark
315      * @param {EventFacade} ev The event that was fired.
316      * @param {Node} button The button that triggered this action.
317      * @param {Node} activity The activity node that this action will be performed on.
318      * @param {String} action The action that has been requested.
319      * @return Boolean
320      */
321     edit_maxmark: function(ev, button, activity) {
322         // Get the element we're working on
323         var instancemaxmark = activity.one(SELECTOR.INSTANCEMAXMARK),
324             instance = activity.one(SELECTOR.ACTIVITYINSTANCE),
325             currentmaxmark = instancemaxmark.get('firstChild'),
326             oldmaxmark = currentmaxmark.get('data'),
327             maxmarktext = oldmaxmark,
328             thisevent,
329             anchor = instancemaxmark, // Grab the anchor so that we can swap it with the edit form.
330             data = {
331                 'class': 'resource',
332                 'field': 'getmaxmark',
333                 'id': Y.Moodle.mod_quiz.util.slot.getId(activity)
334             };
336         // Prevent the default actions.
337         ev.preventDefault();
339         this.send_request(data, null, function(response) {
340             if (M.core.actionmenu && M.core.actionmenu.instance) {
341                 M.core.actionmenu.instance.hideMenu(ev);
342             }
344             // Try to retrieve the existing string from the server.
345             if (response.instancemaxmark) {
346                 maxmarktext = response.instancemaxmark;
347             }
349             // Create the editor and submit button.
350             var editform = Y.Node.create('<form action="#" />');
351             var editinstructions = Y.Node.create('<span class="' + CSS.EDITINSTRUCTIONS + '" id="id_editinstructions" />')
352                 .set('innerHTML', M.util.get_string('edittitleinstructions', 'moodle'));
353             var editor = Y.Node.create('<input name="maxmark" type="text" class="' + CSS.TITLEEDITOR + '" />').setAttrs({
354                 'value': maxmarktext,
355                 'autocomplete': 'off',
356                 'aria-describedby': 'id_editinstructions',
357                 'maxLength': '12',
358                 'size': parseInt(this.get('config').questiondecimalpoints, 10) + 2
359             });
361             // Clear the existing content and put the editor in.
362             editform.appendChild(editor);
363             editform.setData('anchor', anchor);
364             instance.insert(editinstructions, 'before');
365             anchor.replace(editform);
367             // We hide various components whilst editing:
368             activity.addClass(CSS.EDITINGMAXMARK);
370             // Focus and select the editor text.
371             editor.focus().select();
373             // Cancel the edit if we lose focus or the escape key is pressed.
374             thisevent = editor.on('blur', this.edit_maxmark_cancel, this, activity, false);
375             this.editmaxmarkevents.push(thisevent);
376             thisevent = editor.on('key', this.edit_maxmark_cancel, 'esc', this, activity, true);
377             this.editmaxmarkevents.push(thisevent);
379             // Handle form submission.
380             thisevent = editform.on('submit', this.edit_maxmark_submit, this, activity, oldmaxmark);
381             this.editmaxmarkevents.push(thisevent);
382         });
383     },
385     /**
386      * Handles the submit event when editing the activity or resources maxmark.
387      *
388      * @protected
389      * @method edit_maxmark_submit
390      * @param {EventFacade} ev The event that triggered this.
391      * @param {Node} activity The activity whose maxmark we are altering.
392      * @param {String} originalmaxmark The original maxmark the activity or resource had.
393      */
394     edit_maxmark_submit: function(ev, activity, originalmaxmark) {
395         // We don't actually want to submit anything.
396         ev.preventDefault();
397         var newmaxmark = Y.Lang.trim(activity.one(SELECTOR.ACTIVITYFORM + ' ' + SELECTOR.ACTIVITYMAXMARK).get('value'));
398         var spinner = this.add_spinner(activity);
399         this.edit_maxmark_clear(activity);
400         activity.one(SELECTOR.INSTANCEMAXMARK).setContent(newmaxmark);
401         if (newmaxmark !== null && newmaxmark !== "" && newmaxmark !== originalmaxmark) {
402             var data = {
403                 'class': 'resource',
404                 'field': 'updatemaxmark',
405                 'maxmark': newmaxmark,
406                 'id': Y.Moodle.mod_quiz.util.slot.getId(activity)
407             };
408             this.send_request(data, spinner, function(response) {
409                 if (response.instancemaxmark) {
410                     activity.one(SELECTOR.INSTANCEMAXMARK).setContent(response.instancemaxmark);
411                 }
412             });
413         }
414     },
416     /**
417      * Handles the cancel event when editing the activity or resources maxmark.
418      *
419      * @protected
420      * @method edit_maxmark_cancel
421      * @param {EventFacade} ev The event that triggered this.
422      * @param {Node} activity The activity whose maxmark we are altering.
423      * @param {Boolean} preventdefault If true we should prevent the default action from occuring.
424      */
425     edit_maxmark_cancel: function(ev, activity, preventdefault) {
426         if (preventdefault) {
427             ev.preventDefault();
428         }
429         this.edit_maxmark_clear(activity);
430     },
432     /**
433      * Handles clearing the editing UI and returning things to the original state they were in.
434      *
435      * @protected
436      * @method edit_maxmark_clear
437      * @param {Node} activity  The activity whose maxmark we were altering.
438      */
439     edit_maxmark_clear: function(activity) {
440         // Detach all listen events to prevent duplicate triggers
441         new Y.EventHandle(this.editmaxmarkevents).detach();
443         var editform = activity.one(SELECTOR.ACTIVITYFORM),
444             instructions = activity.one('#id_editinstructions');
445         if (editform) {
446             editform.replace(editform.getData('anchor'));
447         }
448         if (instructions) {
449             instructions.remove();
450         }
452         // Remove the editing class again to revert the display.
453         activity.removeClass(CSS.EDITINGMAXMARK);
455         // Refocus the link which was clicked originally so the user can continue using keyboard nav.
456         Y.later(100, this, function() {
457             activity.one(SELECTOR.EDITMAXMARK).focus();
458         });
460         // TODO MDL-50768 This hack is to keep Behat happy until they release a version of
461         // MinkSelenium2Driver that fixes
462         // https://github.com/Behat/MinkSelenium2Driver/issues/80.
463         if (!Y.one('input[name=maxmark')) {
464             Y.one('body').append('<input type="text" name="maxmark" style="display: none">');
465         }
466     },
468     /**
469      * Joins or separates the given slot with the page of the previous slot. Reorders the pages of
470      * the other slots
471      *
472      * @protected
473      * @method update_page_break
474      * @param {EventFacade} ev The event that was fired.
475      * @param {Node} button The button that triggered this action.
476      * @param {Node} activity The activity node that this action will be performed on.
477      * @param {String} action The action, addpagebreak or removepagebreak.
478      * @chainable
479      */
480     update_page_break: function(ev, button, activity, action) {
481         // Prevent the default button action
482         ev.preventDefault();
484         var nextactivity = activity.next('li.activity.slot');
485         var spinner = this.add_spinner(nextactivity);
486         var value = action === 'removepagebreak' ? 1 : 2;
488         var data = {
489             'class': 'resource',
490             'field': 'updatepagebreak',
491             'id':    Y.Moodle.mod_quiz.util.slot.getId(nextactivity),
492             'value': value
493         };
495         this.send_request(data, spinner, function(response) {
496             if (response.slots) {
497                 if (action === 'addpagebreak') {
498                     Y.Moodle.mod_quiz.util.page.add(activity);
499                 } else {
500                     var page = activity.next(Y.Moodle.mod_quiz.util.page.SELECTORS.PAGE);
501                     Y.Moodle.mod_quiz.util.page.remove(page, true);
502                 }
503                 this.reorganise_edit_page();
504             }
505         });
507         return this;
508     },
510     /**
511      * Updates a slot to either require the question in the previous slot to
512      * have been answered, or not,
513      *
514      * @protected
515      * @method update_page_break
516      * @param {EventFacade} ev The event that was fired.
517      * @param {Node} button The button that triggered this action.
518      * @param {Node} activity The activity node that this action will be performed on.
519      * @param {String} action The action, adddependency or removedependency.
520      * @chainable
521      */
522     update_dependency: function(ev, button, activity, action) {
523         // Prevent the default button action.
524         ev.preventDefault();
525         var spinner = this.add_spinner(activity);
527         var data = {
528             'class': 'resource',
529             'field': 'updatedependency',
530             'id':    Y.Moodle.mod_quiz.util.slot.getId(activity),
531             'value': action === 'adddependency' ? 1 : 0
532         };
534         this.send_request(data, spinner, function(response) {
535             if (response.hasOwnProperty('requireprevious')) {
536                 Y.Moodle.mod_quiz.util.slot.updateDependencyIcon(activity, response.requireprevious);
537             }
538         });
540         return this;
541     },
543     /**
544      * Reorganise the UI after every edit action.
545      *
546      * @protected
547      * @method reorganise_edit_page
548      */
549     reorganise_edit_page: function() {
550         Y.Moodle.mod_quiz.util.slot.reorderSlots();
551         Y.Moodle.mod_quiz.util.slot.reorderPageBreaks();
552         Y.Moodle.mod_quiz.util.page.reorderPages();
553         Y.Moodle.mod_quiz.util.slot.updateOneSlotSections();
554         Y.Moodle.mod_quiz.util.slot.updateAllDependencyIcons();
555     },
557     NAME: 'mod_quiz-resource-toolbox',
558     ATTRS: {
559         courseid: {
560             'value': 0
561         },
562         quizid: {
563             'value': 0
564         }
565     }
567 });
569 M.mod_quiz.resource_toolbox = null;
570 M.mod_quiz.init_resource_toolbox = function(config) {
571     M.mod_quiz.resource_toolbox = new RESOURCETOOLBOX(config);
572     return M.mod_quiz.resource_toolbox;
573 };