diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 167cc5bb..f44f5a60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,46 +4,45 @@ on: [push, pull_request] jobs: test: runs-on: 'ubuntu-latest' - strategy: - fail-fast: false - matrix: - include: - - php: '8.0' - moodle-branch: 'master' - database: 'pgsql' - - php: '7.4' - moodle-branch: 'MOODLE_400_STABLE' - database: 'pgsql' - - php: '7.3' - moodle-branch: 'MOODLE_400_STABLE' - database: 'mariadb' services: postgres: - image: postgres + image: postgres:13 env: POSTGRES_USER: 'postgres' POSTGRES_HOST_AUTH_METHOD: 'trust' - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 3 ports: - 5432:5432 - + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 mariadb: image: mariadb:10 env: MYSQL_USER: 'root' MYSQL_ALLOW_EMPTY_PASSWORD: "true" + MYSQL_CHARACTER_SET_SERVER: "utf8mb4" + MYSQL_COLLATION_SERVER: "utf8mb4_unicode_ci" + ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 3 + strategy: + fail-fast: false + matrix: + include: + - php: '8.0' + moodle-branch: 'MOODLE_401_STABLE' + database: 'pgsql' + - php: '8.0' + moodle-branch: 'MOODLE_400_STABLE' + database: 'pgsql' + - php: '7.4' + moodle-branch: 'MOODLE_400_STABLE' + database: 'mariadb' + steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: plugin @@ -53,6 +52,8 @@ jobs: php-version: ${{ matrix.php }} extensions: ${{ matrix.extensions }} ini-values: max_input_vars=5000 + # none to use phpdbg fallback. Specify pcov (Moodle 3.10 and up) or xdebug to use them instead. + coverage: none - name: Deploy moodle-plugin-ci run: | diff --git a/README.txt b/README.txt index a1f9f0d8..5a18c293 100644 --- a/README.txt +++ b/README.txt @@ -2,6 +2,14 @@ The questionnaire module allows you to construct questionnaires (surveys) from a variety of question type. It was originally based on phpESP, and Open Source survey tool. +-------------------------------------------------------------------------------- +Developers Note: + +There is no main branch. Questionnaire is maintained in MOODLE_XX_STABLE +branches. Use the latest STABLE branch for development or installation. +The current stable branch is MOODLE_400_STABLE, and supports Moodle 4.0 and up. +Use the MOODLE_311_STABLE branch for Moodle 3.9 through 3.11. + -------------------------------------------------------------------------------- To Install: @@ -16,9 +24,3 @@ To Upgrade: -------------------------------------------------------------------------------- Please read the CHANGES.md file for more info about successive changes - --------------------------------------------------------------------------------- -Developers Note: - -Do not use the main branch. Questionnaire is maintained in MOODLE_XX_STABLE -branches. Use the latest STABLE branch for development or installation. diff --git a/backup/moodle2/restore_questionnaire_activity_task.class.php b/backup/moodle2/restore_questionnaire_activity_task.class.php index 7f41e4c6..ebb066b7 100644 --- a/backup/moodle2/restore_questionnaire_activity_task.class.php +++ b/backup/moodle2/restore_questionnaire_activity_task.class.php @@ -52,8 +52,10 @@ public static function define_decode_contents() { $contents[] = new restore_decode_content('questionnaire', array('intro'), 'questionnaire'); $contents[] = new restore_decode_content('questionnaire_survey', - array('info', 'thank_head', 'thank_body', 'thanks_page'), 'questionnaire_survey'); + array('info', 'thank_head', 'thank_body', 'thanks_page', 'feedbacknotes'), 'questionnaire_survey'); $contents[] = new restore_decode_content('questionnaire_question', array('content'), 'questionnaire_question'); + $contents[] = new restore_decode_content('questionnaire_fb_sections', array('sectionheading'), 'questionnaire_fb_sections'); + $contents[] = new restore_decode_content('questionnaire_feedback', array('feedbacktext'), 'questionnaire_feedback'); return $contents; } diff --git a/backup/moodle2/restore_questionnaire_stepslib.php b/backup/moodle2/restore_questionnaire_stepslib.php index 9b3b46e6..d5235635 100644 --- a/backup/moodle2/restore_questionnaire_stepslib.php +++ b/backup/moodle2/restore_questionnaire_stepslib.php @@ -13,6 +13,7 @@ // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +use mod_questionnaire\feedback\section; /** * Define all the restore steps that will be used by the restore_questionnaire_activity_task. @@ -189,7 +190,7 @@ protected function process_questionnaire_fb_sections($data) { // If this questionnaire has separate sections feedbacks. if (isset($data->scorecalculation)) { - $scorecalculation = unserialize($data->scorecalculation); + $scorecalculation = section::decode_scorecalculation($data->scorecalculation); $newscorecalculation = array(); foreach ($scorecalculation as $qid => $val) { $newqid = $this->get_mappingid('questionnaire_question', $qid); diff --git a/classes/completion/custom_completion.php b/classes/completion/custom_completion.php new file mode 100644 index 00000000..3e7542f8 --- /dev/null +++ b/classes/completion/custom_completion.php @@ -0,0 +1,85 @@ +. +declare(strict_types=1); + +namespace mod_questionnaire\completion; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/questionnaire/lib.php'); + +use coding_exception; +use core_completion\activity_custom_completion; +use moodle_exception; + +/** + * Activity custom completion subclass for the data activity. + * + * Class for defining mod_oucontent's custom completion rules and fetching the completion statuses + * of the custom completion rules for a given data instance and a user. + * + * @package mod_questionnaire + * @copyright 2022 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_completion extends activity_custom_completion { + /** + * Fetches the completion state for a given completion rule. + * + * @param string $rule + * @return int + */ + public function get_state(string $rule): int { + $this->validate_rule($rule); + $userid = $this->userid; + $cm = $this->cm; + $status = questionnaire_get_completion_state($cm, $userid, $rule); + return $status ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE; + } + + /** + * Fetch the list of custom completion rules that this module defines. + * + * @return array + */ + public static function get_defined_custom_rules(): array { + return [ + 'completionsubmit' + ]; + } + + /** + * Returns an associative array of the descriptions of custom completion rules. + * + * @return array + */ + public function get_custom_rule_descriptions(): array { + return [ + 'completionsubmit' => get_string('completionsubmit', 'questionnaire') + ]; + } + + /** + * Returns an array of all completion rules, in the order they should be displayed to users. + * + * @return array + */ + public function get_sort_order(): array { + return [ + 'completionsubmit', + ]; + } +} diff --git a/classes/dates.php b/classes/dates.php new file mode 100644 index 00000000..e266c9d9 --- /dev/null +++ b/classes/dates.php @@ -0,0 +1,71 @@ +. + +/** + * Contains the class for fetching the important dates in mod_questionnaire for a given module instance and a user. + * + * @package mod_questionnaire + * @copyright 2022 University of Vienna + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace mod_questionnaire; + +use core\activity_dates; + +/** + * Class for fetching the important dates in mod_questionnaire for a given module instance and a user. + * + * @copyright 2022 University of Vienna + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class dates extends activity_dates { + + /** + * Returns a list of important dates in mod_questionnaire + * + * @return array + */ + protected function get_dates(): array { + global $DB; + + $timeopen = $this->cm->customdata->opendate ?? null; + $timeclose = $this->cm->customdata->closedate ?? null; + + $now = time(); + $dates = []; + + if ($timeopen) { + $openlabelid = $timeopen > $now ? 'activitydate:opens' : 'activitydate:opened'; + $dates[] = [ + 'label' => get_string($openlabelid, 'core_course'), + 'timestamp' => (int) $timeopen, + ]; + } + + if ($timeclose) { + $closelabelid = $timeclose > $now ? 'activitydate:closes' : 'activitydate:closed'; + $dates[] = [ + 'label' => get_string($closelabelid, 'core_course'), + 'timestamp' => (int) $timeclose, + ]; + } + + return $dates; + } +} diff --git a/classes/edit_question_form.php b/classes/edit_question_form.php index 6e10d60a..92098e84 100644 --- a/classes/edit_question_form.php +++ b/classes/edit_question_form.php @@ -90,6 +90,36 @@ public function validation($data, $files) { } } + // If this is a slider question. + if ($data['type_id'] == QUESSLIDER) { + if (isset($data['minrange']) && isset($data['maxrange']) && isset($data['startingvalue']) && + isset($data['stepvalue'])) { + if ($data['minrange'] >= $data['maxrange']) { + $errors['maxrange'] = get_string('invalidrange', 'questionnaire'); + } + + if (($data['startingvalue'] > $data['maxrange']) || ($data['startingvalue'] < $data['minrange'])) { + $errors['startingvalue'] = get_string('invalidstartingvalue', 'questionnaire'); + } + + if ($data['startingvalue'] > 100 || $data['startingvalue'] < -100) { + $errors['startingvalue'] = get_string('invalidstartingvalue', 'questionnaire'); + } + + if (($data['stepvalue'] > $data['maxrange']) || $data['stepvalue'] < 1) { + $errors['stepvalue'] = get_string('invalidincrement', 'questionnaire'); + } + + if ($data['minrange'] < -100) { + $errors['minrange'] = get_string('invalidminmaxrange', 'questionnaire'); + } + + if ($data['maxrange'] > 100) { + $errors['maxrange'] = get_string('invalidminmaxrange', 'questionnaire'); + } + } + } + return $errors; } diff --git a/classes/feedback/section.php b/classes/feedback/section.php index abcad77a..f17cb4cd 100644 --- a/classes/feedback/section.php +++ b/classes/feedback/section.php @@ -97,7 +97,7 @@ public static function new_section($surveyid, $sectionlabel = '') { $newsection->surveyid = $surveyid; $newsection->section = $maxsection + 1; $newsection->sectionlabel = $sectionlabel; - $newsection->scorecalculation = ''; + $newsection->scorecalculation = $newsection->encode_scorecalculation([]); $newsecid = $DB->insert_record(self::TABLE, $newsection); $newsection->id = $newsecid; $newsection->scorecalculation = []; @@ -143,7 +143,7 @@ public function load_section($params) { $this->id = $feedbackrec->id; $this->surveyid = $feedbackrec->surveyid; $this->section = $feedbackrec->section; - $this->scorecalculation = $this->decode_scorecalculation($feedbackrec->scorecalculation); + $this->scorecalculation = $this->get_valid_scorecalculation($feedbackrec->scorecalculation); $this->sectionlabel = $feedbackrec->sectionlabel; $this->sectionheading = $feedbackrec->sectionheading; $this->sectionheadingformat = $feedbackrec->sectionheadingformat; @@ -219,13 +219,14 @@ public function delete() { $DB->delete_records(self::TABLE, ['id' => $this->id]); // Resequence the section numbers as necessary. - $allsections = $DB->get_records(self::TABLE, ['surveyid' => $this->surveyid], 'section ASC'); - $count = 1; - foreach ($allsections as $id => $section) { - if ($section->section != $count) { - $DB->set_field(self::TABLE, 'section', $count, ['id' => $id]); + if ($allsections = $DB->get_records(self::TABLE, ['surveyid' => $this->surveyid], 'section ASC')) { + $count = 1; + foreach ($allsections as $id => $section) { + if ($section->section != $count) { + $DB->set_field(self::TABLE, 'section', $count, ['id' => $id]); + } + $count++; } - $count++; } } @@ -253,7 +254,7 @@ public function update() { $this->scorecalculation = $this->encode_scorecalculation($this->scorecalculation); $DB->update_record(self::TABLE, $this); - $this->scorecalculation = $this->decode_scorecalculation($this->scorecalculation); + $this->scorecalculation = $this->get_valid_scorecalculation($this->scorecalculation); foreach ($this->sectionfeedback as $sectionfeedback) { $sectionfeedback->update(); @@ -261,12 +262,12 @@ public function update() { } /** - * Return the decoded calculation array/ - * @param string $codedstring - * @return mixed + * Decode and ensure scorecalculation is what we expect. + * @param string|null $codedstring + * @return array * @throws coding_exception */ - protected function decode_scorecalculation($codedstring) { + public static function decode_scorecalculation(?string $codedstring): array { // Expect a serialized data string. if (($codedstring == null)) { $codedstring = ''; @@ -275,11 +276,33 @@ protected function decode_scorecalculation($codedstring) { throw new coding_exception('Invalid scorecalculation format.'); } if (!empty($codedstring)) { - $scorecalculation = unserialize($codedstring); + $scorecalculation = unserialize_array($codedstring) ?: []; } else { $scorecalculation = []; } + if (!is_array($scorecalculation)) { + throw new coding_exception('Invalid scorecalculation format.'); + } + + foreach ($scorecalculation as $score) { + if (!empty($score) && !is_numeric($score)) { + throw new coding_exception('Invalid scorecalculation format.'); + } + } + + return $scorecalculation; + } + + /** + * Return the decoded and validated calculation array. + * @param string $codedstring + * @return mixed + * @throws coding_exception + */ + protected function get_valid_scorecalculation($codedstring) { + $scorecalculation = static::decode_scorecalculation($codedstring); + // Check for deleted questions and questions that don't support scores. foreach ($scorecalculation as $qid => $score) { if (!isset($this->questions[$qid])) { diff --git a/classes/feedback_form.php b/classes/feedback_form.php index a856e62a..25260847 100644 --- a/classes/feedback_form.php +++ b/classes/feedback_form.php @@ -40,9 +40,9 @@ public function definition() { $mform =& $this->_form; // Questionnaire Feedback Sections and Messages. + $mform->addElement('header', 'submithdr', get_string('feedbackoptions', 'questionnaire')); $feedbackoptions = []; $feedbackoptions[0] = get_string('feedbacknone', 'questionnaire'); - $mform->addElement('header', 'submithdr', get_string('feedbackoptions', 'questionnaire')); $feedbackoptions[1] = get_string('feedbackglobal', 'questionnaire'); $feedbackoptions[2] = get_string('feedbacksections', 'questionnaire'); diff --git a/classes/output/mobile.php b/classes/output/mobile.php index 0dd58aa4..74d3f549 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -258,6 +258,10 @@ protected static function add_pagequestion_data($questionnaire, $pagenum, $respo $question = $questionnaire->questions[$questionid]; if ($question->supports_mobile()) { $pagequestions[] = $question->mobile_question_display($qnum, $questionnaire->autonum); + $mobileotherdata = $question->mobile_otherdata(); + if (!empty($mobileotherdata)) { + $responses = array_merge($responses, $mobileotherdata); + } if (($response !== null) && isset($response->answers[$questionid])) { $responses = array_merge($responses, $question->get_mobile_response_data($response)); } diff --git a/classes/question/check.php b/classes/question/check.php index f736ef8e..5211f48e 100644 --- a/classes/question/check.php +++ b/classes/question/check.php @@ -197,6 +197,27 @@ protected function response_survey_display($response) { return $resptags; } + /** + * Check question's form data for complete response. + * + * @param object $responsedata The data entered into the response. + * @return boolean + */ + public function response_complete($responsedata) { + if (isset($responsedata->{'q'.$this->id}) && $this->required() && + is_array($responsedata->{'q'.$this->id})) { + foreach ($responsedata->{'q' . $this->id} as $key => $choice) { + // If only an 'other' choice is selected and empty, question is not completed. + if ((strpos($key, 'o') === 0) && empty($choice)) { + return false; + } else { + return true; + } + } + } + return parent::response_complete($responsedata); + } + /** * Check question's form data for valid response. Override this is type has specific format requirements. * @@ -218,16 +239,15 @@ public function response_valid($responsedata) { } } } else if (isset($responsedata->{'q'.$this->id})) { - foreach ($responsedata->{'q'.$this->id} as $answer) { - if (strpos($answer, 'other_') !== false) { + foreach ($responsedata->{'q'.$this->id} as $key => $answer) { + if (strpos($key, 'o') === 0) { // ..."other" choice is checked but text box is empty. - $othercontent = "q".$this->id.substr($answer, 5); - if (trim($responsedata->$othercontent) == false) { + $okey = substr($key, 1); + if (isset($responsedata->{'q'.$this->id}[$okey]) && empty(trim($answer))) { $valid = false; break; } - $nbrespchoices++; - } else if (is_numeric($answer)) { + } else if (is_numeric($key)) { $nbrespchoices++; } } diff --git a/classes/question/question.php b/classes/question/question.php index b05eab7c..38a90682 100644 --- a/classes/question/question.php +++ b/classes/question/question.php @@ -41,6 +41,7 @@ define('QUESRATE', 8); define('QUESDATE', 9); define('QUESNUMERIC', 10); +define('QUESSLIDER', 11); define('QUESPAGEBREAK', 99); define('QUESSECTIONTEXT', 100); @@ -117,7 +118,8 @@ abstract class question { QUESDATE => 'date', QUESNUMERIC => 'numerical', QUESPAGEBREAK => 'pagebreak', - QUESSECTIONTEXT => 'sectiontext' + QUESSECTIONTEXT => 'sectiontext', + QUESSLIDER => 'slider', ]; /** @var array $notifications Array of extra messages for display purposes. */ @@ -138,7 +140,7 @@ public function __construct($id = 0, $question = null, $context = null, $params if ($qtypes === null) { $qtypes = $DB->get_records('questionnaire_question_type', [], 'typeid', - 'typeid, type, has_choices, response_table'); + 'typeid, type, has_choices, response_table') ?? []; } if ($id) { @@ -280,14 +282,15 @@ private function get_dependencies() { global $DB; $this->dependencies = []; - $dependencies = $DB->get_records('questionnaire_dependency', - ['questionid' => $this->id , 'surveyid' => $this->surveyid], 'id ASC'); - foreach ($dependencies as $dependency) { - $this->dependencies[$dependency->id] = new \stdClass(); - $this->dependencies[$dependency->id]->dependquestionid = $dependency->dependquestionid; - $this->dependencies[$dependency->id]->dependchoiceid = $dependency->dependchoiceid; - $this->dependencies[$dependency->id]->dependlogic = $dependency->dependlogic; - $this->dependencies[$dependency->id]->dependandor = $dependency->dependandor; + if ($dependencies = $DB->get_records('questionnaire_dependency', + ['questionid' => $this->id , 'surveyid' => $this->surveyid], 'id ASC')) { + foreach ($dependencies as $dependency) { + $this->dependencies[$dependency->id] = new \stdClass(); + $this->dependencies[$dependency->id]->dependquestionid = $dependency->dependquestionid; + $this->dependencies[$dependency->id]->dependchoiceid = $dependency->dependchoiceid; + $this->dependencies[$dependency->id]->dependlogic = $dependency->dependlogic; + $this->dependencies[$dependency->id]->dependandor = $dependency->dependandor; + } } } @@ -1563,6 +1566,9 @@ public function mobile_question_display($qnum, $autonum = false) { ]; $mobiledata->choices = $this->mobile_question_choices_display(); + if ($this->mobile_question_extradata_display()) { + $mobiledata->extradata = json_decode($this->extradata); + } if ($autonum) { $mobiledata->content = $qnum . '. ' . $mobiledata->content; $mobiledata->content_stripped = $qnum . '. ' . $mobiledata->content_stripped; @@ -1617,4 +1623,22 @@ public function get_mobile_response_data($response) { return $resultdata; } + + /** + * True if question need extradata for mobile app. + * + * @return bool + */ + public function mobile_question_extradata_display() { + return false; + } + + /** + * Return the otherdata to be used by the mobile app. + * + * @return array + */ + public function mobile_otherdata() { + return []; + } } diff --git a/classes/question/rate.php b/classes/question/rate.php index 4e6e4e6c..2d30245e 100644 --- a/classes/question/rate.php +++ b/classes/question/rate.php @@ -590,10 +590,8 @@ public function response_complete($responsedata) { } if ($num == 0) { - if (!$this->has_dependencies()) { - if ($this->required()) { - $answered = false; - } + if ($this->required()) { + $answered = false; } } return $answered; diff --git a/classes/question/sectiontext.php b/classes/question/sectiontext.php index 38135965..46c59d63 100644 --- a/classes/question/sectiontext.php +++ b/classes/question/sectiontext.php @@ -16,6 +16,8 @@ namespace mod_questionnaire\question; +use mod_questionnaire\feedback\section; + /** * This file contains the parent class for sectiontext question types. * @@ -96,14 +98,16 @@ protected function question_survey_display($response, $descendantsdata, $blankqu return ''; } - $fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $this->surveyid]); $filteredsections = []; // In which section(s) is this question? - foreach ($fbsections as $key => $fbsection) { - $scorecalculation = unserialize($fbsection->scorecalculation); - if (array_key_exists($this->id, $scorecalculation)) { - array_push($filteredsections, $fbsection->section); + if ($fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $this->surveyid])) { + foreach ($fbsections as $key => $fbsection) { + if ($scorecalculation = section::decode_scorecalculation($fbsection->scorecalculation)) { + if (array_key_exists($this->id, $scorecalculation)) { + array_push($filteredsections, $fbsection->section); + } + } } } diff --git a/classes/question/slider.php b/classes/question/slider.php new file mode 100644 index 00000000..37c284ed --- /dev/null +++ b/classes/question/slider.php @@ -0,0 +1,304 @@ +. + +namespace mod_questionnaire\question; + +/** + * This file contains the parent class for slider question types. + * + * @author Hieu Vu Van + * @copyright 2022 The Open University. + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + * @package mod_questionnaire + */ +class slider extends question { + + /** + * Return the responseclass used. + * @return string + */ + protected function responseclass() { + return '\\mod_questionnaire\\responsetype\\slider'; + } + + /** + * Return the help name. + * @return string + */ + public function helpname() { + return 'slider'; + } + + /** + * Return true if the question has choices. + */ + public function has_choices() { + return false; + } + + /** + * Override and return a form template if provided. Output of question_survey_display is iterpreted based on this. + * + * @return boolean | string + */ + public function question_template() { + return 'mod_questionnaire/question_slider'; + } + + /** + * Override and return a response template if provided. Output of response_survey_display is iterpreted based on this. + * + * @return boolean | string + */ + public function response_template() { + return 'mod_questionnaire/response_slider'; + } + + /** + * Return the context tags for the check question template. + * + * @param \mod_questionnaire\responsetype\response\response $response + * @param array $dependants Array of all questions/choices depending on this question. + * @param boolean $blankquestionnaire + * @return object The check question context tags. + * + */ + protected function question_survey_display($response, $dependants = [], $blankquestionnaire = false) { + global $PAGE; + $PAGE->requires->js_init_call('M.mod_questionnaire.init_slider', null, false, questionnaire_get_js_module()); + $extradata = json_decode($this->extradata); + $questiontags = new \stdClass(); + if (isset($response->answers[$this->id][0])) { + $extradata->startingvalue = $response->answers[$this->id][0]->value; + } + $extradata->name = 'q' . $this->id; + $extradata->id = self::qtypename($this->type_id) . $this->id; + $questiontags->qelements = new \stdClass(); + $questiontags->qelements->extradata = $extradata; + return $questiontags; + } + + /** + * Return the context tags for the slider response template. + * @param \mod_questionnaire\responsetype\response\response $response + * @return \stdClass The check question response context tags. + */ + protected function response_survey_display($response) { + global $PAGE; + $PAGE->requires->js_init_call('M.mod_questionnaire.init_slider', null, false, questionnaire_get_js_module()); + + $resptags = new \stdClass(); + if (isset($response->answers[$this->id])) { + $answer = reset($response->answers[$this->id]); + $resptags->content = format_text($answer->value, FORMAT_HTML); + if (!empty($response->answers[$this->id]['extradata'])) { + $resptags->extradata = $response->answers[$this->id]['extradata']; + } else { + $extradata = json_decode($this->extradata); + $resptags->extradata = $extradata; + } + } + return $resptags; + } + + /** + * Add the form required field. + * @param \MoodleQuickForm $mform + * @return \MoodleQuickForm + */ + protected function form_required(\MoodleQuickForm $mform) { + return $mform; + } + + /** + * Return the form precision. + * @param \MoodleQuickForm $mform + * @param string $helptext + * @return \MoodleQuickForm|void + */ + protected function form_precise(\MoodleQuickForm $mform, $helptext = '') { + return question::form_precise_hidden($mform); + } + + /** + * Return the form length. + * @param \MoodleQuickForm $mform + * @param string $helptext + * @return \MoodleQuickForm|void + */ + protected function form_length(\MoodleQuickForm $mform, $helptext = '') { + return question::form_length_hidden($mform); + } + + /** + * Override if the question uses the extradata field. + * @param \MoodleQuickForm $mform + * @param string $helpname + * @return \MoodleQuickForm + */ + protected function form_extradata(\MoodleQuickForm $mform, $helpname = '') { + $minelementname = 'minrange'; + $maxelementname = 'maxrange'; + $startingvalue = 'startingvalue'; + $stepvalue = 'stepvalue'; + + $ranges = []; + if (!empty($this->extradata)) { + $ranges = json_decode($this->extradata); + } + $mform->addElement('text', 'leftlabel', get_string('leftlabel', 'questionnaire')); + $mform->setType('leftlabel', PARAM_RAW); + if (isset($ranges->leftlabel)) { + $mform->setDefault('leftlabel', $ranges->leftlabel); + } + $mform->addElement('text', 'centerlabel', get_string('centerlabel', 'questionnaire')); + $mform->setType('centerlabel', PARAM_RAW); + if (isset($ranges->centerlabel)) { + $mform->setDefault('centerlabel', $ranges->centerlabel); + } + $mform->addElement('text', 'rightlabel', get_string('rightlabel', 'questionnaire')); + $mform->setType('rightlabel', PARAM_RAW); + if (isset($ranges->rightlabel)) { + $mform->setDefault('rightlabel', $ranges->rightlabel); + } + + $patterint = '/^-?\d+$/'; + $mform->addElement('text', $minelementname, get_string($minelementname, 'questionnaire'), ['size' => '3']); + $mform->setType($minelementname, PARAM_RAW); + $mform->addRule($minelementname, get_string('err_required', 'form'), 'required', null, 'client'); + $mform->addRule($minelementname, get_string('err_numeric', 'form'), 'numeric', '', 'client'); + $mform->addRule($minelementname, get_string('err_numeric', 'form'), 'regex', $patterint, 'client'); + $mform->addHelpButton($minelementname, $minelementname, 'questionnaire'); + if (isset($ranges->minrange)) { + $mform->setDefault($minelementname, $ranges->minrange); + } else { + $mform->setDefault($minelementname, 1); + } + + $mform->addElement('text', $maxelementname, get_string($maxelementname, 'questionnaire'), ['size' => '3']); + $mform->setType($maxelementname, PARAM_RAW); + $mform->addHelpButton($maxelementname, $maxelementname, 'questionnaire'); + $mform->addRule($maxelementname, get_string('err_required', 'form'), 'required', null, 'client'); + $mform->addRule($maxelementname, get_string('err_numeric', 'form'), 'numeric', '', 'client'); + $mform->addRule($maxelementname, get_string('err_numeric', 'form'), 'regex', $patterint, 'client'); + if (isset($ranges->maxrange)) { + $mform->setDefault($maxelementname, $ranges->maxrange); + } else { + $mform->setDefault($maxelementname, 10); + } + + $mform->addElement('text', $startingvalue, get_string($startingvalue, 'questionnaire'), ['size' => '3']); + $mform->setType($startingvalue, PARAM_RAW); + $mform->addHelpButton($startingvalue, $startingvalue, 'questionnaire'); + $mform->addRule($startingvalue, get_string('err_required', 'form'), 'required', null, 'client'); + $mform->addRule($startingvalue, get_string('err_numeric', 'form'), 'numeric', '', 'client'); + $mform->addRule($startingvalue, get_string('err_numeric', 'form'), 'regex', $patterint, 'client'); + if (isset($ranges->startingvalue)) { + $mform->setDefault($startingvalue, $ranges->startingvalue); + } else { + $mform->setDefault($startingvalue, 5); + } + + $mform->addElement('text', $stepvalue, get_string($stepvalue, 'questionnaire'), ['size' => '3']); + $mform->setType($stepvalue, PARAM_RAW); + $mform->addHelpButton($stepvalue, $stepvalue, 'questionnaire'); + $mform->addRule($stepvalue, get_string('err_required', 'form'), 'required', null, 'client'); + $mform->addRule($stepvalue, get_string('err_numeric', 'form'), 'numeric', '', 'client'); + $mform->addRule($stepvalue, get_string('err_numeric', 'form'), 'regex', '/^-?\d+$/', 'client'); + + if (isset($ranges->stepvalue)) { + $mform->setDefault($stepvalue, $ranges->stepvalue); + } else { + $mform->setDefault($stepvalue, 1); + } + return $mform; + } + + /** + * Any preprocessing of general data. + * @param \stdClass $formdata + * @return bool + */ + protected function form_preprocess_data($formdata) { + $ranges = []; + if (isset($formdata->minrange)) { + $ranges['minrange'] = $formdata->minrange; + } + if (isset($formdata->maxrange)) { + $ranges['maxrange'] = $formdata->maxrange; + } + if (isset($formdata->startingvalue)) { + $ranges['startingvalue'] = $formdata->startingvalue; + } + if (isset($formdata->stepvalue)) { + $ranges['stepvalue'] = $formdata->stepvalue; + } + if (isset($formdata->leftlabel)) { + $ranges['leftlabel'] = $formdata->leftlabel; + } + if (isset($formdata->rightlabel)) { + $ranges['rightlabel'] = $formdata->rightlabel; + } + if (isset($formdata->centerlabel)) { + $ranges['centerlabel'] = $formdata->centerlabel; + } + + // Now store the new named degrees in extradata. + $formdata->extradata = json_encode($ranges); + return parent::form_preprocess_data($formdata); + } + + /** + * True if question provides mobile support. + * + * @return bool + */ + public function supports_mobile() { + return true; + } + + /** + * True if question need extradata for mobile app. + * + * @return bool + */ + public function mobile_question_extradata_display() { + return true; + } + + /** + * Return the mobile question display. + * + * @param int $qnum + * @param bool $autonum + * @return \stdClass + */ + public function mobile_question_display($qnum, $autonum = false) { + $mobiledata = parent::mobile_question_display($qnum, $autonum); + $mobiledata->isslider = true; + return $mobiledata; + } + + /** + * Return the otherdata to be used by the mobile app. + * + * @return array + */ + public function mobile_otherdata() { + $extradata = json_decode($this->extradata); + return [$this->mobile_fieldkey() => $extradata->startingvalue]; + } +} diff --git a/classes/questions_form.php b/classes/questions_form.php index 40966eb2..769cbe86 100644 --- a/classes/questions_form.php +++ b/classes/questions_form.php @@ -256,7 +256,7 @@ public function definition() { $manageqgroup[] =& $mform->createElement('image', 'editbutton['.$question->id.']', $esrc, $eextra); $manageqgroup[] =& $mform->createElement('image', 'removebutton['.$question->id.']', $rsrc, $rextra); - if ($tid != QUESPAGEBREAK && $tid != QUESSECTIONTEXT) { + if ($tid != QUESPAGEBREAK && $tid != QUESSECTIONTEXT && $tid != QUESSLIDER) { if ($required == 'y') { $reqsrc = $questionnaire->renderer->image_url('t/stop'); $strrequired = get_string('required', 'questionnaire'); diff --git a/classes/responsetype/slider.php b/classes/responsetype/slider.php new file mode 100644 index 00000000..5055c3d1 --- /dev/null +++ b/classes/responsetype/slider.php @@ -0,0 +1,54 @@ +. + +namespace mod_questionnaire\responsetype; + +/** + * Class for slider text response types. + * + * @author Hieu Vu Van + * @copyright 2022 The Open University. + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + * @package mod_questionnaire + */ +class slider extends numericaltext { + /** + * Return an array of answer objects by question for the given response id. + * THIS SHOULD REPLACE response_select. + * + * @param int $rid The response id. + * @return array array answer + * @throws \dml_exception + */ + public static function response_answers_by_question($rid) { + global $DB; + + $answers = []; + $sql = 'SELECT qs.id, qs.response_id as responseid, qs.question_id as questionid, + 0 as choiceid, qs.response as value, qq.extradata ' . + 'FROM {' . static::response_table() . '} qs ' . + 'INNER JOIN {questionnaire_question} qq ON qq.id = qs.question_id ' . + 'WHERE response_id = ? '; + $records = $DB->get_records_sql($sql, [$rid]); + foreach ($records as $record) { + $answers[$record->questionid][] = answer\answer::create_from_data($record); + if (!empty($record->extradata)) { + $answers[$record->questionid]['extradata'] = json_decode($record->extradata); + } + } + return $answers; + } +} diff --git a/db/install.php b/db/install.php index 2e630363..55b0eda4 100644 --- a/db/install.php +++ b/db/install.php @@ -93,6 +93,13 @@ function xmldb_questionnaire_install() { $questiontype->response_table = 'response_text'; $id = $DB->insert_record('questionnaire_question_type', $questiontype); + $questiontype = new stdClass(); + $questiontype->typeid = 11; + $questiontype->type = 'Slider'; + $questiontype->has_choices = 'n'; + $questiontype->response_table = 'response_text'; + $id = $DB->insert_record('questionnaire_question_type', $questiontype); + $questiontype = new stdClass(); $questiontype->typeid = 99; $questiontype->type = 'Page Break'; diff --git a/db/mobile.php b/db/mobile.php index cbc7dcef..01bec8fb 100644 --- a/db/mobile.php +++ b/db/mobile.php @@ -36,7 +36,7 @@ 'method' => 'mobile_view_activity', 'styles' => [ 'url' => $CFG->wwwroot . '/mod/questionnaire/styles_app.css', - 'version' => '1.4' + 'version' => '1.5' ] ] ], diff --git a/db/upgrade.php b/db/upgrade.php index daf754d2..3861f365 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -983,6 +983,20 @@ function xmldb_questionnaire_upgrade($oldversion=0) { upgrade_mod_savepoint(true, 2020062301, 'questionnaire'); } + if ($oldversion < 2022092200) { + // Add new slider question type. + $exist = $DB->record_exists('questionnaire_question_type', ['typeid' => 11]); + if (!$exist) { + $questiontype = new stdClass(); + $questiontype->typeid = 11; + $questiontype->type = 'Slider'; + $questiontype->has_choices = 'n'; + $questiontype->response_table = 'response_text'; + $DB->insert_record('questionnaire_question_type', $questiontype); + } + upgrade_mod_savepoint(true, 2022092200, 'questionnaire'); + } + return $result; } diff --git a/feedback.php b/feedback.php index 834d1160..cf6c2f50 100644 --- a/feedback.php +++ b/feedback.php @@ -91,7 +91,7 @@ } if ($settings = $feedbackform->get_data()) { - if (isset($settings->feedbacksettingsbutton1) || isset($settings->buttongroup)) { + if (isset($settings->feedbacksettingsbutton1) || isset($settings->feedbacksettingsbutton2) || isset($settings->buttongroup)) { if (isset ($settings->feedbackscores)) { $sdata->feedbackscores = $settings->feedbackscores; } else { diff --git a/lang/en/questionnaire.php b/lang/en/questionnaire.php index d904e11b..dce6e767 100644 --- a/lang/en/questionnaire.php +++ b/lang/en/questionnaire.php @@ -117,6 +117,7 @@ $string['createcontent_help'] = 'Select one of the radio button options. \'Create new\' is the default.'; $string['createcontent_link'] = 'mod/questionnaire/mod#Content_Options'; $string['createnew'] = 'Create new'; +$string['centerlabel'] = 'Centre label'; $string['date'] = 'Date'; $string['date_help'] = 'Use this question type if you expect the response to be a correctly formatted date.'; $string['date_link'] = 'mod/questionnaire/questions#Date'; @@ -263,12 +264,17 @@ $string['invalidresponserecord'] = 'Invalid response record specified.'; $string['invalidsurveyid'] = 'Invalid questionnaire ID.'; $string['invalidsectionid'] = 'Invalid feedback section specified.'; +$string['invalidrange'] = 'The maximum slider value must be greater than the minimum slider value.'; +$string['invalidstartingvalue'] = 'The starting value must be equal to or between the minimum and maximum values. For example, if using a scale of 1-10, the starting value could be 5.'; +$string['invalidminmaxrange'] = 'This question type supports an absolute maximum range of -100 to +100. We expect the vast majority of questionnaire designs to use a range of 1-10 or -10 to +10.'; +$string['invalidincrement'] = 'Note that the value increments must be lower than the maximum value. For example, if a scale of 1-10, the increment value would probably be 1.'; $string['indirectwarnings'] = 'This list shows the indirect dependent questions and the remaining dependencies for direct dependent questions:'; $string['kindofratescale'] = 'Type of rate scale'; $string['kindofratescale_help'] = 'Right-click on the More Help link below.'; $string['kindofratescale_link'] = 'mod/questionnaire/questions#Type_of_rate_scale'; $string['lastrespondent'] = 'Last Respondent'; $string['length'] = 'Length'; +$string['leftlabel'] = 'Left label'; $string['managequestions'] = 'Manage questions'; $string['managequestions_help'] = 'In the Manage questions section of the Edit Questions page, you can conduct a number of operations on a Questionnaire\'s questions.'; $string['managequestions_link'] = 'mod/questionnaire/questions#Manage_questions'; @@ -305,6 +311,10 @@ $string['myresponses'] = 'All your responses'; $string['myresponsetitle'] = 'Your {$a} response(s)'; $string['myresults'] = 'Your Results'; +$string['minrange'] = 'Minimum slider range (left)'; +$string['minrange_help'] = 'Set the minimum value of the range on the left-hand side. It defaults to 1, but can set as low as -100. If you use a negative number (-100 to -1), the right-hand maximum will be expressed with a positive (+) sign.'; +$string['maxrange'] = 'Maximum slider range (right)'; +$string['maxrange_help'] = 'Set the maximum value of the range on the right-hand side. It defaults to 100, but it could be any number between 1-100. If the minimum value for the left-hand is a negative value, the maximum range will be expressed with a positive (+) sign.'; $string['name'] = 'Name'; $string['navigate'] = 'Allow branching questions'; $string['navigate_help'] = 'Enable Yes/No and Radio Buttons questions to have Child questions dependent on their choices in your questionnaire.'; @@ -548,6 +558,7 @@ $string['resume_link'] = 'mod/questionnaire/mod#Save/Resume_answers'; $string['resumesurvey'] = 'Resume questionnaire'; $string['return'] = 'Return'; +$string['rightlabel'] = 'Right label'; $string['save'] = 'Save'; $string['save_and_exit'] = 'Save and exit'; $string['saveasnew'] = 'Save as New Question'; @@ -596,6 +607,12 @@ $string['surveynotexists'] = 'questionnaire does not exist.'; $string['surveyowner'] = 'You must be a questionnaire owner to perform this operation.'; $string['surveyresponse'] = 'Response from questionnaire'; +$string['slider'] = 'Slider'; +$string['slider_help'] = 'The slider question allows respondents to select a value from a continuous range by dragging a slider between two extremes. A centre value can also be set.'; +$string['startingvalue'] = 'Slider starting value'; +$string['startingvalue_help'] = 'The slider starting value specifies where the slider should first appear for respondents. It defaults to 1 because the range is unknown. You may wish to start it in the centre of the range by giving a central value (a range of 1-100 has a centre value of 50).'; +$string['stepvalue'] = 'Slider increment value'; +$string['stepvalue_help'] = 'The slider increment value specifies how finely you wish respondents to indicate their response in the range. The question defaults to a range of 1-100 with an increment of one, allowing respondents to give values of 70, 71, 72, 73, 74 etc. But you could instead set increments of five, allowing respondents to give values of 60, 65, 70, 75, 80 etc., or even just a range of 1-10 with increments of 1.'; $string['template'] = 'Template'; $string['templatenotviewable'] = 'Template questionnaires are not viewable.'; $string['text'] = 'Question Text'; diff --git a/lib.php b/lib.php index 46f15244..8fdfd8e0 100644 --- a/lib.php +++ b/lib.php @@ -53,7 +53,8 @@ function questionnaire_supports($feature) { return true; case FEATURE_SHOW_DESCRIPTION: return true; - + case FEATURE_MOD_PURPOSE: + return MOD_PURPOSE_COMMUNICATION; default: return null; } @@ -238,6 +239,42 @@ function questionnaire_delete_instance($id) { return $result; } +/** + * Add a get_coursemodule_info function in case any questionnaire type wants to add 'extra' information + * for the course (see resource). + * + * Given a course_module object, this function returns any "extra" information that may be needed + * when printing this activity in a course listing. See get_array_of_activities() in course/lib.php. + * + * @param stdClass $coursemodule The coursemodule object (record). + * @return cached_cm_info An object on information that the courses + * will know about (most noticeably, an icon). + */ +function questionnaire_get_coursemodule_info($coursemodule) { + global $DB; + + $questionnaire = $DB->get_record('questionnaire', + array('id' => $coursemodule->instance), 'id, name, intro, introformat, opendate, closedate, + completionsubmit'); + if (!$questionnaire) { + return null; + } + + $info = new cached_cm_info(); + $info->customdata = (object)[]; + // Populate the custom completion rules as key => value pairs, but only if the completion mode is 'automatic'. + if ($coursemodule->completion == COMPLETION_TRACKING_AUTOMATIC) { + $info->customdata->customcompletionrules['completionsubmit'] = $questionnaire->completionsubmit; + } + if ($questionnaire->opendate) { + $info->customdata->opendate = $questionnaire->opendate; + } + if ($questionnaire->closedate) { + $info->customdata->closedate = $questionnaire->closedate; + } + return $info; +} + /** * Return a small object with summary information about what a user has done with a given particular instance of this module. * Used for user activity reports. @@ -342,7 +379,7 @@ function questionnaire_get_user_grades($questionnaire, $userid=0) { $sql = "SELECT r.id, u.id AS userid, r.grade AS rawgrade, r.submitted AS dategraded, r.submitted AS datesubmitted FROM {user} u, {questionnaire_response} r WHERE u.id = r.userid AND r.questionnaireid = $questionnaire->id AND r.complete = 'y' $usersql"; - return $DB->get_records_sql($sql, $params); + return $DB->get_records_sql($sql, $params) ?? []; } /** @@ -543,20 +580,19 @@ function questionnaire_pluginfile($course, $cm, $context, $filearea, $args, $for * * $settings is unused, but API requires it. Suppress PHPMD warning. */ -function questionnaire_extend_settings_navigation(settings_navigation $settings, - navigation_node $questionnairenode) { +function questionnaire_extend_settings_navigation(settings_navigation $settings, navigation_node $questionnairenode) { + global $DB, $USER, $CFG; - global $PAGE, $DB, $USER, $CFG; $individualresponse = optional_param('individualresponse', false, PARAM_INT); $rid = optional_param('rid', false, PARAM_INT); // Response id. $currentgroupid = optional_param('group', 0, PARAM_INT); // Group id. require_once($CFG->dirroot.'/mod/questionnaire/questionnaire.class.php'); - $context = $PAGE->cm->context; - $cmid = $PAGE->cm->id; - $cm = $PAGE->cm; - $course = $PAGE->course; + $cm = $settings->get_page()->cm; + $context = $cm->context; + $cmid = $cm->id; + $course = $settings->get_page()->course; if (! $questionnaire = $DB->get_record("questionnaire", array("id" => $cm->instance))) { throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire'); @@ -1061,7 +1097,7 @@ function questionnaire_print_overview($courses, &$htmlarray) { // Deadline. $str .= $OUTPUT->box(get_string('closeson', 'questionnaire', userdate($questionnaire->closedate)), 'info'); $attempts = $DB->get_records('questionnaire_response', - ['questionnaireid' => $questionnaire->id, 'userid' => $USER->id, 'complete' => 'y']); + ['questionnaireid' => $questionnaire->id, 'userid' => $USER->id, 'complete' => 'y']) ?? []; $nbattempts = count($attempts); // Do not display a questionnaire as due if it can only be sumbitted once and it has already been submitted! @@ -1184,15 +1220,13 @@ function questionnaire_reset_userdata($data) { * Obtains the automatic completion state for this questionnaire based on the condition * in questionnaire settings. * - * @param stdClass $course Course - * @param stdClass $cm Course-module + * @param object $cm Course-module * @param int $userid User ID * @param bool $type Type of comparison (or/and; can be used as return value if no conditions) * @return bool True if completed, false if not, $type if conditions not set. * - * $course is unused, but API requires it. Suppress PHPMD warning. */ -function questionnaire_get_completion_state($course, $cm, $userid, $type) { +function questionnaire_get_completion_state($cm, $userid, $type) { global $DB; // Get questionnaire details. diff --git a/locallib.php b/locallib.php index 089834bb..6eec045a 100644 --- a/locallib.php +++ b/locallib.php @@ -168,7 +168,7 @@ function questionnaire_get_user_responses($questionnaireid, $userid, $complete=t WHERE questionnaireid = ? AND userid = ? ".$andcomplete." - ORDER BY submitted ASC ", array($questionnaireid, $userid)); + ORDER BY submitted ASC ", array($questionnaireid, $userid)) ?? []; } /** @@ -416,7 +416,7 @@ function questionnaire_get_survey_list($courseid=0, $type='') { $params = [$courseid]; } } - return $DB->get_records_sql($sql, $params); + return $DB->get_records_sql($sql, $params) ?? []; } /** @@ -487,6 +487,8 @@ function questionnaire_get_type ($id) { return get_string('date', 'questionnaire'); case 10: return get_string('numeric', 'questionnaire'); + case 11: + return get_string('slider', 'questionnaire'); case 100: return get_string('sectiontext', 'questionnaire'); case 99: @@ -575,15 +577,7 @@ function questionnaire_get_incomplete_users($cm, $sid, // First get all users who can complete this questionnaire. $cap = 'mod/questionnaire:submit'; $fields = 'u.id, u.username'; - if (!$allusers = get_users_by_capability($context, - $cap, - $fields, - $sort, - '', - '', - $group, - '', - true)) { + if (!$allusers = get_enrolled_users($context, $cap, $group, $fields, $sort)) { return false; } $allusers = array_keys($allusers); @@ -746,72 +740,86 @@ function questionnaire_check_page_breaks($questionnaire) { $newpbids = array(); $delpb = 0; $sid = $questionnaire->survey->id; - $questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n'], 'id'); $positions = array(); - foreach ($questions as $key => $qu) { - $positions[$qu->position]['question_id'] = $key; - $positions[$qu->position]['type_id'] = $qu->type_id; - $positions[$qu->position]['qname'] = $qu->name; - $positions[$qu->position]['qpos'] = $qu->position; - - $dependencies = $DB->get_records('questionnaire_dependency', ['questionid' => $key , 'surveyid' => $sid], - 'id ASC', 'id, dependquestionid, dependchoiceid, dependlogic'); - $positions[$qu->position]['dependencies'] = $dependencies; + if ($questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n'], 'position')) { + foreach ($questions as $key => $qu) { + $newqu = new stdClass(); + $newqu->question_id = $key; + $newqu->type_id = $qu->type_id; + $newqu->qname = $qu->name; + $newqu->qpos = $qu->position; + + $dependencies = $DB->get_records('questionnaire_dependency', ['questionid' => $key, 'surveyid' => $sid], + 'id ASC', 'id, dependquestionid, dependchoiceid, dependlogic'); + $newqu->dependencies = $dependencies ?? []; + $positions[] = (array)$newqu; + } } $count = count($positions); - for ($i = $count; $i > 0; $i--) { + for ($i = $count - 1; $i >= 0; $i--) { $qu = $positions[$i]; $questionnb = $i; + $prevqu = null; + $prevtypeid = null; + if ($i > 0) { + $prevqu = $positions[$i - 1]; + $prevtypeid = $prevqu['type_id']; + } if ($qu['type_id'] == QUESPAGEBREAK) { $questionnb--; // If more than one consecutive page breaks, remove extra one(s). - $prevqu = null; - $prevtypeid = null; - if ($i > 1) { - $prevqu = $positions[$i - 1]; - $prevtypeid = $prevqu['type_id']; - } - // If $i == $count then remove that extra page break in last position. - if ($prevtypeid == QUESPAGEBREAK || $i == $count || $qu['qpos'] == 1) { + // Remove that extra page break in 1st position. + if ($prevtypeid == QUESPAGEBREAK || $i == $count - 1 || $qu['qpos'] == 1) { $qid = $qu['question_id']; $delpb ++; $msg .= get_string("checkbreaksremoved", "questionnaire", $delpb).'
'; // Need to reload questions. - $questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n'], 'id'); - $DB->set_field('questionnaire_question', 'deleted', 'y', ['id' => $qid, 'surveyid' => $sid]); - $select = 'surveyid = '.$sid.' AND deleted = \'n\' AND position > '. - $questions[$qid]->position; - if ($records = $DB->get_records_select('questionnaire_question', $select, null, 'position ASC')) { - foreach ($records as $record) { - $DB->set_field('questionnaire_question', 'position', $record->position - 1, ['id' => $record->id]); + if ($questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n'], 'id')) { + $DB->set_field('questionnaire_question', 'deleted', 'y', ['id' => $qid, 'surveyid' => $sid]); + $select = 'surveyid = ' . $sid . ' AND deleted = \'n\' AND position > ' . + $questions[$qid]->position; + if ($records = $DB->get_records_select('questionnaire_question', $select, null, 'position ASC')) { + foreach ($records as $record) { + $DB->set_field('questionnaire_question', 'position', $record->position - 1, ['id' => $record->id]); + } } } } } // Add pagebreak between question child and not dependent question that follows. if ($qu['type_id'] != QUESPAGEBREAK) { - $j = $i - 1; - if ($j != 0) { - $prevtypeid = $positions[$j]['type_id']; - $prevdependencies = $positions[$j]['dependencies']; - + if ($prevqu) { + $prevdependencies = $prevqu['dependencies']; $outerdependencies = count($qu['dependencies']) >= count($prevdependencies) ? $qu['dependencies'] : $prevdependencies; $innerdependencies = count($qu['dependencies']) < count($prevdependencies) ? $qu['dependencies'] : $prevdependencies; + $okeys = []; + $ikeys = []; foreach ($outerdependencies as $okey => $outerdependency) { foreach ($innerdependencies as $ikey => $innerdependency) { if ($outerdependency->dependquestionid === $innerdependency->dependquestionid && - $outerdependency->dependchoiceid === $innerdependency->dependchoiceid && - $outerdependency->dependlogic === $innerdependency->dependlogic) { - unset($outerdependencies[$okey]); - unset($innerdependencies[$ikey]); + $outerdependency->dependchoiceid === $innerdependency->dependchoiceid && + $outerdependency->dependlogic === $innerdependency->dependlogic) { + $okeys[] = $okey; + $ikeys[] = $ikey; } } } + foreach ($okeys as $key) { + if (key_exists($key, $outerdependencies)) { + unset($outerdependencies[$key]); + } + } + foreach ($ikeys as $key) { + if (key_exists($key, $innerdependencies)) { + unset($innerdependencies[$key]); + } + } + $diffdependencies = count($outerdependencies) + count($innerdependencies); if (($prevtypeid != QUESPAGEBREAK && $diffdependencies != 0) @@ -833,9 +841,8 @@ function questionnaire_check_page_breaks($questionnaire) { return (false); } $newpbids[] = $newqid; - $movetopos = $i; $questionnaire = new questionnaire($course, $cm, $questionnaire->id, null); - $questionnaire->move_question($newqid, $movetopos); + $questionnaire->move_question($newqid, $qu['qpos']); } } } diff --git a/module.js b/module.js index 28d7b628..f3eb8d60 100644 --- a/module.js +++ b/module.js @@ -241,4 +241,35 @@ M.mod_questionnaire.init_sendmessage = function(Y) { }); }, '#checkstarted'); -}; \ No newline at end of file +}; +M.mod_questionnaire.init_slider = function(Y) { + const allRanges = document.querySelectorAll(".slider"); + allRanges.forEach(wrap => { + const range = wrap.querySelector("input.questionnaire-slider"); + const bubble = wrap.querySelector(".bubble"); + + range.addEventListener("input", () => { + setBubble(range, bubble); + }); + setBubble(range, bubble); + }); + + function setBubble(range, bubble) { + const val = range.value; + const min = range.min ? range.min : 0; + const max = range.max ? range.max : 100; + var newVal = Number(((val - min) * 100) / (max - min)); + var positiveVal = ''; + if (range.min && range.min < 0) { + if (range.max && range.max > 0) { + if (val > 0) { + positiveVal = '+'; + } + } + } + bubble.innerHTML = positiveVal + val; + + // Sorta magic numbers based on size of the native UI thumb + bubble.style.left = `calc(${newVal}% + (${8 - newVal * 0.15}px))`; + } +}; diff --git a/pix/monologo.png b/pix/monologo.png new file mode 100644 index 00000000..5fe03149 Binary files /dev/null and b/pix/monologo.png differ diff --git a/pix/monologo.svg b/pix/monologo.svg new file mode 100644 index 00000000..dcf41261 --- /dev/null +++ b/pix/monologo.svg @@ -0,0 +1,97 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/questionnaire.class.php b/questionnaire.class.php index ef599ca4..5ae2234b 100644 --- a/questionnaire.class.php +++ b/questionnaire.class.php @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +use mod_questionnaire\feedback\section; + defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot.'/mod/questionnaire/locallib.php'); @@ -862,7 +864,7 @@ public function get_responses($userid=false, $groupid=0) { } $sql .= ' ORDER BY r.id'; - return $DB->get_records_sql($sql, $params); + return $DB->get_records_sql($sql, $params) ?? []; } /** @@ -1772,42 +1774,45 @@ public function survey_copy($owner) { } // Replicate all dependency data. - $dependquestions = $DB->get_records('questionnaire_dependency', ['surveyid' => $this->survey->id], 'questionid'); - foreach ($dependquestions as $dquestion) { - $record = new stdClass(); - $record->questionid = $qidarray[$dquestion->questionid]; - $record->surveyid = $newsid; - $record->dependquestionid = $qidarray[$dquestion->dependquestionid]; - // The response may not use choice id's (example boolean). If not, just copy the value. - $responsetype = $this->questions[$dquestion->dependquestionid]->responsetype; - if ($responsetype->transform_choiceid($dquestion->dependchoiceid) == $dquestion->dependchoiceid) { - $record->dependchoiceid = $cidarray[$dquestion->dependchoiceid]; - } else { - $record->dependchoiceid = $dquestion->dependchoiceid; + if ($dependquestions = $DB->get_records('questionnaire_dependency', ['surveyid' => $this->survey->id], 'questionid')) { + foreach ($dependquestions as $dquestion) { + $record = new stdClass(); + $record->questionid = $qidarray[$dquestion->questionid]; + $record->surveyid = $newsid; + $record->dependquestionid = $qidarray[$dquestion->dependquestionid]; + // The response may not use choice id's (example boolean). If not, just copy the value. + $responsetype = $this->questions[$dquestion->dependquestionid]->responsetype; + if ($responsetype->transform_choiceid($dquestion->dependchoiceid) == $dquestion->dependchoiceid) { + $record->dependchoiceid = $cidarray[$dquestion->dependchoiceid]; + } else { + $record->dependchoiceid = $dquestion->dependchoiceid; + } + $record->dependlogic = $dquestion->dependlogic; + $record->dependandor = $dquestion->dependandor; + $DB->insert_record('questionnaire_dependency', $record); } - $record->dependlogic = $dquestion->dependlogic; - $record->dependandor = $dquestion->dependandor; - $DB->insert_record('questionnaire_dependency', $record); } // Replicate any feedback data. // TODO: Need to handle image attachments (same for other copies above). - $fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $this->survey->id], 'id'); - foreach ($fbsections as $fbsid => $fbsection) { - $fbsection->surveyid = $newsid; - $scorecalculation = unserialize($fbsection->scorecalculation); - $newscorecalculation = []; - foreach ($scorecalculation as $qid => $val) { - $newscorecalculation[$qidarray[$qid]] = $val; - } - $fbsection->scorecalculation = serialize($newscorecalculation); - unset($fbsection->id); - $newfbsid = $DB->insert_record('questionnaire_fb_sections', $fbsection); - $feedbackrecs = $DB->get_records('questionnaire_feedback', ['sectionid' => $fbsid], 'id'); - foreach ($feedbackrecs as $feedbackrec) { - $feedbackrec->sectionid = $newfbsid; - unset($feedbackrec->id); - $DB->insert_record('questionnaire_feedback', $feedbackrec); + if ($fbsections = $DB->get_records('questionnaire_fb_sections', ['surveyid' => $this->survey->id], 'id')) { + foreach ($fbsections as $fbsid => $fbsection) { + $fbsection->surveyid = $newsid; + $scorecalculation = section::decode_scorecalculation($fbsection->scorecalculation); + $newscorecalculation = []; + foreach ($scorecalculation as $qid => $val) { + $newscorecalculation[$qidarray[$qid]] = $val; + } + $fbsection->scorecalculation = serialize($newscorecalculation); + unset($fbsection->id); + $newfbsid = $DB->insert_record('questionnaire_fb_sections', $fbsection); + if ($feedbackrecs = $DB->get_records('questionnaire_feedback', ['sectionid' => $fbsid], 'id')) { + foreach ($feedbackrecs as $feedbackrec) { + $feedbackrec->sectionid = $newfbsid; + unset($feedbackrec->id); + $DB->insert_record('questionnaire_feedback', $feedbackrec); + } + } } } @@ -1841,22 +1846,24 @@ private function response_check_format($section, $formdata, $checkmissing = true } $qnum = $i - 1; - foreach ($this->questionsbysec[$section] as $questionid) { - $tid = $this->questions[$questionid]->type_id; - if ($tid != QUESSECTIONTEXT) { - $qnum++; - } - if (!$this->questions[$questionid]->response_complete($formdata)) { - $missing++; - $strnum = get_string('num', 'questionnaire').$qnum.'. '; - $strmissing .= $strnum; - // Pop-up notification at the point of the error. - $strnoti = get_string('missingquestion', 'questionnaire').$strnum; - $this->questions[$questionid]->add_notification($strnoti); - } - if (!$this->questions[$questionid]->response_valid($formdata)) { - $wrongformat++; - $strwrongformat .= get_string('num', 'questionnaire').$qnum.'. '; + if (key_exists($section, $this->questionsbysec)) { + foreach ($this->questionsbysec[$section] as $questionid) { + $tid = $this->questions[$questionid]->type_id; + if ($tid != QUESSECTIONTEXT) { + $qnum++; + } + if (!$this->questions[$questionid]->response_complete($formdata)) { + $missing++; + $strnum = get_string('num', 'questionnaire') . $qnum . '. '; + $strmissing .= $strnum; + // Pop-up notification at the point of the error. + $strnoti = get_string('missingquestion', 'questionnaire') . $strnum; + $this->questions[$questionid]->add_notification($strnoti); + } + if (!$this->questions[$questionid]->response_valid($formdata)) { + $wrongformat++; + $strwrongformat .= get_string('num', 'questionnaire') . $qnum . '. '; + } } } $message = ''; @@ -3012,7 +3019,7 @@ protected function get_survey_all_responses($rid = '', $userid = '', $groupid = // If a questionnaire is "public", and this is the master course, need to get responses from all instances. if ($this->survey_is_public_master()) { - $qids = array_keys($DB->get_records('questionnaire', ['sid' => $this->sid], 'id')); + $qids = array_keys($DB->get_records('questionnaire', ['sid' => $this->sid], 'id') ?? []); } else { $qids = $this->id; } @@ -3030,7 +3037,7 @@ protected function get_survey_all_responses($rid = '', $userid = '', $groupid = $allresponsessql .= " ORDER BY usrid, id"; $allresponses = $DB->get_recordset_sql($allresponsessql, $allresponsesparams); - return $allresponses; + return $allresponses ?? []; } /** @@ -3245,7 +3252,8 @@ public function generate_csv($currentgroupid, $rid='', $userid='', $choicecodes= '0', // 7: rating -> number '0', // 8: rate -> number '1', // 9: date -> string - '0' // 10: numeric -> number. + '0', // 10: numeric -> number. + '0', // 11: slider -> number. ); if (!$survey = $DB->get_record('questionnaire_survey', array('id' => $this->survey->id))) { @@ -3783,11 +3791,12 @@ public function response_analysis($rid, $resps, $compare, $isgroupmember, $allre $sectionlabel = $fbsections[$sectionid]->sectionlabel; $sectionheading = $fbsections[$sectionid]->sectionheading; - $feedbacks = $DB->get_records('questionnaire_feedback', ['sectionid' => $sectionid]); $labels = array(); - foreach ($feedbacks as $feedback) { - if ($feedback->feedbacklabel != '') { - $labels[] = $feedback->feedbacklabel; + if ($feedbacks = $DB->get_records('questionnaire_feedback', ['sectionid' => $sectionid])) { + foreach ($feedbacks as $feedback) { + if ($feedback->feedbacklabel != '') { + $labels[] = $feedback->feedbacklabel; + } } } $feedback = $DB->get_record_select('questionnaire_feedback', @@ -3839,6 +3848,7 @@ public function response_analysis($rid, $resps, $compare, $isgroupmember, $allre $oppositeallscore = ' | '.$allscore[1].'%'; } if ($this->survey->feedbackscores) { + $table = $table ?? new html_table(); if ($compare) { $table->data[] = array($sectionlabel, $score[0].'%'.$oppositescore, $allscore[0].'%'.$oppositeallscore); } else { @@ -3880,7 +3890,7 @@ public function response_analysis($rid, $resps, $compare, $isgroupmember, $allre foreach ($fbsections as $key => $fbsection) { if ($fbsection->section == $section) { $feedbacksectionid = $key; - $scorecalculation = unserialize($fbsection->scorecalculation); + $scorecalculation = section::decode_scorecalculation($fbsection->scorecalculation); if (empty($scorecalculation) && !is_array($scorecalculation)) { $scorecalculation = []; } @@ -3967,24 +3977,28 @@ public function response_analysis($rid, $resps, $compare, $isgroupmember, $allre default: } - foreach ($allscore as $key => $sc) { - if (isset($chartlabels[$key])) { - $lb = explode("|", $chartlabels[$key]); - $oppositescore = ''; - $oppositeallscore = ''; - if (count($lb) > 1) { - $sectionlabel = $lb[0] . ' | ' . $lb[1]; - $oppositescore = ' | ' . $oppositescorepercent[$key] . '%'; - $oppositeallscore = ' | ' . $alloppositescorepercent[$key] . '%'; - } else { - $sectionlabel = $chartlabels[$key]; - } - // If all questions of $section are unseen then don't show feedbackscores for this section. - if ($compare && !is_nan($scorepercent[$key])) { - $table->data[] = array($sectionlabel, $scorepercent[$key] . '%' . $oppositescore, - $allscorepercent[$key] . '%' . $oppositeallscore); - } else if (isset($allscorepercent[$key]) && !is_nan($allscorepercent[$key])) { - $table->data[] = array($sectionlabel, $allscorepercent[$key] . '%' . $oppositeallscore); + if ($this->survey->feedbackscores) { + foreach ($allscore as $key => $sc) { + if (isset($chartlabels[$key])) { + $lb = explode("|", $chartlabels[$key]); + $oppositescore = ''; + $oppositeallscore = ''; + if (count($lb) > 1) { + $sectionlabel = $lb[0] . ' | ' . $lb[1]; + $oppositescore = ' | ' . $oppositescorepercent[$key] . '%'; + $oppositeallscore = ' | ' . $alloppositescorepercent[$key] . '%'; + } else { + $sectionlabel = $chartlabels[$key]; + } + // If all questions of $section are unseen then don't show feedbackscores for this section. + if ($compare && !is_nan($scorepercent[$key])) { + $table = $table ?? new html_table(); + $table->data[] = array($sectionlabel, $scorepercent[$key] . '%' . $oppositescore, + $allscorepercent[$key] . '%' . $oppositeallscore); + } else if (isset($allscorepercent[$key]) && !is_nan($allscorepercent[$key])) { + $table = $table ?? new html_table(); + $table->data[] = array($sectionlabel, $allscorepercent[$key] . '%' . $oppositeallscore); + } } } } @@ -4036,7 +4050,7 @@ public function save_mobile_data($userid, $sec, $completed, $rid, $submit, $acti global $DB, $CFG; // Do not delete "$CFG". $ret = []; - $response = $this->build_response_from_appdata($responses, $sec); + $response = $this->build_response_from_appdata((object)$responses, $sec); $response->sec = $sec; $response->rid = $rid; $response->id = $rid; diff --git a/questions.php b/questions.php index 5a59b557..c24a8765 100644 --- a/questions.php +++ b/questions.php @@ -69,7 +69,7 @@ } $questionnairehasdependencies = $questionnaire->has_dependencies(); -$haschildren = []; +$dependants = null; if (!isset($SESSION->questionnaire)) { $SESSION->questionnaire = new stdClass(); } @@ -85,7 +85,7 @@ $questionnaireid = $questionnaire->id; // Need to reload questions before setting deleted question to 'y'. - $questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n'], 'id'); + $questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n'], 'id') ?? []; $DB->set_field('questionnaire_question', 'deleted', 'y', ['id' => $qid, 'surveyid' => $sid]); // Delete all dependency records for this question. @@ -182,17 +182,16 @@ if ($qtype == QUESPAGEBREAK) { redirect($CFG->wwwroot.'/mod/questionnaire/questions.php?id='.$questionnaire->cm->id.'&delq='.$qid); } + + $action = "confirmdelquestion"; if ($questionnairehasdependencies) { // Important: due to possibly multiple parents per question // just remove the dependency and inform the user about it. - $haschildren = $questionnaire->get_all_dependants($qid); - } - if (count($haschildren) != 0) { - $action = "confirmdelquestionparent"; - } else { - $action = "confirmdelquestion"; + $dependants = $questionnaire->get_all_dependants($qid); + if (!(empty($dependants->directs) && empty($dependants->indirects))) { + $action = "confirmdelquestionparent"; + } } - } else if (isset($qformdata->editbutton)) { // Switch to edit question screen. $action = 'question'; @@ -400,12 +399,14 @@ if ($action == "confirmdelquestionparent") { $strnum = get_string('position', 'questionnaire'); $qid = key($qformdata->removebutton); - // Show the dependencies and inform about the dependencies to be removed. - // Split dependencies in direct and indirect ones to separate for the confirm-dialogue. Only direct ones will be deleted. - // List direct dependencies. - $msg .= $questionnaire->renderer->dependency_warnings($haschildren->directs, 'directwarnings', $strnum); - // List indirect dependencies. - $msg .= $questionnaire->renderer->dependency_warnings($haschildren->indirects, 'indirectwarnings', $strnum); + if ($dependants) { + // Show the dependencies and inform about the dependencies to be removed. + // Split dependencies in direct and indirect ones to separate for the confirm-dialogue. Only direct ones will be deleted. + // List direct dependencies. + $msg .= $questionnaire->renderer->dependency_warnings($dependants->directs, 'directwarnings', $strnum); + // List indirect dependencies. + $msg .= $questionnaire->renderer->dependency_warnings($dependants->indirects, 'indirectwarnings', $strnum); + } } $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno)); diff --git a/report.php b/report.php index 1d55b610..9d1cd115 100755 --- a/report.php +++ b/report.php @@ -646,7 +646,7 @@ // not an array. $errorreporting = error_reporting(0); $pdf->writeHTML($html); - @$pdf->Output('dump.pdf', 'D'); + @$pdf->Output(clean_param($questionnaire->name, PARAM_FILE), 'D'); error_reporting($errorreporting); } else { // Default to HTML. @@ -779,7 +779,7 @@ // not an array. $errorreporting = error_reporting(0); $pdf->writeHTML($html); - @$pdf->Output('dump.pdf', 'D'); + @$pdf->Output(clean_param($questionnaire->name, PARAM_FILE), 'D'); error_reporting($errorreporting); } else { // Default to HTML. diff --git a/styles.css b/styles.css index 28f45a3c..41dcd324 100644 --- a/styles.css +++ b/styles.css @@ -453,4 +453,93 @@ td.selected { #page-mod-questionnaire-questions #fitem_id_allchoices #id_allchoices, #page-mod-questionnaire-questions #fitem_id_allnameddegrees #id_allnameddegrees { resize: both; -} \ No newline at end of file +} + +.path-mod-questionnaire .slidecontainer { + width: 100%; +} + +.path-mod-questionnaire .slider { + -webkit-appearance: none; + width: 100%; + outline: none; + opacity: 0.7; + -webkit-transition: .2s; + transition: opacity .2s; + float: left; + margin-top: 40px; +} +.path-mod-questionnaire .slider input { + width: 100%; +} + +.path-mod-questionnaire .slider:hover { + opacity: 1; +} + +.path-mod-questionnaire .slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 25px; + background: #04aa6d; + cursor: pointer; + border-radius: 50%; +} + +.path-mod-questionnaire .slider::-moz-range-thumb { + width: 25px; + height: 25px; + background: #04aa6d; + cursor: pointer; +} + +.path-mod-questionnaire .question-slider { + display: flex; + align-items: baseline; +} + +.path-mod-questionnaire .left-side-label { + text-align: right; + padding-right: 20px; + margin-top: 40px; + flex-grow: 1; +} + +.path-mod-questionnaire .right-side-label { + text-align: left; + padding-left: 20px; + margin-top: 40px; + flex-grow: 1; +} + +.path-mod-questionnaire .middle-side-content { + flex-grow: 8; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.path-mod-questionnaire .middle-side-label { + text-align: center; +} + +.path-mod-questionnaire .bubble { + background: #000; + color: white; + padding: 3px; + border-radius: 10px; + left: 50%; + transform: translate(-52%, -50px); + position: relative; + text-align: center; + width: 40px; +} +.path-mod-questionnaire .bubble::after { + content: ""; + position: absolute; + width: 2px; + height: 2px; + left: 50%; +} diff --git a/styles_app.css b/styles_app.css index 925f2ed4..e1b368db 100644 --- a/styles_app.css +++ b/styles_app.css @@ -1,4 +1,17 @@ span.mobileratequestion { padding-left: 2em; padding-right: 2em; +} + +.mod_questionnaire_slider .range-has-pin .range-pin { + -webkit-transform: translate3d(0, 0, 0) scale(1); + transform: translate3d(0, 0, 0) scale(1); +} +.mod_questionnaire_slider .range-has-pin::part(pin){ + -webkit-transform: translate3d(0, -24px, 0) scale(1); + transform: translate3d(0, -24px, 0) scale(1); +} + +ion-label.disabled { + opacity: 0.8 !important; } \ No newline at end of file diff --git a/templates/local/mobile/ionic3/slider_question.mustache b/templates/local/mobile/ionic3/slider_question.mustache new file mode 100644 index 00000000..64aef335 --- /dev/null +++ b/templates/local/mobile/ionic3/slider_question.mustache @@ -0,0 +1,55 @@ +{{! + 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_questionnaire/mobile_slider_question + + Template which defines a slider question display in the mobile app. + + Context variables required for this template: + * fieldkey - integer: ID of the question. + * completed - boolean: True if question already completed. + + Example context (json): + { + "fieldkey": 985, + "extradata": { + "minrange" : 1 + "maxrange" : 10 + "startingvalue" : 5 + "stepvalue" : 1 + "leftlabel" : "left label" + "rightlabel" : "right label" + "centerlabel": "center label" + }, + completed: 0 + + } + +}} +{{=<% %>=}} +<%#extradata%> + + disabled="true"<%/completed%> + min="<%extradata.minrange%>" max="<%extradata.maxrange%>" + pin="true" step="<%extradata.stepvalue%>" + [(ngModel)]="CONTENT_OTHERDATA.<%fieldkey%>"> + <%extradata.leftlabel%> + <%extradata.rightlabel%> + + +<%extradata.centerlabel%> +<%/extradata%> diff --git a/templates/local/mobile/ionic3/view_activity_page.mustache b/templates/local/mobile/ionic3/view_activity_page.mustache index d48ddd29..62e9ae53 100644 --- a/templates/local/mobile/ionic3/view_activity_page.mustache +++ b/templates/local/mobile/ionic3/view_activity_page.mustache @@ -121,6 +121,9 @@ <%#israte%> <%> mod_questionnaire/local/mobile/ionic3/rate_question %> <%/israte%> + <%#isslider%> + <%> mod_questionnaire/local/mobile/ionic3/slider_question %> + <%/isslider%> <%/pagequestions%> <%^pagequestions%> diff --git a/templates/local/mobile/latest/slider_question.mustache b/templates/local/mobile/latest/slider_question.mustache new file mode 100644 index 00000000..80623356 --- /dev/null +++ b/templates/local/mobile/latest/slider_question.mustache @@ -0,0 +1,57 @@ +{{! + 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_questionnaire/mobile_slider_question + + Template which defines a slider question display in the mobile app. + + Context variables required for this template: + * fieldkey - integer: ID of the question. + * completed - boolean: True if question already completed. + + Example context (json): + { + "fieldkey": 985, + "extradata": { + "minrange" : 1 + "maxrange" : 10 + "startingvalue" : 5 + "stepvalue" : 1 + "leftlabel" : "left label" + "rightlabel" : "right label" + "centerlabel": "center label" + }, + "completed": 0 + } + +}} +{{=<% %>=}} +<%#extradata%> + + disabled="true"<%/completed%> + min="<%extradata.minrange%>" max="<%extradata.maxrange%>" + pin="true" step="<%extradata.stepvalue%>" + [(ngModel)]="CONTENT_OTHERDATA.<%fieldkey%>"> + <%extradata.leftlabel%> + <%extradata.rightlabel%> + + + class="disabled"<%/completed%>> + + +<%/extradata%> diff --git a/templates/local/mobile/latest/view_activity_page.mustache b/templates/local/mobile/latest/view_activity_page.mustache index c0d01651..54a1bae8 100644 --- a/templates/local/mobile/latest/view_activity_page.mustache +++ b/templates/local/mobile/latest/view_activity_page.mustache @@ -124,6 +124,9 @@ <%#israte%> <%> mod_questionnaire/local/mobile/latest/rate_question %> <%/israte%> + <%#isslider%> + <%> mod_questionnaire/local/mobile/latest/slider_question %> + <%/isslider%> <%/pagequestions%> <%^pagequestions%> diff --git a/templates/question_slider.mustache b/templates/question_slider.mustache new file mode 100644 index 00000000..9d4099db --- /dev/null +++ b/templates/question_slider.mustache @@ -0,0 +1,73 @@ +{{! + 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_questionnaire/question_yesno + + Template which defines a yes/no type question survey display. + + Classes required for JS: + * /mod/questionnaire/module.js + + Data attributes required for JS: + * none + + Context variables required for this template: + + Example context (json): + { + "qelements": { + "choice": [ + { + "id": "choice1", + "value": "y", + "name": "q23", + "checked": 1, + "disabled": "", + "onclick": "dosomething()", + "label": "Yes" + }, + { + "id": "choice2", + "value": "n", + "name": "q23", + "checked": 0, + "disabled": "", + "onclick": "dosomething()", + "label": "No" + } + ] + } + } + }} + +
+ {{#qelements}} + {{#extradata}} +
{{extradata.leftlabel}}
+
+
+ + +
+
{{extradata.centerlabel}}
+
+
{{extradata.rightlabel}}
+ {{/extradata}} + {{/qelements}} +
+ diff --git a/templates/response_slider.mustache b/templates/response_slider.mustache new file mode 100644 index 00000000..9a6532f5 --- /dev/null +++ b/templates/response_slider.mustache @@ -0,0 +1,52 @@ +{{! + 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_questionnaire/response_text + + Template which defines a text type question survey display. + + Classes required for JS: + * /mod/questionnaire/module.js + + Data attributes required for JS: + * none + + Context variables required for this template: + + Example context (json): + { + "content": "HTML for numeric" + } + }} + +
+
+ {{#extradata}} +
{{extradata.leftlabel}}
+
+
+ + +
+
{{extradata.centerlabel}}
+
+
{{extradata.rightlabel}}
+ {{/extradata}} +
+
+ diff --git a/templates/results_choice.mustache b/templates/results_choice.mustache index 32753a2f..b46f2d1a 100644 --- a/templates/results_choice.mustache +++ b/templates/results_choice.mustache @@ -87,7 +87,7 @@ {{#responses}} {{#response}} - {{response.text}} + {{{response.text}}} {{response.alt1}}{{response.alt2}}{{response.alt3}} {{{response.percent}}} diff --git a/tests/behat/behat_mod_questionnaire.php b/tests/behat/behat_mod_questionnaire.php index 850812c5..b0d67965 100644 --- a/tests/behat/behat_mod_questionnaire.php +++ b/tests/behat/behat_mod_questionnaire.php @@ -118,7 +118,8 @@ public function i_add_a_question_and_i_fill_the_form_with($questiontype, TableNo 'Radio Buttons', 'Rate (scale 1..5)', 'Text Box', - 'Yes/No'); + 'Yes/No', + 'Slider'); if (!in_array($questiontype, $validtypes)) { throw new ExpectationException('Invalid question type specified.', $this->getSession()); diff --git a/tests/behat/check_responses.feature b/tests/behat/check_responses.feature index 1d290d43..46065874 100644 --- a/tests/behat/check_responses.feature +++ b/tests/behat/check_responses.feature @@ -80,3 +80,44 @@ Feature: Review responses And I press "Delete" Then I should see "You are not eligible to take this questionnaire." And I should not see "View all responses" + + @javascript + Scenario: Choices with HTML should display filtered HTML in the responses on the response page + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | description | course | idnumber | + | questionnaire | Test questionnaire | Test questionnaire description | C1 | questionnaire0 | + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I follow "Test questionnaire" + And I navigate to "Questions" in current page administration + And I add a "Check Boxes" question and I fill the form with: + | Question Name | Q1 | + | Yes | y | + | Min. forced responses | 1 | + | Max. forced responses | 2 | + | Question Text | Select one or two choices only | + | Possible answers | One,Two,Three,Four | + And I log in as "student1" + And I am on "Course 1" course homepage + And I follow "Test questionnaire" + And I navigate to "Answer the questions..." in current page administration + Then I should see "Select one or two choices only" + # And I set the field "Do you own a car?" to "y" + And I set the field "One" to "checked" + And I press "Submit questionnaire" + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I follow "Test questionnaire" + And I navigate to "View all responses" in current page administration + Then "//b[text()='One']" "xpath_element" should exist diff --git a/tests/behat/checkbox_other_responses.feature b/tests/behat/checkbox_other_responses.feature new file mode 100644 index 00000000..762ea77b --- /dev/null +++ b/tests/behat/checkbox_other_responses.feature @@ -0,0 +1,48 @@ +@mod @mod_questionnaire +Feature: Checkbox questions can have other options that can be typed in. + + Background: Add a checkbox question to a questionnaire with an 'other' option. + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | description | course | idnumber | + | questionnaire | Test questionnaire | Test questionnaire description | C1 | questionnaire0 | + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I follow "Test questionnaire" + And I navigate to "Questions" in current page administration + And I add a "Check Boxes" question and I fill the form with: + | Question Name | Q1 | + | Yes | y | + | Question Text | Select one or two choices only | + | Possible answers | One,Two,Three,Four,!other | + And I add a "Check Boxes" question and I fill the form with: + | Question Name | Q2 | + | No | n | + | Question Text | Select one or two choices only | + | Possible answers | Red,Blue,Yellow,Green,!other=Other colour | + And I log out + + @javascript + Scenario: Student must enter a valid value when "other" is selected. + And I log in as "student1" + And I am on "Course 1" course homepage + And I follow "Test questionnaire" + And I navigate to "Answer the questions..." in current page administration + Then I should see "Select one or two choices only" + And I press "Submit questionnaire" + Then I should see "Please answer required question #1" + And I set the field "Other" to "checked" + And I set the field "Other colour" to "checked" + And I press "Submit questionnaire" + Then I should see "There is something wrong with your answer to questions:" + And I should see "#1. #2." diff --git a/tests/behat/questionnaire_activity_completion.feature b/tests/behat/questionnaire_activity_completion.feature new file mode 100644 index 00000000..1e964ffa --- /dev/null +++ b/tests/behat/questionnaire_activity_completion.feature @@ -0,0 +1,67 @@ +@mod @mod_questionnaire @core_completion +Feature: View activity completion information in the questionnaire activity + In order to have visibility of questionnaire completion requirements + As a student + I need to be able to view my questionnaire completion progress + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | enablecompletion | + | Course 1 | C1 | 1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | teacher1 | C1 | editingteacher | + And the following "activities" exist: + | activity | name | introduction | course | idnumber | completion | completionview | completionpostsenabled | completionposts | + | questionnaire | Test questionnaire completion | Test questionnaire description | C1 | questionnaire2 | 2 | 1 | 1 | 1 | + + @javascript + Scenario: Check questionnaire completion feature in web. + Given I log in as "teacher1" + And I am on "Course 1" course homepage + And I follow "Test questionnaire completion" + Then I click on "Add questions" "link" + And I add a "Yes/No" question and I fill the form with: + | Question Name | Q1 | + | Yes | y | + | Question Text | Are you still in School? | + Then I should see "[Yes/No] (Q1)" + And I add a "Radio Buttons" question and I fill the form with: + | Question Name | Q2 | + | Yes | y | + | Horizontal | Checked | + | Question Text | Select one choice | + | Possible answers | 1=One,2=Two,3=Three,4=Four | + Then I should see "[Radio Buttons] (Q2)" + And I add a "Text Box" question and I fill the form with: + | Question Name | Q8 | + | No | n | + | Input box length | 10 | + | Max. text length | 15 | + | Question Text | Enter some text | + Then I should see "[Text Box] (Q8)" + And I am on the "Test questionnaire completion" "questionnaire activity editing" page + And I set the following fields to these values: + | Completion tracking | Show activity as complete when conditions are met | + And I click on "Student must submit this questionnaire to complete it" "checkbox" + And I press "Save and display" + + And I am on the "Test questionnaire completion" "questionnaire activity" page + Then I should see "You are not eligible to take this questionnaire." + + And I am on the "Test questionnaire completion" "questionnaire activity" page logged in as "student1" + And I click on "Answer the questions..." "link" + Then I should see "Are you still in School?" + And I should see "Select one choice" + And I should see "Enter some text" + And I click on "Yes" "radio" + And I click on "Three" "radio" + And I press "Submit questionnaire" + Then I should see "Thank you for completing this Questionnaire." + And I press "Continue" + Then I should see "View your response(s)" diff --git a/tests/behat/set_availability.feature b/tests/behat/set_availability.feature new file mode 100644 index 00000000..660b34c5 --- /dev/null +++ b/tests/behat/set_availability.feature @@ -0,0 +1,37 @@ +@mod @mod_questionnaire +Feature: View questionnaire availability information in the course view + In order to have visibility of the questionnaire availability requirements + As a student + I need to be able to view the availability dates + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | enablecompletion | + | Course 1 | C1 | 1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | teacher1 | C1 | editingteacher | + And the following "activities" exist: + | activity | name | introduction | course | idnumber | + | questionnaire | Test questionnaire | Test questionnaire description | C1 | questionnaire0 | + + @javascript + Scenario: Student can see the open and close dates on the course page + Given I log in as "teacher1" + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Settings" in current page administration + And I follow "Expand all" + And I set the field "Allow responses from" to "##3 days ago##" + And I set the field "Allow responses until" to "##tomorrow noon##" + And I press "Save and return to course" + And I log out + + When I log in as "student1" + And I am on "Course 1" course homepage + Then I should see "Opened: " + And I should see "Closes: " diff --git a/tests/behat/slider_question.feature b/tests/behat/slider_question.feature new file mode 100644 index 00000000..7e134d34 --- /dev/null +++ b/tests/behat/slider_question.feature @@ -0,0 +1,87 @@ +@mod @mod_questionnaire +Feature: Slider questions can add slider with range for users to choose + In order to setup a slider question + As a teacher + I need to specify the range. + + Background: Add a slider question to a questionnaire. + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | description | course | idnumber | + | questionnaire | Test questionnaire | Test questionnaire description | C1 | questionnaire0 | + And I log in as "teacher1" + And I am on "Course 1" course homepage + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Questions" in current page administration + And I add a "Slider" question and I fill the form with: + | Question Name | Q1 | + | Question Text | Slider quesrion test | + | Left label | Left | + | Right label | Right | + | Centre label | Center | + | Minimum slider range (left) | 5 | + | Maximum slider range (right) | 100 | + | Slider starting value | 5 | + | Slider increment value | 5 | + Then I should see "position 1" + And I should see " [Slider] (Q1)" + And I should see "Slider quesrion test" + And I log out + + @javascript + Scenario: Student use slider questionnaire. + And I log in as "student1" + And I am on "Course 1" course homepage + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Answer the questions..." in current page administration + Then I should see "Slider quesrion test" + And I should see "Left" + And I should see "Right" + And I should see "Center" + And I press "Submit questionnaire" + Then I should see "Thank you for completing this Questionnaire." + And I press "Continue" + Then I should see "View your response(s)" + + @javascript + Scenario: Teacher use slider questionnaire with invalid setting. + Given I log in as "teacher1" + And I am on "Course 1" course homepage + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Questions" in current page administration + And I add a "Slider" question and I fill the form with: + | Question Name | Q1 | + | Question Text | Slider quesrion test | + | Left label | Left | + | Right label | Right | + | Centre label | Center | + | Minimum slider range (left) | 10 | + | Maximum slider range (right) | 5 | + | Slider starting value | 10 | + | Slider increment value | 15 | + And I should see "The maximum slider value must be greater than the minimum slider value." + And I should see "Note that the value increments must be lower than the maximum value. For example, if a scale of 1-10, the increment value would probably be 1." + And I am on "Course 1" course homepage + And I am on the "Test questionnaire" "questionnaire activity" page + And I navigate to "Questions" in current page administration + And I add a "Slider" question and I fill the form with: + | Question Name | Q1 | + | Question Text | Slider quesrion test | + | Left label | Left | + | Right label | Right | + | Centre label | Center | + | Minimum slider range (left) | -999 | + | Maximum slider range (right) | 999 | + | Slider starting value | 10 | + | Slider increment value | 15 | + And I should see "This question type supports an absolute maximum range of -100 to +100. We expect the vast majority of questionnaire designs to use a range of 1-10 or -10 to +10." diff --git a/tests/csvexport_test.php b/tests/csvexport_test.php index dd164e71..04f3687c 100644 --- a/tests/csvexport_test.php +++ b/tests/csvexport_test.php @@ -91,15 +91,15 @@ private function expected_complete_output() { "Q07_Check Boxes 1012->twelve Q07_Check Boxes 1012->thirteen Q08_Rate Scale 1014->fourteen " . "Q08_Rate Scale 1014->fifteen Q08_Rate Scale 1014->sixteen Q08_Rate Scale 1014->seventeen " . "Q08_Rate Scale 1014->eighteen Q08_Rate Scale 1014->nineteen Q08_Rate Scale 1014->twenty " . - "Q08_Rate Scale 1014->happy Q08_Rate Scale 1014->sad Q08_Rate Scale 1014->jealous", + "Q08_Rate Scale 1014->happy Q08_Rate Scale 1014->sad Q08_Rate Scale 1014->jealous Q09_Slider 1016", " Test course 1 Testy Lastname1 username1 Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 ", + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5", " Test course 1 Testy Lastname2 username2 Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 ", + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5", " Test course 1 Testy Lastname3 username3 Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 ", + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5", " Test course 1 Testy Lastname4 username4 Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 "]; + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5"]; } /** @@ -115,16 +115,16 @@ private function expected_incomplete_output() { "Q07_Check Boxes 1012->twelve Q07_Check Boxes 1012->thirteen Q08_Rate Scale 1014->fourteen " . "Q08_Rate Scale 1014->fifteen Q08_Rate Scale 1014->sixteen Q08_Rate Scale 1014->seventeen " . "Q08_Rate Scale 1014->eighteen Q08_Rate Scale 1014->nineteen Q08_Rate Scale 1014->twenty " . - "Q08_Rate Scale 1014->happy Q08_Rate Scale 1014->sad Q08_Rate Scale 1014->jealous", + "Q08_Rate Scale 1014->happy Q08_Rate Scale 1014->sad Q08_Rate Scale 1014->jealous Q09_Slider 1016", " Test course 1 Testy Lastname1 username1 y Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 ", + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5", " Test course 1 Testy Lastname2 username2 y Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 ", + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5", " Test course 1 Testy Lastname3 username3 y Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 ", + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5", " Test course 1 Testy Lastname4 username4 y Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 ", + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5", " Test course 1 Testy Lastname5 username5 n Test answer Some header textSome paragraph text 83 " . - "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 "]; + "27/12/2017 wind three 0 0 0 0 0 0 0 0 0 1 1 2 3 4 5 1 2 3 4 5"]; } } diff --git a/tests/custom_completion_test.php b/tests/custom_completion_test.php new file mode 100644 index 00000000..278d82d5 --- /dev/null +++ b/tests/custom_completion_test.php @@ -0,0 +1,213 @@ +. + +/** + * Contains unit tests for core_completion/activity_custom_completion. + * + * @package mod_questionnaire + * @copyright 2022 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace mod_questionnaire; + +use advanced_testcase; +use cm_info; +use coding_exception; +use mod_questionnaire\completion\custom_completion; +use moodle_exception; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/completionlib.php'); +require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php'); + +/** + * Class for unit testing mod_questionnaire/custom_completion. + * + * @package mod_questionnaire + * @copyright 2022 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_completion_test extends advanced_testcase { + + /** + * Data provider for get_state(). + * + * @return array[] + */ + public function get_state_provider(): array { + return [ + 'Undefined rule' => [ + 'somenonexistentrule', COMPLETION_DISABLED, false, null, coding_exception::class + ], + 'Rule not available' => [ + 'completionsubmit', COMPLETION_DISABLED, false, null, moodle_exception::class + ], + 'Rule available, user has not submitted' => [ + 'completionsubmit', COMPLETION_ENABLED, false, COMPLETION_INCOMPLETE, null + ], + 'Rule available, user has submitted' => [ + 'completionsubmit', COMPLETION_ENABLED, true, COMPLETION_COMPLETE, null + ], + ]; + } + + /** + * Test for get_state(). + * + * @dataProvider get_state_provider + * @param string $rule The custom completion rule. + * @param int $available Whether this rule is available. + * @param bool $submitted + * @param int|null $status Expected status. + * @param string|null $exception Expected exception. + * @throws coding_exception + */ + public function test_get_state(string $rule, int $available, ?bool $submitted, ?int $status, ?string $exception) { + if (!is_null($exception)) { + $this->expectException($exception); + } + + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire'); + + $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $questionnaire = $generator->create_instance(['course' => $course->id, 'completion' => COMPLETION_TRACKING_AUTOMATIC, + $rule => $available]); + + $questiondata['type_id'] = 1; + $questiondata['surveyid'] = $questionnaire->sid; + $questiondata['name'] = 'Q1'; + $questiondata['content'] = 'Test content'; + $question = $generator->create_question($questionnaire, $questiondata); + + // For case user done completion. + if ($status !== COMPLETION_INCOMPLETE) { + $response = $generator->create_question_response($questionnaire, $question, 'y', (int)$student->id); + } + + $this->setUser($student); + $cm = get_coursemodule_from_instance('questionnaire', $questionnaire->id); + $cm = cm_info::create($cm); + + $customcompletion = new custom_completion($cm, (int)$student->id); + $this->assertEquals($status, $customcompletion->get_state($rule)); + } + + /** + * Test for get_defined_custom_rules(). + */ + public function test_get_defined_custom_rules() { + $rules = custom_completion::get_defined_custom_rules(); + $this->assertCount(1, $rules); + $this->assertEquals('completionsubmit', reset($rules)); + } + + /** + * Test for get_defined_custom_rule_descriptions(). + */ + public function test_get_custom_rule_descriptions() { + // Get defined custom rules. + $rules = custom_completion::get_defined_custom_rules(); + + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + + // Instantiate a custom_completion object using the mocked cm_info. + $customcompletion = new custom_completion($mockcminfo, 1); + + // Get custom rule descriptions. + $ruledescriptions = $customcompletion->get_custom_rule_descriptions(); + + // Confirm that defined rules and rule descriptions are consistent with each other. + $this->assertEquals(count($rules), count($ruledescriptions)); + foreach ($rules as $rule) { + $this->assertArrayHasKey($rule, $ruledescriptions); + } + } + + /** + * Test for is_defined(). + */ + public function test_is_defined() { + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->getMock(); + + $customcompletion = new custom_completion($mockcminfo, 1); + + // Rule is defined. + $this->assertTrue($customcompletion->is_defined('completionsubmit')); + + // Undefined rule. + $this->assertFalse($customcompletion->is_defined('somerandomrule')); + } + + /** + * Data provider for test_get_available_custom_rules(). + * + * @return array[] + */ + public function get_available_custom_rules_provider(): array { + return [ + 'Completion submit available' => [ + COMPLETION_ENABLED, ['completionsubmit'] + ], + 'Completion submit not available' => [ + COMPLETION_DISABLED, [] + ], + ]; + } + + /** + * Test for get_available_custom_rules(). + * + * @dataProvider get_available_custom_rules_provider + * @param int $status + * @param array $expected + */ + public function test_get_available_custom_rules(int $status, array $expected) { + $customdataval = [ + 'customcompletionrules' => [ + 'completionsubmit' => $status + ] + ]; + + // Build a mock cm_info instance. + $mockcminfo = $this->getMockBuilder(cm_info::class) + ->disableOriginalConstructor() + ->onlyMethods(['__get']) + ->getMock(); + + // Mock the return of magic getter for the customdata attribute. + $mockcminfo->expects($this->any()) + ->method('__get') + ->with('customdata') + ->willReturn($customdataval); + + $customcompletion = new custom_completion($mockcminfo, 1); + $this->assertEquals($expected, $customcompletion->get_available_custom_rules()); + } +} diff --git a/tests/generator/lib.php b/tests/generator/lib.php index fa0d52bd..d62a9950 100644 --- a/tests/generator/lib.php +++ b/tests/generator/lib.php @@ -22,6 +22,7 @@ global $CFG; require_once($CFG->dirroot.'/mod/questionnaire/locallib.php'); +require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php'); /** * The mod_questionnaire data generator. @@ -33,6 +34,11 @@ */ class mod_questionnaire_generator extends testing_module_generator { + /** + * @var int Current position of assigned options. + */ + protected $curpos = 0; + /** * @var int keep track of how many questions have been created. */ @@ -386,6 +392,10 @@ public function type_str($qtypeid) { break; case QUESPAGEBREAK: $qtype = 'sectionbreak'; + break; + case QUESSLIDER: + $qtype = 'Slider'; + break; } return $qtype; } @@ -429,6 +439,10 @@ public function type_name($qtypeid) { break; case QUESPAGEBREAK: $qtype = 'Section Break'; + break; + case QUESSLIDER: + $qtype = 'Slider'; + break; } return $qtype; } @@ -570,8 +584,6 @@ public function create_response($questionresponses, $record = null, $complete = * @param int $number */ public function assign_opts($number = 5) { - static $curpos = 0; - $opts = 'blue, red, yellow, orange, green, purple, white, black, earth, wind, fire, space, car, truck, train' . ', van, tram, one, two, three, four, five, six, seven, eight, nine, ten, eleven, twelve, thirteen' . ', fourteen, fifteen, sixteen, seventeen, eighteen, nineteen, twenty, happy, sad, jealous, angry'; @@ -584,10 +596,10 @@ public function assign_opts($number = 5) { $retopts = []; while (count($retopts) < $number) { - $retopts[] = $opts[$curpos]; + $retopts[] = $opts[$this->curpos]; $retopts = array_unique($retopts); - if (++$curpos == $numopts) { - $curpos = 0; + if (++$this->curpos == $numopts) { + $this->curpos = 0; } } // Return re-indexed version of array (otherwise you can get a weird index of 1,2,5,9, etc). @@ -651,6 +663,9 @@ public function generate_response($questionnaire, $questions, $userid, $complete } $responses[] = new question_response($question->id, $answers); break; + case QUESSLIDER : + $responses[] = new question_response($question->id, 5); + break; } } @@ -671,7 +686,8 @@ public function create_and_fully_populate($coursecount = 4, $studentcount = 20, $dg = $this->datagenerator; $qdg = $this; - $questiontypes = [QUESTEXT, QUESESSAY, QUESNUMERIC, QUESDATE, QUESRADIO, QUESDROP, QUESCHECK, QUESRATE]; + $this->curpos = 0; + $questiontypes = [QUESTEXT, QUESESSAY, QUESNUMERIC, QUESDATE, QUESRADIO, QUESDROP, QUESCHECK, QUESRATE, QUESSLIDER]; $students = []; $courses = []; $questionnaires = []; diff --git a/tests/privacy_provider_test.php b/tests/privacy_provider_test.php index 21ee99ea..c5bbf014 100644 --- a/tests/privacy_provider_test.php +++ b/tests/privacy_provider_test.php @@ -129,8 +129,8 @@ public function test_export_user_data() { $this->assertEquals('7. Numeric 1004', $data->responses[0]['questions'][7]->questionname); $this->assertEquals(83, $data->responses[0]['questions'][7]->answers[0]); $this->assertEquals('22. Rate Scale 1014', $data->responses[0]['questions'][22]->questionname); - $this->assertEquals('eleven = 1', $data->responses[0]['questions'][22]->answers[0]); - $this->assertEquals('eighteen = 3', $data->responses[0]['questions'][22]->answers[7]); + $this->assertEquals('fourteen = 1', $data->responses[0]['questions'][22]->answers[0]); + $this->assertEquals('happy = 3', $data->responses[0]['questions'][22]->answers[7]); } /** diff --git a/tests/questiontypes_test.php b/tests/questiontypes_test.php index be7aff5e..ad4ceabf 100644 --- a/tests/questiontypes_test.php +++ b/tests/questiontypes_test.php @@ -29,6 +29,7 @@ global $CFG; require_once($CFG->dirroot.'/mod/questionnaire/locallib.php'); +require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php'); /** * Unit tests for questionnaire_questiontypes_testcase. @@ -86,6 +87,12 @@ public function test_create_question_textbox() { $this->create_test_question(QUESTEXT, '\\mod_questionnaire\\question\\text', $questiondata); } + public function test_create_question_slider() { + $questiondata = array( + 'content' => 'Enter a number'); + $this->create_test_question(QUESSLIDER, '\\mod_questionnaire\\question\\slider', $questiondata); + } + public function test_create_question_yesno() { $this->create_test_question(QUESYESNO, '\\mod_questionnaire\\question\\yesno', array('content' => 'Enter yes or no')); } diff --git a/tests/responsetypes_test.php b/tests/responsetypes_test.php index 9c48b01c..76c83e10 100644 --- a/tests/responsetypes_test.php +++ b/tests/responsetypes_test.php @@ -31,6 +31,7 @@ require_once($CFG->dirroot.'/mod/questionnaire/locallib.php'); require_once($CFG->dirroot . '/mod/questionnaire/tests/generator_test.php'); require_once($CFG->dirroot . '/mod/questionnaire/tests/questiontypes_test.php'); +require_once($CFG->dirroot . '/mod/questionnaire/classes/question/question.php'); /** * Unit tests for questionnaire_responsetypes_testcase. @@ -90,6 +91,33 @@ public function test_create_response_text() { $this->assertEquals('This is my essay.', $textresponse->response); } + public function test_create_response_slider() { + global $DB; + + $this->resetAfterTest(); + + // Some common variables used below. + $userid = 1; + + // Set up a questionnaire with one text response question. + $course = $this->getDataGenerator()->create_course(); + $generator = $this->getDataGenerator()->get_plugin_generator('mod_questionnaire'); + $questiondata = ['content' => 'Enter some text']; + $questionnaire = $generator->create_test_questionnaire($course, QUESSLIDER, $questiondata); + $question = reset($questionnaire->questions); + $response = $generator->create_question_response($questionnaire, $question, 5, $userid); + + // Test the responses for this questionnaire. + $this->response_tests($questionnaire->id, $response->id, $userid); + + // Retrieve the specific text response. + $textresponses = $DB->get_records('questionnaire_response_text', ['response_id' => $response->id]); + $this->assertEquals(1, count($textresponses)); + $textresponse = reset($textresponses); + $this->assertEquals($question->id, $textresponse->question_id); + $this->assertEquals(5, $textresponse->response); + } + public function test_create_response_date() { global $DB; diff --git a/version.php b/version.php index cf3c5b81..2240b82a 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2022030301; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2022092200; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2022030300; // Moodle version (4.0). $plugin->component = 'mod_questionnaire'; diff --git a/view.php b/view.php index e2212603..5500e99d 100644 --- a/view.php +++ b/view.php @@ -65,12 +65,7 @@ $PAGE->set_heading(format_string($course->fullname)); echo $questionnaire->renderer->header(); -$questionnaire->page->add_to_page('questionnairename', format_string($questionnaire->name)); - -// Print the main part of the page. -if ($questionnaire->intro) { - $questionnaire->page->add_to_page('intro', format_module_intro('questionnaire', $questionnaire, $cm->id)); -} +// No need to print out intro or name in Moodle 4 and above. $cm = $questionnaire->cm; $currentgroupid = groups_get_activity_group($cm);