Commit 231420f0 authored by Huong Nguyen's avatar Huong Nguyen
Browse files

MDL-74984 quiz: Add quick Duplicate question option to Quiz

Including in this commit:
 - External function mod_quiz_add_question_to_quiz was added to support adding the question to Quiz
 - New Javascript to handle Duplicate icon.
 - New Behat and PHP Unit to cover the new feature
parent 6d6f57d3
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
// 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 <http://www.gnu.org/licenses/>.
/**
* A javascript module to handle question duplicating for quiz.
*
* @module mod_quiz/duplicate
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.1
*/
import Notification from 'core/notification';
import Prefetch from 'core/prefetch';
import {get_string as getString} from 'core/str';
import * as Modal from 'core/modal_factory';
import * as ModalEvents from 'core/modal_events';
import * as QuizRepository from 'mod_quiz/repository';
import * as DuplicateQuestionRepository from 'qbank_duplicatequestion/repository';
const SELECTOR = {
duplicateQuestion: '.path-mod-quiz [data-action="duplicatequestion"]'
};
/**
* Register events for duplicate links.
*
* @param {int} contextId Context id
* @param {string} returnUrl Return url
* @param {int} quizId Quiz id
*/
const registerEventListeners = (contextId, returnUrl, quizId) => {
document.addEventListener('click', e => {
const duplicateAction = e.target.closest(SELECTOR.duplicateQuestion);
if (duplicateAction) {
e.preventDefault();
const questionId = duplicateAction.dataset.questionid;
const questionName = duplicateAction.dataset.questionname;
const questionType = duplicateAction.dataset.questiontype;
const pageNumber = duplicateAction.dataset.page;
const modalPromise = Modal.create({
type: Modal.types.SAVE_CANCEL,
title: getString('confirmation', 'admin'),
body: getString('duplicatequestioncheck', 'qbank_duplicatequestion', questionName),
buttons: {
save: getString('yes')
},
}).then(modal => {
modal.show();
return modal;
});
modalPromise.then(modal => {
modal.getRoot().on(ModalEvents.save, () => {
if (questionType == 'random') {
// Random question type. No need to duplicate the question in the question bank.
QuizRepository.addQuestionToQuiz(questionId, true, quizId, pageNumber).then(data => {
if (data.status) {
return window.location.assign(returnUrl);
} else {
return Notification.alert(getString('error'), data.warnings[0].message);
}
}).catch(Notification.exception);
} else {
// Normal question type.
// First, we need to duplicate the question in the question bank.
DuplicateQuestionRepository.duplicateQuestion(questionId, contextId, returnUrl).then(data => {
if (data.status) {
// After we duplicate the question in the question bank.
// Get the created question id and add it to the Quiz.
const createdQuestionId = data.createdquestionid;
QuizRepository.addQuestionToQuiz(createdQuestionId, false, quizId, pageNumber).then(data => {
if (data.status) {
window.location.assign(returnUrl);
} else {
Notification.alert(getString('error'), data.warnings[0].message);
}
return;
}).catch(Notification.exception);
return;
} else {
Notification.alert(getString('error'), data.warnings[0].message);
return;
}
}).catch(Notification.exception);
}
});
return modal;
}).catch(Notification.exception);
}
});
};
/**
* Initialises.
*
* @param {int} contextId Context id
* @param {string} returnUrl Return url
* @param {int} quizId Quiz id
*/
export const init = (contextId, returnUrl, quizId) => {
Prefetch.prefetchStrings('moodle', ['yes', 'error']);
Prefetch.prefetchStrings('core_admin', ['confirmation']);
Prefetch.prefetchStrings('qbank_duplicatequestion', ['duplicatequestioncheck']);
registerEventListeners(contextId, returnUrl, quizId);
};
// 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 <http://www.gnu.org/licenses/>.
/**
* A javascript module to handle quiz ajax actions.
*
* @module mod_quiz/repository
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.1
*/
import Ajax from 'core/ajax';
/**
* Add question to the quiz.
*
* @param {int} questionId The question id
* @param {boolean} isRandom Is random question or not
* @param {int} quizId The quiz id
* @param {int} pageNumber The page number
* @return {promise}
*/
export const addQuestionToQuiz = (questionId, isRandom, quizId, pageNumber) => {
const request = {
methodname: 'mod_quiz_add_question_to_quiz',
args: {
questionid: questionId,
israndom: isRandom,
quizid: quizId,
page: pageNumber,
}
};
return Ajax.call([request])[0];
};
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Quiz external API for adding the question to quiz.
*
* @package mod_quiz
* @category external
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_quiz\external;
use external_api;
use external_function_parameters;
use external_single_structure;
use external_value;
use external_warnings;
use quiz;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
/**
* Quiz external API for adding the question to quiz.
*
* @package mod_quiz
* @category external
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 4.1
*/
class add_question_to_quiz extends external_api {
/**
* Describes the parameters for adding the question to the quiz.
*
* @return external_function_parameters
*/
public static function execute_parameters(): external_function_parameters {
return new external_function_parameters([
'questionid' => new external_value(PARAM_INT, 'The question id'),
'israndom' => new external_value(PARAM_BOOL, 'True if the question is a random question', VALUE_REQUIRED, false),
'quizid' => new external_value(PARAM_INT, 'The context id'),
'page' => new external_value(PARAM_INT, 'Which page in quiz to add the question on. If 0 (default), add at the end',
VALUE_REQUIRED, 0),
]);
}
/**
* External function to add the question to the quiz.
*
* @param int $questionid The question id that need to be added to the quiz.
* @param bool $israndom If the question is a random question or not.
* @param int $quizid The quiz id.
* @param int $page The page id.
* @return array
*/
public static function execute(int $questionid, bool $israndom, int $quizid, int $page): array {
global $DB;
// Parameter validation.
[
'questionid' => $questionid,
'israndom' => $israndom,
'quizid' => $quizid,
'page' => $page
] = self::validate_parameters(self::execute_parameters(), [
'questionid' => $questionid,
'israndom' => $israndom,
'quizid' => $quizid,
'page' => $page
]);
$response = [
'status' => false,
'warnings' => []
];
// Verify that the quiz is existed.
if (!$DB->record_exists('quiz', ['id' => $quizid])) {
$response['warnings'][] = [
'item' => $quizid,
'warningcode' => 'erroraddquestiontoquiz',
'message' => get_string('errorinvalidquiz', 'quiz')
];
return $response;
}
// Create the Quiz object.
$quizobj = quiz::create($quizid);
// Get the Quiz setting.
$quiz = $quizobj->get_quiz();
if ($israndom) {
// Random question.
$slot = $questionid;
// Get the quiz structure.
$structure = $quizobj->get_structure();
// Get the slot information.
$slot = $structure->get_slot_by_number($slot);
// Add the random question to the Quiz.
$slottags = [];
if (isset($slot->randomtags)) {
foreach ($slot->randomtags as $slottag) {
$slottag = explode(',', $slottag);
$slottags[] = $slottag[0];
}
}
quiz_add_random_questions($quiz, $page, $slot->category, 1, $slot->randomrecurse, $slottags);
$response['status'] = true;
return $response;
}
// Verify that the question is existed.
if (!$DB->record_exists('question', ['id' => $questionid])) {
$response['warnings'][] = [
'item' => $questionid,
'warningcode' => 'erroraddquestiontoquiz',
'message' => get_string('questiondoesnotexist', 'question')
];
return $response;
}
// Add the question to the Quiz.
quiz_add_quiz_question($questionid, $quiz, $page);
$response['status'] = true;
return $response;
}
/**
* Describes the data returned from the external function.
*
* @return external_single_structure
*/
public static function execute_returns(): external_single_structure {
return new external_single_structure([
'status' => new external_value(PARAM_BOOL, 'status: true if success'),
'warnings' => new external_warnings()
]);
}
}
...@@ -122,6 +122,12 @@ class edit_renderer extends \plugin_renderer_base { ...@@ -122,6 +122,12 @@ class edit_renderer extends \plugin_renderer_base {
\core\plugininfo\qbank::is_plugin_enabled(\qbank_managecategories\helper::PLUGINNAME), \core\plugininfo\qbank::is_plugin_enabled(\qbank_managecategories\helper::PLUGINNAME),
]); ]);
$this->page->requires->js_call_amd('mod_quiz/duplicate_question', 'init', [
$thiscontext->id,
$pageurl->out(true),
$quizobj->get_quiz()->id
]);
// Include the question chooser. // Include the question chooser.
$output .= $this->question_chooser(); $output .= $this->question_chooser();
} }
...@@ -824,6 +830,7 @@ class edit_renderer extends \plugin_renderer_base { ...@@ -824,6 +830,7 @@ class edit_renderer extends \plugin_renderer_base {
null, null, $qtype); null, null, $qtype);
} }
if ($structure->can_be_edited()) { if ($structure->can_be_edited()) {
$questionicons .= $this->question_duplicate_icon($structure, $slot);
$questionicons .= $this->question_remove_icon($structure, $slot, $pageurl); $questionicons .= $this->question_remove_icon($structure, $slot, $pageurl);
} }
$questionicons .= $this->marked_out_of_field($structure, $slot); $questionicons .= $this->marked_out_of_field($structure, $slot);
...@@ -845,6 +852,31 @@ class edit_renderer extends \plugin_renderer_base { ...@@ -845,6 +852,31 @@ class edit_renderer extends \plugin_renderer_base {
); );
} }
/**
* Render the question duplicate icon.
*
* @param structure $structure object containing the structure of the quiz.
* @param int $slot the first slot on the page we are outputting.
* @return string HTML fragment
*/
public function question_duplicate_icon(structure $structure, int $slot): string {
$question = $structure->get_question_in_slot($slot);
$url = new \moodle_url('#');
$strdelete = get_string('duplicate');
$image = $this->pix_icon('t/copy', $strdelete);
return $this->action_link($url, $image, null, [
'title' => $strdelete,
'class' => 'cm-edit-action editing_duplicate',
'data-action' => 'duplicatequestion',
'data-questionid' => $question->qtype != 'random' ? $question->questionid : $question->slot,
'data-questionname' => $question->name,
'data-questiontype' => $question->qtype,
'data-page' => $question->page,
]);
}
/** /**
* Output the question number. * Output the question number.
* @param string $number The number, or 'i'. * @param string $number The number, or 'i'.
......
...@@ -199,4 +199,12 @@ $functions = array( ...@@ -199,4 +199,12 @@ $functions = array(
'capabilities' => 'mod/quiz:view', 'capabilities' => 'mod/quiz:view',
'ajax' => true, 'ajax' => true,
], ],
'mod_quiz_add_question_to_quiz' => [
'classname' => 'mod_quiz\external\add_question_to_quiz',
'description' => 'Add the specific question to the quiz',
'type' => 'write',
'capabilities' => 'mod/quiz:view',
'ajax' => true,
],
); );
...@@ -346,6 +346,7 @@ $string['enabled'] = 'Enabled'; ...@@ -346,6 +346,7 @@ $string['enabled'] = 'Enabled';
$string['endtest'] = 'Finish attempt ...'; $string['endtest'] = 'Finish attempt ...';
$string['erroraccessingreport'] = 'You cannot access this report'; $string['erroraccessingreport'] = 'You cannot access this report';
$string['errorinquestion'] = 'Error in question'; $string['errorinquestion'] = 'Error in question';
$string['errorinvalidquiz'] = 'Invalid Quiz Id';
$string['errormissingquestion'] = 'Error: The system is missing the question with id {$a}'; $string['errormissingquestion'] = 'Error: The system is missing the question with id {$a}';
$string['errornotnumbers'] = 'Error - answers must be numeric'; $string['errornotnumbers'] = 'Error - answers must be numeric';
$string['errorunexpectedevent'] = 'Unexpected event code {$a->event} found for question {$a->questionid} in attempt {$a->attemptid}.'; $string['errorunexpectedevent'] = 'Unexpected event code {$a->event} found for question {$a->questionid} in attempt {$a->attemptid}.';
......
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Quiz external add question API unit tests.
*
* @package mod_quiz
* @category test
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_quiz;
use advanced_testcase;
use mod_quiz\external\add_question_to_quiz;
use quiz;
/**
* Quiz external add question API unit tests.
*
* @package mod_quiz
* @category test
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @coversDefaultClass \mod_quiz\external\add_question_to_quiz
*/
class add_question_to_quiz_test extends advanced_testcase {
/**
* This method is called before each test.
*/
public function setUp(): void {
parent::setUp();
$this->setAdminUser();
$this->course = $this->getDataGenerator()->create_course();
$this->questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$this->quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
}
/**
* Test the add question to Quiz API.
*
* @covers ::execute
*/
public function test_add_question_to_quiz() {
$this->resetAfterTest();
// Create Quiz.
$quiz = $this->quizgenerator->create_instance(['course' => $this->course->id]);
// Create a couple of questions.
$cat = $this->questiongenerator->create_question_category();
$saq = $this->questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
$numq = $this->questiongenerator->create_question('numerical', null, ['category' => $cat->id]);
$tfq = $this->questiongenerator->create_question('truefalse', null, ['category' => $cat->id]);
// Get the Quiz object.
$quizobj = quiz::create($quiz->id);
// Verify that there is no question in the Quiz.
$this->assertEquals(0, $quizobj->get_structure()->get_question_count());
// Add the saq question to the Quiz via API.
$result = add_question_to_quiz::execute($saq->id, $quiz->id, 0);
$this->assertTrue($result['status']);
$this->assertEmpty($result['warnings']);
// Verify that there is 1 question in the Quiz now.
$this->assertEquals(1, $quizobj->get_structure()->get_question_count());
// Verify that the saq question will be added to slot 1.
$this->assertEquals(1, $quizobj->get_structure()->get_question_by_id($saq->id)->slot);
$this->assertEquals($saq->id, $quizobj->get_structure()->get_question_in_slot(1)->questionid);
// Verify that the saq question will be added to page 1.
$this->assertEquals(1, $quizobj->get_structure()->get_question_by_id($saq->id)->page);
// Add the numq question to the Quiz via API (in the same page with the saq).
$result = add_question_to_quiz::execute($numq->id, $quiz->id, 1);
$this->assertTrue($result['status']);
$this->assertEmpty($result['warnings']);
// Verify that there is 2 questions in the Quiz now.
$this->assertEquals(2, $quizobj->get_structure()->get_question_count());
// Verify that the numq question will be added to slot 2.
$this->assertEquals(2, $quizobj->get_structure()->get_question_by_id($numq->id)->slot);
$this->assertEquals($numq->id, $quizobj->get_structure()->get_question_in_slot(2)->questionid);
// Verify that the numq question will be added to page 1.
$this->assertEquals(1, $quizobj->get_structure()->get_question_by_id($numq->id)->page);
// Add the tfq question to the Quiz via API (in new page).
$result = add_question_to_quiz::execute($tfq->id, $quiz->id, 0);
$this->assertTrue($result['status']);
$this->assertEmpty($result['warnings']);
// Verify that there is 3 questions in the Quiz now.
$this->assertEquals(3, $quizobj->get_structure()->get_question_count());
// Verify that the tfq question will be added to slot 3.
$this->assertEquals(3, $quizobj->get_structure()->get_question_by_id($tfq->id)->slot);
$this->assertEquals($tfq->id, $quizobj->get_structure()->get_question_in_slot(3)->questionid);
// Verify that the tfq question will be added to page 2.
$this->assertEquals(2, $quizobj->get_structure()->get_question_by_id($tfq->id)->page);
// Add the non-existing question.
$result = add_question_to_quiz::execute(9999, $quiz->id, 0);
$this->assertFalse($result['status']);
$this->assertNotEmpty($result['warnings']);
$warning = $result['warnings'][0];
$this->assertEquals(get_string('questiondoesnotexist', 'question'), $warning['message']);
$this->assertEquals('erroraddquestiontoquiz', $warning['warningcode']);
$this->assertEquals(9999, $warning['item']);
// Add the non-existing quiz.
$result = add_question_to_quiz::execute($tfq->id, 9999, 0);
$this->assertFalse($result['status']);
$this->assertNotEmpty($result['warnings']);
$warning = $result['warnings'][0];
$this->assertEquals(get_string('errorinvalidquiz', 'quiz'), $warning['message']);
$this->assertEquals('erroraddquestiontoquiz', $warning['warningcode']);
$this->assertEquals(9999, $warning['item']);
}
}
...@@ -945,6 +945,19 @@ class behat_mod_quiz extends behat_question_base { ...@@ -945,6 +945,19 @@ class behat_mod_quiz extends behat_question_base {
$this->set_user(); $this->set_user();
} }
/**
* Duplicate a question on the Edit quiz page by clicking on the Duplicate icon
*
* @param string $questionname the name of the question we are looking for.
* @When /^I duplicate "(?P<question_name>(?:[^"]|\\")*)" in the quiz by clicking the duplicate icon$/
*/
public function i_duplicate_question_by_clicking_the_duplicate_icon($questionname) {
$slotxpath = "//li[contains(@class, ' slot ') and contains(., '" . $this->escape($questionname) . "')]";
$duplicatexpath = "//a[contains(@class, 'editing_duplicate')]";
$this->execute("behat_general::i_click_on", [$slotxpath . $duplicatexpath, "xpath_element"]);
}
/** /**
* Return a list of the exact named selectors for the component. * Return a list of the exact named selectors for the component.
* *
......
@mod @mod_quiz
Feature: Edit quiz page - duplicate question
In order to build the quiz I want my students to attempt
As a teacher
I need to be able to duplicate question in the quiz.
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | T1 | Teacher1 | teacher1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist: