8a09a694499a98cecbcd6747df7010738d03237b
[moodle.git] / question / type / multichoice / renderer.php
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/>.
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  */
27 defined('MOODLE_INTERNAL') || die();
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 {
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);
49     protected abstract function get_input_type();
51     protected abstract function get_input_name(question_attempt $qa, $value);
53     protected abstract function get_input_value($value);
55     protected abstract function get_input_id(question_attempt $qa, $value);
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);
64     protected abstract function prompt();
66     public function formulation_and_controls(question_attempt $qa,
67             question_display_options $options) {
69         $question = $qa->get_question();
70         $response = $question->get_response($qa);
72         $inputname = $qa->get_qt_field_name('answer');
73         $inputattributes = array(
74             'type' => $this->get_input_type(),
75             'name' => $inputname,
76         );
78         if ($options->readonly) {
79             $inputattributes['disabled'] = 'disabled';
80         }
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             $isselected = $question->is_choice_selected($response, $value);
92             if ($isselected) {
93                 $inputattributes['checked'] = 'checked';
94             } else {
95                 unset($inputattributes['checked']);
96             }
97             $hidden = '';
98             if (!$options->readonly && $this->get_input_type() == 'checkbox') {
99                 $hidden = html_writer::empty_tag('input', array(
100                     'type' => 'hidden',
101                     'name' => $inputattributes['name'],
102                     'value' => 0,
103                 ));
104             }
105             $radiobuttons[] = $hidden . html_writer::empty_tag('input', $inputattributes) .
106                     html_writer::tag('label',
107                         html_writer::span($this->number_in_style($value, $question->answernumbering), 'answernumber') .
108                         html_writer::tag('div',
109                         $question->format_text(
110                                     $ans->answer, $ans->answerformat,
111                                     $qa, 'question', 'answer', $ansid),
112                         array('class' => 'flex-fill ml-1')),
113                         array('for' => $inputattributes['id'], 'class' => 'd-flex w-100'));
115             // Param $options->suppresschoicefeedback is a hack specific to the
116             // oumultiresponse question type. It would be good to refactor to
117             // avoid refering to it here.
118             if ($options->feedback && empty($options->suppresschoicefeedback) &&
119                     $isselected && trim($ans->feedback)) {
120                 $feedback[] = html_writer::tag('div',
121                         $question->make_html_inline($question->format_text(
122                                 $ans->feedback, $ans->feedbackformat,
123                                 $qa, 'question', 'answerfeedback', $ansid)),
124                         array('class' => 'specificfeedback'));
125             } else {
126                 $feedback[] = '';
127             }
128             $class = 'r' . ($value % 2);
129             if ($options->correctness && $isselected) {
130                 $feedbackimg[] = $this->feedback_image($this->is_right($ans));
131                 $class .= ' ' . $this->feedback_class($this->is_right($ans));
132             } else {
133                 $feedbackimg[] = '';
134             }
135             $classes[] = $class;
136         }
138         $result = '';
139         $result .= html_writer::tag('div', $question->format_questiontext($qa),
140                 array('class' => 'qtext'));
142         $result .= html_writer::start_tag('div', array('class' => 'ablock'));
143         if ($question->showstandardinstruction == 1) {
144             $result .= html_writer::tag('div', $this->prompt(), array('class' => 'prompt'));
145         }
147         $result .= html_writer::start_tag('div', array('class' => 'answer'));
148         foreach ($radiobuttons as $key => $radio) {
149             $result .= html_writer::tag('div', $radio . ' ' . $feedbackimg[$key] . $feedback[$key],
150                     array('class' => $classes[$key])) . "\n";
151         }
152         $result .= html_writer::end_tag('div'); // Answer.
154         $result .= $this->after_choices($qa, $options);
156         $result .= html_writer::end_tag('div'); // Ablock.
158         if ($qa->get_state() == question_state::$invalid) {
159             $result .= html_writer::nonempty_tag('div',
160                     $question->get_validation_error($qa->get_last_qt_data()),
161                     array('class' => 'validationerror'));
162         }
164         return $result;
165     }
167     protected function number_html($qnum) {
168         return $qnum . '. ';
169     }
171     /**
172      * @param int $num The number, starting at 0.
173      * @param string $style The style to render the number in. One of the
174      * options returned by {@link qtype_multichoice:;get_numbering_styles()}.
175      * @return string the number $num in the requested style.
176      */
177     protected function number_in_style($num, $style) {
178         switch($style) {
179             case 'abc':
180                 $number = chr(ord('a') + $num);
181                 break;
182             case 'ABCD':
183                 $number = chr(ord('A') + $num);
184                 break;
185             case '123':
186                 $number = $num + 1;
187                 break;
188             case 'iii':
189                 $number = question_utils::int_to_roman($num + 1);
190                 break;
191             case 'IIII':
192                 $number = strtoupper(question_utils::int_to_roman($num + 1));
193                 break;
194             case 'none':
195                 return '';
196             default:
197                 return 'ERR';
198         }
199         return $this->number_html($number);
200     }
202     public function specific_feedback(question_attempt $qa) {
203         return $this->combined_feedback($qa);
204     }
206     /**
207      * Function returns string based on number of correct answers
208      * @param array $right An Array of correct responses to the current question
209      * @return string based on number of correct responses
210      */
211     protected function correct_choices(array $right) {
212         // Return appropriate string for single/multiple correct answer(s).
213         if (count($right) == 1) {
214                 return get_string('correctansweris', 'qtype_multichoice',
215                         implode(', ', $right));
216         } else if (count($right) > 1) {
217                 return get_string('correctanswersare', 'qtype_multichoice',
218                         implode(', ', $right));
219         } else {
220                 return "";
221         }
222     }
226 /**
227  * Subclass for generating the bits of output specific to multiple choice
228  * single questions.
229  *
230  * @copyright  2009 The Open University
231  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
232  */
233 class qtype_multichoice_single_renderer extends qtype_multichoice_renderer_base {
234     protected function get_input_type() {
235         return 'radio';
236     }
238     protected function get_input_name(question_attempt $qa, $value) {
239         return $qa->get_qt_field_name('answer');
240     }
242     protected function get_input_value($value) {
243         return $value;
244     }
246     protected function get_input_id(question_attempt $qa, $value) {
247         return $qa->get_qt_field_name('answer' . $value);
248     }
250     protected function is_right(question_answer $ans) {
251         return $ans->fraction;
252     }
254     protected function prompt() {
255         return get_string('selectone', 'qtype_multichoice');
256     }
258     public function correct_response(question_attempt $qa) {
259         $question = $qa->get_question();
261         // Put all correct answers (100% grade) into $right.
262         $right = array();
263         foreach ($question->answers as $ansid => $ans) {
264             if (question_state::graded_state_for_fraction($ans->fraction) ==
265                     question_state::$gradedright) {
266                 $right[] = $question->make_html_inline($question->format_text($ans->answer, $ans->answerformat,
267                         $qa, 'question', 'answer', $ansid));
268             }
269         }
270         return $this->correct_choices($right);
271     }
273     public function after_choices(question_attempt $qa, question_display_options $options) {
274         // Only load the clear choice feature if it's not read only.
275         if ($options->readonly) {
276             return '';
277         }
279         $question = $qa->get_question();
280         $response = $question->get_response($qa);
281         $hascheckedchoice = false;
282         foreach ($question->get_order($qa) as $value => $ansid) {
283             if ($question->is_choice_selected($response, $value)) {
284                 $hascheckedchoice = true;
285                 break;
286             }
287         }
289         $clearchoiceid = $this->get_input_id($qa, -1);
290         $clearchoicefieldname = $qa->get_qt_field_name('clearchoice');
291         $clearchoiceradioattrs = [
292             'type' => $this->get_input_type(),
293             'name' => $qa->get_qt_field_name('answer'),
294             'id' => $clearchoiceid,
295             'value' => -1,
296             'class' => 'sr-only'
297         ];
299         $cssclass = 'qtype_multichoice_clearchoice';
300         // When no choice selected during rendering, then hide the clear choice option.
301         $linktabindex = 0;
302         if (!$hascheckedchoice && $response == -1) {
303             $cssclass .= ' sr-only';
304             $clearchoiceradioattrs['checked'] = 'checked';
305             $linktabindex = -1;
306         }
307         // Adds an hidden radio that will be checked to give the impression the choice has been cleared.
308         $clearchoiceradio = html_writer::empty_tag('input', $clearchoiceradioattrs);
309         $clearchoiceradio .= html_writer::link('', get_string('clearchoice', 'qtype_multichoice'),
310             ['for' => $clearchoiceid, 'role' => 'button', 'tabindex' => $linktabindex]);
312         // Now wrap the radio and label inside a div.
313         $result = html_writer::tag('div', $clearchoiceradio, ['id' => $clearchoicefieldname, 'class' => $cssclass]);
315         // Load required clearchoice AMD module.
316         $this->page->requires->js_call_amd('qtype_multichoice/clearchoice', 'init',
317             [$qa->get_outer_question_div_unique_id(), $clearchoicefieldname]);
319         return $result;
320     }
324 /**
325  * Subclass for generating the bits of output specific to multiple choice
326  * multi=select questions.
327  *
328  * @copyright  2009 The Open University
329  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
330  */
331 class qtype_multichoice_multi_renderer extends qtype_multichoice_renderer_base {
332     protected function after_choices(question_attempt $qa, question_display_options $options) {
333         return '';
334     }
336     protected function get_input_type() {
337         return 'checkbox';
338     }
340     protected function get_input_name(question_attempt $qa, $value) {
341         return $qa->get_qt_field_name('choice' . $value);
342     }
344     protected function get_input_value($value) {
345         return 1;
346     }
348     protected function get_input_id(question_attempt $qa, $value) {
349         return $this->get_input_name($qa, $value);
350     }
352     protected function is_right(question_answer $ans) {
353         if ($ans->fraction > 0) {
354             return 1;
355         } else {
356             return 0;
357         }
358     }
360     protected function prompt() {
361         return get_string('selectmulti', 'qtype_multichoice');
362     }
364     public function correct_response(question_attempt $qa) {
365         $question = $qa->get_question();
367         $right = array();
368         foreach ($question->answers as $ansid => $ans) {
369             if ($ans->fraction > 0) {
370                 $right[] = $question->make_html_inline($question->format_text($ans->answer, $ans->answerformat,
371                         $qa, 'question', 'answer', $ansid));
372             }
373         }
374         return $this->correct_choices($right);
375     }
377     protected function num_parts_correct(question_attempt $qa) {
378         if ($qa->get_question()->get_num_selected_choices($qa->get_last_qt_data()) >
379                 $qa->get_question()->get_num_correct_choices()) {
380             return get_string('toomanyselected', 'qtype_multichoice');
381         }
383         return parent::num_parts_correct($qa);
384     }