diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fa8c53d8..958429ce6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,4 +112,4 @@ jobs: - name: Behat features if: ${{ always() }} - run: moodle-plugin-ci behat --profile chrome + run: moodle-plugin-ci behat --profile chrome --scss-deprecations diff --git a/CHANGES.md b/CHANGES.md index 493b4a376..4c026a25b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,8 +1,26 @@ Release Notes -Release 4.1.0 (Build - 2023081100) +Release 4.4.0 (Build - 2025110900) +New Features: +* [PR590](https://github.com/PoetOS/moodle-mod_questionnaire/pull/590): Allow responses to be deleted automatically after a specified time. This is disabled by default. -Initial release for Moodle 4.1 forward. +Improvements: +* [PR618](https://github.com/PoetOS/moodle-mod_questionnaire/pull/618): Make printable questionnaire more readable. +* Updated deprecated functions. +* [PR589](https://github.com/PoetOS/moodle-mod_questionnaire/pull/589): Allow selection of submitted responses or in progress responses on results pages. +* [PR596](https://github.com/PoetOS/moodle-mod_questionnaire/pull/596): Allow sorting on name and submission date on summary page. +* [PR588](https://github.com/PoetOS/moodle-mod_questionnaire/pull/588): Add a print button to the My report page. +* [PR378](https://github.com/PoetOS/moodle-mod_questionnaire/pull/378): Add open and close dates back to view page. +* [PR604](https://github.com/PoetOS/moodle-mod_questionnaire/pull/604): Show all "other" free choices for rate questions individually on report pages. +* [PR616](https://github.com/PoetOS/moodle-mod_questionnaire/pull/616): Only show dependent questions answered on response page. +* [I120](https://github.com/PoetOS/moodle-mod_questionnaire/issues/120): Added idnumber to export. + +Bug Fixes: +* Ensure numbering doesn't appear on results pages when they are turned off. +* Mobile - ensure pull to refresh doesn't resend question responses. +* Fixed empty key error. +* Fixed oversize icon display in 4.5. +* Thank you page header is now filtered. Release 4.1.1 (Build - 2024082900) @@ -34,5 +52,9 @@ Bug Fixes: * PR514 - Section text qtype should not support feedback. * PR516 - Course description displays properly. +Release 4.1.0 (Build - 2023081100) + +Initial release for Moodle 4.1 forward. + (see CHANGES.md in release 4.00 for earlier changes.) diff --git a/backup/moodle2/restore_questionnaire_stepslib.php b/backup/moodle2/restore_questionnaire_stepslib.php index d52356357..769383eac 100644 --- a/backup/moodle2/restore_questionnaire_stepslib.php +++ b/backup/moodle2/restore_questionnaire_stepslib.php @@ -163,7 +163,10 @@ protected function process_questionnaire_question($data) { $data = (object)$data; $oldid = $data->id; $data->surveyid = $this->get_new_parentid('questionnaire_survey'); - + // Cover for legacy backup data. + if ($data->deleted === 'n') { + $data->deleted = null; + } // Insert the questionnaire_question record. $newitemid = $DB->insert_record('questionnaire_question', $data); $this->set_mapping('questionnaire_question', $oldid, $newitemid, true); diff --git a/classes/output/renderer.php b/classes/output/renderer.php index 85497f8a3..a35cdb944 100755 --- a/classes/output/renderer.php +++ b/classes/output/renderer.php @@ -244,7 +244,8 @@ public function question_output($question, $response, $qnum, $blankquestionnaire $pagetags->notifications = $this->notification($notification, \core\output\notification::NOTIFY_ERROR); } } - + $pagetags->questionname = $question->name; + return $this->render_from_template('mod_questionnaire/question_container', $pagetags); } diff --git a/classes/question/question.php b/classes/question/question.php index ed191496b..86690220a 100644 --- a/classes/question/question.php +++ b/classes/question/question.php @@ -103,8 +103,8 @@ abstract class question { /** @var boolean $required The required flag. */ public $required = 'n'; - /** @var boolean $deleted The deleted flag. */ - public $deleted = 'n'; + /** @var int $deleted The deleted flag. */ + public $deleted = null; /** @var mixed $extradata Any custom data for the question type. */ public $extradata = ''; @@ -598,7 +598,7 @@ public function response_complete($responsedata) { // If $responsedata is webform data, check that its not empty. $answered = isset($responsedata->{'q'.$this->id}) && ($responsedata->{'q'.$this->id} != ''); } - return !($this->required() && ($this->deleted == 'n') && !$answered); + return !($this->required() && (empty($this->deleted)) && !$answered); } /** @@ -663,8 +663,8 @@ public function add($questionrecord, array $choicerecords = null, $calcposition // Set the position to the end. $sql = 'SELECT MAX(position) as maxpos '. 'FROM {questionnaire_question} '. - 'WHERE surveyid = ? AND deleted = ?'; - $params = ['surveyid' => $questionrecord->surveyid, 'deleted' => 'n']; + 'WHERE surveyid = ? AND deleted IS NULL'; + $params = ['surveyid' => $questionrecord->surveyid]; if ($record = $DB->get_record_sql($sql, $params)) { $questionrecord->position = $record->maxpos + 1; } else { diff --git a/classes/questions_form.php b/classes/questions_form.php index 0bb2b4697..aa7f46719 100644 --- a/classes/questions_form.php +++ b/classes/questions_form.php @@ -50,6 +50,7 @@ public function definition() { $sid = $questionnaire->survey->id; $mform =& $this->_form; + $qidrestore = optional_param(QUESTIONNAIRE_RESTORE_PARAM, 0, PARAM_INT); $mform->addElement('header', 'questionhdr', get_string('addquestions', 'questionnaire')); $mform->addHelpButton('questionhdr', 'questiontypes', 'questionnaire'); @@ -138,7 +139,7 @@ public function definition() { // No page break in first position! if ($tid == QUESPAGEBREAK && $pos == 1) { - $DB->set_field('questionnaire_question', 'deleted', 'y', ['id' => $qid, 'surveyid' => $sid]); + $DB->set_field('questionnaire_question', 'deleted', time(), ['id' => $qid, 'surveyid' => $sid]); 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, array('id' => $record->id)); @@ -172,7 +173,12 @@ public function definition() { // Begin div qn-container with indent if questionnaire has child. $mform->addElement('html', '
'); } else { - $mform->addElement('html', '
'); // Begin div qn-container. + $containerclass = "qn-container"; + if (isset($qidrestore) && $qidrestore == $question->id) { + $containerclass .= " restored-question"; + } + // Begin div qn-container. + $mform->addElement('html', "
"); } $mextra = array('value' => $question->id, @@ -220,14 +226,15 @@ public function definition() { // Do not allow moving or deleting a page break if immediately followed by a child question // or immediately preceded by a question with a dependency and followed by a non-dependent question. if ($tid == QUESPAGEBREAK) { - if ($nextquestion = $DB->get_record('questionnaire_question', - ['surveyid' => $sid, 'position' => $pos + 1, 'deleted' => 'n'], 'id, name, content') ) { - + $select = 'surveyid = ? AND position = ? AND deleted IS NULL'; + $nextquestion = $DB->get_record_select('questionnaire_question', $select, + [$sid, $pos + 1], 'id, name, content'); + if ($nextquestion) { $nextquestiondependencies = $DB->get_records('questionnaire_dependency', ['questionid' => $nextquestion->id , 'surveyid' => $sid], 'id ASC'); - if ($previousquestion = $DB->get_record('questionnaire_question', - ['surveyid' => $sid, 'position' => $pos - 1, 'deleted' => 'n'], 'id, name, content')) { + if ($previousquestion = $DB->get_record_select('questionnaire_question', $select, + [$sid, $pos - 1], 'id, name, content')) { $previousquestiondependencies = $DB->get_records('questionnaire_dependency', ['questionid' => $previousquestion->id , 'surveyid' => $sid], 'id ASC'); @@ -359,6 +366,68 @@ public function definition() { } } + // Question deletion area. + $mform->addElement('header', 'deletionq', get_string('deletionquetions', 'questionnaire')); + $mform->addHelpButton('deletionq', 'deletionquetions', 'questionnaire'); + $mform->addElement('html', '
'); + if (isset($questionnaire->deletequestions)) { + $restoreimg = $questionnaire->renderer->image_url('i/up'); + $deleteimg = $questionnaire->renderer->image_url('t/delete'); + $rangetimecrontask = questionnaire_get_range_time_permanently(); + foreach ($questionnaire->deletequestions as $deletequestion) { + $delquestiongroup = []; + // Preparing deleted time to display time permanently question. + $timedeleted = $deletequestion->deleted ?? ""; + if ($rangetimecrontask == 0) { + $timedeleted = get_string('recylebindisabled', 'questionnaire'); + } else { + if (!empty($timedeleted)) { + $timedeleted = get_string('timedeletednext7days', 'questionnaire', + date("D j M, Y", $timedeleted + $rangetimecrontask)); + } + } + $qtypeandname = []; + $qtypeandname['name'] = $deletequestion->name; + $qtypeandname['type'] = questionnaire_get_type($deletequestion->type_id); + + $content = format_text( + file_rewrite_pluginfile_urls($deletequestion->content, 'pluginfile.php', + $deletequestion->context->id, + 'mod_questionnaire', 'question', $deletequestion->id), + FORMAT_HTML, ['noclean' => true] + ); + + $qnumber = '

NA

'; + $restorextra = [ + 'value' => $deletequestion->id, + 'alt' => get_string('restorebutton', 'questionnaire'), + 'title' => get_string('restorebutton', 'questionnaire'), + 'class' => 'mod_questionnaire_recycleq', + ]; + $deleleextra = [ + 'value' => $deletequestion->id, + 'alt' => get_string('deletepermanentlybutton', 'questionnaire'), + 'title' => get_string('deletepermanentlybutton', 'questionnaire'), + 'class' => 'mod_questionnaire_recycleq', + ]; + $mform->addElement('html', '
'); // Begin div qn-container. + $delquestiongroup[] =& $mform->createElement('static', 'opentag_' . $deletequestion->id, '', ''); + $delquestiongroup[] =& $mform->createElement('image', 'restorebutton[' . $deletequestion->id . ']', + $restoreimg, $restorextra); + $delquestiongroup[] =& $mform->createElement('image', 'deletebutton[' . $deletequestion->id . ']', + $deleteimg, $deleleextra); + $delquestiongroup[] =& $mform->createElement('static', 'closetag_' . $deletequestion->id, '', ''); + $delquestiongroup[] =& $mform->createElement('static', 'qinfo_' . $deletequestion->id, '', + get_string('questiontypeandname', 'questionnaire', $qtypeandname)); + $delquestiongroup[] =& $mform->createElement('static', 'qinfo_' . $deletequestion->id, + '', $timedeleted); + $mform->addGroup($delquestiongroup, 'delquestiongroup', '', ' ', false); + $mform->addElement('static', 'qcontent_'.$deletequestion->id, '', + $qnumber.'
'.$content.'
'); + $mform->addElement('html', '
'); // End div qn-container. + } + } + if ($this->moveq) { $mform->addElement('hidden', 'moveq', $this->moveq); } @@ -373,6 +442,9 @@ public function definition() { $mform->setType('moveq', PARAM_RAW); $mform->addElement('html', '
'); + $mform->setExpanded('questionhdr'); + $mform->setExpanded('manageq'); + $mform->setExpanded('deletionq'); } /** diff --git a/classes/responsetype/responsetype.php b/classes/responsetype/responsetype.php index aa9c5dd25..841ea11d4 100644 --- a/classes/responsetype/responsetype.php +++ b/classes/responsetype/responsetype.php @@ -287,6 +287,7 @@ protected function user_fields_sql() { $userfieldsarr = get_all_user_name_fields(); } $userfieldsarr = array_merge($userfieldsarr, ['username', 'department', 'institution']); + $userfieldsarr = array_merge($userfieldsarr, ['username', 'department', 'institution', 'idnumber']); $userfields = ''; foreach ($userfieldsarr as $field) { $userfields .= $userfields === '' ? '' : ', '; diff --git a/classes/search/question.php b/classes/search/question.php index 0d5d4b60d..f38700fbc 100644 --- a/classes/search/question.php +++ b/classes/search/question.php @@ -74,8 +74,8 @@ public function get_document($record, $options = []) { // Because there is no database agnostic way to combine all of the possible question content data into one record in // get_recordset_by_timestamp, I need to grab it all now and add it to the document. - $recordset = $DB->get_recordset('questionnaire_question', ['surveyid' => $record->sid, 'deleted' => 'n'], - 'id', 'id,content'); + $recordset = $DB->get_recordset_select('questionnaire_question', + 'surveyid = ? AND deleted IS NULL', [$record->sid], 'id', 'id,content'); // If no question data, don't index this document. if (empty($recordset)) { diff --git a/classes/task/cron_task.php b/classes/task/cron_task.php new file mode 100644 index 000000000..d715d53b3 --- /dev/null +++ b/classes/task/cron_task.php @@ -0,0 +1,54 @@ +. + +namespace mod_questionnaire\task; +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot.'/mod/questionnaire/questionnaire.class.php'); +/** + * A schedule task for mod_questionnaire cron. + * + * @package mod_questionnaire + * @copyright 2022 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cron_task extends \core\task\scheduled_task { + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('cleanrecylebin', 'mod_questionnaire'); + } + + /** + * Run mod_questionnaire cron. + */ + public function execute() { + global $DB; + $rangetimecrontask = questionnaire_get_range_time_permanently(); + $sql = "SELECT * + FROM {questionnaire_question} + WHERE deleted IS NOT NULL + AND deleted < ?"; + if ($deletequestions = $DB->get_records_sql($sql, [time() - $rangetimecrontask])) { + foreach ($deletequestions as $question) { + questionnaire_delete_permanently_questions($question->id, $question->surveyid); + } + } + } +} diff --git a/db/install.xml b/db/install.xml index e5892b26b..49f51f097 100644 --- a/db/install.xml +++ b/db/install.xml @@ -77,7 +77,7 @@ - + diff --git a/db/tasks.php b/db/tasks.php index 40d241abc..8629011cb 100644 --- a/db/tasks.php +++ b/db/tasks.php @@ -36,5 +36,14 @@ 'day' => '*', 'month' => '*', 'dayofweek' => '*' - ) + ), + [ + 'classname' => 'mod_questionnaire\task\cron_task', + 'blocking' => 0, + 'minute' => '*', + 'hour' => '*', + 'day' => '*/7', + 'month' => '*', + 'dayofweek' => '*' + ], ); diff --git a/db/upgrade.php b/db/upgrade.php index b07cea8c2..756582807 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -1016,6 +1016,32 @@ function xmldb_questionnaire_upgrade($oldversion=0) { upgrade_mod_savepoint(true, 2022121601.01, 'questionnaire'); } + if ($oldversion < 2024080100.01) { + $table = new xmldb_table('questionnaire_question'); + $index = new xmldb_index('quest_question_sididx', XMLDB_INDEX_NOTUNIQUE, ['surveyid', 'deleted']); + if ($dbman->index_exists($table, $index)) { + $dbman->drop_index($table, $index); + } + $field = new xmldb_field('deleted', XMLDB_TYPE_CHAR, '10', XMLDB_UNSIGNED, null, null, null, 'required'); + if ($dbman->field_exists($table, $field)) { + $dbman->change_field_type($table, $field); + } + unset($field); + + $field = new xmldb_field('deleted', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, null, null, null, 'required'); + if ($dbman->field_exists($table, $field)) { + // Instead of updating all 'deleted' = 'y' to a timestamp (which could be slow on large tables), + // simply delete those records, as they were not restorable before this upgrade. + $DB->delete_records('questionnaire_question', ['deleted' => 'y']); + // Optionally, clear 'deleted' = 'n' to null if required by new logic. + $DB->set_field('questionnaire_question', 'deleted', null, ['deleted' => 'n']); + $dbman->change_field_type($table, $field); + } + unset($field); + // Questionnaire savepoint reached. + upgrade_mod_savepoint(true, 2024080100.01, 'questionnaire'); + } + return true; } diff --git a/lang/en/questionnaire.php b/lang/en/questionnaire.php index e28d93520..2da21d836 100644 --- a/lang/en/questionnaire.php +++ b/lang/en/questionnaire.php @@ -99,6 +99,7 @@ $string['closed'] = 'Closed on {$a}.'; $string['closedate'] = 'Allow responses until'; $string['closeson'] = 'Questionnaire closes on {$a}'; +$string['cleanrecylebin'] = "Empty Questionnaire 'Recycle bin'"; $string['completionsubmit'] = 'Student must submit this questionnaire to complete it'; $string['condition'] = 'Condition'; $string['confalts'] = '- OR -
Confirmation page'; @@ -110,7 +111,8 @@ $string['confirmdelallresp'] = 'Are you sure you want to delete ALL the responses in this questionnaire?'; $string['confirmdelchildren'] = 'If you delete this question, its child(ren) question(s) will also be deleted:'; $string['confirmdelgroupresp'] = 'Are you sure you want to delete ALL the responses of {$a}?'; -$string['confirmdelquestion'] = 'Are you sure you want to delete the question at position {$a}?'; +$string['confirmdelquestion'] = 'Are you sure you want to move the question at position {$a} to the deletion area?'; +$string['confirmdelpermanentlyq'] = 'Are you sure you want to permanently delete this question?'; $string['confirmdelquestionresps'] = 'This will also delete the {$a} response(s) already given to that question.'; $string['confirmdelresp'] = 'Are you sure you want to delete the response by {$a} ?'; $string['confirmdeletesection'] = 'Are you sure you want to delete feedback section "{$a}"?'; @@ -142,7 +144,10 @@ $string['deletedresp'] = 'Deleted Response'; $string['deleteresp'] = 'Delete this Response'; $string['deletesection'] = 'Delete this section'; +$string['deletepermanentlybutton'] = 'Permanently delete question'; $string['deletingresp'] = 'Deleting Response'; +$string['deletequestionsolderthan'] = 'Delete questions older than'; +$string['deletesettingdescription'] = 'The scheduled task will delete questions in deletion area that are older than this many days'; $string['dependencies'] = 'Dependencies'; $string['dependquestion'] = 'Parent Question'; $string['dependquestion_help'] = 'You can select a parent question and a choice option for this question. A child question will only be displayed @@ -264,7 +269,8 @@ $string['gradesdeleted'] = 'Questionnaire grades deleted'; $string['headingtext'] = 'Heading text'; $string['horizontal'] = 'Horizontal'; -$string['id'] = 'ID'; +$string['id'] = 'User id'; +$string['useridnumber'] = 'User idnumber'; $string['includerankaverages'] = 'Include rank question averages'; $string['includechoicecodes'] = 'Include choice codes'; $string['includechoicetext'] = 'Include choice text'; @@ -290,6 +296,8 @@ $string['leftpart'] = '{$a->min} is {$a->leftlabel}'; $string['leftpartdefault'] = '{$a->min} is minimum slider range'; $string['managequestions'] = 'Manage questions'; +$string['deletionquetions'] = 'Question deletion area'; +$string['deletionquetions_help'] = 'Deleted or otherwise orphaned questions will be first moved here rather than be outright deleted. The questions can be permanently deleted either manually through a user pressing the \'X\' icon, or the system will automatically remove any questions after a week. Use the \'up arrow\' button to restore the question.'; $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'; $string['mandatory'] = 'Mandatory - All these dependencies must be fulfilled.'; @@ -526,6 +534,7 @@ $string['questiontypes'] = 'Question types'; $string['questiontypes_help'] = 'See the Moodle Documentation below'; $string['questiontypes_link'] = 'mod/questionnaire/questions#Question_Types'; +$string['questiontypeandname'] = '[{$a->type}] ({$a->name})'; $string['radiobuttons'] = 'Radio Buttons'; $string['radiobuttons_help'] = 'In this question type, the respondent must select one out of the choices offered.'; $string['radiobuttons_link'] = 'mod/questionnaire/questions#Radio_Buttons'; @@ -533,6 +542,7 @@ $string['ratescale'] = 'Rate (scale 1..5)'; $string['ratescale_help'] = 'See the Moodle Documentation below'; $string['ratescale_link'] = 'mod/questionnaire/questions#Rate_.28scale_1..5.29'; +$string['recylebindisabled'] = 'Automatic deletion is disabled'; $string['realm'] = 'Questionnaire Type'; $string['realm_help'] = '* **There are three types of questionnaires:** * Private - belongs to the course it is defined in only. @@ -540,7 +550,7 @@ * Public - can be shared among courses.'; $string['realm_link'] = 'mod/questionnaire/qsettings#Questionnaire_Type'; $string['redirecturl'] = 'The URL to which a user is redirected after completing this questionnaire.'; -$string['remove'] = 'Delete'; +$string['remove'] = 'Move to deletion area'; $string['removenotinuse'] = 'This questionnaire used to depend on a Public questionnaire which has been deleted. It can no longer be used and should be deleted.'; $string['responsesnotsubmitted'] = 'Responses not submitted'; @@ -569,6 +579,7 @@ $string['responseformat'] = 'Response format'; $string['responseoptions'] = 'Response options'; $string['responses'] = 'Responses'; +$string['responses_breakdown'] = '(Submissions: {$a->responses} | In progress: {$a->incomplete})'; $string['responseview'] = 'Students can view ALL responses'; $string['responseview_help'] = 'You can specify who can see the responses of all respondents to submitted questionnaires (general statistics tables).'; $string['responseview_link'] = 'mod/questionnaire/mod#Response_viewing'; @@ -582,6 +593,7 @@ Users can leave the questionnaire unfinished and resume from the save point at a later date.'; $string['resume_link'] = 'mod/questionnaire/mod#Save/Resume_answers'; $string['resumesurvey'] = 'Resume questionnaire'; +$string['restorebutton'] = 'Restore this question'; $string['return'] = 'Return'; $string['removeoldresponsesdefault'] = 'Never remove'; $string['removeoldresponses'] = 'Delete old responses'; @@ -666,6 +678,7 @@ $string['thousands'] = 'Do not use thousands separators.'; $string['title'] = 'Title'; $string['title_help'] = 'Title of this questionnaire, which will appear at the top of every page. By default Title is set to the questionnaire Name, but you can edit it as you like.'; +$string['timedeletednext7days'] = 'Time of permanent deletion: night of {$a}'; $string['today'] = 'today'; $string['total'] = 'Total'; $string['totalofnumbers'] = 'Total of numbers entered'; diff --git a/locallib.php b/locallib.php index 4ed962ec8..4685b6fa7 100644 --- a/locallib.php +++ b/locallib.php @@ -47,6 +47,9 @@ define('QUESTIONNAIRE_DEFAULT_PAGE_COUNT', 20); +define('QUESTIONNAIRE_CONFIRM_DELETE_PERMANENTLY', 'confirmdelpermanentlyq'); +define('QUESTIONNAIRE_RESTORE_PARAM', 'restoreq'); + global $questionnairetypes; $questionnairetypes = array (QUESTIONNAIREUNLIMITED => get_string('qtypeunlimited', 'questionnaire'), QUESTIONNAIREONCE => get_string('qtypeonce', 'questionnaire'), @@ -300,6 +303,78 @@ function questionnaire_delete_survey($sid, $questionnaireid) { return $status; } +/** + * Delete permanently questions and data reference. + * + * @param int $qid question id. + * @param int $sid survey question id. + * @return void + */ +function questionnaire_delete_permanently_questions($qid, $sid) { + global $DB; + $select = 'id = :id AND surveyid = :sid AND deleted IS NOT NULL'; + $DB->delete_records_select('questionnaire_question', $select , ['id' => $qid, 'sid' => $sid]); + $DB->delete_records('questionnaire_response', ['questionnaireid' => $qid]); + questionnaire_delete_responses($qid); + questionnaire_delete_dependencies($qid); +} + +/** + * Log question deleted event. + * + * @param int $cmid of module. + * @param string $questiontype of question. + * @param int $courseid of question. + * @return void. + */ +function questionnaire_observe_event_delete($cmid, $questiontype, $courseid) { + $context = context_module::instance($cmid); + $params = [ + 'context' => $context, + 'courseid' => $courseid, + 'other' => ['questiontype' => $questiontype] + ]; + $event = \mod_questionnaire\event\question_deleted::create($params); + $event->trigger(); +} + +/** + * Restore deleted questions. + * + * @param int $qid question id. + * @param int $sid survey id. + * @return void + */ +function questionnaire_restore_deleted_question($qid, $sid) { + global $DB; + // Get current deleted question and last position. + $sql = "SELECT *, ( + SELECT position + 1 + FROM {questionnaire_question} + WHERE surveyid = ? + AND deleted IS NULL + ORDER BY position DESC + LIMIT 1 ) as lastposition + FROM {questionnaire_question} + WHERE id = ? + AND surveyid = ? + AND deleted IS NOT NULL"; + $question = $DB->get_record_sql($sql, [$sid, $qid, $sid]); + if ($question) { + // Update question using Moodle API. Only 'id' is needed for update_record. + $question->deleted = null; + $question->position = $question->lastposition ?? 1; + $DB->update_record('questionnaire_question', $question); + } +} + +/** + * Get range of time permanently in setup cron task. + */ +function questionnaire_get_range_time_permanently() { + return get_config('questionnaire_questiondeletion', 'duration'); +} + /** * Delete the response. * @param stdClass $response @@ -373,6 +448,21 @@ function questionnaire_delete_dependencies($qid) { return true; } +/** + * Delete all page break deleted. + * + * @param int $sid question survey id. + */ +function questionnaire_delete_pagebreaks($sid) { + global $DB; + $DB->delete_records_select('questionnaire_question', + 'surveyid = :sid AND deleted IS NOT NULL AND type_id = :type_id', + [ + 'sid' => $sid, + 'type_id' => QUESPAGEBREAK + ]); +} + /** * Get a survey selection records. * @param int $courseid @@ -750,7 +840,8 @@ function questionnaire_check_page_breaks($questionnaire) { $delpb = 0; $sid = $questionnaire->survey->id; $positions = array(); - if ($questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n'], 'position')) { + if ($questions = $DB->get_records_select('questionnaire_question', + 'surveyid = :sid AND deleted IS NULL', ['sid' => $sid], 'position')) { foreach ($questions as $key => $qu) { $newqu = new stdClass(); $newqu->question_id = $key; @@ -784,11 +875,15 @@ function questionnaire_check_page_breaks($questionnaire) { $delpb ++; $msg .= get_string("checkbreaksremoved", "questionnaire", $delpb).'
'; // Need to reload questions. - 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')) { + if ($questions = $DB->get_records_select('questionnaire_question', + 'surveyid = :sid AND deleted IS NULL', ['sid' => $sid], 'id')) { + + + $DB->set_field('questionnaire_question', 'deleted', time(), ['id' => $qid, 'surveyid' => $sid]); + $select = 'surveyid = :sid AND deleted IS NULL AND position > :pos'; + $records = $DB->get_records_select('questionnaire_question', $select, + ['sid' => $sid, 'pos' => $questions[$qid]->position], 'position ASC'); + if ($records) { foreach ($records as $record) { $DB->set_field('questionnaire_question', 'position', $record->position - 1, ['id' => $record->id]); } @@ -833,9 +928,11 @@ function questionnaire_check_page_breaks($questionnaire) { if (($prevtypeid != QUESPAGEBREAK && $diffdependencies != 0) || (!isset($qu['dependencies']) && isset($prevdependencies))) { - $sql = 'SELECT MAX(position) as maxpos FROM {questionnaire_question} ' . - 'WHERE surveyid = ' . $questionnaire->survey->id . ' AND deleted = \'n\''; - if ($record = $DB->get_record_sql($sql)) { + $sql = "SELECT MAX(position) as maxpos + FROM {questionnaire_question} + WHERE surveyid = :sid + AND deleted IS NULL"; + if ($record = $DB->get_record_sql($sql, ['sid' => $questionnaire->survey->id])) { $pos = $record->maxpos + 1; } else { $pos = 1; @@ -951,6 +1048,28 @@ function questionnaire_get_standard_page_items($id = null, $a = null) { } +/** + * Count responses already saved for that question. + * + * @param int $qid question id. + * @param int $qtype question type. + * @return int number or 0 if responses were not found. + */ +function count_reponses_question(int $qid, int $qtype): int { + global $DB; + + $countresps = 0; + if ($qtype != QUESSECTIONTEXT) { + $responsetable = $DB->get_field('questionnaire_question_type', 'response_table', ['typeid' => $qtype]); + if (!empty($responsetable)) { + $countresps = $DB->count_records('questionnaire_'.$responsetable, ['question_id' => $qid]); + } + } + + return $countresps; +} + + /** * Create options for remove old responses in the questionare. * diff --git a/questionnaire.class.php b/questionnaire.class.php index 5fd3bebee..b117e281f 100644 --- a/questionnaire.class.php +++ b/questionnaire.class.php @@ -38,6 +38,11 @@ class questionnaire { */ public $questions = []; + /** + * @var \mod_questionnaire\question\question[] $deletequestions + */ + public $deletequestions = []; + /** * The survey record. * @var object $survey @@ -105,6 +110,27 @@ public function __construct(&$course, &$cm, $id = 0, $questionnaire = null, $add $this->responses = []; } + /** + * Get all delete questions by survey id. + * + * @return void + * @throws dml_exception + */ + public function get_delete_questions() { + global $DB; + $sql = "SELECT * + FROM {questionnaire_question} + WHERE deleted IS NOT NULL + AND surveyid = ? AND type_id != ? + ORDER BY deleted DESC"; + if ($records = $DB->get_records_sql($sql, [$this->sid, QUESPAGEBREAK])) { + foreach ($records as $record) { + $this->deletequestions[$record->id] = \mod_questionnaire\question\question::question_builder($record->type_id, + $record, $this->context); + } + } + } + /** * Adding a survey record to the object. * @param int $sid @@ -136,9 +162,8 @@ public function add_questions($sid = false) { $this->questionsbysec = []; } - $select = 'surveyid = ? AND deleted = ?'; - $params = [$sid, 'n']; - if ($records = $DB->get_records_select('questionnaire_question', $select, $params, 'position')) { + $select = 'surveyid = ? AND deleted IS NULL'; + if ($records = $DB->get_records_select('questionnaire_question', $select, [$sid], 'position')) { $sec = 1; $isbreak = false; foreach ($records as $record) { @@ -423,6 +448,10 @@ public function view_response($rid, $referer= '', $resps = '', $compare = false, } $pdf = ($outputtarget == 'pdf') ? true : false; foreach ($this->questions as $question) { + // Only show eligible questions in the response. + if (!$question->dependency_fulfilled($rid, $this->questions)) { + continue; + } if ($question->type_id < QUESPAGEBREAK) { $i++; } @@ -885,7 +914,7 @@ private function has_required($section = 0) { return true; } } - } else { + } else if (key_exists($section, $this->questionsbysec)) { foreach ($this->questionsbysec[$section] as $questionid) { if ($this->questions[$questionid]->required()) { return true; @@ -1326,21 +1355,23 @@ private function survey_render(&$formdata, $section = 1, $message = '') { $this->page->add_to_page('progressbar', $this->renderer->render_progress_bar($section, $this->questionsbysec)); } - foreach ($this->questionsbysec[$section] as $questionid) { - if ($this->questions[$questionid]->is_numbered()) { - $i++; - } - // Need questionnaire id to get the questionnaire object in sectiontext (Label) question class. - $formdata->questionnaire_id = $this->id; - if (isset($formdata->rid) && !empty($formdata->rid)) { - $this->add_response($formdata->rid); - } else { - $this->add_response_from_formdata($formdata); + if (key_exists($section, $this->questionsbysec)) { + foreach ($this->questionsbysec[$section] as $questionid) { + if ($this->questions[$questionid]->is_numbered()) { + $i++; + } + // Need questionnaire id to get the questionnaire object in sectiontext (Label) question class. + $formdata->questionnaire_id = $this->id; + if (isset($formdata->rid) && !empty($formdata->rid)) { + $this->add_response($formdata->rid); + } else { + $this->add_response_from_formdata($formdata); + } + $this->page->add_to_page('questions', + $this->renderer->question_output($this->questions[$questionid], + (isset($this->responses[$formdata->rid]) ? $this->responses[$formdata->rid] : []), + $i, $this->usehtmleditor, [])); } - $this->page->add_to_page('questions', - $this->renderer->question_output($this->questions[$questionid], - (isset($this->responses[$formdata->rid]) ? $this->responses[$formdata->rid] : []), - $i, $this->usehtmleditor, [])); } $this->print_survey_end($section, $numsections); @@ -2002,8 +2033,8 @@ private function response_select_max_sec($rid) { global $DB; $pos = $this->response_select_max_pos($rid); - $select = 'surveyid = ? AND type_id = ? AND position < ? AND deleted = ?'; - $params = [$this->sid, QUESPAGEBREAK, $pos, 'n']; + $select = 'surveyid = ? AND type_id = ? AND position < ? AND deleted IS NULL'; + $params = [$this->sid, QUESPAGEBREAK, $pos]; $max = $DB->count_records_select('questionnaire_question', $select, $params) + 1; return $max; @@ -2025,7 +2056,7 @@ private function response_select_max_pos($rid) { 'WHERE a.response_id = ? AND '. 'q.id = a.question_id AND '. 'q.surveyid = ? AND '. - 'q.deleted = \'n\''; + 'q.deleted IS NULL'; if ($record = $DB->get_record_sql($sql, array($rid, $this->sid))) { $newmax = (int)$record->num; if ($newmax > $max) { @@ -2520,7 +2551,7 @@ private function response_goto_thankyou() { $this->page->add_to_page('progressbar', $this->renderer->render_progress_bar(count($this->questionsbysec) + 1, $this->questionsbysec)); } - $this->page->add_to_page('title', $thankhead); + $this->page->add_to_page('title', format_string($thankhead)); $this->page->add_to_page('addinfo', format_text(file_rewrite_pluginfile_urls($thankbody, 'pluginfile.php', $this->context->id, 'mod_questionnaire', 'thankbody', $this->survey->id), FORMAT_HTML, ['noclean' => true])); @@ -2887,6 +2918,23 @@ public function survey_results($rid = '', $uid=false, $pdf = false, $currentgrou if ($userview === 'y') { $respondentstring = get_string('submissions', 'questionnaire'); } + if (!$userview) { + $completedcount = 0; + $inprogresscount = 0; + + foreach ($rows as $row) { + if ($row->complete === 'y') { + $completedcount++; + } else if ($row->complete === 'n') { + $inprogresscount++; + } + } + $numresps .= ' ' . get_string('responses_breakdown', 'questionnaire', + [ + 'responses' => $completedcount, + 'incomplete' => $inprogresscount, + ]); + } $this->page->add_to_page('respondentinfo', ' ' . $respondentstring . ': ' . $numresps . ''); if (empty($rows)) { @@ -3012,6 +3060,7 @@ protected function user_fields() { $userfieldsarr = get_all_user_name_fields(); } $userfieldsarr = array_merge($userfieldsarr, ['username', 'department', 'institution']); + $userfieldsarr = array_merge($userfieldsarr, ['username', 'department', 'institution', 'idnumber']); return $userfieldsarr; } @@ -3182,6 +3231,9 @@ protected function process_csv_row(array &$row, if (in_array('id', $options)) { array_push($positioned, $uid); } + if (in_array('useridnumber', $options)) { + array_push($positioned, $user->idnumber); + } if (in_array('fullname', $options)) { array_push($positioned, $fullname); } @@ -3241,7 +3293,7 @@ public function generate_csv($currentgroupid, $rid='', $userid='', $choicecodes= $columns = array(); $types = array(); foreach ($options as $option) { - if (in_array($option, array('response', 'submitted', 'id'))) { + if (in_array($option, array('response', 'submitted', 'id', 'useridnumber'))) { $columns[] = get_string($option, 'questionnaire'); $types[] = 0; } else if ($option == 'useridentityfields') { diff --git a/questions.php b/questions.php index 6175512c5..1bbe7d559 100644 --- a/questions.php +++ b/questions.php @@ -33,6 +33,8 @@ $delq = optional_param('delq', 0, PARAM_INT); // Question id to delete. $qtype = optional_param('type_id', 0, PARAM_INT); // Question type. $currentgroupid = optional_param('group', 0, PARAM_INT); // Group id. +$delpermanentlyq = optional_param('delpermanentlyq', 0, PARAM_INT); // Question id to delete. +$restoreq = optional_param(QUESTIONNAIRE_RESTORE_PARAM, 0, PARAM_INT); // Question id to restore question. if (! $cm = get_coursemodule_from_id('questionnaire', $id)) { throw new \moodle_exception('invalidcoursemodule', 'mod_questionnaire'); @@ -59,6 +61,7 @@ $PAGE->set_context($context); $questionnaire = new questionnaire($course, $cm, 0, $questionnaire); +$questionnaire->get_delete_questions(); // Add renderer and page objects to the questionnaire object for display use. $questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire')); @@ -85,15 +88,26 @@ $questionnaireid = $questionnaire->id; // Need to reload questions before setting deleted question to 'y'. - $questions = $DB->get_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n'], 'id') ?? []; - $DB->set_field('questionnaire_question', 'deleted', 'y', ['id' => $qid, 'surveyid' => $sid]); + $questions = $DB->get_records_select('questionnaire_question', + 'surveyid = :sid AND deleted IS NULL', ['sid' => $sid], 'id'); + if (isset($questions[$qid]) && $questions[$qid]->type_id == QUESPAGEBREAK) { + $DB->delete_records('questionnaire_question', ['id' => $qid]); + } else { + $updatesql = "UPDATE {questionnaire_question} + SET deleted = ? + WHERE id = ? + AND surveyid = ?"; + $DB->execute($updatesql, [time(), $qid, $sid]); + } // Delete all dependency records for this question. questionnaire_delete_dependencies($qid); + // Delete all page break that references to question deleted. + questionnaire_delete_pagebreaks($sid); // Just in case the page is refreshed (F5) after a question has been deleted. if (isset($questions[$qid])) { - $select = 'surveyid = '.$sid.' AND deleted = \'n\' AND position > '. + $select = 'surveyid = '.$sid.' AND deleted IS NULL AND position > '. $questions[$qid]->position; } else { redirect($CFG->wwwroot.'/mod/questionnaire/questions.php?id='.$questionnaire->cm->id); @@ -113,21 +127,15 @@ questionnaire_delete_responses($qid); // If no questions left in this questionnaire, remove all responses. - if ($DB->count_records('questionnaire_question', ['surveyid' => $sid, 'deleted' => 'n']) == 0) { + if ($DB->count_records_select('questionnaire_question', + 'surveyid = :sid AND deleted IS NULL', ['sid' => $sid]) == 0) { $DB->delete_records('questionnaire_response', ['questionnaireid' => $qid]); } } // Log question deleted event. - $context = context_module::instance($questionnaire->cm->id); $questiontype = \mod_questionnaire\question\question::qtypename($questionnaire->questions[$qid]->type_id); - $params = array( - 'context' => $context, - 'courseid' => $questionnaire->course->id, - 'other' => array('questiontype' => $questiontype) - ); - $event = \mod_questionnaire\event\question_deleted::create($params); - $event->trigger(); + questionnaire_observe_event_delete($questionnaire->cm->id, $questiontype, $questionnaire->course->id); if ($questionnairehasdependencies) { $SESSION->questionnaire->validateresults = questionnaire_check_page_breaks($questionnaire); @@ -135,6 +143,33 @@ $reload = true; } +// Delete question permanently. +if ($delpermanentlyq) { + $qid = $delpermanentlyq; + $sid = $questionnaire->survey->id; + questionnaire_delete_permanently_questions($qid, $sid); + $deletedquestion = $questionnaire->deletequestions[$qid] ?? null; + if ($deletedquestion !== null) { + $questiontype = \mod_questionnaire\question\question::qtypename($deletedquestion->type_id); + questionnaire_observe_event_delete($questionnaire->cm->id, $questiontype, $questionnaire->course->id); + $url = new moodle_url('/mod/questionnaire/questions.php', ['id' => $questionnaire->cm->id]); + $PAGE->set_url($url->out(false)); + $reload = true; + } +} + +// Restore question. +if ($restoreq) { + $qid = $restoreq; + $qdeleted = isset($questionnaire->deletequestions[$qid]) ? $questionnaire->deletequestions[$qid] : false; + if ($qid && $qdeleted) { + questionnaire_restore_deleted_question($qid, $qdeleted->surveyid); + } + $url = new moodle_url('/mod/questionnaire/questions.php', ['id' => $questionnaire->cm->id]); + $PAGE->set_url($url->out(false)); + $reload = true; +} + if ($action == 'main') { $questionsform = new \mod_questionnaire\questions_form('questions.php', $moveq); $sdata = clone($questionnaire->survey); @@ -169,6 +204,10 @@ $qformdata->removebutton = $exformdata->removebutton; } else if (isset($exformdata->requiredbutton)) { $qformdata->requiredbutton = $exformdata->requiredbutton; + } else if (isset($exformdata->deletebutton)) { + $qformdata->deletebutton = $exformdata->deletebutton; + } else if (isset($exformdata->restorebutton)) { + $qformdata->restorebutton = $exformdata->restorebutton; } // Insert a section break. @@ -180,7 +219,8 @@ // Delete section breaks without asking for confirmation. if ($qtype == QUESPAGEBREAK) { - redirect($CFG->wwwroot.'/mod/questionnaire/questions.php?id='.$questionnaire->cm->id.'&delq='.$qid); + redirect(new \moodle_url('/mod/questionnaire/questions.php', + ['id' => $questionnaire->cm->id, 'delq' => $qid])); } $action = "confirmdelquestion"; @@ -259,6 +299,12 @@ // Validates page breaks for depend questions. $SESSION->questionnaire->validateresults = questionnaire_check_page_breaks($questionnaire); $reload = true; + } else if (isset($qformdata->deletebutton)) { + $action = QUESTIONNAIRE_CONFIRM_DELETE_PERMANENTLY; + } else if (isset($qformdata->restorebutton)) { + $qid = key($qformdata->restorebutton); + redirect(new moodle_url('/mod/questionnaire/questions.php', + ['id' => $questionnaire->cm->id, QUESTIONNAIRE_RESTORE_PARAM => $qid])); } } @@ -313,6 +359,7 @@ if ($reload) { unset($questionsform); $questionnaire = new questionnaire($course, $cm, $questionnaire->id, null); + $questionnaire->get_delete_questions(); // Add renderer and page objects to the questionnaire object for display use. $questionnaire->add_renderer($PAGE->get_renderer('mod_questionnaire')); $questionnaire->add_page(new \mod_questionnaire\output\questionspage()); @@ -359,14 +406,7 @@ $question = $questionnaire->questions[$qid]; $qtype = $question->type_id; - // Count responses already saved for that question. - $countresps = 0; - if ($qtype != QUESSECTIONTEXT) { - $responsetable = $DB->get_field('questionnaire_question_type', 'response_table', array('typeid' => $qtype)); - if (!empty($responsetable)) { - $countresps = $DB->count_records('questionnaire_'.$responsetable, array('question_id' => $qid)); - } - } + $countresps = count_reponses_question($qid, $qtype); // Needed to print potential media in question text. @@ -410,6 +450,25 @@ } $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno)); +} else if ($action === QUESTIONNAIRE_CONFIRM_DELETE_PERMANENTLY) { + $qid = key($qformdata->deletebutton); + $qtype = $questionnaire->deletequestions[$qid]->type_id; + $questiondelete = $questionnaire->deletequestions[$qid]; + $countresps = count_reponses_question($qid, $qtype); + + $urlno = new moodle_url("/mod/questionnaire/questions.php", ['id' => $questionnaire->cm->id]); + $urlyes = new moodle_url("/mod/questionnaire/questions.php", ['id' => $questionnaire->cm->id, "delpermanentlyq" => $qid]); + $buttonyes = new single_button($urlyes, get_string('yes')); + $buttonno = new single_button($urlno, get_string('no')); + $msg = '

'.get_string('confirmdelpermanentlyq', 'questionnaire').'

'; + if ($countresps !== 0) { + $msg .= '

'.get_string('confirmdelquestionresps', 'questionnaire', $countresps).'

'; + } + $msg .= '
'; + $msg .= '
NA ('. $questiondelete->name .') +
'.$questiondelete->content.'
'; + + $questionnaire->page->add_to_page('formarea', $questionnaire->renderer->confirm($msg, $buttonyes, $buttonno)); } else { $questionnaire->page->add_to_page('formarea', $questionsform->render()); } diff --git a/report.php b/report.php index 5c81c84a9..a627ec4bb 100755 --- a/report.php +++ b/report.php @@ -727,6 +727,13 @@ } } + // Add group filter dropdown. + if ($groupmode > 0) { + $groupselect = groups_print_activity_menu($cm, $url->out(), true); + $questionnaire->page->add_to_page('respondentinfo', $groupselect); + $currentgroupid = groups_get_activity_group($cm); + } + if ($byresponse || $rid) { // Available group modes (0 = no groups; 1 = separate groups; 2 = visible groups). if ($groupmode > 0) { @@ -765,12 +772,7 @@ $rid = $rids[0]; } - if ($noresponses) { - $questionnaire->page->add_to_page('respondentinfo', - get_string('group') . ' ' . groups_get_group_name($currentgroupid) . ': ' . - get_string('noresponses', 'questionnaire')); - - } else if ($outputtarget == 'pdf') { + if ($outputtarget == 'pdf') { $pdf = questionnaire_report_start_pdf(); if ($currentgroupid > 0) { $groupname = get_string('group') . ': ' . groups_get_group_name($currentgroupid) . ''; @@ -789,6 +791,12 @@ error_reporting($errorreporting); } else { // Default to HTML. + if ($noresponses) { + $questionnaire->page->add_to_page('respondentinfo', + get_string('group') . ' ' . groups_get_group_name($currentgroupid) . ': ' . + get_string('noresponses', 'questionnaire')); + } + // Print the page header. $PAGE->set_title(get_string('questionnairereport', 'questionnaire')); $PAGE->set_heading(format_string($course->fullname)); diff --git a/settings.php b/settings.php index 30e640574..dc94193c5 100644 --- a/settings.php +++ b/settings.php @@ -43,6 +43,7 @@ 'course' => get_string('course'), 'group' => get_string('group'), 'id' => get_string('id', 'questionnaire'), + 'useridnumber' => get_string('useridnumber', 'questionnaire'), 'fullname' => get_string('fullname'), 'username' => get_string('username'), 'useridentityfields' => get_string('showuseridentity', 'admin') @@ -54,6 +55,10 @@ $settings->add(new admin_setting_configcheckbox('questionnaire/allowemailreporting', get_string('configemailreporting', 'questionnaire'), get_string('configemailreportinglong', 'questionnaire'), 0)); + $settings->add(new admin_setting_configduration('questionnaire_questiondeletion/duration', + get_string('deletequestionsolderthan', 'questionnaire'), + get_string('deletesettingdescription', 'questionnaire'), 7 * 86400)); + // Delete old responses after. The default value is 24 months. $options = [ '0' => new lang_string('disabled', 'questionnaire'), diff --git a/styles.css b/styles.css index 831b4bd3b..86164eb54 100644 --- a/styles.css +++ b/styles.css @@ -477,6 +477,28 @@ td.selected { margin-left: 20px; } +#page-mod-questionnaire-questions .qcontainer .restored-question { + border: 1px solid #008196; + background: #fff2d8; + color: #343a40; +} + +#page-mod-questionnaire-questions .qcontainer .timedeletednext7days { + color: #f00; +} + +#page-mod-questionnaire-questions .generalbox .modal-content { + border: 1px solid #b0dfeb; +} + +#page-mod-questionnaire-questions #id_deletionq .qn-question { + margin-left: 50px; +} + +input[type=image].mod_questionnaire_recycleq { + max-height: 1em; +} + .mod_questionnaire_flex-container { display: inline-flex; } diff --git a/templates/question_container.mustache b/templates/question_container.mustache index f29c93acf..97ebd1a96 100644 --- a/templates/question_container.mustache +++ b/templates/question_container.mustache @@ -58,7 +58,7 @@ {{{required}}}
{{/qnum}} -
+
{{#label}}