diff --git a/mod/quiz/addrandomform.php b/mod/quiz/addrandomform.php index 09f4d26b7e0..eec332d0268 100644 --- a/mod/quiz/addrandomform.php +++ b/mod/quiz/addrandomform.php @@ -37,6 +37,8 @@ require_once($CFG->libdir.'/formslib.php'); class quiz_add_random_form extends moodleform { protected function definition() { + global $OUTPUT, $PAGE; + $mform =& $this->_form; $mform->setDisableShortforms(); @@ -71,6 +73,9 @@ class quiz_add_random_form extends moodleform { $mform->addElement('select', 'numbertoadd', get_string('randomnumber', 'quiz'), $this->get_number_of_questions_to_add_choices()); + $previewhtml = $OUTPUT->render_from_template('mod_quiz/random_question_form_preview', []); + $mform->addElement('html', $previewhtml); + $mform->addElement('submit', 'existingcategory', get_string('addrandomquestion', 'quiz')); // Random from a new category section. @@ -97,6 +102,12 @@ class quiz_add_random_form extends moodleform { $mform->setType('cmid', PARAM_INT); $mform->addElement('hidden', 'returnurl', 0); $mform->setType('returnurl', PARAM_LOCALURL); + + // Add the javascript required to enhance this mform. + $PAGE->requires->js_call_amd('mod_quiz/add_random_form', 'init', [ + $mform->getAttribute('id'), + $contexts->lowest()->id + ]); } public function validation($fromform, $files) { diff --git a/mod/quiz/amd/build/add_random_form.min.js b/mod/quiz/amd/build/add_random_form.min.js new file mode 100644 index 00000000000..03d7365bec4 --- /dev/null +++ b/mod/quiz/amd/build/add_random_form.min.js @@ -0,0 +1 @@ +define(["jquery","mod_quiz/random_question_form_preview"],function(a,b){var c=2e3,d={PREVIEW_CONTAINER:'[data-region="random-question-preview-container"]',CATEGORY_FORM_ELEMENT:'[name="category"]',SUBCATEGORY_FORM_ELEMENT:'[name="includesubcategories"]',TAG_IDS_FORM_ELEMENT:'[name="fromtags[]"]'},e=function(a){var b=a.find(d.CATEGORY_FORM_ELEMENT).val(),c=b.split(",");return c[0]},f=function(a){return a.find(d.SUBCATEGORY_FORM_ELEMENT).is(":checked")},g=function(a){var b=a.find(d.TAG_IDS_FORM_ELEMENT).val();return b.map(function(a){var b=a.split(",");return b[0]})},h=function(a,c){var h=a.find(d.PREVIEW_CONTAINER);b.reload(h,e(a),f(a),g(a),c)},i=function(a){return a.closest(d.CATEGORY_FORM_ELEMENT).length>0||(a.closest(d.SUBCATEGORY_FORM_ELEMENT).length>0||a.closest(d.TAG_IDS_FORM_ELEMENT).length>0)},j=function(d,e){var f=null;d.on("change",function(g){i(a(g.target))&&(b.showLoadingIcon(d),f&&clearTimeout(f),f=setTimeout(function(){h(d,e)},c))})},k=function(b,c){var d=a("#"+b);h(d,c),j(d,c)};return{init:k}}); \ No newline at end of file diff --git a/mod/quiz/amd/build/random_question_form_preview.min.js b/mod/quiz/amd/build/random_question_form_preview.min.js new file mode 100644 index 00000000000..40a3bc58e53 --- /dev/null +++ b/mod/quiz/amd/build/random_question_form_preview.min.js @@ -0,0 +1 @@ +define(["jquery","core/ajax","core/str","core/notification","core/templates","core/paged_content_factory"],function(a,b,c,d,e,f){var g=5,h="mod_quiz/random_question_form_preview_question_list",i={LOADING_ICON_CONTAINER:'[data-region="overlay-icon-container"]',QUESTION_COUNT_CONTAINER:'[data-region="question-count-container"]',QUESTION_LIST_CONTAINER:'[data-region="question-list-container"]'},j=function(a){a.find(i.LOADING_ICON_CONTAINER).removeClass("hidden")},k=function(a){a.find(i.LOADING_ICON_CONTAINER).addClass("hidden")},l=function(a,b){c.get_string("questionsmatchingfilter","mod_quiz",b).then(function(b){a.find(i.QUESTION_COUNT_CONTAINER).html(b)}).fail(d.exception)},m=function(a,c,d,e,f,g){var h={methodname:"core_question_get_random_question_summaries",args:{categoryid:a,includesubcategories:c,tagids:d,contextid:e,limit:f,offset:g}};return b.call([h])[0]},n=function(a,b,c,i,j,k){return f.createFromAjax(j,g,function(f){return f.map(function(f){var g=f.limit,j=f.offset;return 0==j?e.render(h,{questions:k}):m(a,b,c,i,g,j).then(function(a){var b=a.questions;return e.render(h,{questions:b})}).fail(d.exception)})})},o=function(b,c,f,h,o){return j(b),m(c,f,h,o,g,0).then(function(a){var c=a.totalcount;return l(b,c),a}).then(function(b){var d=b.totalcount,e=b.questions;return e.length?n(c,f,h,o,d,e):a.Deferred().resolve("","")}).then(function(a,c){var d=b.find(i.QUESTION_LIST_CONTAINER);e.replaceNodeContents(d,a,c)}).always(function(){k(b)}).fail(d.exception)};return{reload:o,showLoadingIcon:j,hideLoadingIcon:k}}); \ No newline at end of file diff --git a/mod/quiz/amd/src/add_random_form.js b/mod/quiz/amd/src/add_random_form.js new file mode 100644 index 00000000000..b2a4b308835 --- /dev/null +++ b/mod/quiz/amd/src/add_random_form.js @@ -0,0 +1,185 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * JavaScript for the add_random_form class. + * + * @module mod_quiz/add_random_form + * @package mod_quiz + * @copyright 2018 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define( + [ + 'jquery', + 'mod_quiz/random_question_form_preview' + ], + function( + $, + RandomQuestionFormPreview + ) { + + // Wait 2 seconds before reloading the question set just in case + // the user is still changing the criteria. + var RELOAD_DELAY = 2000; + var SELECTORS = { + PREVIEW_CONTAINER: '[data-region="random-question-preview-container"]', + CATEGORY_FORM_ELEMENT: '[name="category"]', + SUBCATEGORY_FORM_ELEMENT: '[name="includesubcategories"]', + TAG_IDS_FORM_ELEMENT: '[name="fromtags[]"]' + }; + + /** + * Get the category id from the form. + * + * @param {jquery} form The form element. + * @return {string} The category id. + */ + var getCategoryId = function(form) { + // The value string is the category id and category context id joined + // by a comma. + var valueString = form.find(SELECTORS.CATEGORY_FORM_ELEMENT).val(); + // Split the two ids. + var values = valueString.split(','); + // Return just the category id. + return values[0]; + }; + + /** + * Check if the form indicates we should include include subcategories in + * the filter. + * + * @param {jquery} form The form element. + * @return {bool} + */ + var shouldIncludeSubcategories = function(form) { + return form.find(SELECTORS.SUBCATEGORY_FORM_ELEMENT).is(':checked'); + }; + + /** + * Get the tag ids for the selected tags in the form. + * + * @param {jquery} form The form element. + * @return {string[]} The tag ids. + */ + var getTagIds = function(form) { + var values = form.find(SELECTORS.TAG_IDS_FORM_ELEMENT).val(); + return values.map(function(value) { + // The tag element value is the tag id and tag name joined + // by a comma. So we need to split them to get the tag id. + var parts = value.split(','); + return parts[0]; + }); + }; + + /** + * Reload the preview section with a new set of filters. + * + * @param {jquery} form The form element. + * @param {int} contextId The current context id. + */ + var reloadQuestionPreview = function(form, contextId) { + var previewContainer = form.find(SELECTORS.PREVIEW_CONTAINER); + RandomQuestionFormPreview.reload( + previewContainer, + getCategoryId(form), + shouldIncludeSubcategories(form), + getTagIds(form), + contextId + ); + }; + + /** + * Is this an element we're interested in listening to changes on. + * + * @param {jquery} element The element to check. + * @return {bool} + */ + var isInterestingElement = function(element) { + if (element.closest(SELECTORS.CATEGORY_FORM_ELEMENT).length > 0) { + return true; + } + + if (element.closest(SELECTORS.SUBCATEGORY_FORM_ELEMENT).length > 0) { + return true; + } + + if (element.closest(SELECTORS.TAG_IDS_FORM_ELEMENT).length > 0) { + return true; + } + + return false; + }; + + /** + * Listen for changes to any of the interesting elements and reload the form + * preview with the new filter values if they are changed. + * + * The reload is delayed for a small amount of time (see RELOAD_DELAY) in case + * the user is actively editing the form. This allows us to avoid having to + * send multiple requests to the server on each change. + * + * Instead we can just send a single request when the user appears to have + * finished editing the form. + * + * @param {jquery} form The form element. + * @param {int} contextId The current context id. + */ + var addEventListeners = function(form, contextId) { + var reloadTimerId = null; + + form.on('change', function(e) { + // Only reload the preview when elements that will change the result + // are modified. + if (!isInterestingElement($(e.target))) { + return; + } + + // Show the loading icon to let the user know that the preview + // will be updated after their actions. + RandomQuestionFormPreview.showLoadingIcon(form); + + if (reloadTimerId) { + // Reset the timer each time the form is modified. + clearTimeout(reloadTimerId); + } + + // Don't immediately reload the question preview section just + // in case the user is still modifying the form. We don't want to + // spam reload requests. + reloadTimerId = setTimeout(function() { + reloadQuestionPreview(form, contextId); + }, RELOAD_DELAY); + }); + }; + + /** + * Trigger the first load of the preview section and then listen for modifications + * to the form to reload the preview with new filter values. + * + * @param {jquery} formId The form element id. + * @param {int} contextId The current context id. + */ + var init = function(formId, contextId) { + var form = $('#' + formId); + + reloadQuestionPreview(form, contextId); + addEventListeners(form, contextId); + }; + + return { + init: init + }; +}); diff --git a/mod/quiz/amd/src/random_question_form_preview.js b/mod/quiz/amd/src/random_question_form_preview.js new file mode 100644 index 00000000000..687a379b64a --- /dev/null +++ b/mod/quiz/amd/src/random_question_form_preview.js @@ -0,0 +1,235 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * JavaScript for the random_question_form_preview of the + * add_random_form class. + * + * @module mod_quiz/random_question_form_preview + * @package mod_quiz + * @copyright 2018 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define( + [ + 'jquery', + 'core/ajax', + 'core/str', + 'core/notification', + 'core/templates', + 'core/paged_content_factory' + ], + function( + $, + Ajax, + Str, + Notification, + Templates, + PagedContentFactory + ) { + + var ITEMS_PER_PAGE = 5; + var TEMPLATE_NAME = 'mod_quiz/random_question_form_preview_question_list'; + var SELECTORS = { + LOADING_ICON_CONTAINER: '[data-region="overlay-icon-container"]', + QUESTION_COUNT_CONTAINER: '[data-region="question-count-container"]', + QUESTION_LIST_CONTAINER: '[data-region="question-list-container"]' + }; + + /** + * Show the loading spinner over the preview section. + * + * @param {jquery} root The root element. + */ + var showLoadingIcon = function(root) { + root.find(SELECTORS.LOADING_ICON_CONTAINER).removeClass('hidden'); + }; + + /** + * Hide the loading spinner. + * + * @param {jquery} root The root element. + */ + var hideLoadingIcon = function(root) { + root.find(SELECTORS.LOADING_ICON_CONTAINER).addClass('hidden'); + }; + + /** + * Render the section of text to show the question count. + * + * @param {jquery} root The root element. + * @param {int} questionCount The number of questions. + */ + var renderQuestionCount = function(root, questionCount) { + Str.get_string('questionsmatchingfilter', 'mod_quiz', questionCount) + .then(function(string) { + root.find(SELECTORS.QUESTION_COUNT_CONTAINER).html(string); + return; + }) + .fail(Notification.exception); + }; + + /** + * Send a request to the server for more questions. + * + * @param {int} categoryId A question category id. + * @param {bool} includeSubcategories If the results should include subcategory questions + * @param {int[]} tagIds The list of tag ids that each question must have. + * @param {int} contextId The context where the questions will be added. + * @param {int} limit How many questions to retrieve. + * @param {int} offset How many questions to skip from the start of the result set. + * @return {promise} Resolved when the preview section has rendered. + */ + var requestQuestions = function( + categoryId, + includeSubcategories, + tagIds, + contextId, + limit, + offset + ) { + var request = { + methodname: 'core_question_get_random_question_summaries', + args: { + categoryid: categoryId, + includesubcategories: includeSubcategories, + tagids: tagIds, + contextid: contextId, + limit: limit, + offset: offset + } + }; + + return Ajax.call([request])[0]; + }; + + /** + * Build a paged content widget for questions with the given criteria. The + * criteria is used to fetch more questions from the server as the user + * requests new pages. + * + * @param {int} categoryId A question category id. + * @param {bool} includeSubcategories If the results should include subcategory questions + * @param {int[]} tagIds The list of tag ids that each question must have. + * @param {int} contextId The context where the questions will be added. + * @param {int} totalQuestionCount How many questions match the criteria above. + * @param {object[]} firstPageQuestions List of questions for the first page. + * @return {promise} A promise resolved with the HTML and JS for the paged content. + */ + var renderQuestionsAsPagedContent = function( + categoryId, + includeSubcategories, + tagIds, + contextId, + totalQuestionCount, + firstPageQuestions + ) { + // Provide a callback, renderQuestionsPages, + // to control how the questions on each page are rendered. + return PagedContentFactory.createFromAjax( + totalQuestionCount, + ITEMS_PER_PAGE, + // Callback function to render the requested pages. + function(pagesData) { + return pagesData.map(function(pageData) { + var limit = pageData.limit; + var offset = pageData.offset; + + if (offset == 0) { + // The first page is being requested and we've already got + // that data so we can just render it immediately. + return Templates.render(TEMPLATE_NAME, {questions: firstPageQuestions}); + } else { + // Otherwise we need to ask the server for the data. + return requestQuestions( + categoryId, + includeSubcategories, + tagIds, + contextId, + limit, + offset + ) + .then(function(response) { + var questions = response.questions; + return Templates.render(TEMPLATE_NAME, {questions: questions}); + }) + .fail(Notification.exception); + } + }); + } + ); + }; + + /** + * Re-render the preview section based on the provided filter criteria. + * + * @param {jquery} root The root element. + * @param {int} categoryId A question category id. + * @param {bool} includeSubcategories If the results should include subcategory questions + * @param {int[]} tagIds The list of tag ids that each question must have. + * @param {int} contextId The context where the questions will be added. + * @return {promise} Resolved when the preview section has rendered. + */ + var reload = function(root, categoryId, includeSubcategories, tagIds, contextId) { + // Show the loading spinner to tell the user that something is happening. + showLoadingIcon(root); + // Load the first set of questions. + return requestQuestions(categoryId, includeSubcategories, tagIds, contextId, ITEMS_PER_PAGE, 0) + .then(function(response) { + var totalCount = response.totalcount; + // Show the help message for the user to indicate how many questions + // match their filter criteria. + renderQuestionCount(root, totalCount); + return response; + }) + .then(function(response) { + var totalQuestionCount = response.totalcount; + var questions = response.questions; + + if (questions.length) { + // We received some questions so render them as paged content + // with a paging bar. + return renderQuestionsAsPagedContent( + categoryId, + includeSubcategories, + tagIds, + contextId, + totalQuestionCount, + questions + ); + } else { + // If we didn't receive any questions then we can return empty + // HTML and JS to clear the preview section. + return $.Deferred().resolve('', ''); + } + }) + .then(function(html, js) { + // Show the user the question set. + var container = root.find(SELECTORS.QUESTION_LIST_CONTAINER); + Templates.replaceNodeContents(container, html, js); + return; + }) + .always(function() { + hideLoadingIcon(root); + }) + .fail(Notification.exception); + }; + + return { + reload: reload, + showLoadingIcon: showLoadingIcon, + hideLoadingIcon: hideLoadingIcon + }; +}); diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 80f1f0a7393..240289e69da 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -651,8 +651,10 @@ $string['questionnotloaded'] = 'Question {$a} has not been loaded from the datab $string['questionorder'] = 'Question order'; $string['questionposition'] = 'New position in order for question {$a}'; $string['questions'] = 'Questions'; +$string['questionsetpreview'] = 'Question set preview'; $string['questionsinclhidden'] = 'Questions (including hidden)'; $string['questionsinthisquiz'] = 'Questions in this quiz'; +$string['questionsmatchingfilter'] = 'Questions matching this filter: {$a}'; $string['questionsperpage'] = 'Questions per page'; $string['questionsperpageselected'] = 'Questions per page has been set so the paging is currently fixed. As a result, the paging controls have been disabled. You can change this in {$a}.'; $string['questionsperpagex'] = 'Questions per page: {$a}'; diff --git a/mod/quiz/templates/random_question_form_preview.mustache b/mod/quiz/templates/random_question_form_preview.mustache new file mode 100644 index 00000000000..1be47e2b589 --- /dev/null +++ b/mod/quiz/templates/random_question_form_preview.mustache @@ -0,0 +1,40 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_quiz/random_question_form_preview + + A preview section for the list of questions that match the random + question criteria in the random question form. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * title A cleaned string (use clean_text()) to display. + * body HTML content for the boday + + Example context (json): + {} +}} +
+
+
+ {{< core/overlay_loading }}{{$hiddenclass}}{{/hiddenclass}}{{/ core/overlay_loading }} +
diff --git a/mod/quiz/templates/random_question_form_preview_question_list.mustache b/mod/quiz/templates/random_question_form_preview_question_list.mustache new file mode 100644 index 00000000000..ad2f9b328ac --- /dev/null +++ b/mod/quiz/templates/random_question_form_preview_question_list.mustache @@ -0,0 +1,58 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_quiz/random_question_form_preview_question_list + + A preview section for the list of questions that match the random + question criteria in the random question form. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * title A cleaned string (use clean_text()) to display. + * body HTML content for the boday + + Example context (json): + { + "questions": [ + { + "name":"Example question 1" + }, + { + "name":"Example question 2" + }, + { + "name":"Example question 3" + }, + { + "name":"Example question 4" + } + ] + } +}} +
    + {{#questions}} +
  • + {{#icon}}{{#pix}} {{key}}, {{component}}, {{{alttext}}} {{/pix}}{{/icon}} + {{name}} +
  • + {{/questions}} +
diff --git a/theme/bootstrapbase/templates/mod_quiz/random_question_form_preview.mustache b/theme/bootstrapbase/templates/mod_quiz/random_question_form_preview.mustache new file mode 100644 index 00000000000..b1e4e344dd3 --- /dev/null +++ b/theme/bootstrapbase/templates/mod_quiz/random_question_form_preview.mustache @@ -0,0 +1,40 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template mod_quiz/random_question_form_preview + + A preview section for the list of questions that match the random + question criteria in the random question form. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * title A cleaned string (use clean_text()) to display. + * body HTML content for the boday + + Example context (json): + {} +}} +
+
+
+ {{< core/overlay_loading }}{{$hiddenclass}}{{/hiddenclass}}{{/ core/overlay_loading }} +