diff --git a/src/core/features/question/classes/base-behaviour-handler.ts b/src/core/features/question/classes/base-behaviour-handler.ts new file mode 100644 index 000000000..e5d7b9da0 --- /dev/null +++ b/src/core/features/question/classes/base-behaviour-handler.ts @@ -0,0 +1,77 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { Type } from '@angular/core'; + +import { CoreQuestion, CoreQuestionState } from '@features/question/services/question'; +import { CoreQuestionBehaviourHandler } from '../services/behaviour-delegate'; +import { CoreQuestionQuestionParsed } from '../services/question'; + +/** + * Base handler for question behaviours. + * + * This class is needed because parent classes cannot have @Injectable in Angular v6, so the default handler cannot be a + * parent class. + */ +export class CoreQuestionBehaviourBaseHandler implements CoreQuestionBehaviourHandler { + + name = 'CoreQuestionBehaviourBase'; + type = 'base'; + + /** + * Determine a question new state based on its answer(s). + * + * @param component Component the question belongs to. + * @param attemptId Attempt ID the question belongs to. + * @param question The question. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return New state (or promise resolved with state). + */ + determineNewState( + component: string, + attemptId: number, + question: CoreQuestionQuestionParsed, + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): CoreQuestionState | Promise { + // Return the current state. + return CoreQuestion.instance.getState(question.state); + } + + /** + * Handle a question behaviour. + * If the behaviour requires a submit button, it should add it to question.behaviourButtons. + * If the behaviour requires to show some extra data, it should return the components to render it. + * + * @param question The question. + * @return Components (or promise resolved with components) to render some extra data in the question + * (e.g. certainty options). Don't return anything if no extra data is required. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + handleQuestion(question: CoreQuestionQuestionParsed): undefined | Type[] | Promise[]> { + // Nothing to do. + return; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + +} diff --git a/src/core/features/question/classes/base-question-handler.ts b/src/core/features/question/classes/base-question-handler.ts new file mode 100644 index 000000000..a7a53b446 --- /dev/null +++ b/src/core/features/question/classes/base-question-handler.ts @@ -0,0 +1,167 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { Type } from '@angular/core'; + +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '../services/question'; +import { CoreQuestionHandler } from '../services/question-delegate'; + +/** + * Base handler for question types. + * + * This class is needed because parent classes cannot have @Injectable in Angular v6, so the default handler cannot be a + * parent class. + */ +export class CoreQuestionBaseHandler implements CoreQuestionHandler { + + name = 'CoreQuestionBase'; + type = 'base'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * 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 question The question to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getComponent(question: CoreQuestionQuestionParsed): undefined | Type | Promise> { + // There is no default component for questions. + return; + } + + /** + * 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 question The question. + * @param behaviour The default behaviour. + * @return The behaviour to use. + */ + getBehaviour(question: CoreQuestionQuestionParsed, behaviour: string): string { + return behaviour; + } + + /** + * Check if a question can be submitted. + * If a question cannot be submitted it should return a message explaining why (translated or not). + * + * @param question The question. + * @return Prevent submit message. Undefined or empty if can be submitted. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getPreventSubmitMessage(question: CoreQuestionQuestionParsed): string | undefined { + // Never prevent by default. + return; + } + + /** + * Check if a response is complete. + * + * @param question The question. + * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. + * @return 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse( + question: CoreQuestionQuestionParsed, // eslint-disable-line @typescript-eslint/no-unused-vars + answers: CoreQuestionsAnswers, // eslint-disable-line @typescript-eslint/no-unused-vars + component: string, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): number { + return -1; + } + + /** + * 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 question The question. + * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. + * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse( + question: CoreQuestionQuestionParsed, // eslint-disable-line @typescript-eslint/no-unused-vars + answers: CoreQuestionsAnswers, // eslint-disable-line @typescript-eslint/no-unused-vars + component: string, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): number { + return -1; + } + + /** + * Check if two responses are the same. + * + * @param question Question. + * @param prevAnswers Object with the previous question answers. + * @param newAnswers Object with the new question answers. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, // eslint-disable-line @typescript-eslint/no-unused-vars + prevAnswers: CoreQuestionsAnswers, // eslint-disable-line @typescript-eslint/no-unused-vars + newAnswers: CoreQuestionsAnswers, // eslint-disable-line @typescript-eslint/no-unused-vars + component: string, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): boolean { + return false; + } + + /** + * Prepare and add to answers the data to send to server based in the input. Return promise if async. + * + * @param question Question. + * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Return a promise resolved when done if async, void if sync. + */ + prepareAnswers( + question: CoreQuestionQuestionParsed, // eslint-disable-line @typescript-eslint/no-unused-vars + answers: CoreQuestionsAnswers, // eslint-disable-line @typescript-eslint/no-unused-vars + offline: boolean, // eslint-disable-line @typescript-eslint/no-unused-vars + component: string, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): void | Promise { + // Nothing to do. + } + + /** + * 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 question The question. + * @param offlineSequenceCheck Sequence check stored in offline. + * @return Whether sequencecheck is valid. + */ + validateSequenceCheck(question: CoreQuestionQuestionParsed, offlineSequenceCheck: string): boolean { + return question.sequencecheck == Number(offlineSequenceCheck); + } + +} diff --git a/src/core/features/question/components/components.module.ts b/src/core/features/question/components/components.module.ts new file mode 100644 index 000000000..adf1e261b --- /dev/null +++ b/src/core/features/question/components/components.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionComponent } from './question/question'; + +@NgModule({ + declarations: [ + CoreQuestionComponent, + ], + imports: [ + CoreSharedModule, + ], + exports: [ + CoreQuestionComponent, + ], +}) +export class CoreQuestionComponentsModule {} diff --git a/src/core/features/question/components/question/core-question.html b/src/core/features/question/components/question/core-question.html new file mode 100644 index 000000000..6e22b31bf --- /dev/null +++ b/src/core/features/question/components/question/core-question.html @@ -0,0 +1,44 @@ + + + + +

{{ 'core.question.errorquestionnotsupported' | translate:{$a: question?.type} }}

+
+ + + + + + + + + + + +

{{ validationError }}

+
+
+ + + + {{ button.value }} + + + + + + + + + + + + + + + + + diff --git a/src/core/features/question/components/question/question.ts b/src/core/features/question/components/question/question.ts new file mode 100644 index 000000000..c7e22df4e --- /dev/null +++ b/src/core/features/question/components/question/question.ts @@ -0,0 +1,176 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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, Input, Output, OnInit, EventEmitter, ChangeDetectorRef, Type } from '@angular/core'; +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; + +import { CoreQuestionBehaviourButton, CoreQuestionHelper, CoreQuestionQuestion } from '@features/question/services/question-helper'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Component to render a question. + */ +@Component({ + selector: 'core-question', + templateUrl: 'core-question.html', + styleUrls: ['../../question.scss'], +}) +export class CoreQuestionComponent implements OnInit { + + @Input() question?: CoreQuestionQuestion; // The question to render. + @Input() component?: string; // The component the question belongs to. + @Input() componentId?: number; // ID of the component the question belongs to. + @Input() attemptId?: number; // Attempt ID. + @Input() usageId?: number; // Usage ID. + @Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline. + @Input() contextLevel?: string; // The context level. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() courseId?: number; // Course ID the question belongs to (if any). It can be used to improve performance with filters. + @Input() review?: boolean; // Whether the user is in review mode. + @Input() preferredBehaviour?: string; // Behaviour to use. + @Output() buttonClicked = new EventEmitter(); // Will emit when a behaviour button is clicked. + @Output() onAbort= new EventEmitter(); // Will emit an event if the question should be aborted. + + componentClass?: Type; // The class of the component to render. + data: Record = {}; // Data to pass to the component. + seqCheck?: { name: string; value: string }; // Sequenche check name and value (if any). + behaviourComponents?: Type[] = []; // Components to render the question behaviour. + loaded = false; + validationError?: string; + + protected logger: CoreLogger; + + constructor( + protected changeDetector: ChangeDetectorRef, + ) { + this.logger = CoreLogger.getInstance('CoreQuestionComponent'); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.offlineEnabled = CoreUtils.instance.isTrueOrOne(this.offlineEnabled); + + if (!this.question || (this.question.type != 'random' && + !CoreQuestionDelegate.instance.isQuestionSupported(this.question.type))) { + this.loaded = true; + + return; + } + + // Get the component to render the question. + this.componentClass = await CoreUtils.instance.ignoreErrors( + CoreQuestionDelegate.instance.getComponentForQuestion(this.question), + ); + + if (!this.componentClass) { + this.loaded = true; + + return; + } + // Set up the data needed by the question and behaviour components. + this.data = { + question: this.question, + component: this.component, + componentId: this.componentId, + attemptId: this.attemptId, + offlineEnabled: this.offlineEnabled, + contextLevel: this.contextLevel, + contextInstanceId: this.contextInstanceId, + courseId: this.courseId, + review: this.review, + buttonClicked: this.buttonClicked, + onAbort: this.onAbort, + }; + + // Treat the question. + CoreQuestionHelper.instance.extractQuestionScripts(this.question, this.usageId); + + // Handle question behaviour. + const behaviour = CoreQuestionDelegate.instance.getBehaviourForQuestion( + this.question, + this.preferredBehaviour || '', + ); + if (!CoreQuestionBehaviourDelegate.instance.isBehaviourSupported(behaviour)) { + // Behaviour not supported, abort. + this.logger.warn('Aborting question because the behaviour is not supported.', this.question.slot); + CoreQuestionHelper.instance.showComponentError( + this.onAbort, + Translate.instance.instant('addon.mod_quiz.errorbehaviournotsupported') + ' ' + behaviour, + ); + + return; + } + + // Get the sequence check (hidden input). This is required. + this.seqCheck = CoreQuestionHelper.instance.getQuestionSequenceCheckFromHtml(this.question.html); + if (!this.seqCheck) { + this.logger.warn('Aborting question because couldn\'t retrieve sequence check.', this.question.slot); + CoreQuestionHelper.instance.showComponentError(this.onAbort); + + return; + } + + + // Load local answers if offline is enabled. + if (this.offlineEnabled && this.component && this.attemptId) { + await CoreQuestionHelper.instance.loadLocalAnswers(this.question, this.component, this.attemptId); + } else { + this.question.localAnswers = {}; + } + + CoreQuestionHelper.instance.extractQbehaviourRedoButton(this.question); + + // Extract the validation error of the question. + this.validationError = CoreQuestionHelper.instance.getValidationErrorFromHtml(this.question.html); + + // Load the local answers in the HTML. + CoreQuestionHelper.instance.loadLocalAnswersInHtml(this.question); + + // Try to extract the feedback and comment for the question. + CoreQuestionHelper.instance.extractQuestionFeedback(this.question); + CoreQuestionHelper.instance.extractQuestionComment(this.question); + + try { + // Handle behaviour. + this.behaviourComponents = await CoreQuestionBehaviourDelegate.instance.handleQuestion( + this.preferredBehaviour || '', + this.question, + ); + } finally { + this.question.html = CoreDomUtils.instance.removeElementFromHtml(this.question.html, '.im-controls'); + this.loaded = true; + } + } + + /** + * Update the sequence check of the question. + * + * @param sequenceChecks Object with sequence checks. The keys are the question slot. + */ + updateSequenceCheck(sequenceChecks: Record): void { + if (!this.question || !sequenceChecks[this.question.slot]) { + return; + } + + this.seqCheck = sequenceChecks[this.question.slot]; + this.changeDetector.detectChanges(); + } + +} diff --git a/src/core/features/question/lang.json b/src/core/features/question/lang.json new file mode 100644 index 000000000..ee7018835 --- /dev/null +++ b/src/core/features/question/lang.json @@ -0,0 +1,22 @@ +{ + "answer": "Answer", + "answersaved": "Answer saved", + "cannotdeterminestatus": "Cannot determine status", + "certainty": "Certainty", + "complete": "Complete", + "correct": "Correct", + "errorattachmentsnotsupportedinsite": "Your site doesn't support attaching files to answers yet.", + "errorembeddedfilesnotsupportedinsite": "Your site doesn't support editing embedded files yet.", + "errorquestionnotsupported": "This question type is not supported by the app: {{$a}}.", + "feedback": "Feedback", + "howtodraganddrop": "Tap to select then tap to drop.", + "incorrect": "Incorrect", + "information": "Information", + "invalidanswer": "Incomplete answer", + "notanswered": "Not answered", + "notyetanswered": "Not yet answered", + "partiallycorrect": "Partially correct", + "questionmessage": "Question {{$a}}: {{$b}}", + "questionno": "Question {{$a}}", + "requiresgrading": "Requires grading" +} \ No newline at end of file diff --git a/src/core/features/question/question.scss b/src/core/features/question/question.scss new file mode 100644 index 000000000..74f9d06ad --- /dev/null +++ b/src/core/features/question/question.scss @@ -0,0 +1,142 @@ +@import "~theme/globals"; + +$core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA520, #FFD700, #F0E68C !default; + +:host { + --core-question-correct-color: var(--green-dark); + --core-question-correct-color-bg: var(--green-light); + --core-question-incorrect-color: var(--red); + --core-question-incorrect-color-bg: var(--red-light); + --core-question-feedback-color: var(--yellow-dark); + --core-question-feedback-color-bg: var(--yellow-light); + --core-question-warning-color: var(--red); + --core-question-saved-color-bg: var(--gray-light); + + --core-question-state-correct-color: var(--green-light); + --core-question-state-partial-color: var(--yellow-light); + --core-question-state-partial-text: var(--yellow); + --core-question-state-incorrect-color: var(--red-light); + + --core-question-feedback-color: var(--yellow-dark); + --core-question-feedback-background-color: var(--yellow-light); + + --core-dd-question-selected-shadow: 2px 2px 4px var(--gray-dark); + + // .core-correct-icon { + // padding: 0 ($content-padding / 2); + // position: absolute; + // @include position(null, 0, $content-padding / 2, null); + // margin-top: 0; + // margin-bottom: 0; + // } + + + // .core-question-answer-correct { + // color: $core-question-correct-color; + // } + + // .core-question-answer-incorrect { + // color: $core-question-incorrect-color; + // } + + // input, select { + // &.core-question-answer-correct, &.core-question-answer-incorrect { + // background-color: $gray-lighter; + // color: $text-color; + // } + // } + + // .core-question-correct, + // .core-question-comment { + // color: $core-question-correct-color; + // background-color: $core-question-correct-color-bg; + + // .label, ion-label.label, .select-text, .select-icon .select-icon-inner { + // color: $core-question-correct-color; + // } + // .radio-icon { + // border-color: $core-question-correct-color; + // } + // .radio-inner { + // background-color: $core-question-correct-color; + // } + // } + + // .core-question-incorrect { + // color: $core-question-incorrect-color; + // background-color: $core-question-incorrect-color-bg; + + // .label, ion-label.label, .select-text, .select-icon .select-icon-inner { + // color: $core-question-incorrect-color; + // } + // .radio-icon { + // border-color: $core-question-incorrect-color; + // } + // .radio-inner { + // background-color: $core-question-incorrect-color; + // } + // } + + .core-question-feedback-container ::ng-deep { + --color: var(--core-question-feedback-color); + --background: var(--core-question-feedback-background-color); + + .specificfeedback, .rightanswer, .im-feedback, .feedback, .generalfeedback { + margin: 0 0 .5em; + } + + .correctness { + display: inline-block; + padding: 2px 4px; + font-weight: bold; + line-height: 14px; + color: var(--white); + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); + background-color: var(--gray-dark); + -webkit-border-radius: 3px; + border-radius: 3px; + + &.incorrect { + background-color: var(--red); + } + &.correct { + background-color: var(--green); + } + } + } + + .core-question-feedback-inline { + display: inline-block; + } + + .core-question-feedback-padding { + @include padding-horizontal(14px, 35px); + padding-top: 8px; + padding-bottom: 8px; + } + + .core-question-correct { + background-color: var(--core-question-state-correct-color); + } + .core-question-partiallycorrect { + background-color: var(--core-question-state-partial-color); + } + .core-question-notanswered, + .core-question-incorrect { + background-color: var(--core-question-state-incorrect-color); + } + .core-question-answersaved, + .core-question-requiresgrading { + color: var(--ion-text-color); + background-color: var(--core-question-saved-color-bg); + } + + .core-question-warning { + color: var(--core-question-warning-color); + } + + .questioncorrectnessicon, + .fa.icon.questioncorrectnessicon { + font-size: 20px; + } +} diff --git a/src/core/features/question/services/behaviour-delegate.ts b/src/core/features/question/services/behaviour-delegate.ts new file mode 100644 index 000000000..e48575953 --- /dev/null +++ b/src/core/features/question/services/behaviour-delegate.ts @@ -0,0 +1,135 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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, Type } from '@angular/core'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { makeSingleton } from '@singletons'; +import { CoreQuestionBehaviourDefaultHandler } from './handlers/default-behaviour'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers, CoreQuestionState } from './question'; +import { CoreQuestionDelegate } from './question-delegate'; + +/** + * Interface that all question behaviour handlers must implement. + */ +export interface CoreQuestionBehaviourHandler extends CoreDelegateHandler { + /** + * Type of the behaviour the handler supports. E.g. 'adaptive'. + */ + type: string; + + /** + * Determine a question new state based on its answer(s). + * + * @param component Component the question belongs to. + * @param attemptId Attempt ID the question belongs to. + * @param question The question. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return State (or promise resolved with state). + */ + determineNewState?( + component: string, + attemptId: number, + question: CoreQuestionQuestionWithAnswers, + componentId: string | number, + siteId?: string, + ): CoreQuestionState | Promise; + + /** + * Handle a question behaviour. + * If the behaviour requires a submit button, it should add it to question.behaviourButtons. + * If the behaviour requires to show some extra data, it should return the components to render it. + * + * @param question The question. + * @return Components (or promise resolved with components) to render some extra data in the question + * (e.g. certainty options). Don't return anything if no extra data is required. + */ + handleQuestion?(question: CoreQuestionQuestionParsed): void | Type[] | Promise[]>; +} + +/** + * Delegate to register question behaviour handlers. + */ +@Injectable({ providedIn: 'root' }) +export class CoreQuestionBehaviourDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor(protected defaultHandler: CoreQuestionBehaviourDefaultHandler) { + super('CoreQuestionBehaviourDelegate', true); + } + + /** + * Determine a question new state based on its answer(s). + * + * @param component Component the question belongs to. + * @param attemptId Attempt ID the question belongs to. + * @param question The question. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with state. + */ + async determineNewState( + behaviour: string, + component: string, + attemptId: number, + question: CoreQuestionQuestionWithAnswers, + componentId: string | number, + siteId?: string, + ): Promise { + behaviour = CoreQuestionDelegate.instance.getBehaviourForQuestion(question, behaviour); + + return this.executeFunctionOnEnabled( + behaviour, + 'determineNewState', + [component, attemptId, question, componentId, siteId], + ); + } + + /** + * Handle a question behaviour. + * If the behaviour requires a submit button, it should add it to question.behaviourButtons. + * If the behaviour requires to show some extra data, it should return a directive to render it. + * + * @param behaviour Default behaviour. + * @param question The question. + * @return Promise resolved with components to render some extra data in the question. + */ + async handleQuestion(behaviour: string, question: CoreQuestionQuestionParsed): Promise[] | undefined> { + behaviour = CoreQuestionDelegate.instance.getBehaviourForQuestion(question, behaviour); + + return this.executeFunctionOnEnabled(behaviour, 'handleQuestion', [question]); + } + + /** + * Check if a question behaviour is supported. + * + * @param behaviour Name of the behaviour. + * @return Whether it's supported. + */ + isBehaviourSupported(behaviour: string): boolean { + return this.hasHandler(behaviour, true); + } + +} + +export class CoreQuestionBehaviourDelegate extends makeSingleton(CoreQuestionBehaviourDelegateService) {} + +/** + * Answers classified by question slot. + */ +export type CoreQuestionQuestionWithAnswers = CoreQuestionQuestionParsed & { + answers?: CoreQuestionsAnswers; +}; diff --git a/src/core/features/question/services/database/question.ts b/src/core/features/question/services/database/question.ts new file mode 100644 index 000000000..848082132 --- /dev/null +++ b/src/core/features/question/services/database/question.ts @@ -0,0 +1,132 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreQuestion service. + */ +export const QUESTION_TABLE_NAME = 'questions'; +export const QUESTION_ANSWERS_TABLE_NAME = 'question_answers'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreQuestionProvider', + version: 1, + tables: [ + { + name: QUESTION_TABLE_NAME, + columns: [ + { + name: 'component', + type: 'TEXT', + notNull: true, + }, + { + name: 'attemptid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'slot', + type: 'INTEGER', + notNull: true, + }, + { + name: 'componentid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'number', + type: 'INTEGER', + }, + { + name: 'state', + type: 'TEXT', + }, + ], + primaryKeys: ['component', 'attemptid', 'slot'], + }, + { + name: QUESTION_ANSWERS_TABLE_NAME, + columns: [ + { + name: 'component', + type: 'TEXT', + notNull: true, + }, + { + name: 'attemptid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'name', + type: 'TEXT', + notNull: true, + }, + { + name: 'componentid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'questionslot', + type: 'INTEGER', + }, + { + name: 'value', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + primaryKeys: ['component', 'attemptid', 'name'], + }, + ], +}; + +/** + * Data about a question. + */ +export type CoreQuestionDBRecord = { + component: string; + attemptid: number; + slot: number; + componentid: number; + userid: number; + number?: number; // eslint-disable-line id-blacklist + state: string; +}; + +/** + * Data about a question answer. + */ +export type CoreQuestionAnswerDBRecord = { + component: string; + attemptid: number; + name: string; + componentid: number; + userid: number; + questionslot: number; + value: string; + timemodified: number; +}; diff --git a/src/core/features/question/services/handlers/default-behaviour.ts b/src/core/features/question/services/handlers/default-behaviour.ts new file mode 100644 index 000000000..a5d3eb86a --- /dev/null +++ b/src/core/features/question/services/handlers/default-behaviour.ts @@ -0,0 +1,28 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 } from '@angular/core'; + +import { CoreQuestionBehaviourBaseHandler } from '@features/question/classes/base-behaviour-handler'; + +/** + * Default handler used when the question behaviour doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class CoreQuestionBehaviourDefaultHandler extends CoreQuestionBehaviourBaseHandler { + + name = 'CoreQuestionBehaviourDefault'; + type = 'default'; + +} diff --git a/src/core/features/question/services/handlers/default-question.ts b/src/core/features/question/services/handlers/default-question.ts new file mode 100644 index 000000000..5ef2629ba --- /dev/null +++ b/src/core/features/question/services/handlers/default-question.ts @@ -0,0 +1,28 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 } from '@angular/core'; + +import { CoreQuestionBaseHandler } from '@features/question/classes/base-question-handler'; + +/** + * Default handler used when the question type doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class CoreQuestionDefaultHandler extends CoreQuestionBaseHandler { + + name = 'CoreQuestionDefault'; + type = 'default'; + +} diff --git a/src/core/features/question/services/question-delegate.ts b/src/core/features/question/services/question-delegate.ts new file mode 100644 index 000000000..d02bbc637 --- /dev/null +++ b/src/core/features/question/services/question-delegate.ts @@ -0,0 +1,460 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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, Type } from '@angular/core'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { CoreQuestionDefaultHandler } from './handlers/default-question'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from './question'; + +/** + * Interface that all question type handlers must implement. + */ +export interface CoreQuestionHandler extends CoreDelegateHandler { + /** + * Type of the question the handler supports. E.g. 'qtype_calculated'. + */ + type: string; + + /** + * 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 question The question to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(question: CoreQuestionQuestionParsed): undefined | Type | Promise>; + + /** + * 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 question The question. + * @param behaviour The default behaviour. + * @return The behaviour to use. + */ + getBehaviour?(question: CoreQuestionQuestionParsed, behaviour: string): string; + + /** + * Check if a question can be submitted. + * If a question cannot be submitted it should return a message explaining why (translated or not). + * + * @param question The question. + * @return Prevent submit message. Undefined or empty if can be submitted. + */ + getPreventSubmitMessage?(question: CoreQuestionQuestionParsed): string | undefined; + + /** + * Check if a response is complete. + * + * @param question The question. + * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. + * @return 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse?( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): number; + + /** + * 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 question The question. + * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. + * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse?( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): number; + + /** + * Check if two responses are the same. + * + * @param question Question. + * @param prevAnswers Object with the previous question answers. + * @param newAnswers Object with the new question answers. + * @return Whether they're the same. + */ + isSameResponse?( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): boolean; + + /** + * Prepare and add to answers the data to send to server based in the input. Return promise if async. + * + * @param question Question. + * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Return a promise resolved when done if async, void if sync. + */ + prepareAnswers?( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + offline: boolean, + component: string, + componentId: string | number, + siteId?: string, + ): void | Promise; + + /** + * 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 question The question. + * @param offlineSequenceCheck Sequence check stored in offline. + * @return Whether sequencecheck is valid. + */ + validateSequenceCheck?(question: CoreQuestionQuestionParsed, offlineSequenceCheck: string): boolean; + + /** + * Get the list of files that needs to be downloaded in addition to the files embedded in the HTML. + * + * @param question Question. + * @param usageId Usage ID. + * @return List of files or URLs. + */ + getAdditionalDownloadableFiles?(question: CoreQuestionQuestionParsed, usageId?: number): CoreWSExternalFile[]; + + /** + * Clear temporary data after the data has been saved. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return If async, promise resolved when done. + */ + clearTmpData?(question: CoreQuestionQuestionParsed, component: string, componentId: string | number): void | Promise; + + /** + * Delete any stored data for the question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + deleteOfflineData?( + question: CoreQuestionQuestionParsed, + component: string, + componentId: string | number, + siteId?: string, + ): void | Promise; + + /** + * Prepare data to send when performing a synchronization. + * + * @param question Question. + * @param answers Answers of the question, without the prefix. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + prepareSyncData?( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + siteId?: string, + ): void | Promise; +} + +/** + * Delegate to register question handlers. + */ +@Injectable({ providedIn: 'root' }) +export class CoreQuestionDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'type'; + + constructor(protected defaultHandler: CoreQuestionDefaultHandler) { + super('CoreQuestionDelegate', true); + } + + /** + * Get the behaviour to use for a certain question type. + * E.g. 'qtype_essay' uses 'manualgraded'. + * + * @param question The question. + * @param behaviour The default behaviour. + * @return The behaviour to use. + */ + getBehaviourForQuestion(question: CoreQuestionQuestionParsed, behaviour: string): string { + const type = this.getTypeName(question); + const questionBehaviour = this.executeFunctionOnEnabled(type, 'getBehaviour', [question, behaviour]); + + return questionBehaviour || behaviour; + } + + /** + * Get the directive to use for a certain question type. + * + * @param question The question to render. + * @return Promise resolved with component to use, undefined if not found. + */ + async getComponentForQuestion(question: CoreQuestionQuestionParsed): Promise | undefined> { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'getComponent', [question]); + } + + /** + * Check if a question can be submitted. + * If a question cannot be submitted it should return a message explaining why (translated or not). + * + * @param question Question. + * @return Prevent submit message. Undefined or empty if can be submitted. + */ + getPreventSubmitMessage(question: CoreQuestionQuestionParsed): string | undefined { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'getPreventSubmitMessage', [question]); + } + + /** + * Given a type name, return the full name of that type. E.g. 'calculated' -> 'qtype_calculated'. + * + * @param type Type to treat. + * @return Type full name. + */ + protected getFullTypeName(type: string): string { + return 'qtype_' + type; + } + + /** + * Given a question, return the full name of its question type. + * + * @param question Question. + * @return Type name. + */ + protected getTypeName(question: CoreQuestionQuestionParsed): string { + return this.getFullTypeName(question.type); + } + + /** + * Check if a response is complete. + * + * @param question The question. + * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. + * @return 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): number { + const type = this.getTypeName(question); + + const isComplete = this.executeFunctionOnEnabled( + type, + 'isCompleteResponse', + [question, answers, component, componentId], + ); + + return isComplete ?? -1; + } + + /** + * 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 question The question. + * @param answers Object with the question answers (without prefix). + * @param component The component the question is related to. + * @param componentId Component ID. + * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): number { + const type = this.getTypeName(question); + + const isGradable = this.executeFunctionOnEnabled( + type, + 'isGradableResponse', + [question, answers, component, componentId], + ); + + return isGradable ?? -1; + } + + /** + * Check if two responses are the same. + * + * @param question Question. + * @param prevAnswers Object with the previous question answers. + * @param newAnswers Object with the new question answers. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): boolean { + const type = this.getTypeName(question); + + return !!this.executeFunctionOnEnabled(type, 'isSameResponse', [question, prevAnswers, newAnswers, component, componentId]); + } + + /** + * Check if a question type is supported. + * + * @param type Question type. + * @return Whether it's supported. + */ + isQuestionSupported(type: string): boolean { + return this.hasHandler(this.getFullTypeName(type), true); + } + + /** + * Prepare the answers for a certain question. + * + * @param question Question. + * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param offline Whether the data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data has been prepared. + */ + async prepareAnswersForQuestion( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + offline: boolean, + component: string, + componentId: string | number, + siteId?: string, + ): Promise { + const type = this.getTypeName(question); + + await this.executeFunctionOnEnabled( + type, + 'prepareAnswers', + [question, answers, offline, component, componentId, siteId], + ); + } + + /** + * Validate if an offline sequencecheck is valid compared with the online one. + * + * @param question The question. + * @param offlineSequenceCheck Sequence check stored in offline. + * @return Whether sequencecheck is valid. + */ + validateSequenceCheck(question: CoreQuestionQuestionParsed, offlineSequenceCheck: string): boolean { + const type = this.getTypeName(question); + + return !!this.executeFunctionOnEnabled(type, 'validateSequenceCheck', [question, offlineSequenceCheck]); + } + + /** + * Get the list of files that needs to be downloaded in addition to the files embedded in the HTML. + * + * @param question Question. + * @param usageId Usage ID. + * @return List of files or URLs. + */ + getAdditionalDownloadableFiles(question: CoreQuestionQuestionParsed, usageId?: number): CoreWSExternalFile[] { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'getAdditionalDownloadableFiles', [question, usageId]) || []; + } + + /** + * Clear temporary data after the data has been saved. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return If async, promise resolved when done. + */ + clearTmpData(question: CoreQuestionQuestionParsed, component: string, componentId: string | number): void | Promise { + const type = this.getTypeName(question); + + return this.executeFunctionOnEnabled(type, 'clearTmpData', [question, component, componentId]); + } + + /** + * Clear temporary data after the data has been saved. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + async deleteOfflineData( + question: CoreQuestionQuestionParsed, + component: string, + componentId: string | number, + siteId?: string, + ): Promise { + const type = this.getTypeName(question); + + await this.executeFunctionOnEnabled(type, 'deleteOfflineData', [question, component, componentId, siteId]); + } + + /** + * Prepare data to send when performing a synchronization. + * + * @param question Question. + * @param answers Answers of the question, without the prefix. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + async prepareSyncData?( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + siteId?: string, + ): Promise { + const type = this.getTypeName(question); + + await this.executeFunctionOnEnabled(type, 'prepareSyncData', [question, answers, component, componentId, siteId]); + } + +} + +export class CoreQuestionDelegate extends makeSingleton(CoreQuestionDelegateService) {} diff --git a/src/core/features/question/services/question-helper.ts b/src/core/features/question/services/question-helper.ts new file mode 100644 index 000000000..0ff86daa6 --- /dev/null +++ b/src/core/features/question/services/question-helper.ts @@ -0,0 +1,895 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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, EventEmitter } from '@angular/core'; + +import { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreQuestion, CoreQuestionProvider, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from './question'; +import { CoreQuestionDelegate } from './question-delegate'; + +/** + * Service with some common functions to handle questions. + */ +@Injectable({ providedIn: 'root' }) +export class CoreQuestionHelperProvider { + + protected lastErrorShown = 0; + + /** + * Add a behaviour button to the question's "behaviourButtons" property. + * + * @param question Question. + * @param button Behaviour button (DOM element). + */ + protected addBehaviourButton(question: CoreQuestionQuestion, button: HTMLInputElement): void { + if (!button || !question) { + return; + } + + question.behaviourButtons = question.behaviourButtons || []; + + // Extract the data we want. + question.behaviourButtons.push({ + id: button.id, + name: button.name, + value: button.value, + disabled: button.disabled, + }); + } + + /** + * Clear questions temporary data after the data has been saved. + * + * @param questions The list of questions. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Promise resolved when done. + */ + async clearTmpData(questions: CoreQuestionQuestionParsed[], component: string, componentId: string | number): Promise { + questions = questions || []; + + await Promise.all(questions.map(async (question) => { + await CoreQuestionDelegate.instance.clearTmpData(question, component, componentId); + })); + } + + /** + * Delete files stored for a question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteStoredQuestionFiles( + question: CoreQuestionQuestionParsed, + component: string, + componentId: string | number, + siteId?: string, + ): Promise { + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId); + + // Ignore errors, maybe the folder doesn't exist. + await CoreUtils.instance.ignoreErrors(CoreFile.instance.removeDir(folderPath)); + } + + /** + * Extract question behaviour submit buttons from the question's HTML and add them to "behaviourButtons" property. + * The buttons aren't deleted from the content because all the im-controls block will be removed afterwards. + * + * @param question Question to treat. + * @param selector Selector to search the buttons. By default, '.im-controls input[type="submit"]'. + */ + extractQbehaviourButtons(question: CoreQuestionQuestionParsed, selector?: string): void { + if (CoreQuestionDelegate.instance.getPreventSubmitMessage(question)) { + // The question is not fully supported, don't extract the buttons. + return; + } + + selector = selector || '.im-controls input[type="submit"]'; + + const element = CoreDomUtils.instance.convertToElement(question.html); + + // Search the buttons. + const buttons = Array.from(element.querySelectorAll(selector)); + buttons.forEach((button) => { + this.addBehaviourButton(question, button); + }); + } + + /** + * Check if the question has CBM and, if so, extract the certainty options and add them to a new + * "behaviourCertaintyOptions" property. + * The value of the selected option is stored in question.behaviourCertaintySelected. + * We don't remove them from HTML because the whole im-controls block will be removed afterwards. + * + * @param question Question to treat. + * @return Wether the certainty is found. + */ + extractQbehaviourCBM(question: CoreQuestionQuestion): boolean { + const element = CoreDomUtils.instance.convertToElement(question.html); + + const labels = Array.from(element.querySelectorAll('.im-controls .certaintychoices label[for*="certainty"]')); + question.behaviourCertaintyOptions = []; + + labels.forEach((label) => { + // Search the radio button inside this certainty and add its data to the options array. + const input = label.querySelector('input[type="radio"]'); + if (input) { + question.behaviourCertaintyOptions!.push({ + id: input.id, + name: input.name, + value: input.value, + text: CoreTextUtils.instance.cleanTags(label.innerHTML), + disabled: input.disabled, + }); + + if (input.checked) { + question.behaviourCertaintySelected = input.value; + } + } + }); + + // If we have a certainty value stored in local we'll use that one. + if (question.localAnswers && typeof question.localAnswers['-certainty'] != 'undefined') { + question.behaviourCertaintySelected = question.localAnswers['-certainty']; + } + + return labels.length > 0; + } + + /** + * Check if the question has a redo button and, if so, add it to "behaviourButtons" property + * and remove it from the HTML. + * + * @param question Question to treat. + */ + extractQbehaviourRedoButton(question: CoreQuestionQuestion): void { + // Create a fake div element so we can search using querySelector. + const redoSelector = 'input[type="submit"][name*=redoslot], input[type="submit"][name*=tryagain]'; + + // Search redo button in feedback. + if (!this.searchBehaviourButton(question, 'html', '.outcome ' + redoSelector)) { + // Not found in question HTML. + if (question.feedbackHtml) { + // We extracted the feedback already, search it in there. + if (this.searchBehaviourButton(question, 'feedbackHtml', redoSelector)) { + // Button found, stop. + return; + } + } + + // Button still not found. Now search in the info box if it exists. + if (question.infoHtml) { + this.searchBehaviourButton(question, 'infoHtml', redoSelector); + } + } + } + + /** + * Check if the question contains a "seen" input. + * If so, add the name and value to a "behaviourSeenInput" property and remove the input. + * + * @param question Question to treat. + * @return Whether the seen input is found. + */ + extractQbehaviourSeenInput(question: CoreQuestionQuestion): boolean { + const element = CoreDomUtils.instance.convertToElement(question.html); + + // Search the "seen" input. + const seenInput = element.querySelector('input[type="hidden"][name*=seen]'); + if (!seenInput) { + return false; + } + + // Get the data and remove the input. + question.behaviourSeenInput = { + name: seenInput.name, + value: seenInput.value, + }; + seenInput.parentElement?.removeChild(seenInput); + question.html = element.innerHTML; + + return true; + } + + /** + * Removes the comment from the question HTML code and adds it in a new "commentHtml" property. + * + * @param question Question. + */ + extractQuestionComment(question: CoreQuestionQuestion): void { + this.extractQuestionLastElementNotInContent(question, '.comment', 'commentHtml'); + } + + /** + * Removes the feedback from the question HTML code and adds it in a new "feedbackHtml" property. + * + * @param question Question. + */ + extractQuestionFeedback(question: CoreQuestionQuestion): void { + this.extractQuestionLastElementNotInContent(question, '.outcome', 'feedbackHtml'); + } + + /** + * Extracts the info box from a question and add it to an "infoHtml" property. + * + * @param question Question. + * @param selector Selector to search the element. + */ + extractQuestionInfoBox(question: CoreQuestionQuestion, selector: string): void { + this.extractQuestionLastElementNotInContent(question, selector, 'infoHtml'); + } + + /** + * Searches the last occurrence of a certain element and check it's not in the question contents. + * If found, removes it from the question HTML and adds it to a new property inside question. + * + * @param question Question. + * @param selector Selector to search the element. + * @param attrName Name of the attribute to store the HTML in. + */ + protected extractQuestionLastElementNotInContent(question: CoreQuestionQuestion, selector: string, attrName: string): void { + const element = CoreDomUtils.instance.convertToElement(question.html); + const matches = Array.from(element.querySelectorAll(selector)); + + // Get the last element and check it's not in the question contents. + let last = matches.pop(); + while (last) { + if (!CoreDomUtils.instance.closest(last, '.formulation')) { + // Not in question contents. Add it to a separate attribute and remove it from the HTML. + question[attrName] = last.innerHTML; + last.parentElement?.removeChild(last); + question.html = element.innerHTML; + + return; + } + + // It's inside the question content, treat next element. + last = matches.pop(); + } + } + + /** + * Removes the scripts from a question's HTML and adds it in a new 'scriptsCode' property. + * It will also search for init_question functions of the question type and add the object to an 'initObjects' property. + * + * @param question Question. + * @param usageId Usage ID. + */ + extractQuestionScripts(question: CoreQuestionQuestion, usageId?: number): void { + question.scriptsCode = ''; + question.initObjects = undefined; + question.amdArgs = undefined; + + // Search the scripts. + const matches = question.html?.match(/]*>[\s\S]*?<\/script>/mg); + if (!matches) { + // No scripts, stop. + return; + } + + matches.forEach((match: string) => { + // Add the script to scriptsCode and remove it from html. + question.scriptsCode += match; + question.html = question.html.replace(match, ''); + + // Search init_question functions for this type. + const initMatches = match.match(new RegExp('M.qtype_' + question.type + '.init_question\\(.*?}\\);', 'mg')); + if (initMatches) { + let initMatch = initMatches.pop()!; + + // Remove start and end of the match, we only want the object. + initMatch = initMatch.replace('M.qtype_' + question.type + '.init_question(', ''); + initMatch = initMatch.substr(0, initMatch.length - 2); + + // Try to convert it to an object and add it to the question. + question.initObjects = CoreTextUtils.instance.parseJSON(initMatch, null); + } + + const amdRegExp = new RegExp('require\\(\\[["\']qtype_' + question.type + '/question["\']\\],[^f]*' + + 'function\\(amd\\)[^\\{]*\\{[^a]*amd\\.init\\((["\'](q|question-' + usageId + '-)' + question.slot + + '["\'].*?)\\);', 'm'); + const amdMatch = match.match(amdRegExp); + + if (amdMatch) { + // Try to convert the arguments to an array and add them to the question. + question.amdArgs = CoreTextUtils.instance.parseJSON('[' + amdMatch[1] + ']', null); + } + }); + } + + /** + * 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 html HTML code. + * @return Object where the keys are the names. + */ + getAllInputNamesFromHtml(html: string): Record { + const element = CoreDomUtils.instance.convertToElement('
' + html + '
'); + const form = element.children[0]; + const answers: Record = {}; + + // 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[CoreQuestion.instance.removeQuestionPrefix(name)] = true; + }); + + return answers; + } + + /** + * Retrieve the answers entered in a form. + * We don't use ngModel because it doesn't detect changes done by JavaScript and some questions might do that. + * + * @param form Form. + * @return Object with the answers. + */ + getAnswersFromForm(form: HTMLFormElement): CoreQuestionsAnswers { + if (!form || !form.elements) { + return {}; + } + + const answers: CoreQuestionsAnswers = {}; + const elements = Array.from(form.elements); + + elements.forEach((element: HTMLInputElement) => { + const name = element.name || element.getAttribute('ng-reflect-name') || ''; + + // Ignore flag and submit inputs. + if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON') { + return; + } + + // Get the value. + if (element.type == 'checkbox') { + answers[name] = !!element.checked; + } else if (element.type == 'radio') { + if (element.checked) { + answers[name] = element.value; + } + } else { + answers[name] = element.value; + } + }); + + 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 + * an HTML containing only the attachments anchors. + * + * @param html HTML code to search in. + * @return Attachments. + */ + getQuestionAttachmentsFromHtml(html: string): CoreWSExternalFile[] { + const element = CoreDomUtils.instance.convertToElement(html); + + // Remove the filemanager (area to attach files to a question). + CoreDomUtils.instance.removeElement(element, 'div[id*=filemanager]'); + + // Search the anchors. + const anchors = Array.from(element.querySelectorAll('a')); + const attachments: CoreWSExternalFile[] = []; + + anchors.forEach((anchor) => { + let content = anchor.innerHTML; + + // Check anchor is valid. + if (anchor.href && content) { + content = CoreTextUtils.instance.cleanTags(content, true).trim(); + attachments.push({ + filename: content, + fileurl: anchor.href, + }); + } + }); + + return attachments; + } + + /** + * Get the sequence check from a question HTML. + * + * @param html Question's HTML. + * @return Object with the sequencecheck name and value. + */ + getQuestionSequenceCheckFromHtml(html: string): { name: string; value: string } | undefined { + if (!html) { + return; + } + + // Search the input holding the sequencecheck. + const element = CoreDomUtils.instance.convertToElement(html); + const input = element.querySelector('input[name*=sequencecheck]'); + + if (!input || input.name === undefined || input.value === undefined) { + return; + } + + return { + name: input.name, + value: input.value, + }; + } + + /** + * Get the CSS class for a question based on its state. + * + * @param name Question's state name. + * @return State class. + */ + getQuestionStateClass(name: string): string { + const state = CoreQuestion.instance.getState(name); + + return state ? state.class : ''; + } + + /** + * Return the files of a certain response file area. + * + * @param question Question. + * @param areaName Name of the area, e.g. 'attachments'. + * @return List of files. + */ + getResponseFileAreaFiles(question: CoreQuestionQuestion, areaName: string): CoreWSExternalFile[] { + if (!question.responsefileareas) { + return []; + } + + const area = question.responsefileareas.find((area) => area.area == areaName); + + return area?.files || []; + } + + /** + * Get files stored for a question. + * + * @param question Question. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + getStoredQuestionFiles( + question: CoreQuestionQuestion, + component: string, + componentId: string | number, + siteId?: string, + ): Promise<(FileEntry | DirectoryEntry)[]> { + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId); + + return CoreFile.instance.getDirectoryContents(folderPath); + } + + /** + * Get the validation error message from a question HTML if it's there. + * + * @param html Question's HTML. + * @return Validation error message if present. + */ + getValidationErrorFromHtml(html: string): string | undefined { + const element = CoreDomUtils.instance.convertToElement(html); + + return CoreDomUtils.instance.getContentsOfElement(element, '.validationerror'); + } + + /** + * Check if some HTML contains draft file URLs for the current site. + * + * @param html Question's HTML. + * @return Whether it contains draft files URLs. + */ + hasDraftFileUrls(html: string): boolean { + let url = CoreSites.instance.getCurrentSite()?.getURL(); + if (!url) { + return false; + } + + if (url.slice(-1) != '/') { + url = url += '/'; + } + url += 'draftfile.php'; + + return html.indexOf(url) != -1; + } + + /** + * Load local answers of a question. + * + * @param question Question. + * @param component Component. + * @param attemptId Attempt ID. + * @return Promise resolved when done. + */ + async loadLocalAnswers(question: CoreQuestionQuestion, component: string, attemptId: number): Promise { + const answers = await CoreUtils.instance.ignoreErrors( + CoreQuestion.instance.getQuestionAnswers(component, attemptId, question.slot), + ); + + if (answers) { + question.localAnswers = CoreQuestion.instance.convertAnswersArrayToObject(answers, true); + } else { + question.localAnswers = {}; + } + } + + /** + * For each input element found in the HTML, search if there's a local answer stored and + * override the HTML's value with the local one. + * + * @param question Question. + */ + loadLocalAnswersInHtml(question: CoreQuestionQuestion): void { + const element = CoreDomUtils.instance.convertToElement('
' + question.html + '
'); + const form = element.children[0]; + + // Search all input elements. + Array.from(form.elements).forEach((element: HTMLInputElement | HTMLButtonElement) => { + let name = element.name || ''; + // Ignore flag and submit inputs. + if (!name || name.match(/_:flagged$/) || element.type == 'submit' || element.tagName == 'BUTTON' || + !question.localAnswers) { + return; + } + + // Search if there's a local answer. + name = CoreQuestion.instance.removeQuestionPrefix(name); + if (question.localAnswers[name] === undefined) { + if (Object.keys(question.localAnswers).length && element.type == 'radio') { + // No answer stored, but there is a sequencecheck or similar. This means the user cleared his choice. + element.removeAttribute('checked'); + } + + return; + } + + if (element.tagName == 'TEXTAREA') { + // Just put the answer inside the textarea. + element.innerHTML = question.localAnswers[name]; + } else if (element.tagName == 'SELECT') { + // Search the selected option and select it. + const selected = element.querySelector('option[value="' + question.localAnswers[name] + '"]'); + if (selected) { + selected.setAttribute('selected', 'selected'); + } + } else if (element.type == 'radio') { + // Check if this radio is selected. + if (element.value == question.localAnswers[name]) { + element.setAttribute('checked', 'checked'); + } else { + element.removeAttribute('checked'); + } + } else if (element.type == 'checkbox') { + // Check if this checkbox is checked. + if (CoreUtils.instance.isTrueOrOne(question.localAnswers[name])) { + element.setAttribute('checked', 'checked'); + } else { + element.removeAttribute('checked'); + } + } else { + // Put the answer in the value. + element.setAttribute('value', question.localAnswers[name]); + } + }); + + // Update the question HTML. + question.html = form.innerHTML; + } + + /** + * Prefetch the files in a question HTML. + * + * @param question Question. + * @param component The component to link the files to. If not defined, question component. + * @param componentId An ID to use in conjunction with the component. If not defined, question ID. + * @param siteId Site ID. If not defined, current site. + * @param usageId Usage ID. Required in Moodle 3.7+. + * @return Promise resolved when all the files have been downloaded. + */ + async prefetchQuestionFiles( + question: CoreQuestionQuestion, + component?: string, + componentId?: string | number, + siteId?: string, + usageId?: number, + ): Promise { + if (!component) { + component = CoreQuestionProvider.COMPONENT; + componentId = question.number; + } + + const files = CoreQuestionDelegate.instance.getAdditionalDownloadableFiles(question, usageId) || []; + + files.push(...CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(question.html)); + + const site = await CoreSites.instance.getSite(siteId); + + const treated: Record = {}; + + await Promise.all(files.map(async (file) => { + const timemodified = file.timemodified || 0; + + if (treated[file.fileurl]) { + return; + } + treated[file.fileurl] = true; + + if (!site.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(file.fileurl)) { + return; + } + + if (file.fileurl.indexOf('theme/image.php') > -1 && file.fileurl.indexOf('flagged') > -1) { + // Ignore flag images. + return; + } + + await CoreFilepool.instance.addToQueueByUrl(site.getId(), file.fileurl, component, componentId, timemodified); + })); + } + + /** + * Prepare and return the answers. + * + * @param questions The list of questions. + * @param answers The input data. + * @param offline True if data should be saved in offline. + * @param component The component the question is related to. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with answers to send to server. + */ + async prepareAnswers( + questions: CoreQuestionQuestion[], + answers: CoreQuestionsAnswers, + offline: boolean, + component: string, + componentId: string | number, + siteId?: string, + ): Promise { + await CoreUtils.instance.allPromises(questions.map(async (question) => { + await CoreQuestionDelegate.instance.prepareAnswersForQuestion( + question, + answers, + offline, + component, + componentId, + siteId, + ); + })); + + return answers; + } + + /** + * Replace Moodle's correct/incorrect classes with the Mobile ones. + * + * @param element DOM element. + */ + replaceCorrectnessClasses(element: HTMLElement): void { + CoreDomUtils.instance.replaceClassesInElement(element, { + correct: 'core-question-answer-correct', + incorrect: 'core-question-answer-incorrect', + }); + } + + /** + * Replace Moodle's feedback classes with the Mobile ones. + * + * @param element DOM element. + */ + replaceFeedbackClasses(element: HTMLElement): void { + CoreDomUtils.instance.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. + * + * @param question Question. + * @param htmlProperty The name of the property containing the HTML to search. + * @param selector The selector to find the button. + * @return Whether the button is found. + */ + protected searchBehaviourButton(question: CoreQuestionQuestion, htmlProperty: string, selector: string): boolean { + const element = CoreDomUtils.instance.convertToElement(question[htmlProperty]); + + const button = element.querySelector(selector); + if (!button) { + return false; + } + + // Add a behaviour button to the question's "behaviourButtons" property. + this.addBehaviourButton(question, button); + + // Remove the button from the HTML. + button.parentElement?.removeChild(button); + + // Update the question's html. + question[htmlProperty] = element.innerHTML; + + return true; + } + + /** + * Convenience function to show a parsing error and abort. + * + * @param onAbort If supplied, will emit an event. + * @param error Error to show. + */ + showComponentError(onAbort: EventEmitter, error?: string): void { + // Prevent consecutive errors. + const now = Date.now(); + if (now - this.lastErrorShown > 500) { + this.lastErrorShown = now; + CoreDomUtils.instance.showErrorModalDefault(error || '', 'addon.mod_quiz.errorparsequestions', true); + } + + onAbort?.emit(); + } + + /** + * Treat correctness icons, replacing them with local icons and setting click events to show the feedback if needed. + * + * @param element DOM element. + */ + treatCorrectnessIcons(element: HTMLElement): void { + const icons = Array.from(element.querySelectorAll('img.icon, img.questioncorrectnessicon')); + icons.forEach((icon) => { + // Replace the icon with the font version. + if (!icon.src) { + return; + } + + // @todo: Check the right classes to use. + const newIcon: HTMLElement = 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.setAttribute('aria-label', 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 || !icon.classList.contains('icon') && !icon.classList.contains('questioncorrectnessicon')) { + return; + } + + icon.classList.add('questioncorrectnessicon'); + + if (span.innerHTML) { + // There's a hidden feedback. Mark the icon as tappable. + // The click listener is only added if treatCorrectnessIconsClicks is called. + // @todo: Check if another attribute needs to be used now instead of tappable. + icon.setAttribute('tappable', ''); + } + }); + } + + /** + * Add click listeners to all tappable correctness icons. + * + * @param element DOM element. + * @param component The component to use when viewing the feedback. + * @param componentId An ID to use in conjunction with the component. + * @param contextLevel The context level. + * @param contextInstanceId Instance ID related to the context. + * @param courseId Course ID the text belongs to. It can be used to improve performance with filters. + */ + treatCorrectnessIconsClicks( + element: HTMLElement, + component?: string, + componentId?: number, + contextLevel?: string, + contextInstanceId?: number, + courseId?: number, + ): void { + + // @todo: Check if another attribute needs to be used now instead of tappable. + const icons = Array.from(element.querySelectorAll('i.icon.questioncorrectnessicon[tappable]')); + const title = Translate.instance.instant('core.question.feedback'); + + icons.forEach((icon) => { + // Search the feedback for the icon. + const span = icon.parentElement?.querySelector('.feedbackspan.accesshide'); + + if (!span) { + return; + } + + // There's a hidden feedback, show it when the icon is clicked. + icon.addEventListener('click', () => { + CoreTextUtils.instance.viewText(title, span.innerHTML, { + component: component, + componentId: componentId, + filter: true, + contextLevel: contextLevel, + instanceId: contextInstanceId, + courseId: courseId, + }); + }); + }); + } + +} + +export class CoreQuestionHelper extends makeSingleton(CoreQuestionHelperProvider) {} + +/** + * Question with calculated data. + */ +export type CoreQuestionQuestion = CoreQuestionQuestionParsed & { + localAnswers?: Record; + commentHtml?: string; + feedbackHtml?: string; + infoHtml?: string; + behaviourButtons?: CoreQuestionBehaviourButton[]; + behaviourCertaintyOptions?: CoreQuestionBehaviourCertaintyOption[]; + behaviourCertaintySelected?: string; + behaviourSeenInput?: { name: string; value: string }; + scriptsCode?: string; + initObjects?: Record | null; + amdArgs?: unknown[] | null; +}; + +/** + * Question behaviour button. + */ +export type CoreQuestionBehaviourButton = { + id: string; + name: string; + value: string; + disabled: boolean; +}; + +/** + * Question behaviour certainty option. + */ +export type CoreQuestionBehaviourCertaintyOption = CoreQuestionBehaviourButton & { + text: string; +}; diff --git a/src/core/features/question/services/question.ts b/src/core/features/question/services/question.ts new file mode 100644 index 000000000..b7b103533 --- /dev/null +++ b/src/core/features/question/services/question.ts @@ -0,0 +1,616 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 } from '@angular/core'; + +import { CoreFile } from '@services/file'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { + CoreQuestionAnswerDBRecord, + CoreQuestionDBRecord, + QUESTION_ANSWERS_TABLE_NAME, + QUESTION_TABLE_NAME, +} from './database/question'; + + +const QUESTION_PREFIX_REGEX = /q\d+:(\d+)_/; +const STATES: Record = { + todo: { + name: 'todo', + class: 'core-question-notyetanswered', + status: 'notyetanswered', + active: true, + finished: false, + }, + invalid: { + name: 'invalid', + class: 'core-question-invalidanswer', + status: 'invalidanswer', + active: true, + finished: false, + }, + complete: { + name: 'complete', + class: 'core-question-answersaved', + status: 'answersaved', + active: true, + finished: false, + }, + needsgrading: { + name: 'needsgrading', + class: 'core-question-requiresgrading', + status: 'requiresgrading', + active: false, + finished: true, + }, + finished: { + name: 'finished', + class: 'core-question-complete', + status: 'complete', + active: false, + finished: true, + }, + gaveup: { + name: 'gaveup', + class: 'core-question-notanswered', + status: 'notanswered', + active: false, + finished: true, + }, + gradedwrong: { + name: 'gradedwrong', + class: 'core-question-incorrect', + status: 'incorrect', + active: false, + finished: true, + }, + gradedpartial: { + name: 'gradedpartial', + class: 'core-question-partiallycorrect', + status: 'partiallycorrect', + active: false, + finished: true, + }, + gradedright: { + name: 'gradedright', + class: 'core-question-correct', + status: 'correct', + active: false, + finished: true, + }, + mangrwrong: { + name: 'mangrwrong', + class: 'core-question-incorrect', + status: 'incorrect', + active: false, + finished: true, + }, + mangrpartial: { + name: 'mangrpartial', + class: 'core-question-partiallycorrect', + status: 'partiallycorrect', + active: false, + finished: true, + }, + mangrright: { + name: 'mangrright', + class: 'core-question-correct', + status: 'correct', + active: false, + finished: true, + }, + cannotdeterminestatus: { // Special state for Mobile, sometimes we won't have enough data to detemrine the state. + name: 'cannotdeterminestatus', + class: 'core-question-unknown', + status: 'cannotdeterminestatus', + active: true, + finished: false, + }, +}; + +/** + * Service to handle questions. + */ +@Injectable({ providedIn: 'root' }) +export class CoreQuestionProvider { + + static readonly COMPONENT = 'mmQuestion'; + + /** + * Compare that all the answers in two objects are equal, except some extra data like sequencecheck or certainty. + * + * @param prevAnswers Object with previous answers. + * @param newAnswers Object with new answers. + * @return Whether all answers are equal. + */ + compareAllAnswers(prevAnswers: Record, newAnswers: Record): boolean { + // Get all the keys. + const keys = CoreUtils.instance.mergeArraysWithoutDuplicates(Object.keys(prevAnswers), Object.keys(newAnswers)); + + // Check that all the keys have the same value on both objects. + for (const i in keys) { + const key = keys[i]; + + // Ignore extra answers like sequencecheck or certainty. + if (!this.isExtraAnswer(key[0])) { + if (!CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, key)) { + return false; + } + } + } + + return true; + } + + /** + * Convert a list of answers retrieved from local DB to an object with name - value. + * + * @param answers List of answers. + * @param removePrefix Whether to remove the prefix in the answer's name. + * @return Object with name -> value. + */ + convertAnswersArrayToObject(answers: CoreQuestionAnswerDBRecord[], removePrefix?: boolean): Record { + const result: Record = {}; + + answers.forEach((answer) => { + if (removePrefix) { + const nameWithoutPrefix = this.removeQuestionPrefix(answer.name); + result[nameWithoutPrefix] = answer.value; + } else { + result[answer.name] = answer.value; + } + }); + + return result; + } + + /** + * Retrieve an answer from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param name Answer's name. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the answer. + */ + async getAnswer(component: string, attemptId: number, name: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(QUESTION_ANSWERS_TABLE_NAME, { component, attemptid: attemptId, name }); + } + + /** + * Retrieve an attempt answers from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the answers. + */ + async getAttemptAnswers(component: string, attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecords(QUESTION_ANSWERS_TABLE_NAME, { component, attemptid: attemptId }); + } + + /** + * Retrieve an attempt questions from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the questions. + */ + async getAttemptQuestions(component: string, attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecords(QUESTION_TABLE_NAME, { component, attemptid: attemptId }); + } + + /** + * Get all the answers that aren't "extra" (sequencecheck, certainty, ...). + * + * @param answers Object with all the answers. + * @return Object with the basic answers. + */ + getBasicAnswers(answers: Record): Record { + const result: Record = {}; + + for (const name in answers) { + if (!this.isExtraAnswer(name)) { + result[name] = answers[name]; + } + } + + return result; + } + + /** + * Get all the answers that aren't "extra" (sequencecheck, certainty, ...). + * + * @param answers List of answers. + * @return List with the basic answers. + */ + protected getBasicAnswersFromArray(answers: CoreQuestionAnswerDBRecord[]): CoreQuestionAnswerDBRecord[] { + const result: CoreQuestionAnswerDBRecord[] = []; + + answers.forEach((answer) => { + if (this.isExtraAnswer(answer.name)) { + result.push(answer); + } + }); + + return result; + } + + /** + * Retrieve a question from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param slot Question slot. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the question. + */ + async getQuestion(component: string, attemptId: number, slot: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(QUESTION_TABLE_NAME, { component, attemptid: attemptId, slot }); + } + + /** + * Retrieve a question answers from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param slot Question slot. + * @param filter Whether it should ignore "extra" answers like sequencecheck or certainty. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the answers. + */ + async getQuestionAnswers( + component: string, + attemptId: number, + slot: number, + filter?: boolean, + siteId?: string, + ): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + const answers = await db.getRecords( + QUESTION_ANSWERS_TABLE_NAME, + { component, attemptid: attemptId, questionslot: slot }, + ); + + if (filter) { + // Get only answers that isn't "extra" data like sequencecheck or certainty. + return this.getBasicAnswersFromArray(answers); + } else { + return answers; + } + } + + /** + * Given a question and a componentId, return a componentId that is unique for the question. + * + * @param question Question. + * @param componentId Component ID. + * @return Question component ID. + */ + getQuestionComponentId(question: CoreQuestionQuestionParsed, componentId: string | number): string { + return componentId + '_' + question.number; + } + + /** + * Get the path to the folder where to store files for an offline question. + * + * @param type Question type. + * @param component Component the question is related to. + * @param componentId Question component ID, returned by getQuestionComponentId. + * @param siteId Site ID. If not defined, current site. + * @return Folder path. + */ + getQuestionFolder(type: string, component: string, componentId: string, siteId?: string): string { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const siteFolderPath = CoreFile.instance.getSiteFolder(siteId); + const questionFolderPath = 'offlinequestion/' + type + '/' + component + '/' + componentId; + + return CoreTextUtils.instance.concatenatePaths(siteFolderPath, questionFolderPath); + } + + /** + * Extract the question slot from a question name. + * + * @param name Question name. + * @return Question slot. + */ + getQuestionSlotFromName(name: string): number { + if (name) { + const match = name.match(QUESTION_PREFIX_REGEX); + if (match && match[1]) { + return parseInt(match[1], 10); + } + } + + return -1; + } + + /** + * Get question state based on state name. + * + * @param name State name. + * @return State. + */ + getState(name?: string): CoreQuestionState { + return STATES[name || 'cannotdeterminestatus']; + } + + /** + * Check if an answer is extra data like sequencecheck or certainty. + * + * @param name Answer name. + * @return Whether it's extra data. + */ + isExtraAnswer(name: string): boolean { + // Maybe the name still has the prefix. + name = this.removeQuestionPrefix(name); + + return name[0] == '-' || name[0] == ':'; + } + + /** + * Parse questions of a WS response. + * + * @param questions Questions to parse. + * @return Parsed questions. + */ + parseQuestions(questions: CoreQuestionQuestionWSData[]): CoreQuestionQuestionParsed[] { + const parsedQuestions: CoreQuestionQuestionParsed[] = questions; + + parsedQuestions.forEach((question) => { + if (!question.settings) { + return; + } + + question.parsedSettings = CoreTextUtils.instance.parseJSON(question.settings, null); + }); + + return parsedQuestions; + } + + /** + * Remove an attempt answers from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async removeAttemptAnswers(component: string, attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(QUESTION_ANSWERS_TABLE_NAME, { component, attemptid: attemptId }); + } + + /** + * Remove an attempt questions from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async removeAttemptQuestions(component: string, attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(QUESTION_TABLE_NAME, { component, attemptid: attemptId }); + } + + /** + * Remove an answer from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param name Answer's name. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async removeAnswer(component: string, attemptId: number, name: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(QUESTION_ANSWERS_TABLE_NAME, { component, attemptid: attemptId, name }); + } + + /** + * Remove a question from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param slot Question slot. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async removeQuestion(component: string, attemptId: number, slot: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(QUESTION_TABLE_NAME, { component, attemptid: attemptId, slot }); + } + + /** + * Remove a question answers from site DB. + * + * @param component Component the attempt belongs to. + * @param attemptId Attempt ID. + * @param slot Question slot. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async removeQuestionAnswers(component: string, attemptId: number, slot: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(QUESTION_ANSWERS_TABLE_NAME, { component, attemptid: attemptId, questionslot: slot }); + } + + /** + * Remove the prefix from a question answer name. + * + * @param name Question name. + * @return Name without prefix. + */ + removeQuestionPrefix(name: string): string { + if (name) { + return name.replace(QUESTION_PREFIX_REGEX, ''); + } + + return ''; + } + + /** + * Save answers in local DB. + * + * @param component Component the answers belong to. E.g. 'mmaModQuiz'. + * @param componentId ID of the component the answers belong to. + * @param attemptId Attempt ID. + * @param userId User ID. + * @param answers Object with the answers to save. + * @param timemodified Time modified to set in the answers. If not defined, current time. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveAnswers( + component: string, + componentId: number, + attemptId: number, + userId: number, + answers: CoreQuestionsAnswers, + timemodified?: number, + siteId?: string, + ): Promise { + timemodified = timemodified || CoreTimeUtils.instance.timestamp(); + + const db = await CoreSites.instance.getSiteDb(siteId); + const promises: Promise[] = []; + + for (const name in answers) { + const entry: CoreQuestionAnswerDBRecord = { + component, + componentid: componentId, + attemptid: attemptId, + userid: userId, + questionslot: this.getQuestionSlotFromName(name), + name, + value: String(answers[name]), + timemodified, + }; + + promises.push(db.insertRecord(QUESTION_ANSWERS_TABLE_NAME, entry)); + } + + await Promise.all(promises); + } + + /** + * Save a question in local DB. + * + * @param component Component the question belongs to. E.g. 'mmaModQuiz'. + * @param componentId ID of the component the question belongs to. + * @param attemptId Attempt ID. + * @param userId User ID. + * @param question The question to save. + * @param state Question's state. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveQuestion( + component: string, + componentId: number, + attemptId: number, + userId: number, + question: CoreQuestionQuestionParsed, + state: string, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + const entry: CoreQuestionDBRecord = { + component, + componentid: componentId, + attemptid: attemptId, + userid: userId, + number: question.number, // eslint-disable-line id-blacklist + slot: question.slot, + state: state, + }; + + await site.getDb().insertRecord(QUESTION_TABLE_NAME, entry); + } + +} + +export class CoreQuestion extends makeSingleton(CoreQuestionProvider) {} + +/** + * Question state. + */ +export type CoreQuestionState = { + name: string; // Name of the state. + class: string; // Class to style the state. + status: string; // The string key to translate the state. + active: boolean; // Whether the question with this state is active. + finished: boolean; // Whether the question with this state is finished. +}; + +/** + * Data returned by WS for a question. + * Currently this specification is based on quiz WS because they're the only ones returning questions. + */ +export type CoreQuestionQuestionWSData = { + slot: number; // Slot number. + type: string; // Question type, i.e: multichoice. + page: number; // Page of the quiz this question appears on. + html: string; // The question rendered. + responsefileareas?: { // Response file areas including files. + area: string; // File area name. + files?: CoreWSExternalFile[]; + }[]; + sequencecheck?: number; // The number of real steps in this attempt. + lastactiontime?: number; // The timestamp of the most recent step in this question attempt. + hasautosavedstep?: boolean; // Whether this question attempt has autosaved data. + flagged: boolean; // Whether the question is flagged or not. + // eslint-disable-next-line id-blacklist + number?: number; // Question ordering number in the quiz. + state?: string; // The state where the question is in. It won't be returned if the user cannot see it. + status?: string; // Current formatted state of the question. + blockedbyprevious?: boolean; // Whether the question is blocked by the previous question. + mark?: string; // The mark awarded. It will be returned only if the user is allowed to see it. + maxmark?: number; // The maximum mark possible for this question attempt. + settings?: string; // Question settings (JSON encoded). +}; +/** + * Question data with parsed data. + */ +export type CoreQuestionQuestionParsed = CoreQuestionQuestionWSData & { + parsedSettings?: Record | null; +}; + +/** + * List of answers to a set of questions. + */ +export type CoreQuestionsAnswers = Record;