Skip to content

Commit 39fc8cc

Browse files
committed
refactor: make static question_ui_renderer::render()
1 parent 4c9bcfa commit 39fc8cc

File tree

4 files changed

+97
-159
lines changed

4 files changed

+97
-159
lines changed

classes/attempt_ui/question_ui_renderer.php

+67-69
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,14 @@
4040
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
4141
*/
4242
class question_ui_renderer {
43-
/** @var DOMDocument $xml */
44-
private DOMDocument $xml;
45-
46-
/** @var DOMXPath $xpath */
47-
private DOMXPath $xpath;
48-
49-
/** @var array $placeholders */
50-
private array $placeholders;
51-
52-
/** @var question_display_options $options */
53-
private question_display_options $options;
54-
55-
/** @var question_attempt $attempt */
56-
private question_attempt $attempt;
57-
5843
/** @var string[]|null $roles names of roles that the current user has (use {@see get_user_roles()} to get the roles) */
5944
private ?array $roles = null;
6045

61-
/** @var render_result|null $result */
62-
private ?render_result $result = null;
46+
/** @var string $html resulting rendered html */
47+
public string $html;
48+
49+
/** @var invalid_option_warning[] $warnings warnings emitted during rendering */
50+
public array $warnings;
6351

6452
/**
6553
* @var string[] $unmappableduplicatefieldnames contains duplicate input field names that cannot be mapped with certainty to
@@ -74,82 +62,88 @@ class question_ui_renderer {
7462
public array $mappableduplicatefieldnames = [];
7563

7664
/**
77-
* Parses the given XML and initializes a new {@see question_ui_renderer} instance.
65+
* Private constructor. Use {@see question_ui_renderer::render()}.
7866
*
79-
* @param string $xml XML as returned by the QPy Server
80-
* @param array $placeholders string to string mapping of placeholder names to the values
67+
* @param DOMDocument $xml XML document to operate on
68+
* @param DOMXPath $xpath
8169
* @param question_display_options $options
82-
* @param question_attempt $attempt
8370
*/
84-
public function __construct(string $xml, array $placeholders, question_display_options $options,
85-
question_attempt $attempt) {
86-
$this->placeholders = $placeholders;
87-
$this->options = $options;
88-
$this->attempt = $attempt;
89-
90-
$xml = $this->replace_qpy_urls($xml);
91-
92-
$this->xml = new DOMDocument();
93-
$this->xml->preserveWhiteSpace = false;
94-
$this->xml->loadXML($xml);
95-
$this->xml->normalizeDocument();
96-
97-
$this->xpath = new DOMXPath($this->xml);
98-
$this->xpath->registerNamespace('xhtml', constants::NAMESPACE_XHTML);
99-
$this->xpath->registerNamespace('qpy', constants::NAMESPACE_QPY);
100-
101-
$this->populate_duplicate_field_names();
71+
private function __construct(
72+
/** @var DOMDocument $xml */
73+
private readonly DOMDocument $xml,
74+
/** @var DOMXPath $xpath */
75+
private readonly DOMXPath $xpath,
76+
/** @var question_display_options $options */
77+
private readonly question_display_options $options
78+
) {
10279
}
10380

10481
/**
105-
* Renders the given XML to HTML.
82+
* Renders the given QuestionPy XHTML to HTML.
10683
*
107-
* @return render_result
84+
* @param string $xml XML as returned by the QPy Server
85+
* @param array $placeholders string to string mapping of placeholder names to the values
86+
* @param question_display_options $options
87+
* @param question_attempt $attempt
88+
* @return question_ui_renderer object containing {@see question_ui_renderer::$html rendered html} and
89+
* {@see question_ui_renderer::$warnings emitted warnings}.
10890
* @throws coding_exception
10991
*/
110-
public function render(): render_result {
111-
if ($this->result) {
112-
return $this->result;
113-
}
92+
public static function render(string $xml, array $placeholders, question_display_options $options,
93+
question_attempt $attempt): static {
94+
$xml = static::replace_qpy_urls($xml, $attempt);
95+
96+
$doc = new DOMDocument();
97+
$doc->preserveWhiteSpace = false;
98+
$doc->loadXML($xml);
99+
$doc->normalizeDocument();
100+
101+
$xpath = new DOMXPath($doc);
102+
$xpath->registerNamespace('xhtml', constants::NAMESPACE_XHTML);
103+
$xpath->registerNamespace('qpy', constants::NAMESPACE_QPY);
104+
105+
$renderer = new static($doc, $xpath, $options);
106+
$renderer->populate_duplicate_field_names();
114107

115108
$nextseed = mt_rand();
116-
$id = $this->attempt->get_database_id();
109+
$id = $attempt->get_database_id();
117110
if ($id === null) {
118111
throw new coding_exception('question_attempt does not have an id');
119112
}
120113

121114
mt_srand($id);
122115
try {
123116
// Handle our custom elements and attributes.
124-
$this->hide_unwanted_feedback();
125-
$this->hide_if_role();
126-
$this->shuffle_contents();
127-
$this->format_floats();
117+
$renderer->hide_unwanted_feedback();
118+
$renderer->hide_if_role();
119+
$renderer->shuffle_contents();
120+
$renderer->format_floats();
128121

129-
$availableoptions = $this->extract_available_options();
122+
$availableoptions = $renderer->extract_available_options();
130123

131124
// Remove all unhandled custom elements, attributes, comments, and non-default xmlns declarations.
132-
$this->clean_up();
125+
$renderer->clean_up();
133126

134127
// Modify standard HTML.
135-
$this->set_input_values_and_readonly();
136-
$this->soften_validation();
137-
$this->defuse_buttons();
128+
$renderer->set_input_values_and_readonly($attempt);
129+
$renderer->soften_validation();
130+
$renderer->defuse_buttons();
138131

139-
$this->add_styles();
132+
$renderer->add_styles();
140133

141134
// We don't want to support QPy elements (and attributes, etc.) in placeholder expansions, so we resolve
142135
// them after replacing QPy elements.
143-
$this->resolve_placeholders();
136+
$renderer->resolve_placeholders($placeholders);
144137
} finally {
145138
// I'm not sure whether it is strictly necessary to reset the PRNG seed here, but it feels safer.
146139
// Resetting it to its original state would be ideal, but that doesn't seem to be possible.
147140
mt_srand($nextseed);
148141
}
149142

150-
$warnings = $this->check_for_and_preserve_unknown_options($availableoptions);
151-
$this->result = new render_result($this->xml->saveHTML(), $warnings);
152-
return $this->result;
143+
$warnings = $renderer->check_for_and_preserve_unknown_options($availableoptions, $attempt);
144+
$renderer->html = $renderer->xml->saveHTML();
145+
$renderer->warnings = $warnings;
146+
return $renderer;
153147
}
154148

155149
/**
@@ -270,11 +264,12 @@ private function replace_shuffled_indices(DOMElement $container, DOMElement $ele
270264
* - If {@see question_display_options::$readonly} is set, the input is disabled.
271265
* - If a value was saved for the input in a previous step, the latest value is added to the HTML.
272266
*
267+
* @param question_attempt $attempt
273268
* @return void
274269
* @throws coding_exception
275270
*/
276-
private function set_input_values_and_readonly(): void {
277-
$lastresponse = utils::get_qpy_response($this->attempt);
271+
private function set_input_values_and_readonly(question_attempt $attempt): void {
272+
$lastresponse = utils::get_qpy_response($attempt);
278273

279274
/** @var DOMElement $element */
280275
foreach ($this->xpath->query('//xhtml:button | //xhtml:input | //xhtml:select | //xhtml:textarea') as $element) {
@@ -395,22 +390,23 @@ private function clean_up(): void {
395390
* Since QPy transformations should not be applied to the content of the placeholders, this method should be called
396391
* near the end (after {@see clean_up()}).
397392
*
393+
* @param array $placeholders
398394
* @return void
399395
*/
400-
private function resolve_placeholders(): void {
396+
private function resolve_placeholders(array $placeholders): void {
401397
/** @var DOMProcessingInstruction $pi */
402398
foreach (iterator_to_array($this->xpath->query("//processing-instruction('p')")) as $pi) {
403399
$parts = preg_split('/\s+/', trim($pi->data));
404400
$key = $parts[0];
405401
$cleanoption = $parts[1] ?? 'clean';
406402

407-
if (!isset($this->placeholders[$key])) {
403+
if (!isset($placeholders[$key])) {
408404
// No value for this placeholder, so we just remove the PI.
409405
$pi->parentNode->removeChild($pi);
410406
continue;
411407
}
412408

413-
$rawvalue = $this->placeholders[$key];
409+
$rawvalue = $placeholders[$key];
414410
if (strtolower($cleanoption) === 'clean') {
415411
// Allow HTML, but clean using Moodle's clean_text to prevent XSS.
416412
$element = dom_utils::html_to_fragment($this->xml, clean_text($rawvalue));
@@ -623,10 +619,11 @@ private function format_floats(): void {
623619
* Replaces QPy-URIs such as `qpy:acme/great_package/static/css/styles.css` with functioning pluginfile URLs.
624620
*
625621
* @param string $input
622+
* @param question_attempt $attempt
626623
* @return string
627624
*/
628-
private function replace_qpy_urls(string $input): string {
629-
$question = $this->attempt->get_question();
625+
private static function replace_qpy_urls(string $input, question_attempt $attempt): string {
626+
$question = $attempt->get_question();
630627
assert($question instanceof qtype_questionpy_question);
631628

632629
return preg_replace_callback(
@@ -775,13 +772,14 @@ private function extract_available_options(): array {
775772
* duplicates.
776773
*
777774
* @param available_opts_info[] $availableoptsinfobyname
775+
* @param question_attempt $attempt
778776
* @return invalid_option_warning[]
779777
* @throws coding_exception
780778
* @see extract_available_options
781779
* @throws \core\exception\coding_exception
782780
*/
783-
private function check_for_and_preserve_unknown_options(array $availableoptsinfobyname): array {
784-
$response = utils::get_qpy_response($this->attempt);
781+
private function check_for_and_preserve_unknown_options(array $availableoptsinfobyname, question_attempt $attempt): array {
782+
$response = utils::get_qpy_response($attempt);
785783

786784
$warnings = [];
787785
foreach ($availableoptsinfobyname as $name => $info) {

classes/attempt_ui/render_result.php

-41
This file was deleted.

renderer.php

+12-14
Original file line numberDiff line numberDiff line change
@@ -195,16 +195,15 @@ protected function get_iframe_document(context $context, qtype_questionpy_questi
195195
*/
196196
protected function formulation_controls_feedback_in_iframe(question_attempt $qa, attempt_ui $ui,
197197
question_display_options $options, string $autosavehintinputid): string {
198-
$qformulation = new question_ui_renderer($ui->formulation, $ui->placeholders, $options, $qa);
199-
$renderresult = $qformulation->render();
198+
$renderer = question_ui_renderer::render($ui->formulation, $ui->placeholders, $options, $qa);
200199

201200
$warningshtml = '';
202-
if ($renderresult->warnings) {
201+
if ($renderer->warnings) {
203202
global $USER;
204203
$isstudent = $qa->get_step(0)->get_user_id() === $USER->id;
205204
$warningshtml .= $this->output->render_from_template('qtype_questionpy/render_warnings', [
206-
'warnings' => $renderresult->warnings,
207-
'should_use_list' => count($renderresult->warnings) > 1,
205+
'warnings' => $renderer->warnings,
206+
'should_use_list' => count($renderer->warnings) > 1,
208207
'should_show_hint_contact_teachers' => $isstudent,
209208
'should_show_hint_editable' => $isstudent && !$options->readonly,
210209
]);
@@ -216,7 +215,7 @@ protected function formulation_controls_feedback_in_iframe(question_attempt $qa,
216215
['class' => 'outcome clearfix']
217216
);
218217

219-
$roles = $qformulation->get_user_roles();
218+
$roles = $renderer->get_user_roles();
220219
$this->page->requires->js_call_amd(
221220
'qtype_questionpy/view_question',
222221
'init',
@@ -225,7 +224,7 @@ protected function formulation_controls_feedback_in_iframe(question_attempt $qa,
225224
$this->add_package_js_calls($ui->javascriptcalls, $roles, $options);
226225

227226
return $this->render_from_template('qtype_questionpy/iframe_question_content', [
228-
'question_html' => $renderresult->html,
227+
'question_html' => $renderer->html,
229228
'feedback_html' => $feedback,
230229
'warnings_html' => $warningshtml,
231230
]);
@@ -238,7 +237,6 @@ protected function formulation_controls_feedback_in_iframe(question_attempt $qa,
238237
* @param string[] $roles names of qpy user roles
239238
* @param question_display_options $options
240239
* @return void
241-
* @throws coding_exception
242240
*/
243241
protected function add_package_js_calls(array $jscalls, array $roles, question_display_options $options): void {
244242
$calls = [];
@@ -394,10 +392,10 @@ protected function feedback_in_iframe(question_attempt $qa, question_display_opt
394392
$hint = null;
395393

396394
if ($options->feedback && !is_null($question->ui->specificfeedback)) {
397-
$renderer = new question_ui_renderer($question->ui->specificfeedback, $question->ui->placeholders, $options, $qa);
395+
$renderer = question_ui_renderer::render($question->ui->specificfeedback, $question->ui->placeholders, $options, $qa);
398396
$output .= html_writer::nonempty_tag(
399397
'div',
400-
$renderer->render(),
398+
$renderer->html,
401399
['class' => 'specificfeedback', 'id' => 'qpy-specific-feedback']
402400
);
403401
$hint = $qa->get_applicable_hint();
@@ -412,19 +410,19 @@ protected function feedback_in_iframe(question_attempt $qa, question_display_opt
412410
}
413411

414412
if ($options->generalfeedback && !is_null($question->ui->generalfeedback)) {
415-
$renderer = new question_ui_renderer($question->ui->generalfeedback, $question->ui->placeholders, $options, $qa);
413+
$renderer = question_ui_renderer::render($question->ui->generalfeedback, $question->ui->placeholders, $options, $qa);
416414
$output .= html_writer::nonempty_tag(
417415
'div',
418-
$renderer->render(),
416+
$renderer->html,
419417
['class' => 'generalfeedback', 'id' => 'qpy-general-feedback']
420418
);
421419
}
422420

423421
if ($options->rightanswer && !is_null($question->ui->rightanswer)) {
424-
$renderer = new question_ui_renderer($question->ui->rightanswer, $question->ui->placeholders, $options, $qa);
422+
$renderer = question_ui_renderer::render($question->ui->rightanswer, $question->ui->placeholders, $options, $qa);
425423
$output .= html_writer::nonempty_tag(
426424
'div',
427-
$renderer->render(),
425+
$renderer->html,
428426
['class' => 'rightanswer', 'id' => 'qpy-right-answer']
429427
);
430428
}

0 commit comments

Comments
 (0)