diff --git a/renderer.php b/renderer.php index 0812d3d0..480c315c 100644 --- a/renderer.php +++ b/renderer.php @@ -22,7 +22,6 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - /** * Base class for generating the bits of output for formulas questions. * @@ -31,11 +30,17 @@ */ class qtype_formulas_renderer extends qtype_with_combined_feedback_renderer { + /** @var string */ + const UNIT_FIELD = 'u'; + + /** @var string */ + const COMBINED_FIELD = ''; + /** - * Generate the display of the formulation part of the question. This is the - * area that contains the question text, and the controls for students to - * input their answers. Once the question is answered, it will contain the green tick - * or the red cross and the part's general / combined feedback. + * Generate the display of the formulation part of the question. This is the area that + * contains the question text and the controls for students to input their answers. + * Once the question is answered, it will contain the green tick or the red cross and + * the part's general / combined feedback. * * @param question_attempt $qa the question attempt to display. * @param question_display_options $options controls what should and should not be displayed. @@ -52,28 +57,23 @@ public function formulation_and_controls(question_attempt $qa, question_display_ } $questiontext = ''; + // First, iterate over all parts, put the corresponding fragment of the main question text at the + // right position, followed by the part's text, input and (if applicable) feedback elements. foreach ($question->parts as $part) { $questiontext .= $question->format_text( - $question->textfragments[$part->partindex], - $question->questiontextformat, - $qa, - 'question', - 'questiontext', - $question->id, - false + $question->textfragments[$part->partindex], $question->questiontextformat, + $qa, 'question', 'questiontext', $question->id, false ); $questiontext .= $this->part_formulation_and_controls($qa, $options, $part); } + // All parts are done. We now append the final fragment of the main question text. Note that this fragment + // might be empty. $questiontext .= $question->format_text( - $question->textfragments[$question->numparts], - $question->questiontextformat, - $qa, - 'question', - 'questiontext', - $question->id, - false + end($question->textfragments), $question->questiontextformat, $qa, 'question', 'questiontext', $question->id, false ); + // Pack everything in a
and, if the question is in an invalid state, append the appropriate error message + // at the very end. $result = html_writer::tag('div', $questiontext, ['class' => 'qtext']); if ($qa->get_state() == question_state::$invalid) { $result .= html_writer::nonempty_tag( @@ -82,6 +82,7 @@ public function formulation_and_controls(question_attempt $qa, question_display_ ['class' => 'validationerror'] ); } + return $result; } @@ -92,7 +93,7 @@ public function formulation_and_controls(question_attempt $qa, question_display_ * @param question_attempt $qa question attempt that will be displayed on the page * @return string HTML fragment */ - public function head_code(question_attempt $qa) { + public function head_code(question_attempt $qa): string { global $CFG; $this->page->requires->js_call_amd('qtype_formulas/answervalidation', 'init'); @@ -109,91 +110,82 @@ public function head_code(question_attempt $qa) { * Return the part text, controls, grading details and feedbacks. * * @param question_attempt $qa question attempt that will be displayed on the page - * @param question_display_options $options - * @param qtype_formulas_part $part + * @param question_display_options $options controls what should and should not be displayed + * @param qtype_formulas_part $part question part * @return void */ public function part_formulation_and_controls(question_attempt $qa, question_display_options $options, - qtype_formulas_part $part) { + qtype_formulas_part $part): string { + // The behaviour might change the display options per part, so it is safer to clone them here. $partoptions = clone $options; - // If using adaptivemultipart behaviour, adjust feedback display options for this part. if ($qa->get_behaviour_name() === 'adaptivemultipart') { $qa->get_behaviour()->adjust_display_options_for_part($part->partindex, $partoptions); } - $sub = $this->get_part_image_and_class($qa, $partoptions, $part); - $output = $this->get_part_formulation( - $qa, - $partoptions, - $part->partindex, - $sub - ); - // Place for the right/wrong feeback image or appended at part's end. - // TODO: this is not documented anywhere. + // Fetch information about the outcome: grade, feedback symbol, CSS class to be used. + $outcomedata = $this->get_part_feedback_class_and_symbol($qa, $partoptions, $part); + + // First of all, we take the part's question text and its input fields. + $output = $this->get_part_formulation($qa, $partoptions, $part, $outcomedata); + + // If the user has requested the feedback symbol to be placed at a special position, we + // do that now. Otherwise, we just append it after the part's text and input boxes. if (strpos($output, '{_m}') !== false) { - $output = str_replace('{_m}', $sub->feedbackimage, $output); + $output = str_replace('{_m}', $outcomedata->feedbacksymbol, $output); } else { - $output .= $sub->feedbackimage; + $output .= $outcomedata->feedbacksymbol; } - $feedback = $this->part_combined_feedback($qa, $partoptions, $part, $sub->fraction); + // The part's feedback consists of the combined feedback (correct, partially correct, incorrect -- depending on the + // outcome) and the general feedback which is given in all cases. + $feedback = $this->part_combined_feedback($qa, $partoptions, $part, $outcomedata->fraction); $feedback .= $this->part_general_feedback($qa, $partoptions, $part); - // If one of the part's coordinates is a MC or select question, the correct answer - // stored in the database is not the right answer, but the index of the right answer, - // so in that case, we need to calculate the right answer. + + // If requested, the correct answer should be appended to the feedback. if ($partoptions->rightanswer) { - $feedback .= $this->part_correct_response($part->partindex, $qa); + $feedback .= $this->part_correct_response($part); } - $output .= html_writer::nonempty_tag( - 'div', - $feedback, - ['class' => 'formulaspartoutcome'] - ); + + // Put all feedback into a
with the appropriate CSS class and append it to the output. + $output .= html_writer::nonempty_tag('div', $feedback, ['class' => 'formulaspartoutcome']); + return html_writer::tag('div', $output , ['class' => 'formulaspart']); } /** - * Return class and image for the part feedback. + * Return class and symbol for the part feedback. * - * @param question_attempt $qa - * @param question_display_options $options - * @param qtype_formulas_part $part - * @return object + * @param question_attempt $qa question attempt that will be displayed on the page + * @param question_display_options $options controls what should and should not be displayed + * @param qtype_formulas_part $part question part + * @return stdClass */ - public function get_part_image_and_class($qa, $options, $part) { - $question = $qa->get_question(); - - $sub = new StdClass; + public function get_part_feedback_class_and_symbol(question_attempt $qa, question_display_options $options, + qtype_formulas_part $part): stdClass { + // Prepare a new object to hold the different elements. + $result = new stdClass; + // Fetch the last response data and grade it. $response = $qa->get_last_qt_data(); - $response = $question->normalize_response($response); - list('answer' => $answergrade, 'unit' => $unitcorrect) = $part->grade($response); - $sub->fraction = $answergrade; + // The fraction will later be used to determine which feedback (correct, partially correct or incorrect) + // to use. We have to take into account a possible deduction for a wrong unit. + $result->fraction = $answergrade; if ($unitcorrect === false) { - $sub->fraction *= (1 - $part->unitpenalty); + $result->fraction *= (1 - $part->unitpenalty); } - // Get the class and image for the feedback. + // By default, we add no feedback at all... + $result->feedbacksymbol = ''; + $result->feedbackclass = ''; + // ... unless correctness is requested in the display options. if ($options->correctness) { - $sub->feedbackimage = $this->feedback_image($sub->fraction); - $sub->feedbackclass = $this->feedback_class($sub->fraction); - if ($part->unitpenalty >= 1) { // All boxes must be correct at the same time, so they are of the same color. - $sub->unitfeedbackclass = $sub->feedbackclass; - $sub->boxfeedbackclass = $sub->feedbackclass; - } else { // Show individual color, all four color combinations are possible. - $sub->unitfeedbackclass = $this->feedback_class($unitcorrect); - $sub->boxfeedbackclass = $this->feedback_class($answergrade); - } - } else { // There should be no feedback if options->correctness is not set for this part. - $sub->feedbackimage = ''; - $sub->feedbackclass = ''; - $sub->unitfeedbackclass = ''; - $sub->boxfeedbackclass = ''; + $result->feedbacksymbol = $this->feedback_image($result->fraction); + $result->feedbackclass = $this->feedback_class($result->fraction); } - return $sub; + return $result; } /** @@ -203,7 +195,7 @@ public function get_part_image_and_class($qa, $options, $part) { * @param string $style style to render the number in, acccording to {@see qtype_multichoice::get_numbering_styles()} * @return string number $num in the requested style */ - protected function number_in_style($num, $style) { + protected static function number_in_style(int $num, string $style): string { switch ($style) { case 'abc': $number = chr(ord('a') + $num); @@ -229,19 +221,311 @@ protected function number_in_style($num, $style) { return $number . '. '; } + /** + * Create a set of radio boxes for a multiple choice answer input. + * + * @param qtype_formulas_part $part question part + * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field + * @param question_attempt $qa question attempt that will be displayed on the page + * @param array $answeroptions array of strings containing the answer options to choose from + * @param question_display_options $displayoptions controls what should and should not be displayed + * @param string $feedbackclass + * @return string HTML fragment + */ + protected function create_radio_mc_answer(qtype_formulas_part $part, $answerindex, question_attempt $qa, + array $answeroptions, question_display_options $displayoptions, string $feedbackclass = ''): string { + /** @var qype_formulas_question $question */ + $question = $qa->get_question(); + + $variablename = "{$part->partindex}_{$answerindex}"; + $currentanswer = $qa->get_last_qt_var($variablename); + $inputname = $qa->get_qt_field_name($variablename); + + $inputattributes['type'] = 'radio'; + $inputattributes['name'] = $inputname; + if ($displayoptions->readonly) { + $inputattributes['disabled'] = 'disabled'; + } + + // First, we open a
around the entire group of options. + $output = html_writer::start_tag('fieldset', ['class' => 'multichoice_answer']); + + // Inside the fieldset, we put the accessibility label, following the example of core's multichoice + // question type, i. e. the label is inside a with class 'sr-only', wrapped in a . + $output .= html_writer::start_tag('legend', ['class' => 'sr-only']); + $output .= html_writer::span( + $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts), + 'sr-only' + ); + $output .= html_writer::end_tag('legend'); + + // Iterate over all options. + foreach ($answeroptions as $i => $optiontext) { + $numbering = html_writer::span(self::number_in_style($i, $question->answernumbering), 'answernumber'); + $labeltext = $question->format_text( + $numbering . $optiontext, $part->subqtextformat , $qa, 'qtype_formulas', 'answersubqtext', $part->id, false + ); + + $inputattributes['id'] = $inputname . '_' . $i; + $inputattributes['value'] = $i; + // Class ml-3 is Bootstrap's class for margin-left: 1rem; it used to be m-l-1. + $label = $this->create_label_for_input($labeltext, $inputattributes['id'], ['class' => 'ml-3']); + $inputattributes['aria-labelledby'] = $label['id']; + + // We must make sure $currentanswer is not null, because otherwise the first radio box + // might be selected if there is no answer at all. It seems better to avoid strict equality, + // because we might compare a string to a number. + $isselected = ($i == $currentanswer && !is_null($currentanswer)); + + // We do not reset the $inputattributes array on each iteration, so we have to add/remove the + // attribute every time. + if ($isselected) { + $inputattributes['checked'] = 'checked'; + } else { + unset($inputattributes['checked']); + } + + // Each option (radio box element plus label) is wrapped in its own
element. + $divclass = 'r' . ($i % 2); + if ($displayoptions->correctness && $isselected) { + $divclass .= ' ' . $feedbackclass; + } + $output .= html_writer::start_div($divclass); + + // Now add the tag and its