diff --git a/src/addons/qtype/calculated/calculated.module.ts b/src/addons/qtype/calculated/calculated.module.ts index 73b7a5a3f76..75c77b89b92 100644 --- a/src/addons/qtype/calculated/calculated.module.ts +++ b/src/addons/qtype/calculated/calculated.module.ts @@ -14,18 +14,10 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; -import { CoreSharedModule } from '@/core/shared.module'; import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; -import { AddonQtypeCalculatedComponent } from './component/calculated'; import { AddonQtypeCalculatedHandler } from './services/handlers/calculated'; @NgModule({ - declarations: [ - AddonQtypeCalculatedComponent, - ], - imports: [ - CoreSharedModule, - ], providers: [ { provide: APP_INITIALIZER, @@ -35,8 +27,5 @@ import { AddonQtypeCalculatedHandler } from './services/handlers/calculated'; }, }, ], - exports: [ - AddonQtypeCalculatedComponent, - ], }) export class AddonQtypeCalculatedModule {} diff --git a/src/addons/qtype/calculated/services/handlers/calculated.ts b/src/addons/qtype/calculated/services/handlers/calculated.ts index 416a94dc901..aee236e113a 100644 --- a/src/addons/qtype/calculated/services/handlers/calculated.ts +++ b/src/addons/qtype/calculated/services/handlers/calculated.ts @@ -12,233 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable, Type } from '@angular/core'; +import { Injectable } from '@angular/core'; +import { AddonQtypeNumericalHandlerService } from '@addons/qtype/numerical/services/handlers/numerical'; -import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; -import { CoreQuestionHandler } from '@features/question/services/question-delegate'; -import { convertTextToHTMLElement } from '@/core/utils/create-html-element'; -import { CoreObject } from '@singletons/object'; -import { makeSingleton, Translate } from '@singletons'; +import { makeSingleton } from '@singletons'; /** * Handler to support calculated question type. + * This question type depends on numeric question type. */ @Injectable({ providedIn: 'root' }) -export class AddonQtypeCalculatedHandlerService implements CoreQuestionHandler { - - static readonly UNITINPUT = '0'; - static readonly UNITRADIO = '1'; - static readonly UNITSELECT = '2'; - static readonly UNITNONE = '3'; - - static readonly UNITGRADED = '1'; - static readonly UNITOPTIONAL = '0'; +export class AddonQtypeCalculatedHandlerService extends AddonQtypeNumericalHandlerService { name = 'AddonQtypeCalculated'; type = 'qtype_calculated'; - /** - * @inheritdoc - */ - async getComponent(): Promise> { - const { AddonQtypeCalculatedComponent } = await import('../../component/calculated'); - - return AddonQtypeCalculatedComponent; - } - - /** - * Check if the units are in a separate field for the question. - * - * @param question Question. - * @returns Whether units are in a separate field. - */ - hasSeparateUnitField(question: CoreQuestionQuestionParsed): boolean { - if (!question.parsedSettings) { - const element = convertTextToHTMLElement(question.html); - - return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]')); - } - - return question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITRADIO || - question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITSELECT; - } - - /** - * @inheritdoc - */ - isCompleteResponse( - question: CoreQuestionQuestionParsed, - answers: CoreQuestionsAnswers, - ): number { - if (!this.isGradableResponse(question, answers)) { - return 0; - } - - const { answer, unit } = this.parseAnswer(question, answers.answer); - if (answer === null) { - return 0; - } - - if (!question.parsedSettings) { - if (this.hasSeparateUnitField(question)) { - return this.isValidValue( answers.unit) ? 1 : 0; - } - - // We cannot know if the answer should contain units or not. - return -1; - } - - if (question.parsedSettings.unitdisplay !== AddonQtypeCalculatedHandlerService.UNITINPUT && unit) { - // There should be no units or be outside of the input, not valid. - return 0; - } - - if (this.hasSeparateUnitField(question) && !this.isValidValue( answers.unit)) { - // Unit not supplied as a separate field and it's required. - return 0; - } - - if (question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITINPUT && - question.parsedSettings.unitgradingtype === AddonQtypeCalculatedHandlerService.UNITGRADED && - !this.isValidValue(unit)) { - // Unit not supplied inside the input and it's required. - return 0; - } - - return 1; - } - - /** - * @inheritdoc - */ - async isEnabled(): Promise { - return true; - } - - /** - * @inheritdoc - */ - isGradableResponse( - question: CoreQuestionQuestionParsed, - answers: CoreQuestionsAnswers, - ): number { - return this.isValidValue( answers.answer) ? 1 : 0; - } - - /** - * @inheritdoc - */ - isSameResponse( - question: CoreQuestionQuestionParsed, - prevAnswers: CoreQuestionsAnswers, - newAnswers: CoreQuestionsAnswers, - ): boolean { - return CoreObject.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') && - CoreObject.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit'); - } - - /** - * Check if a value is valid (not empty). - * - * @param value Value to check. - * @returns Whether the value is valid. - */ - isValidValue(value: string | number | null): boolean { - return !!value || value === '0' || value === 0; - } - - /** - * Parse an answer string. - * - * @param question Question. - * @param answer Answer. - * @returns Answer and unit. - */ - parseAnswer(question: CoreQuestionQuestionParsed, answer: string): { answer: number | null; unit: string | null } { - if (!answer) { - return { answer: null, unit: null }; - } - - let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; - - // Strip spaces (which may be thousands separators) and change other forms of writing e to e. - answer = answer.replace(/ /g, ''); - answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1'); - - // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it. - // Else assume it is a decimal separator, and change it to '.'. - if (answer.indexOf('.') !== -1 || answer.split(',').length - 1 > 1) { - answer = answer.replace(',', ''); - } else { - answer = answer.replace(',', '.'); - } - - let unitsLeft = false; - let match: RegExpMatchArray | null = null; - - if (!question.parsedSettings || question.parsedSettings.unitsleft === null) { - // We don't know if units should be before or after so we check both. - match = answer.match(new RegExp('^' + regexString)); - if (!match) { - unitsLeft = true; - match = answer.match(new RegExp(regexString + '$')); - } - } else { - unitsLeft = question.parsedSettings.unitsleft === '1'; - regexString = unitsLeft ? regexString + '$' : '^' + regexString; - - match = answer.match(new RegExp(regexString)); - } - - if (!match) { - return { answer: null, unit: null }; - } - - const numberString = match[0]; - const unit = unitsLeft ? answer.substring(0, answer.length - match[0].length) : answer.substring(match[0].length); - - // No need to calculate the multiplier. - return { answer: Number(numberString), unit }; - } - - /** - * @inheritdoc - */ - getValidationError( - question: CoreQuestionQuestionParsed, - answers: CoreQuestionsAnswers, - ): string | undefined { - if (!this.isGradableResponse(question, answers)) { - return Translate.instant('addon.qtype_numerical.pleaseenterananswer'); - } - - const { answer, unit } = this.parseAnswer(question, answers.answer); - if (answer === null) { - return Translate.instant('addon.qtype_numerica.invalidnumber'); - } - - if (!question.parsedSettings) { - if (this.hasSeparateUnitField(question)) { - return Translate.instant('addon.qtype_numerica.unitnotselected'); - } - - // We cannot know if the answer should contain units or not. - return; - } - - if (question.parsedSettings.unitdisplay !== AddonQtypeCalculatedHandlerService.UNITINPUT && unit) { - return Translate.instant('addon.qtype_numerica.invalidnumbernounit'); - } - - if (question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITINPUT && - question.parsedSettings.unitgradingtype === AddonQtypeCalculatedHandlerService.UNITGRADED && - !this.isValidValue(unit)) { - return Translate.instant('addon.qtype_numerica.invalidnumber'); - } - - return; - } - } export const AddonQtypeCalculatedHandler = makeSingleton(AddonQtypeCalculatedHandlerService); diff --git a/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts b/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts index f4ce213940d..c7cc17de447 100644 --- a/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts +++ b/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts @@ -12,83 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable, Type } from '@angular/core'; +import { Injectable } from '@angular/core'; -import { CoreQuestionHandler } from '@features/question/services/question-delegate'; -import { AddonQtypeCalculatedHandler } from '@addons/qtype/calculated/services/handlers/calculated'; -import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; import { makeSingleton } from '@singletons'; +import { AddonQtypeNumericalHandlerService } from '@addons/qtype/numerical/services/handlers/numerical'; /** * Handler to support calculated simple question type. + * This question type depends on numeric question type. */ @Injectable({ providedIn: 'root' }) -export class AddonQtypeCalculatedSimpleHandlerService implements CoreQuestionHandler { +export class AddonQtypeCalculatedSimpleHandlerService extends AddonQtypeNumericalHandlerService { name = 'AddonQtypeCalculatedSimple'; type = 'qtype_calculatedsimple'; - /** - * @inheritdoc - */ - async getComponent(): Promise> { - // Calculated simple behaves like a calculated, use the same component. - const { AddonQtypeCalculatedComponent } = await import('@addons/qtype/calculated/component/calculated'); - - return AddonQtypeCalculatedComponent; - } - - /** - * @inheritdoc - */ - isCompleteResponse( - question: CoreQuestionQuestionParsed, - answers: CoreQuestionsAnswers, - ): number { - // This question type depends on calculated. - return AddonQtypeCalculatedHandler.isCompleteResponse(question, answers); - } - - /** - * @inheritdoc - */ - async isEnabled(): Promise { - return true; - } - - /** - * @inheritdoc - */ - isGradableResponse( - question: CoreQuestionQuestionParsed, - answers: CoreQuestionsAnswers, - ): number { - // This question type depends on calculated. - return AddonQtypeCalculatedHandler.isGradableResponse(question, answers); - } - - /** - * @inheritdoc - */ - isSameResponse( - question: CoreQuestionQuestionParsed, - prevAnswers: CoreQuestionsAnswers, - newAnswers: CoreQuestionsAnswers, - ): boolean { - // This question type depends on calculated. - return AddonQtypeCalculatedHandler.isSameResponse(question, prevAnswers, newAnswers); - } - - /** - * @inheritdoc - */ - getValidationError( - question: CoreQuestionQuestionParsed, - answers: CoreQuestionsAnswers, - ): string | undefined { - return AddonQtypeCalculatedHandler.getValidationError(question, answers); - } - } export const AddonQtypeCalculatedSimpleHandler = makeSingleton(AddonQtypeCalculatedSimpleHandlerService); diff --git a/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts b/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts index 67e5acf3802..2295737a99e 100644 --- a/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts +++ b/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts @@ -19,6 +19,7 @@ import { makeSingleton } from '@singletons'; /** * Handler to support drag-and-drop words into sentences question type. + * This question type is a variation of gapselect. */ @Injectable({ providedIn: 'root' }) export class AddonQtypeDdwtosHandlerService extends AddonQtypeGapSelectHandlerService { diff --git a/src/addons/qtype/calculated/component/addon-qtype-calculated.html b/src/addons/qtype/numerical/component/numerical.html similarity index 96% rename from src/addons/qtype/calculated/component/addon-qtype-calculated.html rename to src/addons/qtype/numerical/component/numerical.html index e9a643c33f1..15b8fc98a21 100644 --- a/src/addons/qtype/calculated/component/addon-qtype-calculated.html +++ b/src/addons/qtype/numerical/component/numerical.html @@ -1,4 +1,4 @@ - + { +export class AddonQtypeNumericalComponent extends CoreQuestionBaseComponent { constructor(elementRef: ElementRef) { - super('AddonQtypeCalculatedComponent', elementRef); + super('AddonQtypeNumericalComponent', elementRef); } /** diff --git a/src/addons/qtype/calculated/lang.json b/src/addons/qtype/numerical/lang.json similarity index 100% rename from src/addons/qtype/calculated/lang.json rename to src/addons/qtype/numerical/lang.json diff --git a/src/addons/qtype/numerical/numerical.module.ts b/src/addons/qtype/numerical/numerical.module.ts index f13ba4fbe71..2313768f778 100644 --- a/src/addons/qtype/numerical/numerical.module.ts +++ b/src/addons/qtype/numerical/numerical.module.ts @@ -16,9 +16,15 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; import { AddonQtypeNumericalHandler } from './services/handlers/numerical'; +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonQtypeNumericalComponent } from './component/numerical'; @NgModule({ declarations: [ + AddonQtypeNumericalComponent, + ], + imports: [ + CoreSharedModule, ], providers: [ { @@ -29,5 +35,8 @@ import { AddonQtypeNumericalHandler } from './services/handlers/numerical'; }, }, ], + exports: [ + AddonQtypeNumericalComponent, + ], }) export class AddonQtypeNumericalModule {} diff --git a/src/addons/qtype/numerical/services/handlers/numerical.ts b/src/addons/qtype/numerical/services/handlers/numerical.ts index 122d4db9d3e..b29e618f05e 100644 --- a/src/addons/qtype/numerical/services/handlers/numerical.ts +++ b/src/addons/qtype/numerical/services/handlers/numerical.ts @@ -12,21 +12,232 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; - -import { AddonQtypeCalculatedHandlerService } from '@addons/qtype/calculated/services/handlers/calculated'; -import { makeSingleton } from '@singletons'; +import { Injectable, Type } from '@angular/core'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { makeSingleton, Translate } from '@singletons'; +import { convertTextToHTMLElement } from '@/core/utils/create-html-element'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreObject } from '@singletons/object'; /** * Handler to support numerical question type. - * This question type depends on calculated question type. */ @Injectable({ providedIn: 'root' }) -export class AddonQtypeNumericalHandlerService extends AddonQtypeCalculatedHandlerService { +export class AddonQtypeNumericalHandlerService implements CoreQuestionHandler { + + static readonly UNITINPUT = '0'; + static readonly UNITRADIO = '1'; + static readonly UNITSELECT = '2'; + static readonly UNITNONE = '3'; + + static readonly UNITGRADED = '1'; + static readonly UNITOPTIONAL = '0'; name = 'AddonQtypeNumerical'; type = 'qtype_numerical'; + /** + * @inheritdoc + */ + async getComponent(): Promise> { + const { AddonQtypeNumericalComponent } = await import('../../component/numerical'); + + return AddonQtypeNumericalComponent; + } + + /** + * Check if the units are in a separate field for the question. + * + * @param question Question. + * @returns Whether units are in a separate field. + */ + protected hasSeparateUnitField(question: CoreQuestionQuestionParsed): boolean { + if (!question.parsedSettings) { + const element = convertTextToHTMLElement(question.html); + + return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]')); + } + + return question.parsedSettings.unitdisplay === AddonQtypeNumericalHandlerService.UNITRADIO || + question.parsedSettings.unitdisplay === AddonQtypeNumericalHandlerService.UNITSELECT; + } + + /** + * @inheritdoc + */ + isCompleteResponse( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + ): number { + if (!this.isGradableResponse(question, answers)) { + return 0; + } + + const { answer, unit } = this.parseAnswer(question, answers.answer); + if (answer === null) { + return 0; + } + + if (!question.parsedSettings) { + if (this.hasSeparateUnitField(question)) { + return this.isValidValue( answers.unit) ? 1 : 0; + } + + // We cannot know if the answer should contain units or not. + return -1; + } + + if (question.parsedSettings.unitdisplay !== AddonQtypeNumericalHandlerService.UNITINPUT && unit) { + // There should be no units or be outside of the input, not valid. + return 0; + } + + if (this.hasSeparateUnitField(question) && !this.isValidValue( answers.unit)) { + // Unit not supplied as a separate field and it's required. + return 0; + } + + if (question.parsedSettings.unitdisplay === AddonQtypeNumericalHandlerService.UNITINPUT && + question.parsedSettings.unitgradingtype === AddonQtypeNumericalHandlerService.UNITGRADED && + !this.isValidValue(unit)) { + // Unit not supplied inside the input and it's required. + return 0; + } + + return 1; + } + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + isGradableResponse( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + ): number { + return this.isValidValue( answers.answer) ? 1 : 0; + } + + /** + * @inheritdoc + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreObject.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') && + CoreObject.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit'); + } + + /** + * Check if a value is valid (not empty). + * + * @param value Value to check. + * @returns Whether the value is valid. + */ + protected isValidValue(value: string | number | null): boolean { + return !!value || value === '0' || value === 0; + } + + /** + * Parse an answer string. + * + * @param question Question. + * @param answer Answer. + * @returns Answer and unit. + */ + protected parseAnswer(question: CoreQuestionQuestionParsed, answer: string): { answer: number | null; unit: string | null } { + if (!answer) { + return { answer: null, unit: null }; + } + + let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; + + // Strip spaces (which may be thousands separators) and change other forms of writing e to e. + answer = answer.replace(/ /g, ''); + answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1'); + + // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it. + // Else assume it is a decimal separator, and change it to '.'. + if (answer.indexOf('.') !== -1 || answer.split(',').length - 1 > 1) { + answer = answer.replace(',', ''); + } else { + answer = answer.replace(',', '.'); + } + + let unitsLeft = false; + let match: RegExpMatchArray | null = null; + + if (!question.parsedSettings || question.parsedSettings.unitsleft === null) { + // We don't know if units should be before or after so we check both. + match = answer.match(new RegExp('^' + regexString)); + if (!match) { + unitsLeft = true; + match = answer.match(new RegExp(regexString + '$')); + } + } else { + unitsLeft = question.parsedSettings.unitsleft === '1'; + regexString = unitsLeft ? regexString + '$' : '^' + regexString; + + match = answer.match(new RegExp(regexString)); + } + + if (!match) { + return { answer: null, unit: null }; + } + + const numberString = match[0]; + const unit = unitsLeft ? answer.substring(0, answer.length - match[0].length) : answer.substring(match[0].length); + + // No need to calculate the multiplier. + return { answer: Number(numberString), unit }; + } + + /** + * @inheritdoc + */ + getValidationError( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + ): string | undefined { + if (!this.isGradableResponse(question, answers)) { + return Translate.instant('addon.qtype_numerical.pleaseenterananswer'); + } + + const { answer, unit } = this.parseAnswer(question, answers.answer); + if (answer === null) { + return Translate.instant('addon.qtype_numerica.invalidnumber'); + } + + if (!question.parsedSettings) { + if (this.hasSeparateUnitField(question)) { + return Translate.instant('addon.qtype_numerica.unitnotselected'); + } + + // We cannot know if the answer should contain units or not. + return; + } + + if (question.parsedSettings.unitdisplay !== AddonQtypeNumericalHandlerService.UNITINPUT && unit) { + return Translate.instant('addon.qtype_numerica.invalidnumbernounit'); + } + + if (question.parsedSettings.unitdisplay === AddonQtypeNumericalHandlerService.UNITINPUT && + question.parsedSettings.unitgradingtype === AddonQtypeNumericalHandlerService.UNITGRADED && + !this.isValidValue(unit)) { + return Translate.instant('addon.qtype_numerica.invalidnumber'); + } + + return; + } + } export const AddonQtypeNumericalHandler = makeSingleton(AddonQtypeNumericalHandlerService); diff --git a/src/core/features/question/classes/base-question-component.ts b/src/core/features/question/classes/base-question-component.ts index 91a55fdf144..b67211cb950 100644 --- a/src/core/features/question/classes/base-question-component.ts +++ b/src/core/features/question/classes/base-question-component.ts @@ -147,7 +147,7 @@ export class CoreQuestionBaseComponent