• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FormulasQuestion / moodle-qtype_formulas / 23045133582

13 Mar 2026 09:38AM UTC coverage: 97.585% (+0.04%) from 97.542%
23045133582

push

github

PhilippImhof
remove bootstrap compatibility helper (#311)

6 of 6 new or added lines in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

4607 of 4721 relevant lines covered (97.59%)

1950.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.03
/renderer.php
1
<?php
2
// This file is part of Moodle - https://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 <https://www.gnu.org/licenses/>.
16

17
use qtype_formulas\local\formulas_part;
18

19
/**
20
 * Formulas question renderer class.
21
 *
22
 * @package    qtype_formulas
23
 * @copyright  2009 The Open University
24
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25
 */
26

27
/**
28
 * Base class for generating the bits of output for formulas questions.
29
 *
30
 * @copyright  2009 The Open University
31
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
32
 */
33
class qtype_formulas_renderer extends qtype_with_combined_feedback_renderer {
34
    /** @var string */
35
    const UNIT_FIELD = 'u';
36

37
    /** @var string */
38
    const COMBINED_FIELD = '';
39

40
    /**
41
     * Generate the display of the formulation part of the question. This is the area that
42
     * contains the question text and the controls for students to input their answers.
43
     * Once the question is answered, it will contain the green tick or the red cross and
44
     * the part's general / combined feedback.
45
     *
46
     * @param question_attempt $qa the question attempt to display.
47
     * @param question_display_options $options controls what should and should not be displayed.
48
     * @return ?string HTML fragment.
49
     */
50
    public function formulation_and_controls(question_attempt $qa, question_display_options $options): ?string {
51
        // First, fetch the instantiated question from the attempt.
52
        /** @var qtype_formulas_question $question */
53
        $question = $qa->get_question();
2,760✔
54

55
        if (count($question->textfragments) !== $question->numparts + 1) {
2,760✔
56
            $this->output->notification(get_string('error_question_damaged', 'qtype_formulas'), 'error');
×
57
            return null;
×
58
        }
59

60
        $questiontext = '';
2,760✔
61
        // First, iterate over all parts, put the corresponding fragment of the main question text at the
62
        // right position, followed by the part's text, input and (if applicable) feedback elements.
63
        foreach ($question->parts as $part) {
2,760✔
64
            $questiontext .= $question->format_text(
2,760✔
65
                $question->textfragments[$part->partindex],
2,760✔
66
                $question->questiontextformat,
2,760✔
67
                $qa,
2,760✔
68
                'question',
2,760✔
69
                'questiontext',
2,760✔
70
                $question->id,
2,760✔
71
                false,
2,760✔
72
            );
2,760✔
73
            $questiontext .= $this->part_formulation_and_controls($qa, $options, $part);
2,760✔
74
        }
75
        // All parts are done. We now append the final fragment of the main question text. Note that this fragment
76
        // might be empty.
77
        $questiontext .= $question->format_text(
2,760✔
78
            end($question->textfragments),
2,760✔
79
            $question->questiontextformat,
2,760✔
80
            $qa,
2,760✔
81
            'question',
2,760✔
82
            'questiontext',
2,760✔
83
            $question->id,
2,760✔
84
            false,
2,760✔
85
        );
2,760✔
86

87
        // Pack everything in a <div> and, if the question is in an invalid state, append the appropriate error message
88
        // at the very end.
89
        $result = html_writer::tag('div', $questiontext, ['class' => 'qtext']);
2,760✔
90
        if ($qa->get_state() == question_state::$invalid) {
2,760✔
91
            $result .= html_writer::nonempty_tag(
46✔
92
                'div',
46✔
93
                $question->get_validation_error($qa->get_last_qt_data()),
46✔
94
                ['class' => 'validationerror']
46✔
95
            );
46✔
96
        }
97

98
        return $result;
2,760✔
99
    }
100

101
    /**
102
     * Return HTML that needs to be included in the page's <head> when this
103
     * question is used.
104
     *
105
     * @param question_attempt $qa question attempt that will be displayed on the page
106
     * @return string HTML fragment
107
     */
108
    public function head_code(question_attempt $qa): string {
UNCOV
109
        $this->page->requires->js_call_amd(
×
110
            'qtype_formulas/answervalidation',
×
111
            'init',
×
112
            [get_config('qtype_formulas', 'debouncedelay')]
×
113
        );
×
114

UNCOV
115
        return '';
×
116
    }
117

118
    /**
119
     * Return the part text, controls, grading details and feedbacks.
120
     *
121
     * @param question_attempt $qa question attempt that will be displayed on the page
122
     * @param question_display_options $options controls what should and should not be displayed
123
     * @param formulas_part $part question part
124
     * @return void
125
     */
126
    public function part_formulation_and_controls(
127
        question_attempt $qa,
128
        question_display_options $options,
129
        formulas_part $part
130
    ): string {
131

132
        // The behaviour might change the display options per part, so it is safer to clone them here.
133
        $partoptions = clone $options;
2,760✔
134
        if ($qa->get_behaviour_name() === 'adaptivemultipart') {
2,760✔
135
            $qa->get_behaviour()->adjust_display_options_for_part($part->partindex, $partoptions);
115✔
136
        }
137

138
        // Fetch information about the outcome: grade, feedback symbol, CSS class to be used.
139
        $outcomedata = $this->get_part_feedback_class_and_symbol($qa, $partoptions, $part);
2,760✔
140

141
        // First of all, we take the part's question text and its input fields.
142
        $output = $this->get_part_formulation($qa, $partoptions, $part, $outcomedata);
2,760✔
143

144
        // If the user has requested the feedback symbol to be placed at a special position, we
145
        // do that now. Otherwise, we just append it after the part's text and input boxes.
146
        if (strpos($output, '{_m}') !== false) {
2,760✔
147
            $output = str_replace('{_m}', $outcomedata->feedbacksymbol, $output);
×
148
        } else {
149
            $output .= $outcomedata->feedbacksymbol;
2,760✔
150
        }
151

152
        // The part's feedback consists of the combined feedback (correct, partially correct, incorrect -- depending on the
153
        // outcome) and the general feedback which is given in all cases.
154
        $feedback = $this->part_combined_feedback($qa, $partoptions, $part, $outcomedata->fraction);
2,760✔
155
        $feedback .= $this->part_general_feedback($qa, $partoptions, $part);
2,760✔
156

157
        // If requested, the correct answer should be appended to the feedback.
158
        if ($partoptions->rightanswer) {
2,760✔
159
            $feedback .= $this->part_correct_response($part);
414✔
160
        }
161

162
        // Put all feedback into a <div> with the appropriate CSS class and append it to the output.
163
        $output .= html_writer::nonempty_tag('div', $feedback, ['class' => 'formulaspartoutcome outcome']);
2,760✔
164

165
        return html_writer::tag('div', $output, ['class' => 'formulaspart']);
2,760✔
166
    }
167

168
    /**
169
     * Return class and symbol for the part feedback.
170
     *
171
     * @param question_attempt $qa question attempt that will be displayed on the page
172
     * @param question_display_options $options controls what should and should not be displayed
173
     * @param formulas_part $part question part
174
     * @return stdClass
175
     */
176
    public function get_part_feedback_class_and_symbol(
177
        question_attempt $qa,
178
        question_display_options $options,
179
        formulas_part $part
180
    ): stdClass {
181
        // Prepare a new object to hold the different elements.
182
        $result = new stdClass();
2,760✔
183

184
        // Fetch the last response data and grade it.
185
        $response = $qa->get_last_qt_data();
2,760✔
186
        ['answer' => $answergrade, 'unit' => $unitcorrect] = $part->grade($response);
2,760✔
187

188
        // The fraction will later be used to determine which feedback (correct, partially correct or incorrect)
189
        // to use. We have to take into account a possible deduction for a wrong unit.
190
        $result->fraction = $answergrade;
2,760✔
191
        if ($unitcorrect === false) {
2,760✔
192
            $result->fraction *= (1 - $part->unitpenalty);
1,679✔
193
        }
194

195
        // By default, we add no feedback at all...
196
        $result->feedbacksymbol = '';
2,760✔
197
        $result->feedbackclass = '';
2,760✔
198
        // ... unless correctness is requested in the display options.
199
        if ($options->correctness) {
2,760✔
200
            $result->feedbacksymbol = $this->feedback_image($result->fraction);
506✔
201
            $result->feedbackclass = $this->feedback_class($result->fraction);
506✔
202
        }
203
        return $result;
2,760✔
204
    }
205

206
    /**
207
     * Format given number according to numbering style, e. g. abc or 123.
208
     *
209
     * @param int $num number
210
     * @param string $style style to render the number in, acccording to {@see qtype_multichoice::get_numbering_styles()}
211
     * @return string number $num in the requested style
212
     */
213
    protected static function number_in_style(int $num, string $style): string {
214
        switch ($style) {
215
            case 'abc':
69✔
216
                $number = chr(ord('a') + $num);
46✔
217
                break;
46✔
218
            case 'ABCD':
23✔
219
                $number = chr(ord('A') + $num);
×
220
                break;
×
221
            case '123':
23✔
222
                $number = $num + 1;
×
223
                break;
×
224
            case 'iii':
23✔
225
                $number = question_utils::int_to_roman($num + 1);
×
226
                break;
×
227
            case 'IIII':
23✔
228
                $number = strtoupper(question_utils::int_to_roman($num + 1));
×
229
                break;
×
230
            case 'none':
23✔
231
                return '';
×
232
            default:
233
                // Default similar to none for compatibility with old questions.
234
                return '';
23✔
235
        }
236
        return $number . '. ';
46✔
237
    }
238

239
    /**
240
     * Create a set of radio boxes for a multiple choice answer input.
241
     *
242
     * @param formulas_part $part question part
243
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
244
     * @param question_attempt $qa question attempt that will be displayed on the page
245
     * @param array $answeroptions array of strings containing the answer options to choose from
246
     * @param bool $shuffle whether the options should be shuffled
247
     * @param question_display_options $displayoptions controls what should and should not be displayed
248
     * @param string $feedbackclass
249
     * @return string HTML fragment
250
     */
251
    protected function create_radio_mc_answer(
252
        formulas_part $part,
253
        $answerindex,
254
        question_attempt $qa,
255
        array $answeroptions,
256
        bool $shuffle,
257
        question_display_options $displayoptions,
258
        string $feedbackclass = ''
259
    ): string {
260
        /** @var qtype_formulas_question $question */
261
        $question = $qa->get_question();
69✔
262

263
        $variablename = "{$part->partindex}_{$answerindex}";
69✔
264
        $currentanswer = $qa->get_last_qt_var($variablename);
69✔
265
        $inputname = $qa->get_qt_field_name($variablename);
69✔
266

267
        $inputattributes['type'] = 'radio';
69✔
268
        $inputattributes['name'] = $inputname;
69✔
269
        if ($displayoptions->readonly) {
69✔
270
            $inputattributes['disabled'] = 'disabled';
23✔
271
        }
272

273
        // First, we open a <fieldset> around the entire group of options.
274
        $output = html_writer::start_tag('fieldset', ['class' => 'multichoice_answer']);
69✔
275

276
        // Inside the fieldset, we put the accessibility label, following the example of core's multichoice
277
        // question type, i. e. the label is inside a <span> with class 'sr-only', wrapped in a <legend>.
278
        $output .= html_writer::start_tag('legend', ['class' => 'sr-only']);
69✔
279
        $output .= html_writer::span(
69✔
280
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
69✔
281
            'sr-only'
69✔
282
        );
69✔
283
        $output .= html_writer::end_tag('legend');
69✔
284

285
        // If needed, shuffle the options while maintaining the keys.
286
        if ($shuffle) {
69✔
287
            $keys = array_keys($answeroptions);
23✔
288
            shuffle($keys);
23✔
289

290
            $shuffledoptions = [];
23✔
291
            foreach ($keys as $key) {
23✔
292
                $shuffledoptions[$key] = $answeroptions[$key];
23✔
293
            }
294
            $answeroptions = $shuffledoptions;
23✔
295
        }
296

297
        // Iterate over all options.
298
        foreach ($answeroptions as $i => $optiontext) {
69✔
299
            $numbering = html_writer::span(self::number_in_style($i, $question->answernumbering), 'answernumber');
69✔
300
            $labeltext = $question->format_text(
69✔
301
                $numbering . $optiontext,
69✔
302
                $part->subqtextformat,
69✔
303
                $qa,
69✔
304
                'qtype_formulas',
69✔
305
                'answersubqtext',
69✔
306
                $part->id,
69✔
307
                false,
69✔
308
            );
69✔
309

310
            $inputattributes['id'] = $inputname . '_' . $i;
69✔
311
            $inputattributes['value'] = $i;
69✔
312
            // Class ml-3 is Bootstrap's class for margin-left: 1rem; it used to be m-l-1.
313
            $label = $this->create_label_for_input($labeltext, $inputattributes['id'], ['class' => 'ml-3']);
69✔
314
            $inputattributes['aria-labelledby'] = $label['id'];
69✔
315

316
            // We must make sure $currentanswer is not null, because otherwise the first radio box
317
            // might be selected if there is no answer at all. It seems better to avoid strict equality,
318
            // because we might compare a string to a number.
319
            $isselected = ($i == $currentanswer && !is_null($currentanswer));
69✔
320

321
            // We do not reset the $inputattributes array on each iteration, so we have to add/remove the
322
            // attribute every time.
323
            if ($isselected) {
69✔
324
                $inputattributes['checked'] = 'checked';
23✔
325
            } else {
326
                unset($inputattributes['checked']);
69✔
327
            }
328

329
            // Each option (radio box element plus label) is wrapped in its own <div> element.
330
            $divclass = 'r' . ($i % 2);
69✔
331
            if ($displayoptions->correctness && $isselected) {
69✔
332
                $divclass .= ' ' . $feedbackclass;
23✔
333
            }
334
            $output .= html_writer::start_div($divclass);
69✔
335

336
            // Now add the <input> tag and its <label>.
337
            $output .= html_writer::empty_tag('input', $inputattributes);
69✔
338
            $output .= $label['html'];
69✔
339

340
            // Close the option's <div>.
341
            $output .= html_writer::end_div();
69✔
342
        }
343

344
        // Close the option group's <fieldset>.
345
        $output .= html_writer::end_tag('fieldset');
69✔
346

347
        return $output;
69✔
348
    }
349

350
    /**
351
     * Translate an array containing formatting options into a CSS format string, e. g. from
352
     * ['w' => '50px', 'bgcol' => 'yellow'] to 'width: 50px; background-color: yellow'. Note:
353
     * - colors can be defined in 3 or 6 digit hex RGB, in 4 or 8 digit hex RGBA or as CSS named color
354
     * - widths can be defined as a number followed by the units px, rem or em; if the unit is omitted, rem will be used
355
     * - alignment can be defined as left, right, center, start or end
356
     *
357
     * @param array $options associative array containing options (in our own denomination) and their settings
358
     * @return string
359
     */
360
    protected function get_css_properties(array $options): string {
361
        // Define some regex pattern.
362
        $hexcolor = '#([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{3}|[0-9A-F]{4})';
2,668✔
363
        $namedcolor = '[A-Z]+';
2,668✔
364
        // We accept floating point numbers with or without a leading integer part and integers.
365
        // Floating point numbers with a trailing decimal point do not work in all browsers.
366
        $length = '(\d+\.\d+|\d*\.\d+|\d+)(px|em|rem)?';
2,668✔
367
        $alignment = 'start|end|left|right|center';
2,668✔
368

369
        $styles = [];
2,668✔
370
        foreach ($options as $name => $value) {
2,668✔
371
            switch ($name) {
372
                case 'bgcol':
2,668✔
373
                    if (!preg_match("/^(($hexcolor)|($namedcolor))$/i", $value)) {
667✔
374
                        break;
46✔
375
                    }
376
                    $styles[] = "background-color: $value";
621✔
377
                    break;
621✔
378
                case 'txtcol':
2,668✔
379
                    if (!preg_match("/^(($hexcolor)|($namedcolor))$/i", $value)) {
276✔
380
                        break;
46✔
381
                    }
382
                    $styles[] = "color: $value";
230✔
383
                    break;
230✔
384
                case 'w':
2,668✔
385
                    if (!preg_match("/^($length)$/i", $value)) {
2,668✔
386
                        break;
184✔
387
                    }
388
                    // If no unit is given, append rem.
389
                    $styles[] = "width: $value" . (preg_match('/\d$/', $value) ? 'rem' : '');
2,576✔
390
                    break;
2,576✔
391
                case 'align':
299✔
392
                    if (!preg_match("/^($alignment)$/i", $value)) {
299✔
393
                        break;
46✔
394
                    }
395
                    $styles[] = "text-align: $value";
253✔
396
                    break;
253✔
397
            }
398
        }
399

400
        return implode(';', $styles);
2,668✔
401
    }
402

403
    /**
404
     * Create a <label> element for a given input control (e. g. a text field). Returns the
405
     * HTML and the label's ID.
406
     *
407
     * @param string $text the label's text
408
     * @param string $inputid ID of the input control for which the label is created
409
     * @param array $additionalattributes possibility to add custom attributes, attribute name => value
410
     * @return array 'id' => label's ID to be used in 'aria-labelledby' attribute, 'html' => HTML code
411
     */
412
    protected function create_label_for_input(string $text, string $inputid, array $additionalattributes = []): array {
413
        $labelid = 'lbl_' . str_replace(':', '__', $inputid);
2,760✔
414
        $attributes = [
2,760✔
415
            'class' => 'subq accesshide',
2,760✔
416
            'for' => $inputid,
2,760✔
417
            'id' => $labelid,
2,760✔
418
        ];
2,760✔
419

420
        // Merging the additional attributes with the default attributes; left array has precedence when
421
        // using the + operator.
422
        $attributes = $additionalattributes + $attributes;
2,760✔
423

424
        return [
2,760✔
425
            'id' => $labelid,
2,760✔
426
            'html' => html_writer::tag('label', $text, $attributes),
2,760✔
427
        ];
2,760✔
428
    }
429

430
    /**
431
     * Create a <select> field for a multiple choice answer input.
432
     *
433
     * @param formulas_part $part question part
434
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
435
     * @param question_attempt $qa question attempt that will be displayed on the page
436
     * @param array $answeroptions array of strings containing the answer options to choose from
437
     * @param bool $shuffle whether the options should be shuffled
438
     * @param question_display_options $displayoptions controls what should and should not be displayed
439
     * @return string HTML fragment
440
     */
441
    protected function create_dropdown_mc_answer(
442
        formulas_part $part,
443
        $answerindex,
444
        question_attempt $qa,
445
        array $answeroptions,
446
        bool $shuffle,
447
        question_display_options $displayoptions
448
    ): string {
449
        /** @var qtype_formulas_question $question */
450
        $question = $qa->get_question();
69✔
451

452
        $variablename = "{$part->partindex}_{$answerindex}";
69✔
453
        $currentanswer = $qa->get_last_qt_var($variablename);
69✔
454
        $inputname = $qa->get_qt_field_name($variablename);
69✔
455

456
        $inputattributes['name'] = $inputname;
69✔
457
        $inputattributes['value'] = $currentanswer;
69✔
458
        $inputattributes['id'] = $inputname;
69✔
459
        $inputattributes['class'] = 'formulas-select';
69✔
460

461
        $label = $this->create_label_for_input(
69✔
462
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
69✔
463
            $inputname
69✔
464
        );
69✔
465
        $inputattributes['aria-labelledby'] = $label['id'];
69✔
466

467
        if ($displayoptions->readonly) {
69✔
468
            $inputattributes['disabled'] = 'disabled';
23✔
469
        }
470

471
        // First, we open a <span> around the dropdown field and its accessibility label.
472
        $output = html_writer::start_tag('span', ['class' => 'formulas_menu']);
69✔
473
        $output .= $label['html'];
69✔
474

475
        // Iterate over all options.
476
        $entries = [];
69✔
477
        foreach ($answeroptions as $optiontext) {
69✔
478
            $entries[] = $question->format_text(
69✔
479
                $optiontext,
69✔
480
                $part->subqtextformat,
69✔
481
                $qa,
69✔
482
                'qtype_formulas',
69✔
483
                'answersubqtext',
69✔
484
                $part->id,
69✔
485
                false,
69✔
486
            );
69✔
487
        }
488

489
        // If needed, shuffle the options while maintaining the keys.
490
        if ($shuffle) {
69✔
491
            $keys = array_keys($entries);
23✔
492
            shuffle($keys);
23✔
493

494
            $shuffledentries = [];
23✔
495
            foreach ($keys as $key) {
23✔
496
                $shuffledentries[$key] = $entries[$key];
23✔
497
            }
498
            $entries = $shuffledentries;
23✔
499
        }
500

501
        $output .= html_writer::select($entries, $inputname, $currentanswer, ['' => ''], $inputattributes);
69✔
502
        $output .= html_writer::end_tag('span');
69✔
503

504
        return $output;
69✔
505
    }
506

507
    /**
508
     * Generate the right label for the input control, depending on the number of answers in the given part
509
     * and the number of parts in the question. Special cases (combined field, unit field) are also taken
510
     * into account. Returns the appropriate string from the language file. Examples are "Answer field for
511
     * part X", "Answer field X for part Y" or "Answer and unit for part X".
512
     *
513
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
514
     * @param int $totalanswers number of answers for the given part
515
     * @param int $partindex number of the part (starting at 0) in this question
516
     * @param int $totalparts number of parts in the question
517
     * @return string localized string
518
     */
519
    protected function generate_accessibility_label_text(
520
        $answerindex,
521
        int $totalanswers,
522
        int $partindex,
523
        int $totalparts
524
    ): string {
525

526
        // Some language strings need parameters.
527
        $labeldata = new stdClass();
2,760✔
528

529
        // The language strings start with 'answerunit' for a separate unit field, 'answercombinedunit' for
530
        // a combined field, 'answercoordinate' for an answer field when there are multiple answers in the
531
        // part or just 'answer' if there is a single field.
532
        $labelstring = 'answer';
2,760✔
533
        if ($answerindex === self::UNIT_FIELD) {
2,760✔
534
            $labelstring .= 'unit';
851✔
535
        } else if ($answerindex === self::COMBINED_FIELD) {
2,760✔
536
            $labelstring .= 'combinedunit';
920✔
537
        } else if ($totalanswers > 1) {
2,277✔
538
            $labelstring .= 'coordinate';
69✔
539
            $labeldata->numanswer = $answerindex + 1;
69✔
540
        }
541

542
        // The language strings end with 'multi' for multi-part questions or 'single' for single-part
543
        // questions.
544
        if ($totalparts > 1) {
2,760✔
545
            $labelstring .= 'multi';
115✔
546
            $labeldata->part = $partindex + 1;
115✔
547
        } else {
548
            $labelstring .= 'single';
2,691✔
549
        }
550

551
        return get_string($labelstring, 'qtype_formulas', $labeldata);
2,760✔
552
    }
553

554
    /**
555
     * Create an <input> field.
556
     *
557
     * @param formulas_part $part question part
558
     * @param int|string $answerindex index of the answer (starting at 0) or special value for combined/separate unit field
559
     * @param question_attempt $qa question attempt that will be displayed on the page
560
     * @param question_display_options $displayoptions controls what should and should not be displayed
561
     * @param array $formatoptions associative array 'optionname' => 'value', e. g. 'w' => '50px'
562
     * @param string $feedbackclass
563
     * @return string HTML fragment
564
     */
565
    protected function create_input_box(
566
        formulas_part $part,
567
        $answerindex,
568
        question_attempt $qa,
569
        question_display_options $displayoptions,
570
        array $formatoptions = [],
571
        string $feedbackclass = ''
572
    ): string {
573
        // Importing global $CFG object to check for the Moodle version. Can be removed once we drop compatibility
574
        // with Moodle 4.5 LTS.
575
        global $CFG;
576

577
        /** @var qtype_formulas_question $question */
578
        $question = $qa->get_question();
2,668✔
579

580
        // The variable name will be N_ for the (single) combined unit field of part N,
581
        // or N_M for answer #M in part #N. If #M is equal to the part's numbox (i. e. the
582
        // number of answers), it is a unit field; note that the fields are numbered starting
583
        // from 0, so with 3 answers, we have N_0, N_1, N_2 and only use N_3 if there is a
584
        // unit.
585
        $variablename = $part->partindex . '_';
2,668✔
586
        if ($answerindex === self::UNIT_FIELD) {
2,668✔
587
            $variablename .= $part->numbox;
851✔
588
        } else {
589
            $variablename .= ($answerindex === self::COMBINED_FIELD ? '' : $answerindex);
2,668✔
590
        }
591

592
        $currentanswer = $qa->get_last_qt_var($variablename);
2,668✔
593
        $inputname = $qa->get_qt_field_name($variablename);
2,668✔
594

595
        // Text fields will have a tooltip attached. The tooltip's content depends on the
596
        // answer type. Special tooltips exist for combined or separate unit fields.
597
        switch ($part->answertype) {
2,668✔
598
            case qtype_formulas::ANSWER_TYPE_NUMERIC:
599
                $titlestring = 'numeric';
138✔
600
                break;
138✔
601
            case qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA:
602
                $titlestring = 'numerical_formula';
138✔
603
                break;
138✔
604
            case qtype_formulas::ANSWER_TYPE_ALGEBRAIC:
605
                $titlestring = 'algebraic_formula';
184✔
606
                break;
184✔
607
            case qtype_formulas::ANSWER_TYPE_NUMBER:
608
            default:
609
                $titlestring = 'number';
2,300✔
610
        }
611
        if ($answerindex === self::COMBINED_FIELD) {
2,668✔
612
            $titlestring .= '_unit';
920✔
613
        }
614
        if ($answerindex === self::UNIT_FIELD) {
2,668✔
615
            $titlestring = 'unit';
851✔
616
        }
617
        $title = get_string($titlestring, 'qtype_formulas');
2,668✔
618

619
        // Fetch the configured default width and use it, if the setting exists. Otherwise,
620
        // the plugin's default as defined in styles.css will be used. If the user did not
621
        // specify a unit, we use pixels (px). Note that this is different from the renderer
622
        // where rem is used in order to allow for the short syntax 'w=3' (3 chars wide).
623
        $defaultformat = [];
2,668✔
624
        $defaultwidth = get_config('qtype_formulas', "defaultwidth_{$titlestring}");
2,668✔
625
        // If the default width has not been set for the current answer box type, $defaultwidth will
626
        // be false and thus not numeric.
627
        if (is_numeric($defaultwidth)) {
2,668✔
628
            $defaultwidthunit = get_config('qtype_formulas', "defaultwidthunit");
2,668✔
629
            if (!in_array($defaultwidthunit, ['px', 'rem', 'em'])) {
2,668✔
630
                $defaultwidthunit = 'px';
184✔
631
            }
632
            $defaultformat = ['w' => $defaultwidth . $defaultwidthunit];
2,668✔
633
        }
634
        // Using the union operator the values from the left array will be kept.
635
        $formatoptions = $formatoptions + $defaultformat;
2,668✔
636

637
        $inputattributes = [
2,668✔
638
            'type' => 'text',
2,668✔
639
            'name' => $inputname,
2,668✔
640
            'value' => $currentanswer,
2,668✔
641
            'id' => $inputname,
2,668✔
642
            'style' => $this->get_css_properties($formatoptions),
2,668✔
643

644
            'data-answertype' => ($answerindex === self::UNIT_FIELD ? 'unit' : $part->answertype),
2,668✔
645
            'data-withunit' => ($answerindex === self::COMBINED_FIELD ? '1' : '0'),
2,668✔
646

647
            'title' => $title,
2,668✔
648
            'class' => "form-control formulas_{$titlestring} {$feedbackclass}",
2,668✔
649
            'maxlength' => 128,
2,668✔
650
        ];
2,668✔
651

652
        // If the answer type is "Number" and it is not a combined field, we only add the tooltip, if the
653
        // corresponding option is set. Note that, starting from Moodle 5.0, we have to use the new namespaced
654
        // data attributes for Bootstrap 5, e. g. data-bs-toggle instead of data-toggle.
655
        $bootstrapnamespace = 'data-bs';
2,668✔
656
        if ($CFG->branch < 500) {
2,668✔
657
            $bootstrapnamespace = 'data';
1,740✔
658
        }
659
        $iscombined = $inputattributes['data-withunit'] === '1';
2,668✔
660
        $isnumber = !$iscombined && $inputattributes['data-answertype'] === qtype_formulas::ANSWER_TYPE_NUMBER;
2,668✔
661
        $shownumbertooltip = get_config('qtype_formulas', 'shownumbertooltip');
2,668✔
662
        if (!$isnumber || $shownumbertooltip) {
2,668✔
663
            $inputattributes += [
2,668✔
664
                "{$bootstrapnamespace}-toggle" => 'tooltip',
2,668✔
665
                "{$bootstrapnamespace}-title" => $title,
2,668✔
666
                "{$bootstrapnamespace}-custom-class" => 'qtype_formulas-tooltip',
2,668✔
667
            ];
2,668✔
668
        }
669

670
        if ($displayoptions->readonly) {
2,668✔
671
            $inputattributes['readonly'] = 'readonly';
391✔
672
        }
673

674
        $label = $this->create_label_for_input(
2,668✔
675
            $this->generate_accessibility_label_text($answerindex, $part->numbox, $part->partindex, $question->numparts),
2,668✔
676
            $inputname
2,668✔
677
        );
2,668✔
678
        $inputattributes['aria-labelledby'] = $label['id'];
2,668✔
679

680
        $output = $label['html'];
2,668✔
681
        $output .= html_writer::empty_tag('input', $inputattributes);
2,668✔
682

683
        return $output;
2,668✔
684
    }
685

686
    /**
687
     * Return the part's text with variables replaced by their values.
688
     *
689
     * @param question_attempt $qa question attempt that will be displayed on the page
690
     * @param question_display_options $options controls what should and should not be displayed
691
     * @param formulas_part $part question part
692
     * @param stdClass $sub class and symbol for the part feedback
693
     * @return string HTML fragment
694
     */
695
    public function get_part_formulation(
696
        question_attempt $qa,
697
        question_display_options $options,
698
        formulas_part $part,
699
        stdClass $sub
700
    ): string {
701
        /** @var qtype_formulas_question $question */
702
        $question = $qa->get_question();
2,760✔
703

704
        // Clone the part's evaluator and remove special variables like _0 etc., because they must
705
        // not be substituted here; otherwise, we would lose input boxes.
706
        $evaluator = clone $part->evaluator;
2,760✔
707
        $evaluator->remove_special_vars();
2,760✔
708
        $text = $evaluator->substitute_variables_in_text($part->subqtext);
2,760✔
709

710
        $subqreplaced = $question->format_text(
2,760✔
711
            $text,
2,760✔
712
            $part->subqtextformat,
2,760✔
713
            $qa,
2,760✔
714
            'qtype_formulas',
2,760✔
715
            'answersubqtext',
2,760✔
716
            $part->id,
2,760✔
717
            false,
2,760✔
718
        );
2,760✔
719

720
        // Get the set of defined placeholders and their options.
721
        $boxes = $part->scan_for_answer_boxes($subqreplaced);
2,760✔
722

723
        // Append missing placholders at the end of part. We do not put a space before the opening
724
        // or after the closing brace, in order to get {_0}{_u} for questions with one answer and
725
        // a unit. This makes sure that the question will receive a combined unit field.
726
        for ($i = 0; $i <= $part->numbox; $i++) {
2,760✔
727
            // If no unit has been set, we do not append the {_u} placeholder.
728
            if ($i == $part->numbox && empty($part->postunit)) {
2,760✔
729
                continue;
1,541✔
730
            }
731
            $placeholder = ($i == $part->numbox) ? '_u' : "_{$i}";
2,760✔
732
            // If the placeholder does not exist yet, we create it with default settings, i. e. no multi-choice
733
            // and no styling.
734
            if (!array_key_exists($placeholder, $boxes)) {
2,760✔
735
                $boxes[$placeholder] = [
667✔
736
                    'placeholder' => '{' . $placeholder . '}',
667✔
737
                    'options' => '',
667✔
738
                    'dropdown' => false,
667✔
739
                    'format' => [],
667✔
740
                ];
667✔
741
                $subqreplaced .= '{' . $placeholder . '}';
667✔
742
            }
743
        }
744

745
        // If part has combined unit answer input.
746
        if ($part->has_combined_unit_field()) {
2,760✔
747
            // For a combined unit field, we try to merge the formatting options from the {_0} and the
748
            // {_u} placeholder, giving precedence to the latter.
749
            $mergedformat = $boxes['_u']['format'] + $boxes['_0']['format'];
920✔
750
            $combinedfieldhtml = $this->create_input_box(
920✔
751
                $part,
920✔
752
                self::COMBINED_FIELD,
920✔
753
                $qa,
920✔
754
                $options,
920✔
755
                $mergedformat,
920✔
756
                $sub->feedbackclass,
920✔
757
            );
920✔
758
            // The combined field must be placed where the user has the {_0}{_u} placeholders, possibly with
759
            // their formatting options.
760
            $boxplaceholders = $boxes['_0']['placeholder'] . $boxes['_u']['placeholder'];
920✔
761
            return str_replace($boxplaceholders, $combinedfieldhtml, $subqreplaced);
920✔
762
        }
763

764
        // Iterate over all boxes again, this time creating the appropriate input control and insert it
765
        // at the position indicated by the placeholder.
766
        for ($i = 0; $i <= $part->numbox; $i++) {
2,277✔
767
            // For normal answer fields, the placeholder is {_N} with N being the number of the
768
            // answer, starting from 0. The unit field, if there is one, comes last and has the
769
            // {_u} placeholder.
770
            if ($i < $part->numbox) {
2,277✔
771
                $answerindex = $i;
2,277✔
772
                $placeholder = "_$i";
2,277✔
773
            } else if (!empty($part->postunit)) {
2,277✔
774
                $answerindex = self::UNIT_FIELD;
851✔
775
                $placeholder = '_u';
851✔
776
            }
777

778
            // If the user has requested a multi-choice element, they must have specified an array
779
            // variable containing the options. We try to fetch that variable. If this fails, we
780
            // simply continue and build a text field instead.
781
            $optiontexts = null;
2,277✔
782
            if (!empty($boxes[$placeholder]['options'])) {
2,277✔
783
                try {
784
                    $optiontexts = $part->evaluator->export_single_variable($boxes[$placeholder]['options']);
161✔
785
                } catch (Exception $e) {
23✔
786
                    // TODO: use non-capturing catch.
787
                    unset($e);
23✔
788
                }
789
            }
790

791
            if ($optiontexts === null) {
2,277✔
792
                $inputfieldhtml = $this->create_input_box(
2,185✔
793
                    $part,
2,185✔
794
                    $answerindex,
2,185✔
795
                    $qa,
2,185✔
796
                    $options,
2,185✔
797
                    $boxes[$placeholder]['format'],
2,185✔
798
                    $sub->feedbackclass,
2,185✔
799
                );
2,185✔
800
            } else if ($boxes[$placeholder]['dropdown']) {
138✔
801
                $inputfieldhtml = $this->create_dropdown_mc_answer(
69✔
802
                    $part,
69✔
803
                    $i,
69✔
804
                    $qa,
69✔
805
                    $optiontexts->value,
69✔
806
                    $boxes[$placeholder]['shuffle'],
69✔
807
                    $options,
69✔
808
                );
69✔
809
            } else {
810
                $inputfieldhtml = $this->create_radio_mc_answer(
69✔
811
                    $part,
69✔
812
                    $i,
69✔
813
                    $qa,
69✔
814
                    $optiontexts->value,
69✔
815
                    $boxes[$placeholder]['shuffle'],
69✔
816
                    $options,
69✔
817
                    $sub->feedbackclass,
69✔
818
                );
69✔
819
            }
820

821
            // The replacement text *might* contain a backslash and in the worst case this might
822
            // lead to an erroneous backreference, e. g. if the student's answer was \1. Thus,
823
            // we better use preg_replace_callback() instead of just preg_replace(), as this allows
824
            // us to ignore such unintentional backreferences.
825
            $subqreplaced = preg_replace_callback(
2,277✔
826
                '/' . preg_quote($boxes[$placeholder]['placeholder'], '/') . '/',
2,277✔
827
                function ($matches) use ($inputfieldhtml) {
2,277✔
828
                    return $inputfieldhtml;
2,277✔
829
                },
2,277✔
830
                $subqreplaced,
2,277✔
831
                1
2,277✔
832
            );
2,277✔
833
        }
834

835
        return $subqreplaced;
2,277✔
836
    }
837

838
    /**
839
     * Correct response for the question. This is not needed for the Formulas question, because
840
     * answers are relative to parts.
841
     *
842
     * @param question_attempt $qa question attempt that will be displayed on the page
843
     * @return string empty string
844
     */
845
    public function correct_response(question_attempt $qa) {
846
        return '';
414✔
847
    }
848

849
    /**
850
     * Generate an automatic description of the correct response for a given part.
851
     *
852
     * @param formulas_part $part question part
853
     * @return string HTML fragment
854
     */
855
    public function part_correct_response($part) {
856
        $answers = $part->get_correct_response(true);
414✔
857
        $answertext = implode('; ', $answers);
414✔
858

859
        $string = ($part->answernotunique ? 'correctansweris' : 'uniquecorrectansweris');
414✔
860
        return html_writer::nonempty_tag(
414✔
861
            'div',
414✔
862
            get_string($string, 'qtype_formulas', $answertext),
414✔
863
            ['class' => 'formulaspartcorrectanswer'],
414✔
864
        );
414✔
865
    }
866

867
    /**
868
     * Generate a brief statement of how many sub-parts of this question the
869
     * student got right.
870
     *
871
     * @param question_attempt $qa question attempt that will be displayed on the page
872
     * @return string HTML fragment
873
     */
874
    protected function num_parts_correct(question_attempt $qa) {
875
        /** @var qtype_formulas_question $question */
876
        $question = $qa->get_question();
276✔
877
        $response = $qa->get_last_qt_data();
276✔
878
        if (!$question->is_gradable_response($response)) {
276✔
879
            return '';
115✔
880
        }
881

882
        $numright = $question->get_num_parts_right($response)[0];
276✔
883
        if ($numright === 1) {
276✔
884
            return get_string('yougotoneright', 'qtype_formulas');
46✔
885
        } else {
886
            return get_string('yougotnright', 'qtype_formulas', $numright);
253✔
887
        }
888
    }
889

890
    /**
891
     * We need to owerwrite this method to replace global variables by their value.
892
     *
893
     * @param question_attempt $qa question attempt that will be displayed on the page
894
     * @param question_hint $hint the hint to be shown
895
     * @return string HTML fragment
896
     */
897
    protected function hint(question_attempt $qa, question_hint $hint) {
898
        /** @var qtype_formulas_question $question */
899
        $question = $qa->get_question();
46✔
900
        $hint->hint = $question->evaluator->substitute_variables_in_text($hint->hint);
46✔
901

902
        return html_writer::nonempty_tag('div', $question->format_hint($hint, $qa), ['class' => 'hint']);
46✔
903
    }
904

905
    /**
906
     * Generate HTML fragment for the question's combined feedback.
907
     *
908
     * @param question_attempt $qa question attempt that will be displayed on the page
909
     * @return string HTML fragment
910
     */
911
    protected function combined_feedback(question_attempt $qa) {
912
        /** @var qtype_formulas_question $question */
913
        $question = $qa->get_question();
529✔
914

915
        $state = $qa->get_state();
529✔
916
        if (!$state->is_finished()) {
529✔
917
            $response = $qa->get_last_qt_data();
161✔
918
            if (!$question->is_gradable_response($response)) {
161✔
919
                return '';
115✔
920
            }
921
            $state = $question->grade_response($response)[1];
161✔
922
        }
923

924
        // The feedback will be in ->correctfeedback, ->partiallycorrectfeedback or ->incorrectfeedback,
925
        // with the corresponding ->...feedbackformat setting. We create the property names here to simplify
926
        // access.
927
        $fieldname = $state->get_feedback_class() . 'feedback';
529✔
928
        $formatname = $state->get_feedback_class() . 'feedbackformat';
529✔
929

930
        // If there is no feedback, we return an empty string.
931
        if (strlen(trim($question->$fieldname)) === 0) {
529✔
932
            return '';
×
933
        }
934

935
        // Otherwise, we return the appropriate feedback. The text is run through format_text() to have
936
        // variables replaced.
937
        return $question->format_text(
529✔
938
            $question->$fieldname,
529✔
939
            $question->$formatname,
529✔
940
            $qa,
529✔
941
            'question',
529✔
942
            $fieldname,
529✔
943
            $question->id,
529✔
944
            false,
529✔
945
        );
529✔
946
    }
947

948
    /**
949
     * Generate the specific feedback. This is feedback that varies according to
950
     * the response the student gave.
951
     *
952
     * @param question_attempt $qa question attempt that will be displayed on the page
953
     * @return string
954
     */
955
    public function specific_feedback(question_attempt $qa) {
956
        return $this->combined_feedback($qa);
529✔
957
    }
958

959
    /**
960
     * Gereate the part's general feedback. This is feedback is shown to all students.
961
     *
962
     * @param question_attempt $qa question attempt that will be displayed on the page
963
     * @param question_display_options $options controls what should and should not be displayed
964
     * @param formulas_part $part question part
965
     * @return string HTML fragment
966
     */
967
    protected function part_general_feedback(question_attempt $qa, question_display_options $options, formulas_part $part) {
968
        /** @var qtype_formulas_question $question */
969
        $question = $qa->get_question();
2,760✔
970
        $state = $qa->get_state();
2,760✔
971

972
        // If no feedback should be shown, we return an empty string.
973
        if (!$options->feedback) {
2,760✔
974
            return '';
2,714✔
975
        }
976

977
        // If we use the adaptive multipart behaviour, there will be some feedback about the grading,
978
        // e. g. the obtained marks for this submission and the attracted penalty.
979
        $gradingdetailsdiv = '';
529✔
980
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
529✔
981
            // This is rather a hack, but it will probably work.
982
            $renderer = $this->page->get_renderer('qbehaviour_adaptivemultipart');
115✔
983
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
115✔
984
            $gradingdetailsdiv = $renderer->render_adaptive_marks($details, $options);
115✔
985
            $state = $details->state;
115✔
986
        }
987
        // If the question is in a state that does not yet allow to give a feedback,
988
        // we return an empty string.
989
        if (empty($state->get_feedback_class())) {
529✔
990
            return '';
46✔
991
        }
992

993
        // If we have a general feedback, we substitute local / grading variables and
994
        // wrap it in a <div>.
995
        $feedbackdiv = '';
506✔
996
        if (strlen(trim($part->feedback)) !== 0) {
506✔
997
            $feedbacktext = $part->evaluator->substitute_variables_in_text($part->feedback);
299✔
998
            $feedbacktext = $question->format_text(
299✔
999
                $feedbacktext,
299✔
1000
                FORMAT_HTML,
299✔
1001
                $qa,
299✔
1002
                'qtype_formulas',
299✔
1003
                'answerfeedback',
299✔
1004
                $part->id,
299✔
1005
                false
299✔
1006
            );
299✔
1007
            $feedbackdiv = html_writer::tag('div', $feedbacktext, ['class' => 'feedback formulaslocalfeedback']);
299✔
1008
        }
1009

1010
        // Append the grading details, if they exist. If the result is not empty, wrap in
1011
        // a <div> and return.
1012
        $feedbackdiv .= $gradingdetailsdiv;
506✔
1013
        if (!empty($feedbackdiv)) {
506✔
1014
            return html_writer::nonempty_tag(
322✔
1015
                'div',
322✔
1016
                $feedbackdiv,
322✔
1017
                ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex],
322✔
1018
            );
322✔
1019
        }
1020

1021
        // Still here? Then we return an empty string.
1022
        return '';
207✔
1023
    }
1024

1025
    /**
1026
     * Generate HTML fragment for the part's combined feedback.
1027
     *
1028
     * @param question_attempt $qa question attempt that will be displayed on the page
1029
     * @param question_display_options $options controls what should and should not be displayed
1030
     * @param formulas_part $part question part
1031
     * @param float $fraction the obtained grade
1032
     * @return string HTML fragment
1033
     */
1034
    protected function part_combined_feedback(
1035
        question_attempt $qa,
1036
        question_display_options $options,
1037
        formulas_part $part,
1038
        float $fraction
1039
    ): string {
1040
        $feedback = '';
2,760✔
1041
        $showfeedback = false;
2,760✔
1042
        /** @var qtype_formulas_question $question */
1043
        $question = $qa->get_question();
2,760✔
1044
        $state = $qa->get_state();
2,760✔
1045
        $feedbackclass = $state->get_feedback_class();
2,760✔
1046

1047
        if ($qa->get_behaviour_name() == 'adaptivemultipart') {
2,760✔
1048
            $details = $qa->get_behaviour()->get_part_mark_details($part->partindex);
115✔
1049
            $feedbackclass = $details->state->get_feedback_class();
115✔
1050
        } else {
1051
            $state = question_state::graded_state_for_fraction($fraction);
2,668✔
1052
            $feedbackclass = $state->get_feedback_class();
2,668✔
1053
        }
1054
        if ($feedbackclass != '') {
2,760✔
1055
            $showfeedback = $options->feedback;
2,760✔
1056
            $field = 'part' . $feedbackclass . 'fb';
2,760✔
1057
            $format = 'part' . $feedbackclass . 'fbformat';
2,760✔
1058
            if ($part->$field) {
2,760✔
1059
                // Clone the part's evaluator and substitute local / grading vars first.
1060
                $part->$field = $part->evaluator->substitute_variables_in_text($part->$field);
2,737✔
1061
                $feedback = $question->format_text(
2,737✔
1062
                    $part->$field,
2,737✔
1063
                    $part->$format,
2,737✔
1064
                    $qa,
2,737✔
1065
                    'qtype_formulas',
2,737✔
1066
                    $field,
2,737✔
1067
                    $part->id,
2,737✔
1068
                    false,
2,737✔
1069
                );
2,737✔
1070
            }
1071
        }
1072
        if ($showfeedback && $feedback) {
2,760✔
1073
                $feedback = html_writer::tag('div', $feedback, ['class' => 'feedback formulaslocalfeedback']);
506✔
1074
                return html_writer::nonempty_tag(
506✔
1075
                    'div',
506✔
1076
                    $feedback,
506✔
1077
                    ['class' => 'formulaspartfeedback formulaspartfeedback-' . $part->partindex],
506✔
1078
                );
506✔
1079
        }
1080
        return '';
2,714✔
1081
    }
1082
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc