Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 310 and 311] [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403] [Versions 39 and 310]

   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              $questionnumber = '';
 108              if ($question->answernumbering !== 'none') {
 109                  $questionnumber = html_writer::span(
 110                          $this->number_in_style($value, $question->answernumbering), 'answernumber');
 111              }
 112              $answertext = $question->format_text($ans->answer, $ans->answerformat, $qa, 'question', 'answer', $ansid);
 113              $questionanswer = html_writer::div($answertext, 'flex-fill ml-1');
 114  
 115              $radiobuttons[] = $hidden . html_writer::empty_tag('input', $inputattributes) .
 116                      html_writer::div($questionnumber . $questionanswer, '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                  $feedbackimg[] = $this->feedback_image($this->is_right($ans));
 137                  $class .= ' ' . $this->feedback_class($this->is_right($ans));
 138              } else {
 139                  $feedbackimg[] = '';
 140              }
 141              $classes[] = $class;
 142          }
 143  
 144          $result = '';
 145          $result .= html_writer::tag('div', $question->format_questiontext($qa),
 146                  array('class' => 'qtext'));
 147  
 148          $result .= html_writer::start_tag('div', array('class' => 'ablock no-overflow visual-scroll-x'));
 149          if ($question->showstandardinstruction == 1) {
 150              $result .= html_writer::tag('div', $this->prompt(), array('class' => 'prompt'));
 151          }
 152  
 153          $result .= html_writer::start_tag('div', array('class' => 'answer'));
 154          foreach ($radiobuttons as $key => $radio) {
 155              $result .= html_writer::tag('div', $radio . ' ' . $feedbackimg[$key] . $feedback[$key],
 156                      array('class' => $classes[$key])) . "\n";
 157          }
 158          $result .= html_writer::end_tag('div'); // Answer.
 159  
 160          // Load JS module for the question answers.
 161          $this->page->requires->js_call_amd('qtype_multichoice/answers', 'init',
 162              [$qa->get_outer_question_div_unique_id()]);
 163          $result .= $this->after_choices($qa, $options);
 164  
 165          $result .= html_writer::end_tag('div'); // Ablock.
 166  
 167          if ($qa->get_state() == question_state::$invalid) {
 168              $result .= html_writer::nonempty_tag('div',
 169                      $question->get_validation_error($qa->get_last_qt_data()),
 170                      array('class' => 'validationerror'));
 171          }
 172  
 173          return $result;
 174      }
 175  
 176      protected function number_html($qnum) {
 177          return $qnum . '. ';
 178      }
 179  
 180      /**
 181       * @param int $num The number, starting at 0.
 182       * @param string $style The style to render the number in. One of the
 183       * options returned by {@link qtype_multichoice:;get_numbering_styles()}.
 184       * @return string the number $num in the requested style.
 185       */
 186      protected function number_in_style($num, $style) {
 187          switch($style) {
 188              case 'abc':
 189                  $number = chr(ord('a') + $num);
 190                  break;
 191              case 'ABCD':
 192                  $number = chr(ord('A') + $num);
 193                  break;
 194              case '123':
 195                  $number = $num + 1;
 196                  break;
 197              case 'iii':
 198                  $number = question_utils::int_to_roman($num + 1);
 199                  break;
 200              case 'IIII':
 201                  $number = strtoupper(question_utils::int_to_roman($num + 1));
 202                  break;
 203              case 'none':
 204                  return '';
 205              default:
 206                  return 'ERR';
 207          }
 208          return $this->number_html($number);
 209      }
 210  
 211      public function specific_feedback(question_attempt $qa) {
 212          return $this->combined_feedback($qa);
 213      }
 214  
 215      /**
 216       * Function returns string based on number of correct answers
 217       * @param array $right An Array of correct responses to the current question
 218       * @return string based on number of correct responses
 219       */
 220      protected function correct_choices(array $right) {
 221          // Return appropriate string for single/multiple correct answer(s).
 222          if (count($right) == 1) {
 223                  return get_string('correctansweris', 'qtype_multichoice',
 224                          implode(', ', $right));
 225          } else if (count($right) > 1) {
 226                  return get_string('correctanswersare', 'qtype_multichoice',
 227                          implode(', ', $right));
 228          } else {
 229                  return "";
 230          }
 231      }
 232  }
 233  
 234  
 235  /**
 236   * Subclass for generating the bits of output specific to multiple choice
 237   * single questions.
 238   *
 239   * @copyright  2009 The Open University
 240   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 241   */
 242  class qtype_multichoice_single_renderer extends qtype_multichoice_renderer_base {
 243      protected function get_input_type() {
 244          return 'radio';
 245      }
 246  
 247      protected function get_input_name(question_attempt $qa, $value) {
 248          return $qa->get_qt_field_name('answer');
 249      }
 250  
 251      protected function get_input_value($value) {
 252          return $value;
 253      }
 254  
 255      protected function get_input_id(question_attempt $qa, $value) {
 256          return $qa->get_qt_field_name('answer' . $value);
 257      }
 258  
 259      protected function is_right(question_answer $ans) {
 260          return $ans->fraction;
 261      }
 262  
 263      protected function prompt() {
 264          return get_string('selectone', 'qtype_multichoice');
 265      }
 266  
 267      public function correct_response(question_attempt $qa) {
 268          $question = $qa->get_question();
 269  
 270          // Put all correct answers (100% grade) into $right.
 271          $right = array();
 272          foreach ($question->answers as $ansid => $ans) {
 273              if (question_state::graded_state_for_fraction($ans->fraction) ==
 274                      question_state::$gradedright) {
 275                  $right[] = $question->make_html_inline($question->format_text($ans->answer, $ans->answerformat,
 276                          $qa, 'question', 'answer', $ansid));
 277              }
 278          }
 279          return $this->correct_choices($right);
 280      }
 281  
 282      public function after_choices(question_attempt $qa, question_display_options $options) {
 283          // Only load the clear choice feature if it's not read only.
 284          if ($options->readonly) {
 285              return '';
 286          }
 287  
 288          $question = $qa->get_question();
 289          $response = $question->get_response($qa);
 290          $hascheckedchoice = false;
 291          foreach ($question->get_order($qa) as $value => $ansid) {
 292              if ($question->is_choice_selected($response, $value)) {
 293                  $hascheckedchoice = true;
 294                  break;
 295              }
 296          }
 297  
 298          $clearchoiceid = $this->get_input_id($qa, -1);
 299          $clearchoicefieldname = $qa->get_qt_field_name('clearchoice');
 300          $clearchoiceradioattrs = [
 301              'type' => $this->get_input_type(),
 302              'name' => $qa->get_qt_field_name('answer'),
 303              'id' => $clearchoiceid,
 304              'value' => -1,
 305              'class' => 'sr-only',
 306              'aria-hidden' => 'true'
 307          ];
 308          $clearchoicewrapperattrs = [
 309              'id' => $clearchoicefieldname,
 310              'class' => 'qtype_multichoice_clearchoice',
 311          ];
 312  
 313          // When no choice selected during rendering, then hide the clear choice option.
 314          // We are using .sr-only and aria-hidden together so while the element is hidden
 315          // from both the monitor and the screen-reader, it is still tabbable.
 316          $linktabindex = 0;
 317          if (!$hascheckedchoice && $response == -1) {
 318              $clearchoicewrapperattrs['class'] .= ' sr-only';
 319              $clearchoicewrapperattrs['aria-hidden'] = 'true';
 320              $clearchoiceradioattrs['checked'] = 'checked';
 321              $linktabindex = -1;
 322          }
 323          // Adds an hidden radio that will be checked to give the impression the choice has been cleared.
 324          $clearchoiceradio = html_writer::empty_tag('input', $clearchoiceradioattrs);
 325          $clearchoice = html_writer::link('#', get_string('clearchoice', 'qtype_multichoice'),
 326              ['tabindex' => $linktabindex, 'role' => 'button', 'class' => 'btn btn-link ml-3 mt-n1 mb-n1']);
 327          $clearchoiceradio .= html_writer::label($clearchoice, $clearchoiceid);
 328  
 329          // Now wrap the radio and label inside a div.
 330          $result = html_writer::tag('div', $clearchoiceradio, $clearchoicewrapperattrs);
 331  
 332          // Load required clearchoice AMD module.
 333          $this->page->requires->js_call_amd('qtype_multichoice/clearchoice', 'init',
 334              [$qa->get_outer_question_div_unique_id(), $clearchoicefieldname]);
 335  
 336          return $result;
 337      }
 338  
 339  }
 340  
 341  /**
 342   * Subclass for generating the bits of output specific to multiple choice
 343   * multi=select questions.
 344   *
 345   * @copyright  2009 The Open University
 346   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 347   */
 348  class qtype_multichoice_multi_renderer extends qtype_multichoice_renderer_base {
 349      protected function after_choices(question_attempt $qa, question_display_options $options) {
 350          return '';
 351      }
 352  
 353      protected function get_input_type() {
 354          return 'checkbox';
 355      }
 356  
 357      protected function get_input_name(question_attempt $qa, $value) {
 358          return $qa->get_qt_field_name('choice' . $value);
 359      }
 360  
 361      protected function get_input_value($value) {
 362          return 1;
 363      }
 364  
 365      protected function get_input_id(question_attempt $qa, $value) {
 366          return $this->get_input_name($qa, $value);
 367      }
 368  
 369      protected function is_right(question_answer $ans) {
 370          if ($ans->fraction > 0) {
 371              return 1;
 372          } else {
 373              return 0;
 374          }
 375      }
 376  
 377      protected function prompt() {
 378          return get_string('selectmulti', 'qtype_multichoice');
 379      }
 380  
 381      public function correct_response(question_attempt $qa) {
 382          $question = $qa->get_question();
 383  
 384          $right = array();
 385          foreach ($question->answers as $ansid => $ans) {
 386              if ($ans->fraction > 0) {
 387                  $right[] = $question->make_html_inline($question->format_text($ans->answer, $ans->answerformat,
 388                          $qa, 'question', 'answer', $ansid));
 389              }
 390          }
 391          return $this->correct_choices($right);
 392      }
 393  
 394      protected function num_parts_correct(question_attempt $qa) {
 395          if ($qa->get_question()->get_num_selected_choices($qa->get_last_qt_data()) >
 396                  $qa->get_question()->get_num_correct_choices()) {
 397              return get_string('toomanyselected', 'qtype_multichoice');
 398          }
 399  
 400          return parent::num_parts_correct($qa);
 401      }
 402  }