From cd68db376adc86e4a46de06813a9ceb4a4a905a7 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 20 Mar 2018 11:02:41 +0100 Subject: [PATCH] MOBILE-2389 qtype: Implement multianswer and gapselect types --- .../qtype/gapselect/component/gapselect.html | 5 + .../qtype/gapselect/component/gapselect.ts | 38 +++++ src/addon/qtype/gapselect/gapselect.module.ts | 46 ++++++ .../qtype/gapselect/providers/handler.ts | 118 +++++++++++++++ .../multianswer/component/multianswer.html | 5 + .../multianswer/component/multianswer.ts | 38 +++++ .../qtype/multianswer/multianswer.module.ts | 46 ++++++ .../qtype/multianswer/providers/handler.ts | 142 ++++++++++++++++++ src/addon/qtype/qtype.module.ts | 4 + .../classes/base-question-component.ts | 39 +++++ src/core/question/providers/helper.ts | 108 ++++++++++++- 11 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 src/addon/qtype/gapselect/component/gapselect.html create mode 100644 src/addon/qtype/gapselect/component/gapselect.ts create mode 100644 src/addon/qtype/gapselect/gapselect.module.ts create mode 100644 src/addon/qtype/gapselect/providers/handler.ts create mode 100644 src/addon/qtype/multianswer/component/multianswer.html create mode 100644 src/addon/qtype/multianswer/component/multianswer.ts create mode 100644 src/addon/qtype/multianswer/multianswer.module.ts create mode 100644 src/addon/qtype/multianswer/providers/handler.ts diff --git a/src/addon/qtype/gapselect/component/gapselect.html b/src/addon/qtype/gapselect/component/gapselect.html new file mode 100644 index 000000000..eaeb68661 --- /dev/null +++ b/src/addon/qtype/gapselect/component/gapselect.html @@ -0,0 +1,5 @@ +
+ +

+
+
diff --git a/src/addon/qtype/gapselect/component/gapselect.ts b/src/addon/qtype/gapselect/component/gapselect.ts new file mode 100644 index 000000000..70a4622cf --- /dev/null +++ b/src/addon/qtype/gapselect/component/gapselect.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, Injector } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; + +/** + * Component to render a gap select question. + */ +@Component({ + selector: 'addon-qtype-gapselect', + templateUrl: 'gapselect.html' +}) +export class AddonQtypeGapSelectComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeGapSelectComponent', injector); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initOriginalTextComponent('.qtext'); + } +} diff --git a/src/addon/qtype/gapselect/gapselect.module.ts b/src/addon/qtype/gapselect/gapselect.module.ts new file mode 100644 index 000000000..f95b7b790 --- /dev/null +++ b/src/addon/qtype/gapselect/gapselect.module.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreQuestionDelegate } from '@core/question/providers/delegate'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonQtypeGapSelectHandler } from './providers/handler'; +import { AddonQtypeGapSelectComponent } from './component/gapselect'; + +@NgModule({ + declarations: [ + AddonQtypeGapSelectComponent + ], + imports: [ + IonicModule, + TranslateModule.forChild(), + CoreDirectivesModule + ], + providers: [ + AddonQtypeGapSelectHandler + ], + exports: [ + AddonQtypeGapSelectComponent + ], + entryComponents: [ + AddonQtypeGapSelectComponent + ] +}) +export class AddonQtypeGapSelectModule { + constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeGapSelectHandler) { + questionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/qtype/gapselect/providers/handler.ts b/src/addon/qtype/gapselect/providers/handler.ts new file mode 100644 index 000000000..e18b357cd --- /dev/null +++ b/src/addon/qtype/gapselect/providers/handler.ts @@ -0,0 +1,118 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreQuestionProvider } from '@core/question/providers/question'; +import { CoreQuestionHandler } from '@core/question/providers/delegate'; +import { AddonQtypeGapSelectComponent } from '../component/gapselect'; + +/** + * Handler to support gapselect question type. + */ +@Injectable() +export class AddonQtypeGapSelectHandler implements CoreQuestionHandler { + name = 'AddonQtypeGapSelect'; + type = 'qtype_gapselect'; + + constructor(private questionProvider: CoreQuestionProvider) { } + + /** + * Return the name of the behaviour to use for the question. + * If the question should use the default behaviour you shouldn't implement this function. + * + * @param {any} question The question. + * @param {string} behaviour The default behaviour. + * @return {string} The behaviour to use. + */ + getBehaviour(question: any, behaviour: string): string { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} question The question to render. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, question: any): any | Promise { + return AddonQtypeGapSelectComponent; + } + + /** + * Check if a response is complete. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse(question: any, answers: any): number { + // We should always get a value for each select so we can assume we receive all the possible answers. + for (const name in answers) { + const value = answers[name]; + if (!value || value === '0') { + return 0; + } + } + + return 1; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse(question: any, answers: any): number { + // We should always get a value for each select so we can assume we receive all the possible answers. + for (const name in answers) { + const value = answers[name]; + if (value) { + return 1; + } + } + + return 0; + } + + /** + * Check if two responses are the same. + * + * @param {any} question Question. + * @param {any} prevAnswers Object with the previous question answers. + * @param {any} newAnswers Object with the new question answers. + * @return {boolean} Whether they're the same. + */ + isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); + } +} diff --git a/src/addon/qtype/multianswer/component/multianswer.html b/src/addon/qtype/multianswer/component/multianswer.html new file mode 100644 index 000000000..ae07a5f43 --- /dev/null +++ b/src/addon/qtype/multianswer/component/multianswer.html @@ -0,0 +1,5 @@ +
+ +

+
+
diff --git a/src/addon/qtype/multianswer/component/multianswer.ts b/src/addon/qtype/multianswer/component/multianswer.ts new file mode 100644 index 000000000..79cdba492 --- /dev/null +++ b/src/addon/qtype/multianswer/component/multianswer.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, Injector } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; + +/** + * Component to render a multianswer question. + */ +@Component({ + selector: 'addon-qtype-multianswer', + templateUrl: 'multianswer.html' +}) +export class AddonQtypeMultiAnswerComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(logger: CoreLoggerProvider, injector: Injector) { + super(logger, 'AddonQtypeMultiAnswerComponent', injector); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initOriginalTextComponent('.formulation'); + } +} diff --git a/src/addon/qtype/multianswer/multianswer.module.ts b/src/addon/qtype/multianswer/multianswer.module.ts new file mode 100644 index 000000000..b1c37c051 --- /dev/null +++ b/src/addon/qtype/multianswer/multianswer.module.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreQuestionDelegate } from '@core/question/providers/delegate'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonQtypeMultiAnswerHandler } from './providers/handler'; +import { AddonQtypeMultiAnswerComponent } from './component/multianswer'; + +@NgModule({ + declarations: [ + AddonQtypeMultiAnswerComponent + ], + imports: [ + IonicModule, + TranslateModule.forChild(), + CoreDirectivesModule + ], + providers: [ + AddonQtypeMultiAnswerHandler + ], + exports: [ + AddonQtypeMultiAnswerComponent + ], + entryComponents: [ + AddonQtypeMultiAnswerComponent + ] +}) +export class AddonQtypeMultiAnswerModule { + constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeMultiAnswerHandler) { + questionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/qtype/multianswer/providers/handler.ts b/src/addon/qtype/multianswer/providers/handler.ts new file mode 100644 index 000000000..b02a4eb70 --- /dev/null +++ b/src/addon/qtype/multianswer/providers/handler.ts @@ -0,0 +1,142 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreQuestionProvider } from '@core/question/providers/question'; +import { CoreQuestionHandler } from '@core/question/providers/delegate'; +import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; +import { AddonQtypeMultiAnswerComponent } from '../component/multianswer'; + +/** + * Handler to support multianswer question type. + */ +@Injectable() +export class AddonQtypeMultiAnswerHandler implements CoreQuestionHandler { + name = 'AddonQtypeMultiAnswer'; + type = 'qtype_multianswer'; + + constructor(private questionProvider: CoreQuestionProvider, private questionHelper: CoreQuestionHelperProvider) { } + + /** + * Return the name of the behaviour to use for the question. + * If the question should use the default behaviour you shouldn't implement this function. + * + * @param {any} question The question. + * @param {string} behaviour The default behaviour. + * @return {string} The behaviour to use. + */ + getBehaviour(question: any, behaviour: string): string { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} question The question to render. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, question: any): any | Promise { + return AddonQtypeMultiAnswerComponent; + } + + /** + * Check if a response is complete. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse(question: any, answers: any): number { + // Get all the inputs in the question to check if they've all been answered. + const names = this.questionProvider.getBasicAnswers(this.questionHelper.getAllInputNamesFromHtml(question.html)); + for (const name in names) { + const value = answers[name]; + if (!value && value !== false && value !== 0) { + return 0; + } + } + + return 1; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse(question: any, answers: any): number { + // We should always get a value for each select so we can assume we receive all the possible answers. + for (const name in answers) { + const value = answers[name]; + if (value || value === false) { + return 1; + } + } + + return 0; + } + + /** + * Check if two responses are the same. + * + * @param {any} question Question. + * @param {any} prevAnswers Object with the previous question answers. + * @param {any} newAnswers Object with the new question answers. + * @return {boolean} Whether they're the same. + */ + isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); + } + + /** + * Validate if an offline sequencecheck is valid compared with the online one. + * This function only needs to be implemented if a specific compare is required. + * + * @param {any} question The question. + * @param {string} offlineSequenceCheck Sequence check stored in offline. + * @return {boolean} Whether sequencecheck is valid. + */ + validateSequenceCheck(question: any, offlineSequenceCheck: string): boolean { + if (question.sequencecheck == offlineSequenceCheck) { + return true; + } + + // For some reason, viewing a multianswer for the first time without answering it creates a new step "todo". + // We'll treat this case as valid. + if (question.sequencecheck == 2 && question.state == 'todo' && offlineSequenceCheck == '1') { + return true; + } + + return false; + } +} diff --git a/src/addon/qtype/qtype.module.ts b/src/addon/qtype/qtype.module.ts index 96df1cebb..a8563022c 100644 --- a/src/addon/qtype/qtype.module.ts +++ b/src/addon/qtype/qtype.module.ts @@ -18,7 +18,9 @@ import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmul import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module'; import { AddonQtypeDescriptionModule } from './description/description.module'; import { AddonQtypeEssayModule } from './essay/essay.module'; +import { AddonQtypeGapSelectModule } from './gapselect/gapselect.module'; import { AddonQtypeMatchModule } from './match/match.module'; +import { AddonQtypeMultiAnswerModule } from './multianswer/multianswer.module'; import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module'; import { AddonQtypeNumericalModule } from './numerical/numerical.module'; import { AddonQtypeRandomSaMatchModule } from './randomsamatch/randomsamatch.module'; @@ -33,7 +35,9 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; AddonQtypeCalculatedSimpleModule, AddonQtypeDescriptionModule, AddonQtypeEssayModule, + AddonQtypeGapSelectModule, AddonQtypeMatchModule, + AddonQtypeMultiAnswerModule, AddonQtypeMultichoiceModule, AddonQtypeNumericalModule, AddonQtypeRandomSaMatchModule, diff --git a/src/core/question/classes/base-question-component.ts b/src/core/question/classes/base-question-component.ts index 6daac231d..5befc6391 100644 --- a/src/core/question/classes/base-question-component.ts +++ b/src/core/question/classes/base-question-component.ts @@ -230,6 +230,45 @@ export class CoreQuestionBaseComponent { } } + /** + * Initialize a question component that uses the original question text with some basic treatment. + * + * @param {string} contentSelector The selector to find the question content (text). + * @return {void|HTMLElement} Element containing the question HTML, void if the data is not valid. + */ + initOriginalTextComponent(contentSelector: string): void | HTMLElement { + if (!this.question) { + this.logger.warn('Aborting because of no question received.'); + + return this.questionHelper.showComponentError(this.onAbort); + } + + const div = document.createElement('div'); + div.innerHTML = this.question.html; + + // Get question content. + const content = div.querySelector(contentSelector); + if (!content) { + this.logger.warn('Aborting because of an error parsing question.', this.question.name); + + return this.questionHelper.showComponentError(this.onAbort); + } + + // Remove sequencecheck and validation error. + this.domUtils.removeElement(content, 'input[name*=sequencecheck]'); + this.domUtils.removeElement(content, '.validationerror'); + + // Replace Moodle's correct/incorrect and feedback classes with our own. + this.questionHelper.replaceCorrectnessClasses(div); + this.questionHelper.replaceFeedbackClasses(div); + + // Treat the correct/incorrect icons. + this.questionHelper.treatCorrectnessIcons(div, this.component, this.componentId); + + // Set the question text. + this.question.text = content.innerHTML; + } + /** * Initialize a question component that has an input of type "text". * diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index 61b67c5a8..f6d317dd2 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable, EventEmitter } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; @@ -27,7 +28,8 @@ export class CoreQuestionHelperProvider { protected div = document.createElement('div'); // A div element to search in HTML code. constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, - private questionProvider: CoreQuestionProvider, private sitesProvider: CoreSitesProvider) { } + private questionProvider: CoreQuestionProvider, private sitesProvider: CoreSitesProvider, + private translate: TranslateService) { } /** * Add a behaviour button to the question's "behaviourButtons" property. @@ -267,6 +269,35 @@ export class CoreQuestionHelperProvider { } } + /** + * Get the names of all the inputs inside an HTML code. + * This function will return an object where the keys are the input names. The values will always be true. + * This is in order to make this function compatible with other functions like CoreQuestionProvider.getBasicAnswers. + * + * @param {string} html HTML code. + * @return {any} Object where the keys are the names. + */ + getAllInputNamesFromHtml(html: string): any { + const form = document.createElement('form'), + answers = {}; + + form.innerHTML = html; + + // Search all input elements. + Array.from(form.elements).forEach((element: HTMLInputElement) => { + const name = element.name || ''; + + // Ignore flag and submit inputs. + if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON') { + return; + } + + answers[this.questionProvider.removeQuestionPrefix(name)] = true; + }); + + return answers; + } + /** * Given an HTML code with list of attachments, returns the list of attached files (filename and fileurl). * Please take into account that this function will treat all the anchors in the HTML, you should provide @@ -397,6 +428,30 @@ export class CoreQuestionHelperProvider { question.html = form.innerHTML; } + /** + * Replace Moodle's correct/incorrect classes with the Mobile ones. + * + * @param {HTMLElement} element DOM element. + */ + replaceCorrectnessClasses(element: HTMLElement): void { + this.domUtils.replaceClassesInElement(element, { + correct: 'core-question-answer-correct', + incorrect: 'core-question-answer-incorrect' + }); + } + + /** + * Replace Moodle's feedback classes with the Mobile ones. + * + * @param {HTMLElement} element DOM element. + */ + replaceFeedbackClasses(element: HTMLElement): void { + this.domUtils.replaceClassesInElement(element, { + outcome: 'core-question-feedback-container core-question-feedback-padding', + specificfeedback: 'core-question-feedback-container core-question-feedback-inline' + }); + } + /** * Search a behaviour button in a certain question property containing HTML. * @@ -443,4 +498,55 @@ export class CoreQuestionHelperProvider { onAbort && onAbort.emit(); } + + /** + * Treat correctness icons, replacing them with local icons and setting click events to show the feedback if needed. + * + * @param {HTMLElement} element DOM element. + */ + treatCorrectnessIcons(element: HTMLElement, component?: string, componentId?: number): void { + + const icons = Array.from(element.querySelectorAll('img.icon, img.questioncorrectnessicon')); + icons.forEach((icon) => { + // Replace the icon with the font version. + if (icon.src) { + const newIcon: any = document.createElement('i'); + + if (icon.src.indexOf('incorrect') > -1) { + newIcon.className = 'icon fa fa-remove text-danger fa-fw questioncorrectnessicon'; + } else if (icon.src.indexOf('correct') > -1) { + newIcon.className = 'icon fa fa-check text-success fa-fw questioncorrectnessicon'; + } else { + return; + } + + newIcon.title = icon.title; + newIcon.ariaLabel = icon.title; + icon.parentNode.replaceChild(newIcon, icon); + } + }); + + const spans = Array.from(element.querySelectorAll('.feedbackspan.accesshide')); + spans.forEach((span) => { + // Search if there's a hidden feedback for this element. + const icon = span.previousSibling; + if (!icon) { + return; + } + + if (!icon.classList.contains('icon') && !icon.classList.contains('questioncorrectnessicon')) { + return; + } + + icon.classList.add('questioncorrectnessicon'); + + if (span.innerHTML) { + // There's a hidden feedback, show it when the icon is clicked. + icon.addEventListener('click', (event) => { + const title = this.translate.instant('core.question.feedback'); + this.textUtils.expandText(title, span.innerHTML, component, componentId); + }); + } + }); + } }