Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Multiple choice question renderer classes.
  19   *
  20   * @package    qtype
  21   * @subpackage multichoice
  22   * @copyright  2009 The Open University
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  
  30  /**
  31   * Base class for generating the bits of output common to multiple choice
  32   * single and multiple questions.
  33   *
  34   * @copyright  2009 The Open University
  35   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  36   */
  37  abstract class qtype_multichoice_renderer_base extends qtype_with_combined_feedback_renderer {
  38  
  39      /**
  40       * Method to generating the bits of output after question choices.
  41       *
  42       * @param question_attempt $qa The question attempt object.
  43       * @param question_display_options $options controls what should and should not be displayed.
  44       *
  45       * @return string HTML output.
  46       */
  47      protected abstract function after_choices(question_attempt $qa, question_display_options $options);
  48  
  49      protected abstract function get_input_type();
  50  
  51      protected abstract function get_input_name(question_attempt $qa, $value);
  52  
  53      protected abstract function get_input_value($value);
  54  
  55      protected abstract function get_input_id(question_attempt $qa, $value);
  56  
  57      /**
  58       * Whether a choice should be considered right, wrong or partially right.
  59       * @param question_answer $ans representing one of the choices.
  60       * @return fload 1.0, 0.0 or something in between, respectively.
  61       */
  62      protected abstract function is_right(question_answer $ans);
  63  
  64      protected abstract function prompt();
  65  
  66      public function formulation_and_controls(question_attempt $qa,
  67              question_display_options $options) {
  68  
  69          $question = $qa->get_question();
  70          $response = $question->get_response($qa);
  71  
  72          $inputname = $qa->get_qt_field_name('answer');
  73          $inputattributes = array(
  74              'type' => $this->get_input_type(),
  75              'name' => $inputname,
  76          );
  77  
  78          if ($options->readonly) {
  79              $inputattributes['disabled'] = 'disabled';
  80          }
  81  
  82          $radiobuttons = array();
  83          $feedbackimg = array();
  84          $feedback = array();
  85          $classes = array();
  86          foreach ($question->get_order($qa) as $value => $ansid) {
  87              $ans = $question->answers[$ansid];
  88              $inputattributes['name'] = $this->get_input_name($qa, $value);
  89              $inputattributes['value'] = $this->get_input_value($value);
  90              $inputattributes['id'] = $this->get_input_id($qa, $value);
  91              $inputattributes['aria-labelledby'] = $inputattributes['id'] . '_label';
  92              $isselected = $question->is_choice_selected($response, $value);
  93              if ($isselected) {
  94                  $inputattributes['checked'] = 'checked';
  95              } else {
  96                  unset($inputattributes['checked']);
  97              }
  98              $hidden = '';
  99              if (!$options->readonly && $this->get_input_type() == 'checkbox') {
 100                  $hidden = html_writer::empty_tag('input', array(
 101                      'type' => 'hidden',
 102                      'name' => $inputattributes['name'],
 103                      'value' => 0,
 104                  ));
 105              }
 106  
 107              $choicenumber = '';
 108              if ($question->answernumbering !== 'none') {
 109                  $choicenumber = html_writer::span(
 110                          $this->number_in_style($value, $question->answernumbering), 'answernumber');
 111              }
 112              $choicetext = $question->format_text($ans->answer, $ans->answerformat, $qa, 'question', 'answer', $ansid);
 113              $choice = html_writer::div($choicetext, 'flex-fill ml-1');
 114  
 115              $radiobuttons[] = $hidden . html_writer::empty_tag('input', $inputattributes) .
 116                      html_writer::div($choicenumber . $choice, 'd-flex w-auto', [
 117                          'id' => $inputattributes['id'] . '_label',
 118                          'data-region' => 'answer-label',
 119                      ]);
 120  
 121              // Param $options->suppresschoicefeedback is a hack specific to the
 122              // oumultiresponse question type. It would be good to refactor to
 123              // avoid refering to it here.
 124              if ($options->feedback && empty($options->suppresschoicefeedback) &&
 125                      $isselected && trim($ans->feedback)) {
 126                  $feedback[] = html_writer::tag('div',
 127                          $question->make_html_inline($question->format_text(
 128                                  $ans->feedback, $ans->feedbackformat,
 129                                  $qa, 'question', 'answerfeedback', $ansid)),
 130                          array('class' => 'specificfeedback'));
 131              } else {
 132                  $feedback[] = '';
 133              }
 134              $class = 'r' . ($value % 2);
 135              if ($options->correctness && $isselected) {
 136                  // Feedback images will be rendered using Font awesome.
 137                  // Font awesome icons are actually characters(text) with special glyphs,
 138                  // so the icons cannot be aligned correctly even if the parent div wrapper is using align-items: flex-start.
 139                  // To make the Font awesome icons follow align-items: flex-start, we need to wrap them inside a span tag.
 140                  $feedbackimg[] = html_writer::span($this->feedback_image($this->is_right($ans)), 'ml-1');
 141                  $class .= ' ' . $this->feedback_class($this->is_right($ans));
 142              } else {
 143                  $feedbackimg[] = '';
 144              }
 145              $classes[] = $class;
 146          }
 147  
 148          $result = '';
 149          $result .= html_writer::tag('div', $question->format_questiontext($qa),
 150                  array('class' => 'qtext'));
 151  
 152          $result .= html_writer::start_tag('fieldset', array('class' => 'ablock no-overflow visual-scroll-x'));
 153          if ($question->showstandardinstruction == 1) {
 154              $legendclass = '';
 155              $questionnumber = $options->add_question_identifier_to_label($this->prompt(), true, true);
 156          } else {
 157              $questionnumber = $options->add_question_identifier_to_label(get_string('answer'), true, true);
 158              $legendclass = 'sr-only';
 159          }
 160          $legendattrs = [
 161              'class' => 'prompt h6 font-weight-normal ' . $legendclass,
 162          ];
 163          $result .= html_writer::tag('legend', $questionnumber, $legendattrs);
 164  
 165          $result .= html_writer::start_tag('div', array('class' => 'answer'));
 166          foreach ($radiobuttons as $key => $radio) {
 167              $result .= html_writer::tag('div', $radio . ' ' . $feedbackimg[$key] . $feedback[$key],
 168                      array('class' => $classes[$key])) . "\n";
 169          }
 170          $result .= html_writer::end_tag('div'); // Answer.
 171  
 172          // Load JS module for the question answers.
 173          $this->page->requires->js_call_amd('qtype_multichoice/answers', 'init',
 174              [$qa->get_outer_question_div_unique_id()]);
 175          $result .= $this->after_choices($qa, $options);
 176  
 177          $result .= html_writer::end_tag('fieldset'); // Ablock.
 178  
 179          if ($qa->get_state() == question_state::$invalid) {
 180              $result .= html_writer::nonempty_tag('div',
 181                      $question->get_validation_error($qa->get_last_qt_data()),
 182                      array('class' => 'validationerror'));
 183          }
 184  
 185          return $result;
 186      }
 187  
 188      protected function number_html($qnum) {
 189          return $qnum . '. ';
 190      }
 191  
 192      /**
 193       * @param int $num The number, starting at 0.
 194       * @param string $style The style to render the number in. One of the
 195       * options returned by {@link qtype_multichoice:;get_numbering_styles()}.
 196       * @return string the number $num in the requested style.
 197       */
 198      protected function number_in_style($num, $style) {
 199          switch($style) {
 200              case 'abc':
 201                  $number = chr(ord('a') + $num);
 202                  break;
 203              case 'ABCD':
 204                  $number = chr(ord('A') + $num);
 205                  break;
 206              case '123':
 207                  $number = $num + 1;
 208                  break;
 209              case 'iii':
 210                  $number = question_utils::int_to_roman($num + 1);
 211                  break;
 212              case 'IIII':
 213                  $number = strtoupper(question_utils::int_to_roman($num + 1));
 214                  break;
 215              case 'none':
 216                  return '';
 217              default:
 218                  return 'ERR';
 219          }
 220          return $this->number_html($number);
 221      }
 222  
 223      public function specific_feedback(question_attempt $qa) {
 224          return $this->combined_feedback($qa);
 225      }
 226  
 227      /**
 228       * Function returns string based on number of correct answers
 229       * @param array $right An Array of correct responses to the current question
 230       * @return string based on number of correct responses
 231       */
 232      protected function correct_choices(array $right) {
 233          // Return appropriate string for single/multiple correct answer(s).
 234          if (count($right) == 1) {
 235                  return get_string('correctansweris', 'qtype_multichoice',
 236                          implode(', ', $right));
 237          } else if (count($right) > 1) {
 238                  return get_string('correctanswersare', 'qtype_multichoice',
 239                          implode(', ', $right));
 240          } else {
 241                  return "";
 242          }
 243      }
 244  }
 245  
 246  
 247  /**
 248   * Subclass for generating the bits of output specific to multiple choice
 249   * single questions.
 250   *
 251   * @copyright  2009 The Open University
 252   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 253   */
 254  class qtype_multichoice_single_renderer extends qtype_multichoice_renderer_base {
 255      protected function get_input_type() {
 256          return 'radio';
 257      }
 258  
 259      protected function get_input_name(question_attempt $qa, $value) {
 260          return $qa->get_qt_field_name('answer');
 261      }
 262  
 263      protected function get_input_value($value) {
 264          return $value;
 265      }
 266  
 267      protected function get_input_id(question_attempt $qa, $value) {
 268          return $qa->get_qt_field_name('answer' . $value);
 269      }
 270  
 271      protected function is_right(question_answer $ans) {
 272          return $ans->fraction;
 273      }
 274  
 275      protected function prompt() {
 276          return get_string('selectone', 'qtype_multichoice');
 277      }
 278  
 279      public function correct_response(question_attempt $qa) {
 280          $question = $qa->get_question();
 281  
 282          // Put all correct answers (100% grade) into $right.
 283          $right = array();
 284          foreach ($question->answers as $ansid => $ans) {
 285              if (question_state::graded_state_for_fraction($ans->fraction) ==
 286                      question_state::$gradedright) {
 287                  $right[] = $question->make_html_inline($question->format_text($ans->answer, $ans->answerformat,
 288                          $qa, 'question', 'answer', $ansid));
 289              }
 290          }
 291          return $this->correct_choices($right);
 292      }
 293  
 294      public function after_choices(question_attempt $qa, question_display_options $options) {
 295          // Only load the clear choice feature if it's not read only.
 296          if ($options->readonly) {
 297              return '';
 298          }
 299  
 300          $question = $qa->get_question();
 301          $response = $question->get_response($qa);
 302          $hascheckedchoice = false;
 303          foreach ($question->get_order($qa) as $value => $ansid) {
 304              if ($question->is_choice_selected($response, $value)) {
 305                  $hascheckedchoice = true;
 306                  break;
 307              }
 308          }
 309  
 310          $clearchoiceid = $this->get_input_id($qa, -1);
 311          $clearchoicefieldname = $qa->get_qt_field_name('clearchoice');
 312          $clearchoiceradioattrs = [
 313              'type' => $this->get_input_type(),
 314              'name' => $qa->get_qt_field_name('answer'),
 315              'id' => $clearchoiceid,
 316              'value' => -1,
 317              'class' => 'sr-only',
 318              'aria-hidden' => 'true'
 319          ];
 320          $clearchoicewrapperattrs = [
 321              'id' => $clearchoicefieldname,
 322              'class' => 'qtype_multichoice_clearchoice',
 323          ];
 324  
 325          // When no choice selected during rendering, then hide the clear choice option.
 326          // We are using .sr-only and aria-hidden together so while the element is hidden
 327          // from both the monitor and the screen-reader, it is still tabbable.
 328          $linktabindex = 0;
 329          if (!$hascheckedchoice && $response == -1) {
 330              $clearchoicewrapperattrs['class'] .= ' sr-only';
 331              $clearchoicewrapperattrs['aria-hidden'] = 'true';
 332              $clearchoiceradioattrs['checked'] = 'checked';
 333              $linktabindex = -1;
 334          }
 335          // Adds an hidden radio that will be checked to give the impression the choice has been cleared.
 336          $clearchoiceradio = html_writer::empty_tag('input', $clearchoiceradioattrs);
 337          $clearchoice = html_writer::link('#', get_string('clearchoice', 'qtype_multichoice'),
 338              ['tabindex' => $linktabindex, 'role' => 'button', 'class' => 'btn btn-link ml-3 mt-n1']);
 339          $clearchoiceradio .= html_writer::label($clearchoice, $clearchoiceid);
 340  
 341          // Now wrap the radio and label inside a div.
 342          $result = html_writer::tag('div', $clearchoiceradio, $clearchoicewrapperattrs);
 343  
 344          // Load required clearchoice AMD module.
 345          $this->page->requires->js_call_amd('qtype_multichoice/clearchoice', 'init',
 346              [$qa->get_outer_question_div_unique_id(), $clearchoicefieldname]);
 347  
 348          return $result;
 349      }
 350  
 351  }
 352  
 353  /**
 354   * Subclass for generating the bits of output specific to multiple choice
 355   * multi=select questions.
 356   *
 357   * @copyright  2009 The Open University
 358   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 359   */
 360  class qtype_multichoice_multi_renderer extends qtype_multichoice_renderer_base {
 361      protected function after_choices(question_attempt $qa, question_display_options $options) {
 362          return '';
 363      }
 364  
 365      protected function get_input_type() {
 366          return 'checkbox';
 367      }
 368  
 369      protected function get_input_name(question_attempt $qa, $value) {
 370          return $qa->get_qt_field_name('choice' . $value);
 371      }
 372  
 373      protected function get_input_value($value) {
 374          return 1;
 375      }
 376  
 377      protected function get_input_id(question_attempt $qa, $value) {
 378          return $this->get_input_name($qa, $value);
 379      }
 380  
 381      protected function is_right(question_answer $ans) {
 382          if ($ans->fraction > 0) {
 383              return 1;
 384          } else {
 385              return 0;
 386          }
 387      }
 388  
 389      protected function prompt() {
 390          return get_string('selectmulti', 'qtype_multichoice');
 391      }
 392  
 393      public function correct_response(question_attempt $qa) {
 394          $question = $qa->get_question();
 395  
 396          $right = array();
 397          foreach ($question->answers as $ansid => $ans) {
 398              if ($ans->fraction > 0) {
 399                  $right[] = $question->make_html_inline($question->format_text($ans->answer, $ans->answerformat,
 400                          $qa, 'question', 'answer', $ansid));
 401              }
 402          }
 403          return $this->correct_choices($right);
 404      }
 405  
 406      protected function num_parts_correct(question_attempt $qa) {
 407          if ($qa->get_question()->get_num_selected_choices($qa->get_last_qt_data()) >
 408                  $qa->get_question()->get_num_correct_choices()) {
 409              return get_string('toomanyselected', 'qtype_multichoice');
 410          }
 411  
 412          return parent::num_parts_correct($qa);
 413      }
 414  }