Merge branch 'MDL-81713-main' of https://github.com/junpataleta/moodle
[moodle.git] / mod / quiz / module.js
blob1921b6e43e127a3a81a0db46c734b23c9bca822d
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15 /* eslint camelcase: off */
17 /**
18  * JavaScript library for the quiz module.
19  *
20  * @package    mod
21  * @subpackage quiz
22  * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
26 M.mod_quiz = M.mod_quiz || {};
28 M.mod_quiz.init_attempt_form = function(Y) {
29     require(['core_question/question_engine'], function(qEngine) {
30         qEngine.initForm('#responseform');
31     });
32     Y.on('submit', M.mod_quiz.timer.stop, '#responseform');
33     require(['core_form/changechecker'], function(FormChangeChecker) {
34         FormChangeChecker.watchFormById('responseform');
35     });
38 M.mod_quiz.init_review_form = function(Y) {
39     require(['core_question/question_engine'], function(qEngine) {
40         qEngine.initForm('.questionflagsaveform');
41     });
42     Y.on('submit', function(e) { e.halt(); }, '.questionflagsaveform');
45 M.mod_quiz.init_comment_popup = function(Y) {
46     // Add a close button to the window.
47     var closebutton = Y.Node.create('<input type="button" class="btn btn-secondary" />');
48     closebutton.set('value', M.util.get_string('cancel', 'moodle'));
49     Y.one('#id_submitbutton').ancestor().append(closebutton);
50     Y.on('click', function() { window.close() }, closebutton);
53 // Code for updating the countdown timer that is used on timed quizzes.
54 M.mod_quiz.timer = {
55     // YUI object.
56     Y: null,
58     // Timestamp at which time runs out, according to the student's computer's clock.
59     endtime: 0,
61     // Is this a quiz preview?
62     preview: 0,
64     // This records the id of the timeout that updates the clock periodically,
65     // so we can cancel.
66     timeoutid: null,
68     // Threshold for updating time remaining, in milliseconds.
69     threshold: 3000,
71     /**
72      * @param Y the YUI object
73      * @param start, the timer starting time, in seconds.
74      * @param preview, is this a quiz preview?
75      */
76     init: function(Y, start, preview) {
77         M.mod_quiz.timer.Y = Y;
78         M.mod_quiz.timer.endtime = M.pageloadstarttime.getTime() + start*1000;
79         M.mod_quiz.timer.preview = preview;
80         M.mod_quiz.timer.update();
82         Y.one('#quiz-timer-wrapper').setStyle('display', 'flex');
83         require(['core_form/changechecker'], function(FormChangeChecker) {
84             M.mod_quiz.timer.FormChangeChecker = FormChangeChecker;
85         });
86         Y.one('#toggle-timer').on('click', function() {
87             M.mod_quiz.timer.toggleVisibility();
88         });
90         // We store the visibility as a user preference. If the value is not '1',
91         // i. e. it is '0' or the item does not exist, the timer must be shown.
92         require(['core_user/repository'], function(UserRepository) {
93             UserRepository.getUserPreference('quiz_timerhidden')
94                 .then((response) => {
95                     M.mod_quiz.timer.setVisibility(response !== '1', false);
96                     return;
97                 })
98                 // If there is an error, we catch and ignore it, because (i) no matter what we do,
99                 // we do not have the stored value, so we will need to take a reasonable default
100                 // and (ii) the student who is currently taking the quiz is probably not interested
101                 // in the technical details why the fetch failed, even less, because they can hardly
102                 // do anything to solve the problem. However, we still log that there was an error
103                 // to leave a trace, e. g. for debugging.
104                 .catch((error) => {
105                     M.mod_quiz.timer.setVisibility(true, false);
106                     Y.log(error, 'error', 'moodle-mod_quiz');
107                 });
108         });
109     },
111     /**
112      * Toggle the timer's visibility.
113      */
114     toggleVisibility: function() {
115         var Y = M.mod_quiz.timer.Y;
116         var timer = Y.one('#quiz-time-left');
118         // If the timer is currently hidden, the visibility should be set to true and vice versa.
119         this.setVisibility(timer.getAttribute('hidden') === 'hidden');
120     },
122     /**
123      * Set visibility of the timer.
124      * @param visible whether the timer should be visible
125      * @param updatePref whether the new status should be stored as a preference
126      */
127     setVisibility: function(visible, updatePref = true) {
128         var Y = M.mod_quiz.timer.Y;
129         var timer = Y.one('#quiz-time-left');
130         var button = Y.one('#toggle-timer');
132         if (visible) {
133             button.setContent(M.util.get_string('hide', 'moodle'));
134             timer.show();
135         } else {
136             button.setContent(M.util.get_string('show', 'moodle'));
137             timer.hide();
138         }
140         // Only update the user preference if this has been requested.
141         if (updatePref) {
142             require(['core_user/repository'], function(UserRepository) {
143                 UserRepository.setUserPreference('quiz_timerhidden', (visible ? '0' : '1'));
144             });
145         }
147     },
149     /**
150      * Stop the timer, if it is running.
151      */
152     stop: function(e) {
153         if (M.mod_quiz.timer.timeoutid) {
154             clearTimeout(M.mod_quiz.timer.timeoutid);
155         }
156     },
158     /**
159      * Function to convert a number between 0 and 99 to a two-digit string.
160      */
161     two_digit: function(num) {
162         if (num < 10) {
163             return '0' + num;
164         } else {
165             return num;
166         }
167     },
169     // Function to update the clock with the current time left, and submit the quiz if necessary.
170     update: function() {
171         var Y = M.mod_quiz.timer.Y;
172         var secondsleft = Math.floor((M.mod_quiz.timer.endtime - new Date().getTime())/1000);
174         // If time has expired, set the hidden form field that says time has expired and submit
175         if (secondsleft < 0) {
176             M.mod_quiz.timer.stop(null);
177             Y.one('#quiz-time-left').setContent(M.util.get_string('timesup', 'quiz'));
178             var input = Y.one('input[name=timeup]');
179             input.set('value', 1);
180             var form = input.ancestor('form');
181             if (form.one('input[name=finishattempt]')) {
182                 form.one('input[name=finishattempt]').set('value', 0);
183             }
184             M.mod_quiz.timer.FormChangeChecker.markFormSubmitted(input.getDOMNode());
185             form.submit();
186             return;
187         }
189         // If time has nearly expired, change the colour.
190         if (secondsleft < 100) {
191             Y.one('#quiz-timer').removeClass('timeleft' + (secondsleft + 2))
192                     .removeClass('timeleft' + (secondsleft + 1))
193                     .addClass('timeleft' + secondsleft);
195             // From now on, the timer should be visible and should not be hideable anymore.
196             // We use the second (optional) parameter in order to leave the user preference
197             // unchanged.
198             M.mod_quiz.timer.setVisibility(true, false);
199             Y.one('#toggle-timer').setAttribute('disabled', true);
200         }
202         // Update the time display.
203         var hours = Math.floor(secondsleft/3600);
204         secondsleft -= hours*3600;
205         var minutes = Math.floor(secondsleft/60);
206         secondsleft -= minutes*60;
207         var seconds = secondsleft;
208         Y.one('#quiz-time-left').setContent(hours + ':' +
209                 M.mod_quiz.timer.two_digit(minutes) + ':' +
210                 M.mod_quiz.timer.two_digit(seconds));
212         // Arrange for this method to be called again soon.
213         M.mod_quiz.timer.timeoutid = setTimeout(M.mod_quiz.timer.update, 100);
214     },
216     // Allow the end time of the quiz to be updated.
217     updateEndTime: function(timeleft) {
218         var newtimeleft = new Date().getTime() + timeleft * 1000;
220         // Timer might not have been initialized yet. We initialize it with
221         // preview = 0, because it's better to take a preview for a real quiz
222         // than to take a real quiz for a preview.
223         if (M.mod_quiz.timer.Y === null) {
224             M.mod_quiz.timer.init(window.Y, timeleft, 0);
225         }
227         // Only update if change is greater than the threshold, so the
228         // time doesn't bounce around unnecessarily.
229         if (Math.abs(newtimeleft - M.mod_quiz.timer.endtime) > M.mod_quiz.timer.threshold) {
230             M.mod_quiz.timer.endtime = newtimeleft;
231             M.mod_quiz.timer.update();
232         }
233     }
236 M.mod_quiz.filesUpload = {
237     /**
238      * YUI object.
239      */
240     Y: null,
242     /**
243      * Number of files uploading.
244      */
245     numberFilesUploading: 0,
247     /**
248      * Disable navigation block when uploading and enable navigation block when all files are uploaded.
249      */
250     disableNavPanel: function() {
251         var quizNavigationBlock = document.getElementById('mod_quiz_navblock');
252         if (quizNavigationBlock) {
253             if (M.mod_quiz.filesUpload.numberFilesUploading) {
254                 quizNavigationBlock.classList.add('nav-disabled');
255             } else {
256                 quizNavigationBlock.classList.remove('nav-disabled');
257             }
258         }
259     }
262 M.mod_quiz.nav = M.mod_quiz.nav || {};
264 M.mod_quiz.nav.update_flag_state = function(attemptid, questionid, newstate) {
265     var Y = M.mod_quiz.nav.Y;
266     var navlink = Y.one('#quiznavbutton' + questionid);
267     navlink.removeClass('flagged');
268     if (newstate == 1) {
269         navlink.addClass('flagged');
270         navlink.one('.accesshide .flagstate').setContent(M.util.get_string('flagged', 'question'));
271     } else {
272         navlink.one('.accesshide .flagstate').setContent('');
273     }
276 M.mod_quiz.nav.init = function(Y) {
277     M.mod_quiz.nav.Y = Y;
279     Y.all('#quiznojswarning').remove();
281     var form = Y.one('#responseform');
282     if (form) {
283         function nav_to_page(pageno) {
284             Y.one('#followingpage').set('value', pageno);
286             // Automatically submit the form. We do it this strange way because just
287             // calling form.submit() does not run the form's submit event handlers.
288             var submit = form.one('input[name="next"]');
289             submit.set('name', '');
290             submit.getDOMNode().click();
291         };
293         Y.delegate('click', function(e) {
294             if (this.hasClass('thispage')) {
295                 return;
296             }
298             e.preventDefault();
300             var pageidmatch = this.get('href').match(/page=(\d+)/);
301             var pageno;
302             if (pageidmatch) {
303                 pageno = pageidmatch[1];
304             } else {
305                 pageno = 0;
306             }
308             var questionidmatch = this.get('href').match(/#question-(\d+)-(\d+)/);
309             if (questionidmatch) {
310                 form.set('action', form.get('action') + questionidmatch[0]);
311             }
313             nav_to_page(pageno);
314         }, document.body, '.qnbutton');
315     }
317     if (Y.one('a.endtestlink')) {
318         Y.on('click', function(e) {
319             e.preventDefault();
320             nav_to_page(-1);
321         }, 'a.endtestlink');
322     }
324     // Navigation buttons should be disabled when the files are uploading.
325     require(['core_form/events'], function(formEvent) {
326         document.addEventListener(formEvent.eventTypes.uploadStarted, function() {
327             M.mod_quiz.filesUpload.numberFilesUploading++;
328             M.mod_quiz.filesUpload.disableNavPanel();
329         });
331         document.addEventListener(formEvent.eventTypes.uploadCompleted, function() {
332             M.mod_quiz.filesUpload.numberFilesUploading--;
333             M.mod_quiz.filesUpload.disableNavPanel();
334         });
335     });
337     if (M.core_question_flags) {
338         M.core_question_flags.add_listener(M.mod_quiz.nav.update_flag_state);
339     }
342 M.mod_quiz.secure_window = {
343     init: function(Y) {
344         if (window.location.href.substring(0, 4) == 'file') {
345             window.location = 'about:blank';
346         }
347         Y.delegate('contextmenu', M.mod_quiz.secure_window.prevent, document, '*');
348         Y.delegate('mousedown',   M.mod_quiz.secure_window.prevent_mouse, 'body', '*');
349         Y.delegate('mouseup',     M.mod_quiz.secure_window.prevent_mouse, 'body', '*');
350         Y.delegate('dragstart',   M.mod_quiz.secure_window.prevent, document, '*');
351         Y.delegate('selectstart', M.mod_quiz.secure_window.prevent_selection, document, '*');
352         Y.delegate('cut',         M.mod_quiz.secure_window.prevent, document, '*');
353         Y.delegate('copy',        M.mod_quiz.secure_window.prevent, document, '*');
354         Y.delegate('paste',       M.mod_quiz.secure_window.prevent, document, '*');
355         Y.on('beforeprint', function() {
356             Y.one(document.body).setStyle('display', 'none');
357         }, window);
358         Y.on('afterprint', function() {
359             Y.one(document.body).setStyle('display', 'block');
360         }, window);
361         Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'press:67,86,88+ctrl');
362         Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'up:67,86,88+ctrl');
363         Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'down:67,86,88+ctrl');
364         Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'press:67,86,88+meta');
365         Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'up:67,86,88+meta');
366         Y.on('key', M.mod_quiz.secure_window.prevent, '*', 'down:67,86,88+meta');
367     },
369     is_content_editable: function(n) {
370         if (n.test('[contenteditable=true]')) {
371             return true;
372         }
373         n = n.get('parentNode');
374         if (n === null) {
375             return false;
376         }
377         return M.mod_quiz.secure_window.is_content_editable(n);
378     },
380     prevent_selection: function(e) {
381         return false;
382     },
384     prevent: function(e) {
385         alert(M.util.get_string('functiondisabledbysecuremode', 'quiz'));
386         e.halt();
387     },
389     prevent_mouse: function(e) {
390         if (e.button == 1 && /^(INPUT|TEXTAREA|BUTTON|SELECT|LABEL|A)$/i.test(e.target.get('tagName'))) {
391             // Left click on a button or similar. No worries.
392             return;
393         }
394         if (e.button == 1 && M.mod_quiz.secure_window.is_content_editable(e.target)) {
395             // Left click in Atto or similar.
396             return;
397         }
398         e.halt();
399     },
401     init_close_button: function(Y, url) {
402         Y.on('click', function(e) {
403             M.mod_quiz.secure_window.close(url, 0)
404         }, '#secureclosebutton');
405     },
407     close: function(url, delay) {
408         setTimeout(function() {
409             if (window.opener) {
410                 window.opener.document.location.reload();
411                 window.close();
412             } else {
413                 window.location.href = url;
414             }
415         }, delay*1000);
416     }