From dd060d8168326a518860f38ae83ce24212ace499 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 5 Feb 2021 14:28:15 +0100 Subject: [PATCH 01/16] MOBILE-3651 question: Implement services and classes --- .../classes/base-behaviour-handler.ts | 77 ++ .../question/classes/base-question-handler.ts | 167 ++++ .../question/components/components.module.ts | 31 + .../components/question/core-question.html | 44 + .../question/components/question/question.ts | 176 ++++ src/core/features/question/lang.json | 22 + src/core/features/question/question.scss | 142 +++ .../question/services/behaviour-delegate.ts | 135 +++ .../question/services/database/question.ts | 132 +++ .../services/handlers/default-behaviour.ts | 28 + .../services/handlers/default-question.ts | 28 + .../question/services/question-delegate.ts | 460 +++++++++ .../question/services/question-helper.ts | 895 ++++++++++++++++++ .../features/question/services/question.ts | 616 ++++++++++++ 14 files changed, 2953 insertions(+) create mode 100644 src/core/features/question/classes/base-behaviour-handler.ts create mode 100644 src/core/features/question/classes/base-question-handler.ts create mode 100644 src/core/features/question/components/components.module.ts create mode 100644 src/core/features/question/components/question/core-question.html create mode 100644 src/core/features/question/components/question/question.ts create mode 100644 src/core/features/question/lang.json create mode 100644 src/core/features/question/question.scss create mode 100644 src/core/features/question/services/behaviour-delegate.ts create mode 100644 src/core/features/question/services/database/question.ts create mode 100644 src/core/features/question/services/handlers/default-behaviour.ts create mode 100644 src/core/features/question/services/handlers/default-question.ts create mode 100644 src/core/features/question/services/question-delegate.ts create mode 100644 src/core/features/question/services/question-helper.ts create mode 100644 src/core/features/question/services/question.ts 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; From 596ef954baa8765e50b6907df0e1be7c42a66223 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 9 Feb 2021 08:21:00 +0100 Subject: [PATCH 02/16] MOBILE-3651 quiz: Implement base services and access rule delegate --- .../mod/lesson/services/handlers/prefetch.ts | 6 +- src/addons/mod/quiz/lang.json | 83 + .../quiz/services/access-rules-delegate.ts | 326 +++ src/addons/mod/quiz/services/database/quiz.ts | 83 + src/addons/mod/quiz/services/quiz-helper.ts | 436 +++ src/addons/mod/quiz/services/quiz-offline.ts | 372 +++ src/addons/mod/quiz/services/quiz.ts | 2402 +++++++++++++++++ src/core/services/utils/utils.ts | 6 +- 8 files changed, 3708 insertions(+), 6 deletions(-) create mode 100644 src/addons/mod/quiz/lang.json create mode 100644 src/addons/mod/quiz/services/access-rules-delegate.ts create mode 100644 src/addons/mod/quiz/services/database/quiz.ts create mode 100644 src/addons/mod/quiz/services/quiz-helper.ts create mode 100644 src/addons/mod/quiz/services/quiz-offline.ts create mode 100644 src/addons/mod/quiz/services/quiz.ts diff --git a/src/addons/mod/lesson/services/handlers/prefetch.ts b/src/addons/mod/lesson/services/handlers/prefetch.ts index 943a3ab32..6cb182a06 100644 --- a/src/addons/mod/lesson/services/handlers/prefetch.ts +++ b/src/addons/mod/lesson/services/handlers/prefetch.ts @@ -60,13 +60,13 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref await modal.present(); - const password = await modal.onWillDismiss(); + const result = await modal.onWillDismiss(); - if (typeof password != 'string') { + if (typeof result.data != 'string') { throw new CoreCanceledError(); } - return password; + return result.data; } /** diff --git a/src/addons/mod/quiz/lang.json b/src/addons/mod/quiz/lang.json new file mode 100644 index 000000000..080059c18 --- /dev/null +++ b/src/addons/mod/quiz/lang.json @@ -0,0 +1,83 @@ +{ + "answercolon": "Answer:", + "attemptfirst": "First attempt", + "attemptlast": "Last attempt", + "attemptnumber": "Attempt", + "attemptquiznow": "Attempt quiz now", + "attemptstate": "State", + "canattemptbutnotsubmit": "You can attempt this quiz in the app, but you will need to submit the attempt in browser for the following reasons:", + "cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:", + "clearchoice": "Clear my choice", + "comment": "Comment", + "completedon": "Completed on", + "confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.", + "confirmcontinueoffline": "This attempt has not been synchronised since {{$a}}. If you have continued this attempt in another device since then, you may lose data.", + "confirmleavequizonerror": "An error occurred while saving the answers. Are you sure you want to leave the quiz?", + "confirmstart": "Your attempt will have a time limit of {{$a}}. When you start, the timer will begin to count down and cannot be paused. You must finish your attempt before it expires. Are you sure you wish to start now?", + "confirmstartheader": "Time limit", + "connectionerror": "Network connection lost. (Autosave failed).\n\nMake a note of any responses entered on this page in the last few minutes, then try to re-connect.\n\nOnce connection has been re-established, your responses should be saved and this message will disappear.", + "continueattemptquiz": "Continue the last attempt", + "continuepreview": "Continue the last preview", + "errorbehaviournotsupported": "This quiz can't be attempted in the app because the question behaviour is not supported by the app:", + "errordownloading": "Error downloading required data.", + "errorgetattempt": "Error getting attempt data.", + "errorgetquestions": "Error getting questions.", + "errorgetquiz": "Error getting quiz data.", + "errorparsequestions": "An error occurred while reading the questions. Please attempt this quiz in a web browser.", + "errorquestionsnotsupported": "This quiz can't be attempted in the app because it only contains questions not supported by the app:", + "errorrulesnotsupported": "This quiz can't be attempted in the app because it has access rules not supported by the app:", + "errorsaveattempt": "An error occurred while saving the attempt data.", + "feedback": "Feedback", + "finishattemptdots": "Finish attempt...", + "finishnotsynced": "Finished but not synchronised", + "grade": "Grade", + "gradeaverage": "Average grade", + "gradehighest": "Highest grade", + "grademethod": "Grading method", + "gradesofar": "{{$a.method}}: {{$a.mygrade}} / {{$a.quizgrade}}.", + "marks": "Marks", + "modulenameplural": "Quizzes", + "mustbesubmittedby": "This attempt must be submitted by {{$a}}.", + "noquestions": "No questions have been added yet", + "noreviewattempt": "You are not allowed to review this attempt.", + "notyetgraded": "Not yet graded", + "opentoc": "Open navigation popover", + "outof": "{{$a.grade}} out of {{$a.maxgrade}}", + "outofpercent": "{{$a.grade}} out of {{$a.maxgrade}} ({{$a.percent}}%)", + "outofshort": "{{$a.grade}}/{{$a.maxgrade}}", + "overallfeedback": "Overall feedback", + "overdue": "Overdue", + "overduemustbesubmittedby": "This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {{$a}}. If you do not submit it by then, no marks from this attempt will be counted.", + "preview": "Preview", + "previewquiznow": "Preview quiz now", + "question": "Question", + "quiznavigation": "Quiz navigation", + "quizpassword": "Quiz password", + "reattemptquiz": "Re-attempt quiz", + "requirepasswordmessage": "To attempt this quiz you need to know the quiz password", + "returnattempt": "Return to attempt", + "review": "Review", + "reviewofattempt": "Review of attempt {{$a}}", + "reviewofpreview": "Review of preview", + "showall": "Show all questions on one page", + "showeachpage": "Show one page at a time", + "startattempt": "Start attempt", + "startedon": "Started on", + "stateabandoned": "Never submitted", + "statefinished": "Finished", + "statefinisheddetails": "Submitted {{$a}}", + "stateinprogress": "In progress", + "stateoverdue": "Overdue", + "stateoverduedetails": "Must be submitted by {{$a}}", + "status": "Status", + "submitallandfinish": "Submit all and finish", + "summaryofattempt": "Summary of attempt", + "summaryofattempts": "Summary of your previous attempts", + "timeleft": "Time left", + "timetaken": "Time taken", + "warningattemptfinished": "Offline attempt discarded as it was finished on the site or not found.", + "warningdatadiscarded": "Some offline answers were discarded because the questions were modified online.", + "warningdatadiscardedfromfinished": "Attempt unfinished because some offline answers were discarded. Please review your answers then resubmit the attempt.", + "warningquestionsnotsupported": "This quiz contains questions not supported by the app:", + "yourfinalgradeis": "Your final grade for this quiz is {{$a}}." +} \ No newline at end of file diff --git a/src/addons/mod/quiz/services/access-rules-delegate.ts b/src/addons/mod/quiz/services/access-rules-delegate.ts new file mode 100644 index 000000000..3f6142853 --- /dev/null +++ b/src/addons/mod/quiz/services/access-rules-delegate.ts @@ -0,0 +1,326 @@ +// (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 { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from './quiz'; + +/** + * Interface that all access rules handlers must implement. + */ +export interface AddonModQuizAccessRuleHandler extends CoreDelegateHandler { + + /** + * Name of the rule the handler supports. E.g. 'password'. + */ + ruleName: string; + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired( + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): boolean | Promise; + + /** + * Add preflight data that doesn't require user interaction. The data should be added to the preflightData param. + * + * @param quiz The quiz the rule belongs to. + * @param preflightData Object where to add the preflight data. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + getFixedPreflightData?( + quiz: AddonModQuizQuizWSData, + preflightData: Record, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): void | Promise; + + /** + * Return the Component to use to display the access rule preflight. + * Implement this if your access rule requires a preflight check with user interaction. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getPreflightComponent?(): Type | Promise>; + + /** + * Function called when the preflight check has passed. This is a chance to record that fact in some way. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + notifyPreflightCheckPassed?( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): void | Promise; + + /** + * Function called when the preflight check fails. This is a chance to record that fact in some way. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + notifyPreflightCheckFailed?( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): void | Promise; + + /** + * Whether or not the time left of an attempt should be displayed. + * + * @param attempt The attempt. + * @param endTime The attempt end time (in seconds). + * @param timeNow The current time in seconds. + * @return Whether it should be displayed. + */ + shouldShowTimeLeft?(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean; +} + +/** + * Delegate to register access rules for quiz module. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessRuleDelegateService extends CoreDelegate { + + protected handlerNameProperty = 'ruleName'; + + constructor() { + super('AddonModQuizAccessRulesDelegate', true); + } + + /** + * Get the handler for a certain rule. + * + * @param ruleName Name of the access rule. + * @return Handler. Undefined if no handler found for the rule. + */ + getAccessRuleHandler(ruleName: string): AddonModQuizAccessRuleHandler { + return this.getHandler(ruleName, true); + } + + /** + * Given a list of rules, get some fixed preflight data (data that doesn't require user interaction). + * + * @param rules List of active rules names. + * @param quiz Quiz. + * @param preflightData Object where to store the preflight data. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when all the data has been gathered. + */ + async getFixedPreflightData( + rules: string[], + quiz: AddonModQuizQuizWSData, + preflightData: Record, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): Promise { + rules = rules || []; + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => { + await this.executeFunctionOnEnabled(rule, 'getFixedPreflightData', [quiz, preflightData, attempt, prefetch, siteId]); + }))); + } + + /** + * Get the Component to use to display the access rule preflight. + * + * @param rule Rule. + * @return Promise resolved with the component to use, undefined if not found. + */ + getPreflightComponent(rule: string): Promise | undefined> { + return Promise.resolve(this.executeFunctionOnEnabled(rule, 'getPreflightComponent', [])); + } + + /** + * Check if an access rule is supported. + * + * @param ruleName Name of the rule. + * @return Whether it's supported. + */ + isAccessRuleSupported(ruleName: string): boolean { + return this.hasHandler(ruleName, true); + } + + /** + * Given a list of rules, check if preflight check is required. + * + * @param rules List of active rules names. + * @param quiz Quiz. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's required. + */ + async isPreflightCheckRequired( + rules: string[], + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): Promise { + rules = rules || []; + let isRequired = false; + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => { + const ruleRequired = await this.isPreflightCheckRequiredForRule(rule, quiz, attempt, prefetch, siteId); + + isRequired = isRequired || ruleRequired; + }))); + + return isRequired; + } + + /** + * Check if preflight check is required for a certain rule. + * + * @param rule Rule name. + * @param quiz Quiz. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's required. + */ + async isPreflightCheckRequiredForRule( + rule: string, + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): Promise { + const isRequired = await this.executeFunctionOnEnabled(rule, 'isPreflightCheckRequired', [quiz, attempt, prefetch, siteId]); + + return !!isRequired; + } + + /** + * Notify all rules that the preflight check has passed. + * + * @param rules List of active rules names. + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async notifyPreflightCheckPassed( + rules: string[], + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): Promise { + rules = rules || []; + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => { + await this.executeFunctionOnEnabled( + rule, + 'notifyPreflightCheckPassed', + [quiz, attempt, preflightData, prefetch, siteId], + ); + }))); + } + + /** + * Notify all rules that the preflight check has failed. + * + * @param rules List of active rules names. + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async notifyPreflightCheckFailed( + rules: string[], + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): Promise { + rules = rules || []; + + await CoreUtils.instance.ignoreErrors(CoreUtils.instance.allPromises(rules.map(async (rule) => { + await this.executeFunctionOnEnabled( + rule, + 'notifyPreflightCheckFailed', + [quiz, attempt, preflightData, prefetch, siteId], + ); + }))); + } + + /** + * Whether or not the time left of an attempt should be displayed. + * + * @param rules List of active rules names. + * @param attempt The attempt. + * @param endTime The attempt end time (in seconds). + * @param timeNow The current time in seconds. + * @return Whether it should be displayed. + */ + shouldShowTimeLeft(rules: string[], attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean { + rules = rules || []; + + for (const i in rules) { + const rule = rules[i]; + + if (this.executeFunctionOnEnabled(rule, 'shouldShowTimeLeft', [attempt, endTime, timeNow])) { + return true; + } + } + + return false; + } + +} + +export class AddonModQuizAccessRuleDelegate extends makeSingleton(AddonModQuizAccessRuleDelegateService) {} diff --git a/src/addons/mod/quiz/services/database/quiz.ts b/src/addons/mod/quiz/services/database/quiz.ts new file mode 100644 index 000000000..7d4ae57b8 --- /dev/null +++ b/src/addons/mod/quiz/services/database/quiz.ts @@ -0,0 +1,83 @@ +// (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 AddonModQuizOfflineProvider. + */ +export const ATTEMPTS_TABLE_NAME = 'addon_mod_quiz_attempts'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModQuizOfflineProvider', + version: 1, + tables: [ + { + name: ATTEMPTS_TABLE_NAME, + columns: [ + { + name: 'id', // Attempt ID. + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'attempt', // Attempt number. + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'userid', + type: 'INTEGER', + }, + { + name: 'quizid', + type: 'INTEGER', + }, + { + name: 'currentpage', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'finished', + type: 'INTEGER', + }, + ], + }, + ], +}; + +/** + * Quiz attempt. + */ +export type AddonModQuizAttemptDBRecord = { + id: number; + attempt: number; + courseid: number; + userid: number; + quizid: number; + currentpage?: number; + timecreated: number; + timemodified: number; + finished: number; +}; diff --git a/src/addons/mod/quiz/services/quiz-helper.ts b/src/addons/mod/quiz/services/quiz-helper.ts new file mode 100644 index 000000000..74967b107 --- /dev/null +++ b/src/addons/mod/quiz/services/quiz-helper.ts @@ -0,0 +1,436 @@ +// (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 { CoreCanceledError } from '@classes/errors/cancelederror'; +import { CoreError } from '@classes/errors/error'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, ModalController, Translate } from '@singletons'; +import { AddonModQuizPreflightModalComponent } from '../components/preflight-modal/preflight-modal'; +import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; +import { + AddonModQuiz, + AddonModQuizAttemptWSData, + AddonModQuizCombinedReviewOptions, + AddonModQuizGetQuizAccessInformationWSResponse, + AddonModQuizProvider, + AddonModQuizQuizWSData, +} from './quiz'; +import { AddonModQuizOffline } from './quiz-offline'; + +/** + * Helper service that provides some features for quiz. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizHelperProvider { + + /** + * Validate a preflight data or show a modal to input the preflight data if required. + * It calls AddonModQuizProvider.startAttempt if a new attempt is needed. + * + * @param quiz Quiz. + * @param accessInfo Quiz access info. + * @param preflightData Object where to store the preflight data. + * @param attempt Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param offline Whether the attempt is offline. + * @param prefetch Whether user is prefetching. + * @param title The title to display in the modal and in the submit button. + * @param siteId Site ID. If not defined, current site. + * @param retrying Whether we're retrying after a failure. + * @return Promise resolved when the preflight data is validated. The resolve param is the attempt. + */ + async getAndCheckPreflightData( + quiz: AddonModQuizQuizWSData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + preflightData: Record, + attempt?: AddonModQuizAttemptWSData, + offline?: boolean, + prefetch?: boolean, + title?: string, + siteId?: string, + retrying?: boolean, + ): Promise { + + const rules = accessInfo?.activerulenames; + + // Check if the user needs to input preflight data. + const preflightCheckRequired = await AddonModQuizAccessRuleDelegate.instance.isPreflightCheckRequired( + rules, + quiz, + attempt, + prefetch, + siteId, + ); + + if (preflightCheckRequired) { + // Preflight check is required. Show a modal with the preflight form. + const data = await this.getPreflightData(quiz, accessInfo, attempt, prefetch, title, siteId); + + // Data entered by the user, add it to preflight data and check it again. + Object.assign(preflightData, data); + } + + // Get some fixed preflight data from access rules (data that doesn't require user interaction). + await AddonModQuizAccessRuleDelegate.instance.getFixedPreflightData(rules, quiz, preflightData, attempt, prefetch, siteId); + + try { + // All the preflight data is gathered, now validate it. + return await this.validatePreflightData(quiz, accessInfo, preflightData, attempt, offline, prefetch, siteId); + } catch (error) { + + if (prefetch) { + throw error; + } else if (retrying && !preflightCheckRequired) { + // We're retrying after a failure, but the preflight check wasn't required. + // This means there's something wrong with some access rule or user is offline and data isn't cached. + // Don't retry again because it would lead to an infinite loop. + throw error; + } + + // Show error and ask for the preflight again. + // Wait to show the error because we want it to be shown over the preflight modal. + setTimeout(() => { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + }, 100); + + return this.getAndCheckPreflightData( + quiz, + accessInfo, + preflightData, + attempt, + offline, + prefetch, + title, + siteId, + true, + ); + } + } + + /** + * Get the preflight data from the user using a modal. + * + * @param quiz Quiz. + * @param accessInfo Quiz access info. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param title The title to display in the modal and in the submit button. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the preflight data. Rejected if user cancels. + */ + async getPreflightData( + quiz: AddonModQuizQuizWSData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + title?: string, + siteId?: string, + ): Promise> { + const notSupported: string[] = []; + const rules = accessInfo?.activerulenames; + + // Check if there is any unsupported rule. + rules.forEach((rule) => { + if (!AddonModQuizAccessRuleDelegate.instance.isAccessRuleSupported(rule)) { + notSupported.push(rule); + } + }); + + if (notSupported.length) { + throw new CoreError( + Translate.instance.instant('addon.mod_quiz.errorrulesnotsupported') + ' ' + JSON.stringify(notSupported), + ); + } + + // Create and show the modal. + const modal = await ModalController.instance.create({ + component: AddonModQuizPreflightModalComponent, + componentProps: { + title: title, + quiz, + attempt, + prefetch: !!prefetch, + siteId: siteId, + rules: rules, + }, + }); + + await modal.present(); + + const result = await modal.onWillDismiss(); + + if (!result.data) { + throw new CoreCanceledError(); + } + + return > result.data; + } + + /** + * Gets the mark string from a question HTML. + * Example result: "Marked out of 1.00". + * + * @param html Question's HTML. + * @return Question's mark. + */ + getQuestionMarkFromHtml(html: string): string | undefined { + const element = CoreDomUtils.instance.convertToElement(html); + + return CoreDomUtils.instance.getContentsOfElement(element, '.grade'); + } + + /** + * Get a quiz ID by attempt ID. + * + * @param attemptId Attempt ID. + * @param options Other options. + * @return Promise resolved with the quiz ID. + */ + async getQuizIdByAttemptId(attemptId: number, options: { cmId?: number; siteId?: string } = {}): Promise { + // Use getAttemptReview to retrieve the quiz ID. + const reviewData = await AddonModQuiz.instance.getAttemptReview(attemptId, options); + + if (reviewData.attempt.quiz) { + return reviewData.attempt.quiz; + } + + throw new CoreError('Cannot get quiz ID.'); + } + + /** + * Handle a review link. + * + * @param attemptId Attempt ID. + * @param page Page to load, -1 to all questions in same page. + * @param courseId Course ID. + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async handleReviewLink(attemptId: number, page?: number, courseId?: number, quizId?: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + if (!quizId) { + quizId = await this.getQuizIdByAttemptId(attemptId, { siteId }); + } + if (!courseId) { + courseId = await CoreCourseHelper.instance.getModuleCourseIdByInstance(quizId, 'quiz', siteId); + } + + // Go to the review page. + const pageParams = { + quizId, + attemptId, + courseId, + page: page == undefined || isNaN(page) ? -1 : page, + }; + + await CoreNavigator.instance.navigateToSitePath('@todo AddonModQuizReviewPage', { params: pageParams, siteId }); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'An error occurred while loading the required data.'); + } finally { + modal.dismiss(); + } + } + + /** + * Add some calculated data to the attempt. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param highlight Whether we should check if attempt should be highlighted. + * @param bestGrade Quiz's best grade (formatted). Required if highlight=true. + * @param isLastAttempt Whether the attempt is the last one. + * @param siteId Site ID. + */ + async setAttemptCalculatedData( + quiz: AddonModQuizQuizData, + attempt: AddonModQuizAttemptWSData, + highlight?: boolean, + bestGrade?: string, + isLastAttempt?: boolean, + siteId?: string, + ): Promise { + const formattedAttempt = attempt; + + formattedAttempt.rescaledGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false); + formattedAttempt.finished = AddonModQuiz.instance.isAttemptFinished(attempt.state); + formattedAttempt.readableState = AddonModQuiz.instance.getAttemptReadableState(quiz, attempt); + + if (quiz.showMarkColumn && formattedAttempt.finished) { + formattedAttempt.readableMark = AddonModQuiz.instance.formatGrade(attempt.sumgrades, quiz.decimalpoints); + } else { + formattedAttempt.readableMark = ''; + } + + if (quiz.showGradeColumn && formattedAttempt.finished) { + formattedAttempt.readableGrade = AddonModQuiz.instance.formatGrade( + Number(formattedAttempt.rescaledGrade), + quiz.decimalpoints, + ); + + // Highlight the highest grade if appropriate. + formattedAttempt.highlightGrade = !!(highlight && !attempt.preview && + attempt.state == AddonModQuizProvider.ATTEMPT_FINISHED && formattedAttempt.readableGrade == bestGrade); + } else { + formattedAttempt.readableGrade = ''; + } + + if (isLastAttempt || isLastAttempt === undefined) { + formattedAttempt.finishedOffline = await AddonModQuiz.instance.isAttemptFinishedOffline(attempt.id, siteId); + } + + return formattedAttempt; + } + + /** + * Add some calculated data to the quiz. + * + * @param quiz Quiz. + * @param options Review options. + */ + setQuizCalculatedData(quiz: AddonModQuizQuizWSData, options: AddonModQuizCombinedReviewOptions): AddonModQuizQuizData { + const formattedQuiz = quiz; + + formattedQuiz.sumGradesFormatted = AddonModQuiz.instance.formatGrade(quiz.sumgrades, quiz.decimalpoints); + formattedQuiz.gradeFormatted = AddonModQuiz.instance.formatGrade(quiz.grade, quiz.decimalpoints); + + formattedQuiz.showAttemptColumn = quiz.attempts != 1; + formattedQuiz.showGradeColumn = options.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX && + AddonModQuiz.instance.quizHasGrades(quiz); + formattedQuiz.showMarkColumn = formattedQuiz.showGradeColumn && quiz.grade != quiz.sumgrades; + formattedQuiz.showFeedbackColumn = !!quiz.hasfeedback && !!options.alloptions.overallfeedback; + + return formattedQuiz; + } + + /** + * Validate the preflight data. It calls AddonModQuizProvider.startAttempt if a new attempt is needed. + * + * @param quiz Quiz. + * @param accessInfo Quiz access info. + * @param preflightData Object where to store the preflight data. + * @param attempt Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param offline Whether the attempt is offline. + * @param sent Whether preflight data has been entered by the user. + * @param prefetch Whether user is prefetching. + * @param title The title to display in the modal and in the submit button. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the preflight data is validated. + */ + async validatePreflightData( + quiz: AddonModQuizQuizWSData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + preflightData: Record, + attempt?: AddonModQuizAttempt, + offline?: boolean, + prefetch?: boolean, + siteId?: string, + ): Promise { + + const rules = accessInfo.activerulenames; + const modOptions = { + cmId: quiz.coursemodule, + readingStrategy: offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + try { + if (attempt) { + if (attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !attempt.finishedOffline) { + // We're continuing an attempt. Call getAttemptData to validate the preflight data. + await AddonModQuiz.instance.getAttemptData(attempt.id, attempt.currentpage!, preflightData, modOptions); + + if (offline) { + // Get current page stored in local. + const storedAttempt = await CoreUtils.instance.ignoreErrors( + AddonModQuizOffline.instance.getAttemptById(attempt.id), + ); + + attempt.currentpage = storedAttempt?.currentpage ?? attempt.currentpage; + } + } else { + // Attempt is overdue or finished in offline, we can only see the summary. + // Call getAttemptSummary to validate the preflight data. + await AddonModQuiz.instance.getAttemptSummary(attempt.id, preflightData, modOptions); + } + } else { + // We're starting a new attempt, call startAttempt. + attempt = await AddonModQuiz.instance.startAttempt(quiz.id, preflightData, false, siteId); + } + + // Preflight data validated. + AddonModQuizAccessRuleDelegate.instance.notifyPreflightCheckPassed( + rules, + quiz, + attempt, + preflightData, + prefetch, + siteId, + ); + + return attempt; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService returned an error, assume the preflight failed. + AddonModQuizAccessRuleDelegate.instance.notifyPreflightCheckFailed( + rules, + quiz, + attempt, + preflightData, + prefetch, + siteId, + ); + } + + throw error; + } + } + +} + +export class AddonModQuizHelper extends makeSingleton(AddonModQuizHelperProvider) {} + +/** + * Quiz data with calculated data. + */ +export type AddonModQuizQuizData = AddonModQuizQuizWSData & { + sumGradesFormatted?: string; + gradeFormatted?: string; + showAttemptColumn?: boolean; + showGradeColumn?: boolean; + showMarkColumn?: boolean; + showFeedbackColumn?: boolean; +}; + +/** + * Attempt data with calculated data. + */ +export type AddonModQuizAttempt = AddonModQuizAttemptWSData & { + finishedOffline?: boolean; + rescaledGrade?: string; + finished?: boolean; + readableState?: string[]; + readableMark?: string; + readableGrade?: string; + highlightGrade?: boolean; +}; diff --git a/src/addons/mod/quiz/services/quiz-offline.ts b/src/addons/mod/quiz/services/quiz-offline.ts new file mode 100644 index 000000000..b4ce83f33 --- /dev/null +++ b/src/addons/mod/quiz/services/quiz-offline.ts @@ -0,0 +1,372 @@ +// (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 { CoreQuestionBehaviourDelegate, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionAnswerDBRecord } from '@features/question/services/database/question'; +import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { AddonModQuizAttemptDBRecord, ATTEMPTS_TABLE_NAME } from './database/quiz'; +import { AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz'; + +/** + * Service to handle offline quiz. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizOfflineProvider { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('AddonModQuizOfflineProvider'); + } + + /** + * Classify the answers in questions. + * + * @param answers List of answers. + * @return Object with the questions, the keys are the slot. Each question contains its answers. + */ + classifyAnswersInQuestions(answers: CoreQuestionsAnswers): AddonModQuizQuestionsWithAnswers { + const questionsWithAnswers: AddonModQuizQuestionsWithAnswers = {}; + + // Classify the answers in each question. + for (const name in answers) { + const slot = CoreQuestion.instance.getQuestionSlotFromName(name); + const nameWithoutPrefix = CoreQuestion.instance.removeQuestionPrefix(name); + + if (!questionsWithAnswers[slot]) { + questionsWithAnswers[slot] = { + answers: {}, + prefix: name.substr(0, name.indexOf(nameWithoutPrefix)), + }; + } + questionsWithAnswers[slot].answers[nameWithoutPrefix] = answers[name]; + } + + return questionsWithAnswers; + } + + /** + * Given a list of questions with answers classified in it, returns a list of answers (including prefix in the name). + * + * @param questions Questions. + * @return Answers. + */ + extractAnswersFromQuestions(questions: AddonModQuizQuestionsWithAnswers): CoreQuestionsAnswers { + const answers: CoreQuestionsAnswers = {}; + + for (const slot in questions) { + const question = questions[slot]; + + for (const name in question.answers) { + answers[question.prefix + name] = question.answers[name]; + } + } + + return answers; + } + + /** + * Get all the offline attempts in a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the offline attempts. + */ + async getAllAttempts(siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getAllRecords(ATTEMPTS_TABLE_NAME); + } + + /** + * Retrieve an attempt answers from site DB. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the answers. + */ + getAttemptAnswers(attemptId: number, siteId?: string): Promise { + return CoreQuestion.instance.getAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId); + } + + /** + * Retrieve an attempt from site DB. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the attempt. + */ + async getAttemptById(attemptId: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + return db.getRecord(ATTEMPTS_TABLE_NAME, { id: attemptId }); + } + + /** + * Retrieve an attempt from site DB. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, user current site's user. + * @return Promise resolved with the attempts. + */ + async getQuizAttempts(quizId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecords(ATTEMPTS_TABLE_NAME, { quizid: quizId, userid: userId || site.getUserId() }); + } + + /** + * Load local state in the questions. + * + * @param attemptId Attempt ID. + * @param questions List of questions. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async loadQuestionsLocalStates( + attemptId: number, + questions: CoreQuestionQuestionParsed[], + siteId?: string, + ): Promise { + + await Promise.all(questions.map(async (question) => { + const dbQuestion = await CoreUtils.instance.ignoreErrors( + CoreQuestion.instance.getQuestion(AddonModQuizProvider.COMPONENT, attemptId, question.slot, siteId), + ); + + if (!dbQuestion) { + // Question not found. + return; + } + + const state = CoreQuestion.instance.getState(dbQuestion.state); + question.state = dbQuestion.state; + question.status = Translate.instance.instant('core.question.' + state.status); + })); + + return questions; + } + + /** + * Process an attempt, saving its data. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param questions Object with the questions of the quiz. The keys should be the question slot. + * @param data Data to save. + * @param finish Whether to finish the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async processAttempt( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + questions: Record, + data: CoreQuestionsAnswers, + finish?: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + const now = CoreTimeUtils.instance.timestamp(); + + const db = await CoreSites.instance.getSiteDb(siteId); + + // Check if an attempt already exists. Return a new one if it doesn't. + let entry = await CoreUtils.instance.ignoreErrors(this.getAttemptById(attempt.id, siteId)); + + if (entry) { + entry.timemodified = now; + entry.finished = finish ? 1 : 0; + } else { + entry = { + quizid: quiz.id, + userid: attempt.userid!, + id: attempt.id, + courseid: quiz.course, + timecreated: now, + attempt: attempt.attempt!, + currentpage: attempt.currentpage, + timemodified: now, + finished: finish ? 1 : 0, + }; + } + + // Save attempt in DB. + await db.insertRecord(ATTEMPTS_TABLE_NAME, entry); + + // Attempt has been saved, now we need to save the answers. + await this.saveAnswers(quiz, attempt, questions, data, now, siteId); + } + + /** + * Remove an attempt and its answers from local DB. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async removeAttemptAndAnswers(attemptId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const db = await CoreSites.instance.getSiteDb(siteId); + + await Promise.all([ + CoreQuestion.instance.removeAttemptAnswers(AddonModQuizProvider.COMPONENT, attemptId, siteId), + CoreQuestion.instance.removeAttemptQuestions(AddonModQuizProvider.COMPONENT, attemptId, siteId), + db.deleteRecords(ATTEMPTS_TABLE_NAME, { id: attemptId }), + ]); + } + + /** + * Remove a question and its answers from local DB. + * + * @param attemptId Attempt ID. + * @param slot Question slot. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when finished. + */ + async removeQuestionAndAnswers(attemptId: number, slot: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await Promise.all([ + CoreQuestion.instance.removeQuestion(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId), + CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attemptId, slot, siteId), + ]); + } + + /** + * Save an attempt's answers and calculate state for questions modified. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param questions Object with the questions of the quiz. The keys should be the question slot. + * @param answers Answers to save. + * @param timeMod 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( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + questions: Record, + answers: CoreQuestionsAnswers, + timeMod?: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + timeMod = timeMod || CoreTimeUtils.instance.timestamp(); + + const questionsWithAnswers: Record = {}; + const newStates: Record = {}; + + // Classify the answers in each question. + for (const name in answers) { + const slot = CoreQuestion.instance.getQuestionSlotFromName(name); + const nameWithoutPrefix = CoreQuestion.instance.removeQuestionPrefix(name); + + if (questions[slot]) { + if (!questionsWithAnswers[slot]) { + questionsWithAnswers[slot] = questions[slot]; + questionsWithAnswers[slot].answers = {}; + } + questionsWithAnswers[slot].answers![nameWithoutPrefix] = answers[name]; + } + } + + // First determine the new state of each question. We won't save the new state yet. + await Promise.all(Object.values(questionsWithAnswers).map(async (question) => { + + const state = await CoreQuestionBehaviourDelegate.instance.determineNewState( + quiz.preferredbehaviour!, + AddonModQuizProvider.COMPONENT, + attempt.id, + question, + quiz.coursemodule, + siteId, + ); + + // Check if state has changed. + if (state && state.name != question.state) { + newStates[question.slot] = state.name; + } + + // Delete previously stored answers for this question. + await CoreQuestion.instance.removeQuestionAnswers(AddonModQuizProvider.COMPONENT, attempt.id, question.slot, siteId); + })); + + // Now save the answers. + await CoreQuestion.instance.saveAnswers( + AddonModQuizProvider.COMPONENT, + quiz.id, + attempt.id, + attempt.userid!, + answers, + timeMod, + siteId, + ); + + try { + // Answers have been saved, now we can save the questions with the states. + await CoreUtils.instance.allPromises(Object.keys(newStates).map(async (slot) => { + const question = questionsWithAnswers[Number(slot)]; + + await CoreQuestion.instance.saveQuestion( + AddonModQuizProvider.COMPONENT, + quiz.id, + attempt.id, + attempt.userid!, + question, + newStates[slot], + siteId, + ); + })); + } catch (error) { + // Ignore errors when saving question state. + this.logger.error('Error saving question state', error); + } + + } + + /** + * Set attempt's current page. + * + * @param attemptId Attempt ID. + * @param page Page to set. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async setAttemptCurrentPage(attemptId: number, page: number, siteId?: string): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + await db.updateRecords(ATTEMPTS_TABLE_NAME, { currentpage: page }, { id: attemptId }); + } + +} + +export class AddonModQuizOffline extends makeSingleton(AddonModQuizOfflineProvider) {} + +/** + * Answers classified by question slot. + */ +export type AddonModQuizQuestionsWithAnswers = Record; diff --git a/src/addons/mod/quiz/services/quiz.ts b/src/addons/mod/quiz/services/quiz.ts new file mode 100644 index 000000000..19fbe96f5 --- /dev/null +++ b/src/addons/mod/quiz/services/quiz.ts @@ -0,0 +1,2402 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { CoreWSError } from '@classes/errors/wserror'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreGradesFormattedItem, CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; +import { + CoreQuestion, + CoreQuestionQuestionParsed, + CoreQuestionQuestionWSData, + CoreQuestionsAnswers, +} from '@features/question/services/question'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEventSiteData } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; +import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; +import { AddonModQuizAttempt } from './quiz-helper'; +import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline'; + +const ROOT_CACHE_KEY = 'mmaModQuiz:'; + +/** + * Service that provides some features for quiz. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizProvider { + + static readonly COMPONENT = 'mmaModQuiz'; + static readonly ATTEMPT_FINISHED_EVENT = 'addon_mod_quiz_attempt_finished'; + + // Grade methods. + static readonly GRADEHIGHEST = 1; + static readonly GRADEAVERAGE = 2; + static readonly ATTEMPTFIRST = 3; + static readonly ATTEMPTLAST = 4; + + // Question options. + static readonly QUESTION_OPTIONS_MAX_ONLY = 1; + static readonly QUESTION_OPTIONS_MARK_AND_MAX = 2; + + // Attempt state. + static readonly ATTEMPT_IN_PROGRESS = 'inprogress'; + static readonly ATTEMPT_OVERDUE = 'overdue'; + static readonly ATTEMPT_FINISHED = 'finished'; + static readonly ATTEMPT_ABANDONED = 'abandoned'; + + // Show the countdown timer if there is less than this amount of time left before the the quiz close date. + static readonly QUIZ_SHOW_TIME_BEFORE_DEADLINE = 3600; + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('AddonModQuizProvider'); + } + + /** + * Formats a grade to be displayed. + * + * @param grade Grade. + * @param decimals Decimals to use. + * @return Grade to display. + */ + formatGrade(grade?: number, decimals?: number): string { + if (typeof grade == 'undefined' || grade == -1 || grade === null || isNaN(grade)) { + return Translate.instance.instant('addon.mod_quiz.notyetgraded'); + } + + return CoreUtils.instance.formatFloat(CoreTextUtils.instance.roundToDecimals(grade, decimals)); + } + + /** + * Get attempt questions. Returns all of them or just the ones in certain pages. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight required data (like password). + * @param options Other options. + * @return Promise resolved with the questions. + */ + async getAllQuestionsData( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + preflightData: Record, + options: AddonModQuizAllQuestionsDataOptions = {}, + ): Promise> { + + const questions: Record = {}; + const isSequential = this.isNavigationSequential(quiz); + const pages = options.pages || this.getPagesFromLayout(attempt.layout); + + await Promise.all(pages.map(async (page) => { + if (isSequential && page < (attempt.currentpage || 0)) { + // Sequential quiz, cannot get pages before the current one. + return; + } + + // Get the questions in the page. + const data = await this.getAttemptData(attempt.id, page, preflightData, options); + + // Add the questions to the result object. + data.questions.forEach((question) => { + questions[question.slot] = question; + }); + })); + + return questions; + } + + /** + * Get cache key for get attempt access information WS calls. + * + * @param quizId Quiz ID. + * @param attemptId Attempt ID. + * @return Cache key. + */ + protected getAttemptAccessInformationCacheKey(quizId: number, attemptId: number): string { + return this.getAttemptAccessInformationCommonCacheKey(quizId) + ':' + attemptId; + } + + /** + * Get common cache key for get attempt access information WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getAttemptAccessInformationCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'attemptAccessInformation:' + quizId; + } + + /** + * Get access information for an attempt. + * + * @param quizId Quiz ID. + * @param attemptId Attempt ID. 0 for user's last attempt. + * @param options Other options. + * @return Promise resolved with the access information. + */ + async getAttemptAccessInformation( + quizId: number, + attemptId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetAttemptAccessInformationWSParams = { + quizid: quizId, + attemptid: attemptId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptAccessInformationCacheKey(quizId, attemptId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_quiz_get_attempt_access_information', params, preSets); + } + + /** + * Get cache key for get attempt data WS calls. + * + * @param attemptId Attempt ID. + * @param page Page. + * @return Cache key. + */ + protected getAttemptDataCacheKey(attemptId: number, page: number): string { + return this.getAttemptDataCommonCacheKey(attemptId) + ':' + page; + } + + /** + * Get common cache key for get attempt data WS calls. + * + * @param attemptId Attempt ID. + * @return Cache key. + */ + protected getAttemptDataCommonCacheKey(attemptId: number): string { + return ROOT_CACHE_KEY + 'attemptData:' + attemptId; + } + + /** + * Get an attempt's data. + * + * @param attemptId Attempt ID. + * @param page Page number. + * @param preflightData Preflight required data (like password). + * @param options Other options. + * @return Promise resolved with the attempt data. + */ + async getAttemptData( + attemptId: number, + page: number, + preflightData: Record, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetAttemptDataWSParams = { + attemptid: attemptId, + page: page, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + true, + ), + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptDataCacheKey(attemptId, page), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const result = await site.read('mod_quiz_get_attempt_data', params, preSets); + + result.questions = CoreQuestion.instance.parseQuestions(result.questions); + + return result; + } + + /** + * Get an attempt's due date. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @return Attempt's due date, 0 if no due date or invalid data. + */ + getAttemptDueDate(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData): number { + const deadlines: number[] = []; + + if (quiz.timelimit && attempt.timestart) { + deadlines.push(attempt.timestart + quiz.timelimit); + } + if (quiz.timeclose) { + deadlines.push(quiz.timeclose); + } + + if (!deadlines.length) { + return 0; + } + + // Get min due date. + const dueDate: number = Math.min.apply(null, deadlines); + if (!dueDate) { + return 0; + } + + switch (attempt.state) { + case AddonModQuizProvider.ATTEMPT_IN_PROGRESS: + return dueDate * 1000; + + case AddonModQuizProvider.ATTEMPT_OVERDUE: + return (dueDate + quiz.graceperiod!) * 1000; + + default: + this.logger.warn('Unexpected state when getting due date: ' + attempt.state); + + return 0; + } + } + + /** + * Get an attempt's warning because of due date. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @return Attempt's warning, undefined if no due date. + */ + getAttemptDueDateWarning(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData): string | undefined { + const dueDate = this.getAttemptDueDate(quiz, attempt); + + if (attempt.state === AddonModQuizProvider.ATTEMPT_OVERDUE) { + return Translate.instance.instant( + 'addon.mod_quiz.overduemustbesubmittedby', + { $a: CoreTimeUtils.instance.userDate(dueDate) }, + ); + } else if (dueDate) { + return Translate.instance.instant('addon.mod_quiz.mustbesubmittedby', { $a: CoreTimeUtils.instance.userDate(dueDate) }); + } + } + + /** + * Turn attempt's state into a readable state, including some extra data depending on the state. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @return List of state sentences. + */ + getAttemptReadableState(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttempt): string[] { + if (attempt.finishedOffline) { + return [Translate.instance.instant('addon.mod_quiz.finishnotsynced')]; + } + + switch (attempt.state) { + case AddonModQuizProvider.ATTEMPT_IN_PROGRESS: + return [Translate.instance.instant('addon.mod_quiz.stateinprogress')]; + + case AddonModQuizProvider.ATTEMPT_OVERDUE: { + const sentences: string[] = []; + const dueDate = this.getAttemptDueDate(quiz, attempt); + + sentences.push(Translate.instance.instant('addon.mod_quiz.stateoverdue')); + + if (dueDate) { + sentences.push(Translate.instance.instant( + 'addon.mod_quiz.stateoverduedetails', + { $a: CoreTimeUtils.instance.userDate(dueDate) }, + )); + } + + return sentences; + } + + case AddonModQuizProvider.ATTEMPT_FINISHED: + return [ + Translate.instance.instant('addon.mod_quiz.statefinished'), + Translate.instance.instant( + 'addon.mod_quiz.statefinisheddetails', + { $a: CoreTimeUtils.instance.userDate(attempt.timefinish! * 1000) }, + ), + ]; + + case AddonModQuizProvider.ATTEMPT_ABANDONED: + return [Translate.instance.instant('addon.mod_quiz.stateabandoned')]; + + default: + return []; + } + } + + /** + * Turn attempt's state into a readable state name, without any more data. + * + * @param state State. + * @return Readable state name. + */ + getAttemptReadableStateName(state: string): string { + switch (state) { + case AddonModQuizProvider.ATTEMPT_IN_PROGRESS: + return Translate.instance.instant('addon.mod_quiz.stateinprogress'); + + case AddonModQuizProvider.ATTEMPT_OVERDUE: + return Translate.instance.instant('addon.mod_quiz.stateoverdue'); + + case AddonModQuizProvider.ATTEMPT_FINISHED: + return Translate.instance.instant('addon.mod_quiz.statefinished'); + + case AddonModQuizProvider.ATTEMPT_ABANDONED: + return Translate.instance.instant('addon.mod_quiz.stateabandoned'); + + default: + return ''; + } + } + + /** + * Get cache key for get attempt review WS calls. + * + * @param attemptId Attempt ID. + * @param page Page. + * @return Cache key. + */ + protected getAttemptReviewCacheKey(attemptId: number, page: number): string { + return this.getAttemptReviewCommonCacheKey(attemptId) + ':' + page; + } + + /** + * Get common cache key for get attempt review WS calls. + * + * @param attemptId Attempt ID. + * @return Cache key. + */ + protected getAttemptReviewCommonCacheKey(attemptId: number): string { + return ROOT_CACHE_KEY + 'attemptReview:' + attemptId; + } + + /** + * Get an attempt's review. + * + * @param attemptId Attempt ID. + * @param options Other options. + * @return Promise resolved with the attempt review. + */ + async getAttemptReview( + attemptId: number, + options: AddonModQuizGetAttemptReviewOptions = {}, + ): Promise { + const page = typeof options.page == 'undefined' ? -1 : options.page; + + const site = await CoreSites.instance.getSite(options.siteId); + + const params = { + attemptid: attemptId, + page: page, + }; + const preSets = { + cacheKey: this.getAttemptReviewCacheKey(attemptId, page), + cacheErrors: ['noreview'], + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const result = await site.read('mod_quiz_get_attempt_review', params, preSets); + + result.questions = CoreQuestion.instance.parseQuestions(result.questions); + + return result; + } + + /** + * Get cache key for get attempt summary WS calls. + * + * @param attemptId Attempt ID. + * @return Cache key. + */ + protected getAttemptSummaryCacheKey(attemptId: number): string { + return ROOT_CACHE_KEY + 'attemptSummary:' + attemptId; + } + + /** + * Get an attempt's summary. + * + * @param attemptId Attempt ID. + * @param preflightData Preflight required data (like password). + * @param options Other options. + * @return Promise resolved with the list of questions for the attempt summary. + */ + async getAttemptSummary( + attemptId: number, + preflightData: Record, + options: AddonModQuizGetAttemptSummaryOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetAttemptSummaryWSParams = { + attemptid: attemptId, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + true, + ), + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptSummaryCacheKey(attemptId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_quiz_get_attempt_summary', params, preSets); + + const questions = CoreQuestion.instance.parseQuestions(response.questions); + + if (options.loadLocal) { + return AddonModQuizOffline.instance.loadQuestionsLocalStates(attemptId, questions, site.getId()); + } + + return questions; + } + + /** + * Get cache key for get combined review options WS calls. + * + * @param quizId Quiz ID. + * @param userId User ID. + * @return Cache key. + */ + protected getCombinedReviewOptionsCacheKey(quizId: number, userId: number): string { + return this.getCombinedReviewOptionsCommonCacheKey(quizId) + ':' + userId; + } + + /** + * Get common cache key for get combined review options WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getCombinedReviewOptionsCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'combinedReviewOptions:' + quizId; + } + + /** + * Get a quiz combined review options. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the combined review options. + */ + async getCombinedReviewOptions( + quizId: number, + options: AddonModQuizUserOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: AddonModQuizGetCombinedReviewOptionsWSParams = { + quizid: quizId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCombinedReviewOptionsCacheKey(quizId, userId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_quiz_get_combined_review_options', + params, + preSets, + ); + + // Convert the arrays to objects with name -> value. + return { + someoptions: > CoreUtils.instance.objectToKeyValueMap(response.someoptions, 'name', 'value'), + alloptions: > CoreUtils.instance.objectToKeyValueMap(response.alloptions, 'name', 'value'), + warnings: response.warnings, + }; + } + + /** + * Get cache key for get feedback for grade WS calls. + * + * @param quizId Quiz ID. + * @param grade Grade. + * @return Cache key. + */ + protected getFeedbackForGradeCacheKey(quizId: number, grade: number): string { + return this.getFeedbackForGradeCommonCacheKey(quizId) + ':' + grade; + } + + /** + * Get common cache key for get feedback for grade WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getFeedbackForGradeCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'feedbackForGrade:' + quizId; + } + + /** + * Get the feedback for a certain grade. + * + * @param quizId Quiz ID. + * @param grade Grade. + * @param options Other options. + * @return Promise resolved with the feedback. + */ + async getFeedbackForGrade( + quizId: number, + grade: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetQuizFeedbackForGradeWSParams = { + quizid: quizId, + grade: grade, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getFeedbackForGradeCacheKey(quizId, grade), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_quiz_get_quiz_feedback_for_grade', params, preSets); + } + + /** + * Determine the correct number of decimal places required to format a grade. + * Based on Moodle's quiz_get_grade_format. + * + * @param quiz Quiz. + * @return Number of decimals. + */ + getGradeDecimals(quiz: AddonModQuizQuizWSData): number { + if (typeof quiz.questiondecimalpoints == 'undefined') { + quiz.questiondecimalpoints = -1; + } + + if (quiz.questiondecimalpoints == -1) { + return quiz.decimalpoints!; + } + + return quiz.questiondecimalpoints; + } + + /** + * Gets a quiz grade and feedback from the gradebook. + * + * @param courseId Course ID. + * @param moduleId Quiz module ID. + * @param ignoreCache Whether it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved with an object containing the grade and the feedback. + */ + async getGradeFromGradebook( + courseId: number, + moduleId: number, + ignoreCache?: boolean, + siteId?: string, + userId?: number, + ): Promise { + + const items = await CoreGradesHelper.instance.getGradeModuleItems( + courseId, + moduleId, + userId, + undefined, + siteId, + ignoreCache, + ); + + return items.shift(); + } + + /** + * Given a list of attempts, returns the last finished attempt. + * + * @param attempts Attempts. + * @return Last finished attempt. + */ + getLastFinishedAttemptFromList(attempts?: AddonModQuizAttemptWSData[]): AddonModQuizAttemptWSData | undefined { + return attempts?.find(attempt => this.isAttemptFinished(attempt.state)); + } + + /** + * Given a list of questions, check if the quiz can be submitted. + * Will return an array with the messages to prevent the submit. Empty array if quiz can be submitted. + * + * @param questions Questions. + * @return List of prevent submit messages. Empty array if quiz can be submitted. + */ + getPreventSubmitMessages(questions: CoreQuestionQuestionParsed[]): string[] { + const messages: string[] = []; + + questions.forEach((question) => { + if (question.type != 'random' && !CoreQuestionDelegate.instance.isQuestionSupported(question.type)) { + // The question isn't supported. + messages.push(Translate.instance.instant('core.question.questionmessage', { + $a: question.slot, + $b: Translate.instance.instant('core.question.errorquestionnotsupported', { $a: question.type }), + })); + } else { + let message = CoreQuestionDelegate.instance.getPreventSubmitMessage(question); + if (message) { + message = Translate.instance.instant(message); + messages.push(Translate.instance.instant('core.question.questionmessage', { $a: question.slot, $b: message })); + } + } + }); + + return messages; + } + + /** + * Get cache key for quiz data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getQuizDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'quiz:' + courseId; + } + + /** + * Get a Quiz with key=value. If more than one is found, only the first will be returned. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved when the Quiz is retrieved. + */ + protected async getQuizByField( + courseId: number, + key: string, + value: unknown, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetQuizzesByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuizDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModQuizProvider.COMPONENT, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_quiz_get_quizzes_by_courses', + params, + preSets, + ); + + // Search the quiz. + const quiz = response.quizzes.find(quiz => quiz[key] == value); + + if (!quiz) { + throw new CoreError('Quiz not found.'); + } + + return quiz; + } + + /** + * Get a quiz by module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the quiz is retrieved. + */ + getQuiz(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getQuizByField(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a quiz by quiz ID. + * + * @param courseId Course ID. + * @param id Quiz ID. + * @param options Other options. + * @return Promise resolved when the quiz is retrieved. + */ + getQuizById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getQuizByField(courseId, 'id', id, options); + } + + /** + * Get cache key for get quiz access information WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getQuizAccessInformationCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'quizAccessInformation:' + quizId; + } + + /** + * Get access information for an attempt. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the access information. + */ + async getQuizAccessInformation( + quizId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetQuizAccessInformationWSParams = { + quizid: quizId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuizAccessInformationCacheKey(quizId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_quiz_get_quiz_access_information', params, preSets); + } + + /** + * Get a readable Quiz grade method. + * + * @param method Grading method. + * @return Readable grading method. + */ + getQuizGradeMethod(method?: number | string): string { + if (method === undefined) { + return ''; + } + + if (typeof method == 'string') { + method = parseInt(method, 10); + } + + switch (method) { + case AddonModQuizProvider.GRADEHIGHEST: + return Translate.instance.instant('addon.mod_quiz.gradehighest'); + case AddonModQuizProvider.GRADEAVERAGE: + return Translate.instance.instant('addon.mod_quiz.gradeaverage'); + case AddonModQuizProvider.ATTEMPTFIRST: + return Translate.instance.instant('addon.mod_quiz.attemptfirst'); + case AddonModQuizProvider.ATTEMPTLAST: + return Translate.instance.instant('addon.mod_quiz.attemptlast'); + default: + return ''; + } + } + + /** + * Get cache key for get quiz required qtypes WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getQuizRequiredQtypesCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'quizRequiredQtypes:' + quizId; + } + + /** + * Get the potential question types that would be required for a given quiz. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the access information. + */ + async getQuizRequiredQtypes(quizId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const params: AddonModQuizGetQuizRequiredQtypesWSParams = { + quizid: quizId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getQuizRequiredQtypesCacheKey(quizId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_quiz_get_quiz_required_qtypes', + params, + preSets, + ); + + return response.questiontypes; + } + + /** + * Given an attempt's layout, return the list of pages. + * + * @param layout Attempt's layout. + * @return Pages. + * @description + * An attempt's layout is a string with the question numbers separated by commas. A 0 indicates a change of page. + * Example: 1,2,3,0,4,5,6,0 + * In the example above, first page has questions 1, 2 and 3. Second page has questions 4, 5 and 6. + * + * This function returns a list of pages. + */ + getPagesFromLayout(layout?: string): number[] { + if (!layout) { + return []; + } + + const split = layout.split(','); + const pages: number[] = []; + let page = 0; + + for (let i = 0; i < split.length; i++) { + if (split[i] == '0') { + pages.push(page); + page++; + } + } + + return pages; + } + + /** + * Given an attempt's layout and a list of questions identified by question slot, + * return the list of pages that have at least 1 of the questions. + * + * @param layout Attempt's layout. + * @param questions List of questions. It needs to be an object where the keys are question slot. + * @return Pages. + * @description + * An attempt's layout is a string with the question numbers separated by commas. A 0 indicates a change of page. + * Example: 1,2,3,0,4,5,6,0 + * In the example above, first page has questions 1, 2 and 3. Second page has questions 4, 5 and 6. + * + * This function returns a list of pages. + */ + getPagesFromLayoutAndQuestions(layout: string, questions: AddonModQuizQuestionsWithAnswers): number[] { + const split = layout.split(','); + const pages: number[] = []; + let page = 0; + let pageAdded = false; + + for (let i = 0; i < split.length; i++) { + const value = Number(split[i]); + + if (value == 0) { + page++; + pageAdded = false; + } else if (!pageAdded && questions[value]) { + pages.push(page); + pageAdded = true; + } + } + + return pages; + } + + /** + * Given a list of question types, returns the types that aren't supported. + * + * @param questionTypes Question types to check. + * @return Not supported question types. + */ + getUnsupportedQuestions(questionTypes: string[]): string[] { + const notSupported: string[] = []; + + questionTypes.forEach((type) => { + if (type != 'random' && !CoreQuestionDelegate.instance.isQuestionSupported(type)) { + notSupported.push(type); + } + }); + + return notSupported; + } + + /** + * Given a list of access rules names, returns the rules that aren't supported. + * + * @param rulesNames Rules to check. + * @return Not supported rules names. + */ + getUnsupportedRules(rulesNames: string[]): string[] { + const notSupported: string[] = []; + + rulesNames.forEach((name) => { + if (!AddonModQuizAccessRuleDelegate.instance.isAccessRuleSupported(name)) { + notSupported.push(name); + } + }); + + return notSupported; + } + + /** + * Get cache key for get user attempts WS calls. + * + * @param quizId Quiz ID. + * @param userId User ID. + * @return Cache key. + */ + protected getUserAttemptsCacheKey(quizId: number, userId: number): string { + return this.getUserAttemptsCommonCacheKey(quizId) + ':' + userId; + } + + /** + * Get common cache key for get user attempts WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getUserAttemptsCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'userAttempts:' + quizId; + } + + /** + * Get quiz attempts for a certain user. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the attempts. + */ + async getUserAttempts( + quizId: number, + options: AddonModQuizGetUserAttemptsOptions = {}, + ): Promise { + + const status = options.status || 'all'; + const includePreviews = typeof options.includePreviews == 'undefined' ? true : options.includePreviews; + + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: AddonModQuizGetUserAttemptsWSParams = { + quizid: quizId, + userid: userId, + status: status, + includepreviews: !!includePreviews, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserAttemptsCacheKey(quizId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_quiz_get_user_attempts', params, preSets); + + return response.attempts; + } + + /** + * Get cache key for get user best grade WS calls. + * + * @param quizId Quiz ID. + * @param userId User ID. + * @return Cache key. + */ + protected getUserBestGradeCacheKey(quizId: number, userId: number): string { + return this.getUserBestGradeCommonCacheKey(quizId) + ':' + userId; + } + + /** + * Get common cache key for get user best grade WS calls. + * + * @param quizId Quiz ID. + * @return Cache key. + */ + protected getUserBestGradeCommonCacheKey(quizId: number): string { + return ROOT_CACHE_KEY + 'userBestGrade:' + quizId; + } + + /** + * Get best grade in a quiz for a certain user. + * + * @param quizId Quiz ID. + * @param options Other options. + * @return Promise resolved with the best grade data. + */ + async getUserBestGrade(quizId: number, options: AddonModQuizUserOptions = {}): Promise { + const site = await CoreSites.instance.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: AddonModQuizGetUserBestGradeWSParams = { + quizid: quizId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserBestGradeCacheKey(quizId, userId), + component: AddonModQuizProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_quiz_get_user_best_grade', params, preSets); + } + + /** + * Invalidates all the data related to a certain quiz. + * + * @param quizId Quiz ID. + * @param courseId Course ID. + * @param attemptId Attempt ID to invalidate some WS calls. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllQuizData( + quizId: number, + courseId?: number, + attemptId?: number, + siteId?: string, + userId?: number, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const promises: Promise[] = []; + + promises.push(this.invalidateAttemptAccessInformation(quizId, siteId)); + promises.push(this.invalidateCombinedReviewOptionsForUser(quizId, siteId, userId)); + promises.push(this.invalidateFeedback(quizId, siteId)); + promises.push(this.invalidateQuizAccessInformation(quizId, siteId)); + promises.push(this.invalidateQuizRequiredQtypes(quizId, siteId)); + promises.push(this.invalidateUserAttemptsForUser(quizId, siteId, userId)); + promises.push(this.invalidateUserBestGradeForUser(quizId, siteId, userId)); + + if (attemptId) { + promises.push(this.invalidateAttemptData(attemptId, siteId)); + promises.push(this.invalidateAttemptReview(attemptId, siteId)); + promises.push(this.invalidateAttemptSummary(attemptId, siteId)); + } + + if (courseId) { + promises.push(this.invalidateGradeFromGradebook(courseId, siteId, userId)); + } + + await Promise.all(promises); + } + + /** + * Invalidates attempt access information for all attempts in a quiz. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptAccessInformation(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getAttemptAccessInformationCommonCacheKey(quizId)); + } + + /** + * Invalidates attempt access information for an attempt. + * + * @param quizId Quiz ID. + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptAccessInformationForAttempt(quizId: number, attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptAccessInformationCacheKey(quizId, attemptId)); + } + + /** + * Invalidates attempt data for all pages. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptData(attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getAttemptDataCommonCacheKey(attemptId)); + } + + /** + * Invalidates attempt data for a certain page. + * + * @param attemptId Attempt ID. + * @param page Page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptDataForPage(attemptId: number, page: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptDataCacheKey(attemptId, page)); + } + + /** + * Invalidates attempt review for all pages. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptReview(attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getAttemptReviewCommonCacheKey(attemptId)); + } + + /** + * Invalidates attempt review for a certain page. + * + * @param attemptId Attempt ID. + * @param page Page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptReviewForPage(attemptId: number, page: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptReviewCacheKey(attemptId, page)); + } + + /** + * Invalidates attempt summary. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptSummary(attemptId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptSummaryCacheKey(attemptId)); + } + + /** + * Invalidates combined review options for all users. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCombinedReviewOptions(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getCombinedReviewOptionsCommonCacheKey(quizId)); + } + + /** + * Invalidates combined review options for a certain user. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCombinedReviewOptionsForUser(quizId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.invalidateWsCacheForKey(this.getCombinedReviewOptionsCacheKey(quizId, userId || site.getUserId())); + } + + /** + * Invalidate the prefetched content except files. + * To invalidate files, use AddonModQuizProvider.invalidateFiles. + * + * @param moduleId The module ID. + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Get required data to call the invalidate functions. + const quiz = await this.getQuiz(courseId, moduleId, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + + const attempts = await this.getUserAttempts(quiz.id, { cmId: moduleId, siteId }); + + // Now invalidate it. + const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined; + + await this.invalidateAllQuizData(quiz.id, courseId, lastAttemptId, siteId); + } + + /** + * Invalidates feedback for all grades of a quiz. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateFeedback(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getFeedbackForGradeCommonCacheKey(quizId)); + } + + /** + * Invalidates feedback for a certain grade. + * + * @param quizId Quiz ID. + * @param grade Grade. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateFeedbackForGrade(quizId: number, grade: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getFeedbackForGradeCacheKey(quizId, grade)); + } + + /** + * Invalidate the prefetched files. + * + * @param moduleId The module ID. + * @return Promise resolved when the files are invalidated. + */ + async invalidateFiles(moduleId: number): Promise { + await CoreFilepool.instance.invalidateFilesByComponent( + CoreSites.instance.getCurrentSiteId(), + AddonModQuizProvider.COMPONENT, + moduleId, + ); + } + + /** + * Invalidates grade from gradebook for a certain user. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateGradeFromGradebook(courseId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await CoreGradesHelper.instance.invalidateGradeModuleItems(courseId, userId || site.getUserId(), undefined, siteId); + } + + /** + * Invalidates quiz access information for a quiz. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuizAccessInformation(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getQuizAccessInformationCacheKey(quizId)); + } + + /** + * Invalidates required qtypes for a quiz. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuizRequiredQtypes(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getQuizRequiredQtypesCacheKey(quizId)); + } + + /** + * Invalidates user attempts for all users. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAttempts(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserAttemptsCommonCacheKey(quizId)); + } + + /** + * Invalidates user attempts for a certain user. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAttemptsForUser(quizId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserAttemptsCacheKey(quizId, userId || site.getUserId())); + } + + /** + * Invalidates user best grade for all users. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserBestGrade(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserBestGradeCommonCacheKey(quizId)); + } + + /** + * Invalidates user best grade for a certain user. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserBestGradeForUser(quizId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserBestGradeCacheKey(quizId, userId || site.getUserId())); + } + + /** + * Invalidates quiz data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateQuizData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getQuizDataCacheKey(courseId)); + } + + /** + * Check if an attempt is finished based on its state. + * + * @param state Attempt's state. + * @return Whether it's finished. + */ + isAttemptFinished(state?: string): boolean { + return state == AddonModQuizProvider.ATTEMPT_FINISHED || state == AddonModQuizProvider.ATTEMPT_ABANDONED; + } + + /** + * Check if an attempt is finished in offline but not synced. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if finished in offline but not synced, false otherwise. + */ + async isAttemptFinishedOffline(attemptId: number, siteId?: string): Promise { + try { + const attempt = await AddonModQuizOffline.instance.getAttemptById(attemptId, siteId); + + return !!attempt.finished; + } catch { + return false; + } + } + + /** + * Check if an attempt is nearly over. We consider an attempt nearly over or over if: + * - Is not in progress + * OR + * - It finished before autosaveperiod passes. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @return Whether it's nearly over or over. + */ + isAttemptTimeNearlyOver(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttemptWSData): boolean { + if (attempt.state != AddonModQuizProvider.ATTEMPT_IN_PROGRESS) { + // Attempt not in progress, return true. + return true; + } + + const dueDate = this.getAttemptDueDate(quiz, attempt); + const autoSavePeriod = quiz.autosaveperiod || 0; + + if (dueDate > 0 && Date.now() + autoSavePeriod >= dueDate) { + return true; + } + + return false; + } + + /** + * Check if last attempt is offline and unfinished. + * + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, user current site's user. + * @return Promise resolved with boolean: true if last offline attempt is unfinished, false otherwise. + */ + async isLastAttemptOfflineUnfinished(quiz: AddonModQuizQuizWSData, siteId?: string, userId?: number): Promise { + try { + const attempts = await AddonModQuizOffline.instance.getQuizAttempts(quiz.id, siteId, userId); + + const last = attempts.pop(); + + return !!last && !last.finished; + } catch { + return false; + } + } + + /** + * Check if a quiz navigation is sequential. + * + * @param quiz Quiz. + * @return Whether navigation is sequential. + */ + isNavigationSequential(quiz: AddonModQuizQuizWSData): boolean { + return quiz.navmethod == 'sequential'; + } + + /** + * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the quiz WS are available. + * + * @return Whether the plugin is enabled. + */ + isPluginEnabled(): boolean { + // Quiz WebServices were introduced in 3.1, it will always be enabled. + return true; + } + + /** + * Check if a question is blocked. + * + * @param question Question. + * @return Whether it's blocked. + */ + isQuestionBlocked(question: CoreQuestionQuestionParsed): boolean { + const element = CoreDomUtils.instance.convertToElement(question.html); + + return !!element.querySelector('.mod_quiz-blocked_question_warning'); + } + + /** + * Check if a quiz is enabled to be used in offline. + * + * @param quiz Quiz. + * @return Whether offline is enabled. + */ + isQuizOffline(quiz: AddonModQuizQuizWSData): boolean { + // Don't allow downloading the quiz if offline is disabled to prevent wasting a lot of data when opening it. + return !!quiz.allowofflineattempts && !CoreSites.instance.getCurrentSite()?.isOfflineDisabled(); + } + + /** + * Report an attempt as being viewed. It did not store logs offline because order of the log is important. + * + * @param attemptId Attempt ID. + * @param page Page number. + * @param preflightData Preflight required data (like password). + * @param offline Whether attempt is offline. + * @param quiz Quiz instance. If set, a Firebase event will be stored. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + async logViewAttempt( + attemptId: number, + page: number = 0, + preflightData: Record = {}, + offline?: boolean, + quiz?: AddonModQuizQuizWSData, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModQuizViewAttemptWSParams = { + attemptid: attemptId, + page: page, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + }; + const promises: Promise[] = []; + + promises.push(site.write('mod_quiz_view_attempt', params)); + if (offline) { + promises.push(AddonModQuizOffline.instance.setAttemptCurrentPage(attemptId, page, site.getId())); + } + if (quiz) { + CorePushNotifications.instance.logViewEvent( + quiz.id, + quiz.name, + 'quiz', + 'mod_quiz_view_attempt', + { attemptid: attemptId, page }, + siteId, + ); + } + + await Promise.all(promises); + } + + /** + * Report an attempt's review as being viewed. + * + * @param attemptId Attempt ID. + * @param quizId Quiz ID. + * @param name Name of the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logViewAttemptReview(attemptId: number, quizId: number, name?: string, siteId?: string): Promise { + const params: AddonModQuizViewAttemptReviewWSParams = { + attemptid: attemptId, + }; + + return CoreCourseLogHelper.instance.logSingle( + 'mod_quiz_view_attempt_review', + params, + AddonModQuizProvider.COMPONENT, + quizId, + name, + 'quiz', + params, + siteId, + ); + } + + /** + * Report an attempt's summary as being viewed. + * + * @param attemptId Attempt ID. + * @param preflightData Preflight required data (like password). + * @param quizId Quiz ID. + * @param name Name of the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logViewAttemptSummary( + attemptId: number, + preflightData: Record, + quizId: number, + name?: string, + siteId?: string, + ): Promise { + const params: AddonModQuizViewAttemptSummaryWSParams = { + attemptid: attemptId, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + }; + + return CoreCourseLogHelper.instance.logSingle( + 'mod_quiz_view_attempt_summary', + params, + AddonModQuizProvider.COMPONENT, + quizId, + name, + 'quiz', + { attemptid: attemptId }, + siteId, + ); + } + + /** + * Report a quiz as being viewed. + * + * @param id Module ID. + * @param name Name of the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logViewQuiz(id: number, name?: string, siteId?: string): Promise { + const params: AddonModQuizViewQuizWSParams = { + quizid: id, + }; + + return CoreCourseLogHelper.instance.logSingle( + 'mod_quiz_view_quiz', + params, + AddonModQuizProvider.COMPONENT, + id, + name, + 'quiz', + {}, + siteId, + ); + } + + /** + * Process an attempt, saving its data. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param finish Whether to finish the quiz. + * @param timeUp Whether the quiz time is up, false otherwise. + * @param offline Whether the attempt is offline. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async processAttempt( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + data: CoreQuestionsAnswers, + preflightData: Record, + finish?: boolean, + timeUp?: boolean, + offline?: boolean, + siteId?: string, + ): Promise { + if (offline) { + return this.processAttemptOffline(quiz, attempt, data, preflightData, finish, siteId); + } + + await this.processAttemptOnline(attempt.id, data, preflightData, finish, timeUp, siteId); + } + + /** + * Process an online attempt, saving its data. + * + * @param attemptId Attempt ID. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param finish Whether to finish the quiz. + * @param timeUp Whether the quiz time is up, false otherwise. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + protected async processAttemptOnline( + attemptId: number, + data: CoreQuestionsAnswers, + preflightData: Record, + finish?: boolean, + timeUp?: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModQuizProcessAttemptWSParams = { + attemptid: attemptId, + data: CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value'), + finishattempt: !!finish, + timeup: !!timeUp, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + }; + + const response = await site.write('mod_quiz_process_attempt', params); + + if (response.warnings?.length) { + // Reject with the first warning. + throw new CoreWSError(response.warnings[0]); + } + + return response.state; + } + + /** + * Process an offline attempt, saving its data. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param finish Whether to finish the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + protected async processAttemptOffline( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + data: CoreQuestionsAnswers, + preflightData: Record, + finish?: boolean, + siteId?: string, + ): Promise { + + // Get attempt summary to have the list of questions. + const questionsArray = await this.getAttemptSummary(attempt.id, preflightData, { + cmId: quiz.coursemodule, + loadLocal: true, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + + // Convert the question array to an object. + const questions = CoreUtils.instance.arrayToObject(questionsArray, 'slot'); + + return AddonModQuizOffline.instance.processAttempt(quiz, attempt, questions, data, finish, siteId); + } + + /** + * Check if it's a graded quiz. Based on Moodle's quiz_has_grades. + * + * @param quiz Quiz. + * @return Whether quiz is graded. + */ + quizHasGrades(quiz: AddonModQuizQuizWSData): boolean { + return quiz.grade! >= 0.000005 && quiz.sumgrades! >= 0.000005; + } + + /** + * Convert the raw grade into a grade out of the maximum grade for this quiz. + * Based on Moodle's quiz_rescale_grade. + * + * @param rawGrade The unadjusted grade, for example attempt.sumgrades. + * @param quiz Quiz. + * @param format True to format the results for display, 'question' to format a question grade + * (different number of decimal places), false to not format it. + * @return Grade to display. + */ + rescaleGrade( + rawGrade: string | number | undefined, + quiz: AddonModQuizQuizWSData, + format: boolean | string = true, + ): string | undefined { + let grade: number | undefined; + + const rawGradeNum = typeof rawGrade == 'string' ? parseFloat(rawGrade) : rawGrade; + if (rawGradeNum !== undefined && !isNaN(rawGradeNum)) { + if (quiz.sumgrades! >= 0.000005) { + grade = rawGradeNum * quiz.grade! / quiz.sumgrades!; + } else { + grade = 0; + } + } + + if (grade === null || grade === undefined) { + return; + } + + if (format === 'question') { + return this.formatGrade(grade, this.getGradeDecimals(quiz)); + } else if (format) { + return this.formatGrade(grade, quiz.decimalpoints!); + } + + return String(grade); + } + + /** + * Save an attempt data. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param offline Whether attempt is offline. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + async saveAttempt( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + data: CoreQuestionsAnswers, + preflightData: Record, + offline?: boolean, + siteId?: string, + ): Promise { + try { + if (offline) { + return await this.processAttemptOffline(quiz, attempt, data, preflightData, false, siteId); + } + + await this.saveAttemptOnline(attempt.id, data, preflightData, siteId); + } catch (error) { + this.logger.error(error); + + throw error; + } + } + + /** + * Save an attempt data. + * + * @param attemptId Attempt ID. + * @param data Data to save. + * @param preflightData Preflight required data (like password). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success, rejected otherwise. + */ + protected async saveAttemptOnline( + attemptId: number, + data: CoreQuestionsAnswers, + preflightData: Record, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModQuizSaveAttemptWSParams = { + attemptid: attemptId, + data: CoreUtils.instance.objectToArrayOfObjects(data, 'name', 'value'), + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + }; + + const response = await site.write('mod_quiz_save_attempt', params); + + if (response.warnings?.length) { + // Reject with the first warning. + throw new CoreWSError(response.warnings[0]); + } else if (!response.status) { + // It shouldn't happen that status is false and no warnings were returned. + throw new CoreError('Cannot save data.'); + } + } + + /** + * Check if time left should be shown. + * + * @param rules List of active rules names. + * @param attempt Attempt. + * @param endTime The attempt end time (in seconds). + * @return Whether time left should be displayed. + */ + shouldShowTimeLeft(rules: string[], attempt: AddonModQuizAttemptWSData, endTime: number): boolean { + const timeNow = CoreTimeUtils.instance.timestamp(); + + if (attempt.state != AddonModQuizProvider.ATTEMPT_IN_PROGRESS) { + return false; + } + + return AddonModQuizAccessRuleDelegate.instance.shouldShowTimeLeft(rules, attempt, endTime, timeNow); + } + + /** + * Start an attempt. + * + * @param quizId Quiz ID. + * @param preflightData Preflight required data (like password). + * @param forceNew Whether to force a new attempt or not. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the attempt data. + */ + async startAttempt( + quizId: number, + preflightData: Record, + forceNew?: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: AddonModQuizStartAttemptWSParams = { + quizid: quizId, + preflightdata: CoreUtils.instance.objectToArrayOfObjects( + preflightData, + 'name', + 'value', + ), + forcenew: !!forceNew, + }; + + const response = await site.write('mod_quiz_start_attempt', params); + + if (response.warnings?.length) { + // Reject with the first warning. + throw new CoreWSError(response.warnings[0]); + } + + return response.attempt; + } + +} + +export class AddonModQuiz extends makeSingleton(AddonModQuizProvider) {} + +/** + * Common options with user ID. + */ +export type AddonModQuizUserOptions = CoreCourseCommonModWSOptions & { + userId?: number; // User ID. If not defined use site's current user. +}; + +/** + * Options to pass to getAllQuestionsData. + */ +export type AddonModQuizAllQuestionsDataOptions = CoreCourseCommonModWSOptions & { + pages?: number[]; // List of pages to get. If not defined, all pages. +}; + +/** + * Options to pass to getAttemptReview. + */ +export type AddonModQuizGetAttemptReviewOptions = CoreCourseCommonModWSOptions & { + page?: number; // List of pages to get. If not defined, all pages. +}; + +/** + * Options to pass to getAttemptSummary. + */ +export type AddonModQuizGetAttemptSummaryOptions = CoreCourseCommonModWSOptions & { + loadLocal?: boolean; // Whether it should load local state for each question. +}; + +/** + * Options to pass to getUserAttempts. + */ +export type AddonModQuizGetUserAttemptsOptions = CoreCourseCommonModWSOptions & { + status?: string; // Status of the attempts to get. By default, 'all'. + includePreviews?: boolean; // Whether to include previews. Defaults to true. + userId?: number; // User ID. If not defined use site's current user. +}; + +/** + * Preflight data in the format accepted by the WebServices. + */ +type AddonModQuizPreflightDataWSParam = { + name: string; // Data name. + value: string; // Data value. +}; + +/** + * Params of mod_quiz_get_attempt_access_information WS. + */ +export type AddonModQuizGetAttemptAccessInformationWSParams = { + quizid: number; // Quiz instance id. + attemptid?: number; // Attempt id, 0 for the user last attempt if exists. +}; + +/** + * Data returned by mod_quiz_get_attempt_access_information WS. + */ +export type AddonModQuizGetAttemptAccessInformationWSResponse = { + endtime?: number; // When the attempt must be submitted (determined by rules). + isfinished: boolean; // Whether there is no way the user will ever be allowed to attempt. + ispreflightcheckrequired?: boolean; // Whether a check is required before the user starts/continues his attempt. + preventnewattemptreasons: string[]; // List of reasons. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_attempt_data WS. + */ +export type AddonModQuizGetAttemptDataWSParams = { + attemptid: number; // Attempt id. + page: number; // Page number. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Data returned by mod_quiz_get_attempt_data WS. + */ +export type AddonModQuizGetAttemptDataWSResponse = { + attempt: AddonModQuizAttemptWSData; + messages: string[]; // Access messages, will only be returned for users with mod/quiz:preview capability. + nextpage: number; // Next page number. + questions: CoreQuestionQuestionWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Attempt data returned by several WebServices. + */ +export type AddonModQuizAttemptWSData = { + id: number; // Attempt id. + quiz?: number; // Foreign key reference to the quiz that was attempted. + userid?: number; // Foreign key reference to the user whose attempt this is. + attempt?: number; // Sequentially numbers this students attempts at this quiz. + uniqueid?: number; // Foreign key reference to the question_usage that holds the details of the the question_attempts. + layout?: string; // Attempt layout. + currentpage?: number; // Attempt current page. + preview?: number; // Whether is a preview attempt or not. + state?: string; // The current state of the attempts. 'inprogress', 'overdue', 'finished' or 'abandoned'. + timestart?: number; // Time when the attempt was started. + timefinish?: number; // Time when the attempt was submitted. 0 if the attempt has not been submitted yet. + timemodified?: number; // Last modified time. + timemodifiedoffline?: number; // Last modified time via webservices. + timecheckstate?: number; // Next time quiz cron should check attempt for state changes. NULL means never check. + sumgrades?: number; // Total marks for this attempt. +}; + +/** + * Get attempt data response with parsed questions. + */ +export type AddonModQuizGetAttemptDataResponse = Omit & { + questions: CoreQuestionQuestionParsed[]; +}; + +/** + * Params of mod_quiz_get_attempt_review WS. + */ +export type AddonModQuizGetAttemptReviewWSParams = { + attemptid: number; // Attempt id. + page?: number; // Page number, empty for all the questions in all the pages. +}; + +/** + * Data returned by mod_quiz_get_attempt_review WS. + */ +export type AddonModQuizGetAttemptReviewWSResponse = { + grade: string; // Grade for the quiz (or empty or "notyetgraded"). + attempt: AddonModQuizAttemptWSData; + additionaldata: AddonModQuizWSAdditionalData[]; + questions: CoreQuestionQuestionWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Additional data returned by mod_quiz_get_attempt_review WS. + */ +export type AddonModQuizWSAdditionalData = { + id: string; // Id of the data. + title: string; // Data title. + content: string; // Data content. +}; + +/** + * Get attempt review response with parsed questions. + */ +export type AddonModQuizGetAttemptReviewResponse = Omit & { + questions: CoreQuestionQuestionParsed[]; +}; + +/** + * Params of mod_quiz_get_attempt_summary WS. + */ +export type AddonModQuizGetAttemptSummaryWSParams = { + attemptid: number; // Attempt id. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Data returned by mod_quiz_get_attempt_summary WS. + */ +export type AddonModQuizGetAttemptSummaryWSResponse = { + questions: CoreQuestionQuestionWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_combined_review_options WS. + */ +export type AddonModQuizGetCombinedReviewOptionsWSParams = { + quizid: number; // Quiz instance id. + userid?: number; // User id (empty for current user). +}; + +/** + * Data returned by mod_quiz_get_combined_review_options WS. + */ +export type AddonModQuizGetCombinedReviewOptionsWSResponse = { + someoptions: AddonModQuizWSReviewOption[]; + alloptions: AddonModQuizWSReviewOption[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Option data returned by mod_quiz_get_combined_review_options. + */ +export type AddonModQuizWSReviewOption = { + name: string; // Option name. + value: number; // Option value. +}; + +/** + * Data returned by mod_quiz_get_combined_review_options WS, formatted to convert the options to objects. + */ +export type AddonModQuizCombinedReviewOptions = Omit & { + someoptions: Record; + alloptions: Record; +}; + +/** + * Params of mod_quiz_get_quiz_feedback_for_grade WS. + */ +export type AddonModQuizGetQuizFeedbackForGradeWSParams = { + quizid: number; // Quiz instance id. + grade: number; // The grade to check. +}; + +/** + * Data returned by mod_quiz_get_quiz_feedback_for_grade WS. + */ +export type AddonModQuizGetQuizFeedbackForGradeWSResponse = { + feedbacktext: string; // The comment that corresponds to this grade (empty for none). + feedbacktextformat?: number; // Feedbacktext format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + feedbackinlinefiles?: CoreWSExternalFile[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_quizzes_by_courses WS. + */ +export type AddonModQuizGetQuizzesByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_quiz_get_quizzes_by_courses WS. + */ +export type AddonModQuizGetQuizzesByCoursesWSResponse = { + quizzes: AddonModQuizQuizWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Quiz data returned by mod_quiz_get_quizzes_by_courses WS. + */ +export type AddonModQuizQuizWSData = { + id: number; // Standard Moodle primary key. + course: number; // Foreign key reference to the course this quiz is part of. + coursemodule: number; // Course module id. + name: string; // Quiz name. + intro?: string; // Quiz introduction text. + introformat?: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + introfiles?: CoreWSExternalFile[]; + timeopen?: number; // The time when this quiz opens. (0 = no restriction.). + timeclose?: number; // The time when this quiz closes. (0 = no restriction.). + timelimit?: number; // The time limit for quiz attempts, in seconds. + overduehandling?: string; // The method used to handle overdue attempts. 'autosubmit', 'graceperiod' or 'autoabandon'. + graceperiod?: number; // The amount of time (in seconds) after time limit during which attempts can still be submitted. + preferredbehaviour?: string; // The behaviour to ask questions to use. + canredoquestions?: number; // Allows students to redo any completed question within a quiz attempt. + attempts?: number; // The maximum number of attempts a student is allowed. + attemptonlast?: number; // Whether subsequent attempts start from the answer to the previous attempt (1) or start blank (0). + grademethod?: number; // One of the values QUIZ_GRADEHIGHEST, QUIZ_GRADEAVERAGE, QUIZ_ATTEMPTFIRST or QUIZ_ATTEMPTLAST. + decimalpoints?: number; // Number of decimal points to use when displaying grades. + questiondecimalpoints?: number; // Number of decimal points to use when displaying question grades. + reviewattempt?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewcorrectness?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewmarks?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewspecificfeedback?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewgeneralfeedback?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewrightanswer?: number; // Whether users are allowed to review their quiz attempts at various times. + reviewoverallfeedback?: number; // Whether users are allowed to review their quiz attempts at various times. + questionsperpage?: number; // How often to insert a page break when editing the quiz, or when shuffling the question order. + navmethod?: string; // Any constraints on how the user is allowed to navigate around the quiz. + shuffleanswers?: number; // Whether the parts of the question should be shuffled, in those question types that support it. + sumgrades?: number; // The total of all the question instance maxmarks. + grade?: number; // The total that the quiz overall grade is scaled to be out of. + timecreated?: number; // The time when the quiz was added to the course. + timemodified?: number; // Last modified time. + password?: string; // A password that the student must enter before starting or continuing a quiz attempt. + subnet?: string; // Used to restrict the IP addresses from which this quiz can be attempted. + browsersecurity?: string; // Restriciton on the browser the student must use. E.g. 'securewindow'. + delay1?: number; // Delay that must be left between the first and second attempt, in seconds. + delay2?: number; // Delay that must be left between the second and subsequent attempt, in seconds. + showuserpicture?: number; // Option to show the user's picture during the attempt and on the review page. + showblocks?: number; // Whether blocks should be shown on the attempt.php and review.php pages. + completionattemptsexhausted?: number; // Mark quiz complete when the student has exhausted the maximum number of attempts. + completionpass?: number; // Whether to require passing grade. + allowofflineattempts?: number; // Whether to allow the quiz to be attempted offline in the mobile app. + autosaveperiod?: number; // Auto-save delay. + hasfeedback?: number; // Whether the quiz has any non-blank feedback text. + hasquestions?: number; // Whether the quiz has questions. + section?: number; // Course section id. + visible?: number; // Module visibility. + groupmode?: number; // Group mode. + groupingid?: number; // Grouping id. +}; + +/** + * Params of mod_quiz_get_quiz_access_information WS. + */ +export type AddonModQuizGetQuizAccessInformationWSParams = { + quizid: number; // Quiz instance id. +}; + +/** + * Data returned by mod_quiz_get_quiz_access_information WS. + */ +export type AddonModQuizGetQuizAccessInformationWSResponse = { + canattempt: boolean; // Whether the user can do the quiz or not. + canmanage: boolean; // Whether the user can edit the quiz settings or not. + canpreview: boolean; // Whether the user can preview the quiz or not. + canreviewmyattempts: boolean; // Whether the users can review their previous attempts or not. + canviewreports: boolean; // Whether the user can view the quiz reports or not. + accessrules: string[]; // List of rules. + activerulenames: string[]; // List of active rules. + preventaccessreasons: string[]; // List of reasons. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_quiz_required_qtypes WS. + */ +export type AddonModQuizGetQuizRequiredQtypesWSParams = { + quizid: number; // Quiz instance id. +}; + +/** + * Data returned by mod_quiz_get_quiz_required_qtypes WS. + */ +export type AddonModQuizGetQuizRequiredQtypesWSResponse = { + questiontypes: string[]; // List of question types used in the quiz. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_user_attempts WS. + */ +export type AddonModQuizGetUserAttemptsWSParams = { + quizid: number; // Quiz instance id. + userid?: number; // User id, empty for current user. + status?: string; // Quiz status: all, finished or unfinished. + includepreviews?: boolean; // Whether to include previews or not. +}; + +/** + * Data returned by mod_quiz_get_user_attempts WS. + */ +export type AddonModQuizGetUserAttemptsWSResponse = { + attempts: AddonModQuizAttemptWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_get_user_best_grade WS. + */ +export type AddonModQuizGetUserBestGradeWSParams = { + quizid: number; // Quiz instance id. + userid?: number; // User id. +}; + +/** + * Data returned by mod_quiz_get_user_best_grade WS. + */ +export type AddonModQuizGetUserBestGradeWSResponse = { + hasgrade: boolean; // Whether the user has a grade on the given quiz. + grade?: number; // The grade (only if the user has a grade). + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_view_attempt WS. + */ +export type AddonModQuizViewAttemptWSParams = { + attemptid: number; // Attempt id. + page: number; // Page number. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Params of mod_quiz_process_attempt WS. + */ +export type AddonModQuizProcessAttemptWSParams = { + attemptid: number; // Attempt id. + data?: { // The data to be saved. + name: string; // Data name. + value: string; // Data value. + }[]; + finishattempt?: boolean; // Whether to finish or not the attempt. + timeup?: boolean; // Whether the WS was called by a timer when the time is up. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Data returned by mod_quiz_process_attempt WS. + */ +export type AddonModQuizProcessAttemptWSResponse = { + state: string; // The new attempt state: inprogress, finished, overdue, abandoned. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_save_attempt WS. + */ +export type AddonModQuizSaveAttemptWSParams = { + attemptid: number; // Attempt id. + data: { // The data to be saved. + name: string; // Data name. + value: string; // Data value. + }[]; + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Params of mod_quiz_start_attempt WS. + */ +export type AddonModQuizStartAttemptWSParams = { + quizid: number; // Quiz instance id. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). + forcenew?: boolean; // Whether to force a new attempt or not. +}; + +/** + * Data returned by mod_quiz_start_attempt WS. + */ +export type AddonModQuizStartAttemptWSResponse = { + attempt: AddonModQuizAttemptWSData; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_quiz_view_attempt_review WS. + */ +export type AddonModQuizViewAttemptReviewWSParams = { + attemptid: number; // Attempt id. +}; + +/** + * Params of mod_quiz_view_attempt_summary WS. + */ +export type AddonModQuizViewAttemptSummaryWSParams = { + attemptid: number; // Attempt id. + preflightdata?: AddonModQuizPreflightDataWSParam[]; // Preflight required data (like passwords). +}; + +/** + * Params of mod_quiz_view_quiz WS. + */ +export type AddonModQuizViewQuizWSParams = { + quizid: number; // Quiz instance id. +}; + +/** + * Data passed to ATTEMPT_FINISHED_EVENT event. + */ +export type AddonModQuizAttemptFinishedData = CoreEventSiteData & { + quizId: number; + attemptId: number; + synced: boolean; +}; diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index bb8ea56a1..cbe98637f 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -1072,13 +1072,13 @@ export class CoreUtilsProvider { * @param sortByValue True to sort values alphabetically, false otherwise. * @return Array of objects with the name & value of each property. */ - objectToArrayOfObjects( + objectToArrayOfObjects>( obj: Record, keyName: string, valueName: string, sortByKey?: boolean, sortByValue?: boolean, - ): Record[] { + ): T[] { // Get the entries from an object or primitive value. const getEntries = (elKey: string, value: unknown): Record[] | unknown => { if (typeof value == 'undefined' || value == null) { @@ -1114,7 +1114,7 @@ export class CoreUtilsProvider { } // "obj" will always be an object, so "entries" will always be an array. - const entries = getEntries('', obj) as Record[]; + const entries = getEntries('', obj) as T[]; if (sortByKey || sortByValue) { return entries.sort((a, b) => { if (sortByKey) { From 7698fa673de840ee728837e80b31d0a778e0f51c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 9 Feb 2021 13:11:29 +0100 Subject: [PATCH 03/16] MOBILE-3651 quiz: Implement sync service and prefetch handler --- src/addons/calendar/services/calendar-sync.ts | 2 +- src/addons/messages/services/messages-sync.ts | 6 +- src/addons/mod/lesson/services/lesson-sync.ts | 2 +- .../mod/quiz/services/handlers/prefetch.ts | 659 ++++++++++++++++++ src/addons/mod/quiz/services/quiz-sync.ts | 513 ++++++++++++++ src/core/features/course/services/sync.ts | 10 +- .../question/services/question-delegate.ts | 2 +- src/core/services/sync.ts | 4 +- 8 files changed, 1185 insertions(+), 13 deletions(-) create mode 100644 src/addons/mod/quiz/services/handlers/prefetch.ts create mode 100644 src/addons/mod/quiz/services/quiz-sync.ts diff --git a/src/addons/calendar/services/calendar-sync.ts b/src/addons/calendar/services/calendar-sync.ts index eee65e310..9f9c80a36 100644 --- a/src/addons/calendar/services/calendar-sync.ts +++ b/src/addons/calendar/services/calendar-sync.ts @@ -53,7 +53,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider { - await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId); + await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, force), siteId); } /** diff --git a/src/addons/messages/services/messages-sync.ts b/src/addons/messages/services/messages-sync.ts index be3a2b599..0e8893068 100644 --- a/src/addons/messages/services/messages-sync.ts +++ b/src/addons/messages/services/messages-sync.ts @@ -74,17 +74,17 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : ''); - return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, [onlyDeviceOffline]), siteId); + return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, onlyDeviceOffline), siteId); } /** * Get all messages pending to be sent in the site. * - * @param siteId Site ID to sync. If not defined, sync all sites. * @param onlyDeviceOffline True to only sync discussions that failed because device was offline. + * @param siteId Site ID to sync. If not defined, sync all sites. * @param Promise resolved if sync is successful, rejected if sync fails. */ - protected async syncAllDiscussionsFunc(siteId: string, onlyDeviceOffline = false): Promise { + protected async syncAllDiscussionsFunc(onlyDeviceOffline: boolean, siteId: string): Promise { const userIds: number[] = []; const conversationIds: number[] = []; const promises: Promise[] = []; diff --git a/src/addons/mod/lesson/services/lesson-sync.ts b/src/addons/mod/lesson/services/lesson-sync.ts index ea7c0a544..413118f71 100644 --- a/src/addons/mod/lesson/services/lesson-sync.ts +++ b/src/addons/mod/lesson/services/lesson-sync.ts @@ -253,7 +253,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid } // Sync finished, set sync time. - await CoreUtils.instance.ignoreErrors(this.setSyncTime(String(lessonId), siteId)); + await CoreUtils.instance.ignoreErrors(this.setSyncTime(lessonId, siteId)); // All done, return the result. return result; diff --git a/src/addons/mod/quiz/services/handlers/prefetch.ts b/src/addons/mod/quiz/services/handlers/prefetch.ts new file mode 100644 index 000000000..e138fe936 --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/prefetch.ts @@ -0,0 +1,659 @@ +// (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 { CoreConstants } from '@/core/constants'; + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModQuizAccessRuleDelegate } from '../access-rules-delegate'; +import { + AddonModQuiz, + AddonModQuizAttemptWSData, + AddonModQuizGetQuizAccessInformationWSResponse, + AddonModQuizProvider, + AddonModQuizQuizWSData, +} from '../quiz'; +import { AddonModQuizHelper } from '../quiz-helper'; +import { AddonModQuizSync, AddonModQuizSyncResult } from '../quiz-sync'; + +/** + * Handler to prefetch quizzes. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModQuiz'; + modName = 'quiz'; + component = AddonModQuizProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^questions$|^attempts$/; + + /** + * Download the module. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param dirPath Path of the directory where to store all the content files. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param canStart If true, start a new attempt if needed. + * @return Promise resolved when all content is downloaded. + */ + download( + module: CoreCourseAnyModuleData, + courseId: number, + dirPath?: string, + single?: boolean, + canStart: boolean = true, + ): Promise { + // Same implementation for download and prefetch. + return this.prefetch(module, courseId, single, dirPath, canStart); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved with the list of files. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getFiles(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise { + try { + const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id); + + const files = this.getIntroFilesFromInstance(module, quiz); + + const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { + cmId: module.id, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + }); + + const attemptFiles = await this.getAttemptsFeedbackFiles(quiz, attempts); + + return files.concat(attemptFiles); + } catch { + // Quiz not found, return empty list. + return []; + } + } + + /** + * Get the list of downloadable files on feedback attemptss. + * + * @param quiz Quiz. + * @param attempts Quiz user attempts. + * @param siteId Site ID. If not defined, current site. + * @return List of Files. + */ + protected async getAttemptsFeedbackFiles( + quiz: AddonModQuizQuizWSData, + attempts: AddonModQuizAttemptWSData[], + siteId?: string, + ): Promise { + const getInlineFiles = CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan('3.2'); + let files: CoreWSExternalFile[] = []; + + await Promise.all(attempts.map(async (attempt) => { + if (!AddonModQuiz.instance.isAttemptFinished(attempt.state)) { + // Attempt not finished, no feedback files. + return; + } + + const attemptGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false); + if (typeof attemptGrade == 'undefined') { + return; + } + + const feedback = await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, Number(attemptGrade), { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + if (getInlineFiles && feedback.feedbackinlinefiles?.length) { + files = files.concat(feedback.feedbackinlinefiles); + } else if (feedback.feedbacktext && !getInlineFiles) { + files = files.concat( + CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(feedback.feedbacktext), + ); + } + })); + + return files; + } + + /** + * Gather some preflight data for an attempt. This function will start a new attempt if needed. + * + * @param quiz Quiz. + * @param accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation. + * @param attempt Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param askPreflight Whether it should ask for preflight data if needed. + * @param modalTitle Lang key of the title to set to preflight modal (e.g. 'addon.mod_quiz.startattempt'). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the preflight data. + */ + async getPreflightData( + quiz: AddonModQuizQuizWSData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + attempt?: AddonModQuizAttemptWSData, + askPreflight?: boolean, + title?: string, + siteId?: string, + ): Promise> { + const preflightData: Record = {}; + + if (askPreflight) { + // We can ask preflight, check if it's needed and get the data. + await AddonModQuizHelper.instance.getAndCheckPreflightData( + quiz, + accessInfo, + preflightData, + attempt, + false, + true, + title, + siteId, + ); + } else { + // Get some fixed preflight data from access rules (data that doesn't require user interaction). + const rules = accessInfo?.activerulenames || []; + + await AddonModQuizAccessRuleDelegate.instance.getFixedPreflightData(rules, quiz, preflightData, attempt, true, siteId); + + if (!attempt) { + // We need to create a new attempt. + await AddonModQuiz.instance.startAttempt(quiz.id, preflightData, false, siteId); + } + } + + return preflightData; + } + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId The course ID the module belongs to. + * @return Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return AddonModQuiz.instance.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + // Invalidate the calls required to check if a quiz is downloadable. + await Promise.all([ + AddonModQuiz.instance.invalidateQuizData(courseId), + AddonModQuiz.instance.invalidateUserAttemptsForUser(module.instance!), + ]); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Whether the module can be downloaded. The promise should never be rejected. + */ + async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise { + if (CoreSites.instance.getCurrentSite()?.isOfflineDisabled()) { + // Don't allow downloading the quiz if offline is disabled to prevent wasting a lot of data when opening it. + return false; + } + + const siteId = CoreSites.instance.getCurrentSiteId(); + + const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, { siteId }); + + if (quiz.allowofflineattempts !== 1 || quiz.hasquestions === 0) { + return false; + } + + // Not downloadable if we reached max attempts or the quiz has an unfinished attempt. + const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { + cmId: module.id, + siteId, + }); + + const isLastFinished = !attempts.length || AddonModQuiz.instance.isAttemptFinished(attempts[attempts.length - 1].state); + + return quiz.attempts === 0 || quiz.attempts! > attempts.length || !isLastFinished; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + async isEnabled(): Promise { + return AddonModQuiz.instance.isPluginEnabled(); + } + + /** + * Prefetch a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param dirPath Path of the directory where to store all the content files. + * @param canStart If true, start a new attempt if needed. + * @return Promise resolved when done. + */ + async prefetch( + module: SyncedModule, + courseId?: number, + single?: boolean, + dirPath?: string, + canStart: boolean = true, + ): Promise { + if (module.attemptFinished) { + // Delete the value so it does not block anything if true. + delete module.attemptFinished; + + // Quiz got synced recently and an attempt has finished. Do not prefetch. + return; + } + + const siteId = CoreSites.instance.getCurrentSiteId(); + + return this.prefetchPackage(module, courseId, this.prefetchQuiz.bind(this, module, courseId, single, siteId, canStart)); + } + + /** + * Prefetch a quiz. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param siteId Site ID. + * @param canStart If true, start a new attempt if needed. + * @return Promise resolved when done. + */ + protected async prefetchQuiz( + module: CoreCourseAnyModuleData, + courseId: number, + single: boolean, + siteId: string, + canStart: boolean, + ): Promise { + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + // Get quiz. + const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, commonOptions); + + const introFiles = this.getIntroFilesFromInstance(module, quiz); + + // Prefetch some quiz data. + // eslint-disable-next-line prefer-const + let [quizAccessInfo, attempts, attemptAccessInfo] = await Promise.all([ + AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions), + AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions), + AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions), + AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, modOptions), + CoreFilepool.instance.addFilesToQueue(siteId, introFiles, AddonModQuizProvider.COMPONENT, module.id), + ]); + + // Check if we need to start a new attempt. + let attempt: AddonModQuizAttemptWSData | undefined = attempts[attempts.length - 1]; + let preflightData: Record = {}; + let startAttempt = false; + + if (canStart || attempt) { + if (canStart && (!attempt || AddonModQuiz.instance.isAttemptFinished(attempt.state))) { + // Check if the user can attempt the quiz. + if (attemptAccessInfo.preventnewattemptreasons.length) { + throw new CoreError(CoreTextUtils.instance.buildMessage(attemptAccessInfo.preventnewattemptreasons)); + } + + startAttempt = true; + attempt = undefined; + } + + // Get the preflight data. This function will also start a new attempt if needed. + preflightData = await this.getPreflightData(quiz, quizAccessInfo, attempt, single, 'core.download', siteId); + } + + const promises: Promise[] = []; + + if (startAttempt) { + // Re-fetch user attempts since we created a new one. + promises.push(AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions).then(async (atts) => { + attempts = atts; + + const attemptFiles = await this.getAttemptsFeedbackFiles(quiz, attempts, siteId); + + return CoreFilepool.instance.addFilesToQueue(siteId, attemptFiles, AddonModQuizProvider.COMPONENT, module.id); + })); + + // Update the download time to prevent detecting the new attempt as an update. + promises.push(CoreUtils.instance.ignoreErrors( + CoreFilepool.instance.updatePackageDownloadTime(siteId, AddonModQuizProvider.COMPONENT, module.id), + )); + } else { + // Use the already fetched attempts. + promises.push(this.getAttemptsFeedbackFiles(quiz, attempts, siteId).then((attemptFiles) => + CoreFilepool.instance.addFilesToQueue(siteId, attemptFiles, AddonModQuizProvider.COMPONENT, module.id))); + } + + // Fetch attempt related data. + promises.push(AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, modOptions)); + promises.push(AddonModQuiz.instance.getUserBestGrade(quiz.id, modOptions)); + promises.push(this.prefetchGradeAndFeedback(quiz, modOptions, siteId)); + promises.push(AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions)); // Last attempt. + + await Promise.all(promises); + + // We have quiz data, now we'll get specific data for each attempt. + await Promise.all(attempts.map(async (attempt) => { + await this.prefetchAttempt(quiz, attempt, preflightData, siteId); + })); + + if (!canStart) { + // Nothing else to do. + return; + } + + // If there's nothing to send, mark the quiz as synchronized. + const hasData = await AddonModQuizSync.instance.hasDataToSync(quiz.id, siteId); + + if (!hasData) { + AddonModQuizSync.instance.setSyncTime(quiz.id, siteId); + } + } + + /** + * Prefetch all WS data for an attempt. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight required data (like password). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the prefetch is finished. Data returned is not reliable. + */ + async prefetchAttempt( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + preflightData: Record, + siteId?: string, + ): Promise { + const pages = AddonModQuiz.instance.getPagesFromLayout(attempt.layout); + const isSequential = AddonModQuiz.instance.isNavigationSequential(quiz); + let promises: Promise[] = []; + + const modOptions: CoreCourseCommonModWSOptions = { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + if (AddonModQuiz.instance.isAttemptFinished(attempt.state)) { + // Attempt is finished, get feedback and review data. + const attemptGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false); + if (typeof attemptGrade != 'undefined') { + promises.push(AddonModQuiz.instance.getFeedbackForGrade(quiz.id, Number(attemptGrade), modOptions)); + } + + // Get the review for each page. + pages.forEach((page) => { + promises.push(CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.getAttemptReview(attempt.id, { + page, + ...modOptions, // Include all options. + }))); + }); + + // Get the review for all questions in same page. + promises.push(this.prefetchAttemptReviewFiles(quiz, attempt, modOptions, siteId)); + } else { + + // Attempt not finished, get data needed to continue the attempt. + promises.push(AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, attempt.id, modOptions)); + promises.push(AddonModQuiz.instance.getAttemptSummary(attempt.id, preflightData, modOptions)); + + if (attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) { + // Get data for each page. + promises = promises.concat(pages.map(async (page) => { + if (isSequential && page < attempt.currentpage!) { + // Sequential quiz, cannot get pages before the current one. + return; + } + + const data = await AddonModQuiz.instance.getAttemptData(attempt.id, page, preflightData, modOptions); + + // Download the files inside the questions. + await Promise.all(data.questions.map(async (question) => { + await CoreQuestionHelper.instance.prefetchQuestionFiles( + question, + this.component, + quiz.coursemodule, + siteId, + attempt.uniqueid, + ); + })); + + })); + } + } + + await Promise.all(promises); + } + + /** + * Prefetch attempt review and its files. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param options Other options. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchAttemptReviewFiles( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + modOptions: CoreCourseCommonModWSOptions, + siteId?: string, + ): Promise { + // Get the review for all questions in same page. + const data = await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.getAttemptReview(attempt.id, { + page: -1, + ...modOptions, // Include all options. + })); + + if (!data) { + return; + } + // Download the files inside the questions. + await Promise.all(data.questions.map((question) => { + CoreQuestionHelper.instance.prefetchQuestionFiles( + question, + this.component, + quiz.coursemodule, + siteId, + attempt.uniqueid, + ); + })); + } + + /** + * Prefetch quiz grade and its feedback. + * + * @param quiz Quiz. + * @param modOptions Other options. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchGradeAndFeedback( + quiz: AddonModQuizQuizWSData, + modOptions: CoreCourseCommonModWSOptions, + siteId?: string, + ): Promise { + try { + const gradebookData = await AddonModQuiz.instance.getGradeFromGradebook(quiz.course, quiz.coursemodule, true, siteId); + + if (typeof gradebookData.graderaw != 'undefined') { + await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, gradebookData.graderaw, modOptions); + } + } catch { + // Ignore errors. + } + } + + /** + * Prefetches some data for a quiz and its last attempt. + * This function will NOT start a new attempt, it only reads data for the quiz and the last attempt. + * + * @param quiz Quiz. + * @param askPreflight Whether it should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prefetchQuizAndLastAttempt(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const modOptions = { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + // Get quiz data. + const [quizAccessInfo, attempts] = await Promise.all([ + AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions), + AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions), + AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, modOptions), + AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, modOptions), + AddonModQuiz.instance.getUserBestGrade(quiz.id, modOptions), + this.prefetchGradeAndFeedback(quiz, modOptions, siteId), + AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions), // Last attempt. + ]); + + const lastAttempt = attempts[attempts.length - 1]; + let preflightData: Record = {}; + if (lastAttempt) { + // Get the preflight data. + preflightData = await this.getPreflightData(quiz, quizAccessInfo, lastAttempt, askPreflight, 'core.download', siteId); + + // Get data for last attempt. + await this.prefetchAttempt(quiz, lastAttempt, preflightData, siteId); + } + + // Prefetch finished, set the right status. + await this.setStatusAfterPrefetch(quiz, { + cmId: quiz.coursemodule, + attempts, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + } + + /** + * Set the right status to a quiz after prefetching. + * If the last attempt is finished or there isn't one, set it as not downloaded to show download icon. + * + * @param quiz Quiz. + * @param options Other options. + * @return Promise resolved when done. + */ + async setStatusAfterPrefetch( + quiz: AddonModQuizQuizWSData, + options: AddonModQuizSetStatusAfterPrefetchOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + let attempts = options.attempts; + + if (!attempts) { + // Get the attempts. + attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, options); + } + + // Check the current status of the quiz. + const status = await CoreFilepool.instance.getPackageStatus(options.siteId, this.component, quiz.coursemodule); + + if (status === CoreConstants.NOT_DOWNLOADED) { + return; + } + + // Quiz was downloaded, set the new status. + // If no attempts or last is finished we'll mark it as not downloaded to show download icon. + const lastAttempt = attempts[attempts.length - 1]; + const isLastFinished = !lastAttempt || AddonModQuiz.instance.isAttemptFinished(lastAttempt.state); + const newStatus = isLastFinished ? CoreConstants.NOT_DOWNLOADED : CoreConstants.DOWNLOADED; + + await CoreFilepool.instance.storePackageStatus(options.siteId, newStatus, this.component, quiz.coursemodule); + } + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async sync(module: SyncedModule, courseId: number, siteId?: string): Promise { + const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, { siteId }); + + try { + const result = await AddonModQuizSync.instance.syncQuiz(quiz, false, siteId); + + module.attemptFinished = result.attemptFinished || false; + + return result; + } catch { + // Ignore errors. + module.attemptFinished = false; + } + } + +} + +export class AddonModQuizPrefetchHandler extends makeSingleton(AddonModQuizPrefetchHandlerService) {} + +/** + * Options to pass to setStatusAfterPrefetch. + */ +export type AddonModQuizSetStatusAfterPrefetchOptions = CoreCourseCommonModWSOptions & { + attempts?: AddonModQuizAttemptWSData[]; // List of attempts. If not provided, they will be calculated. +}; + +/** + * Module data with some calculated data. + */ +type SyncedModule = CoreCourseAnyModuleData & { + attemptFinished?: boolean; +}; diff --git a/src/addons/mod/quiz/services/quiz-sync.ts b/src/addons/mod/quiz/services/quiz-sync.ts new file mode 100644 index 000000000..061f0463b --- /dev/null +++ b/src/addons/mod/quiz/services/quiz-sync.ts @@ -0,0 +1,513 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreQuestion, CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { CoreApp } from '@services/app'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { AddonModQuizAttemptDBRecord } from './database/quiz'; +import { AddonModQuizPrefetchHandler } from './handlers/prefetch'; +import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz'; +import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline'; + +/** + * Service to sync quizzes. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_quiz_autom_synced'; + + protected componentTranslate?: string; + + constructor() { + super('AddonModQuizSyncProvider'); + } + + /** + * Finish a sync process: remove offline data if needed, prefetch quiz data, set sync time and return the result. + * + * @param siteId Site ID. + * @param quiz Quiz. + * @param courseId Course ID. + * @param warnings List of warnings generated by the sync. + * @param options Other options. + * @return Promise resolved on success. + */ + protected async finishSync( + siteId: string, + quiz: AddonModQuizQuizWSData, + courseId: number, + warnings: string[], + options?: FinishSyncOptions, + ): Promise { + options = options || {}; + + // Invalidate the data for the quiz and attempt. + await CoreUtils.instance.ignoreErrors( + AddonModQuiz.instance.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId), + ); + + if (options.removeAttempt && options.attemptId) { + const promises: Promise[] = []; + + promises.push(AddonModQuizOffline.instance.removeAttemptAndAnswers(options.attemptId, siteId)); + + if (options.onlineQuestions) { + for (const slot in options.onlineQuestions) { + promises.push(CoreQuestionDelegate.instance.deleteOfflineData( + options.onlineQuestions[slot], + AddonModQuizProvider.COMPONENT, + quiz.coursemodule, + siteId, + )); + } + } + + await Promise.all(promises); + } + + if (options.updated) { + try { + // Data has been sent. Update prefetched data. + const module = await CoreCourse.instance.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId); + + await this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId); + } catch { + // Ignore errors. + } + } + + await CoreUtils.instance.ignoreErrors(this.setSyncTime(quiz.id, siteId)); + + // Check if online attempt was finished because of the sync. + let attemptFinished = false; + if (options.onlineAttempt && !AddonModQuiz.instance.isAttemptFinished(options.onlineAttempt.state)) { + // Attempt wasn't finished at start. Check if it's finished now. + const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { cmId: quiz.coursemodule, siteId }); + + const attempt = attempts.find(attempt => attempt.id == options?.onlineAttempt?.id); + + attemptFinished = attempt ? AddonModQuiz.instance.isAttemptFinished(attempt.state) : false; + } + + return { warnings, attemptFinished, updated: !!options.updated || !!options.removeAttempt }; + } + + /** + * Check if a quiz has data to synchronize. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it has data to sync. + */ + async hasDataToSync(quizId: number, siteId?: string): Promise { + try { + const attempts = await AddonModQuizOffline.instance.getQuizAttempts(quizId, siteId); + + return !!attempts.length; + } catch { + return false; + } + } + + /** + * Conveniece function to prefetch data after an update. + * + * @param module Module. + * @param quiz Quiz. + * @param courseId Course ID. + * @param regex If regex matches, don't download the data. Defaults to check files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prefetchAfterUpdateQuiz( + module: CoreCourseAnyModuleData, + quiz: AddonModQuizQuizWSData, + courseId: number, + regex?: RegExp, + siteId?: string, + ): Promise { + regex = regex || /^.*files$/; + + let shouldDownload = false; + + // Get the module updates to check if the data was updated or not. + const result = await CoreCourseModulePrefetchDelegate.instance.getModuleUpdates(module, courseId, true, siteId); + + if (result?.updates?.length) { + // Only prefetch if files haven't changed. + shouldDownload = !result.updates.find((entry) => entry.name.match(regex!)); + + if (shouldDownload) { + await AddonModQuizPrefetchHandler.instance.download(module, courseId, undefined, false, false); + } + } + + // Prefetch finished or not needed, set the right status. + await AddonModQuizPrefetchHandler.instance.setStatusAfterPrefetch(quiz, { + cmId: module.id, + readingStrategy: shouldDownload ? CoreSitesReadingStrategy.PreferCache : undefined, + siteId, + }); + } + + /** + * Try to synchronize all the quizzes in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllQuizzes(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this, !!force), siteId); + } + + /** + * Sync all quizzes on a site. + * + * @param siteId Site ID to sync. + * @param force Wether to force sync not depending on last execution. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllQuizzesFunc(siteId: string, force: boolean): Promise { + // Get all offline attempts. + const attempts = await AddonModQuizOffline.instance.getAllAttempts(siteId); + + const quizIds: Record = {}; // To prevent duplicates. + + // Sync all quizzes that haven't been synced for a while and that aren't attempted right now. + await Promise.all(attempts.map(async (attempt) => { + if (quizIds[attempt.quizid]) { + // Quiz already treated. + return; + } + quizIds[attempt.quizid] = true; + + if (CoreSync.instance.isBlocked(AddonModQuizProvider.COMPONENT, attempt.quizid, siteId)) { + return; + } + + // Quiz not blocked, try to synchronize it. + const quiz = await AddonModQuiz.instance.getQuizById(attempt.courseid, attempt.quizid, { siteId }); + + const data = await (force ? this.syncQuiz(quiz, false, siteId) : this.syncQuizIfNeeded(quiz, false, siteId)); + + if (data?.warnings?.length) { + // Store the warnings to show them when the user opens the quiz. + await this.setSyncWarnings(quiz.id, data.warnings, siteId); + } + + if (data) { + // Sync successful. Send event. + CoreEvents.trigger(AddonModQuizSyncProvider.AUTO_SYNCED, { + quizId: quiz.id, + attemptFinished: data.attemptFinished, + warnings: data.warnings, + }, siteId); + } + })); + } + + /** + * Sync a quiz only if a certain time has passed since the last time. + * + * @param quiz Quiz. + * @param askPreflight Whether we should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the quiz is synced or if it doesn't need to be synced. + */ + async syncQuizIfNeeded( + quiz: AddonModQuizQuizWSData, + askPreflight?: boolean, + siteId?: string, + ): Promise { + const needed = await this.isSyncNeeded(quiz.id, siteId); + + if (needed) { + return this.syncQuiz(quiz, askPreflight, siteId); + } + } + + /** + * Try to synchronize a quiz. + * The promise returned will be resolved with an array with warnings if the synchronization is successful. + * + * @param quiz Quiz. + * @param askPreflight Whether we should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + syncQuiz(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.isSyncing(quiz.id, siteId)) { + // There's already a sync ongoing for this quiz, return the promise. + return this.getOngoingSync(quiz.id, siteId)!; + } + + // Verify that quiz isn't blocked. + if (CoreSync.instance.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) { + this.logger.debug('Cannot sync quiz ' + quiz.id + ' because it is blocked.'); + this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('quiz'); + + throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + return this.addOngoingSync(quiz.id, this.performSyncQuiz(quiz, askPreflight, siteId), siteId); + } + + /** + * Perform the quiz sync. + * + * @param quiz Quiz. + * @param askPreflight Whether we should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + async performSyncQuiz(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const warnings: string[] = []; + const courseId = quiz.course; + const modOptions = { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId); + + // Sync offline logs. + await CoreUtils.instance.ignoreErrors( + CoreCourseLogHelper.instance.syncActivity(AddonModQuizProvider.COMPONENT, quiz.id, siteId), + ); + + // Get all the offline attempts for the quiz. It should always be 0 or 1 attempt + const offlineAttempts = await AddonModQuizOffline.instance.getQuizAttempts(quiz.id, siteId); + + if (!offlineAttempts.length) { + // Nothing to sync, finish. + return this.finishSync(siteId, quiz, courseId, warnings); + } + + if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreError(Translate.instance.instant('core.cannotconnect')); + } + + const offlineAttempt = offlineAttempts.pop()!; + + // Now get the list of online attempts to make sure this attempt exists and isn't finished. + const onlineAttempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions); + + const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined; + const onlineAttempt = onlineAttempts.find((attempt) => attempt.id == offlineAttempt.id); + + if (!onlineAttempt || AddonModQuiz.instance.isAttemptFinished(onlineAttempt.state)) { + // Attempt not found or it's finished in online. Discard it. + warnings.push(Translate.instance.instant('addon.mod_quiz.warningattemptfinished')); + + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: offlineAttempt.id, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + }); + } + + // Get the data stored in offline. + const answersList = await AddonModQuizOffline.instance.getAttemptAnswers(offlineAttempt.id, siteId); + + if (!answersList.length) { + // No answers stored, finish. + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: lastAttemptId, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + }); + } + + const offlineAnswers = CoreQuestion.instance.convertAnswersArrayToObject(answersList); + const offlineQuestions = AddonModQuizOffline.instance.classifyAnswersInQuestions(offlineAnswers); + + // We're going to need preflightData, get it. + const info = await AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions); + + const preflightData = await AddonModQuizPrefetchHandler.instance.getPreflightData( + quiz, + info, + onlineAttempt, + askPreflight, + 'core.settings.synchronization', + siteId, + ); + + // Now get the online questions data. + const onlineQuestions = await AddonModQuiz.instance.getAllQuestionsData(quiz, onlineAttempt, preflightData, { + pages: AddonModQuiz.instance.getPagesFromLayoutAndQuestions(onlineAttempt.layout || '', offlineQuestions), + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + // Validate questions, discarding the offline answers that can't be synchronized. + const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); + + // Let questions prepare the data to send. + await Promise.all(Object.keys(offlineQuestions).map(async (slotString) => { + const slot = Number(slotString); + const onlineQuestion = onlineQuestions[slot]; + + await CoreQuestionDelegate.instance.prepareSyncData( + onlineQuestion, + offlineQuestions[slot].answers, + AddonModQuizProvider.COMPONENT, + quiz.coursemodule, + siteId, + ); + })); + + // Get the answers to send. + const answers = AddonModQuizOffline.instance.extractAnswersFromQuestions(offlineQuestions); + const finish = !!offlineAttempt.finished && !discardedData; + + if (discardedData) { + if (offlineAttempt.finished) { + warnings.push(Translate.instance.instant('addon.mod_quiz.warningdatadiscardedfromfinished')); + } else { + warnings.push(Translate.instance.instant('addon.mod_quiz.warningdatadiscarded')); + } + } + + // Send the answers. + await AddonModQuiz.instance.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, siteId); + + if (!finish) { + // Answers sent, now set the current page. + // Don't pass the quiz instance because we don't want to trigger a Firebase event in this case. + await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.logViewAttempt( + onlineAttempt.id, + offlineAttempt.currentpage, + preflightData, + false, + undefined, + siteId, + )); + } + + // Data sent. Finish the sync. + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: lastAttemptId, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + updated: true, + onlineQuestions, + }); + } + + /** + * Validate questions, discarding the offline answers that can't be synchronized. + * + * @param attemptId Attempt ID. + * @param onlineQuestions Online questions + * @param offlineQuestions Offline questions. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if some offline data was discarded, false otherwise. + */ + async validateQuestions( + attemptId: number, + onlineQuestions: Record, + offlineQuestions: AddonModQuizQuestionsWithAnswers, + siteId?: string, + ): Promise { + let discardedData = false; + + await Promise.all(Object.keys(offlineQuestions).map(async (slotString) => { + const slot = Number(slotString); + const offlineQuestion = offlineQuestions[slot]; + const onlineQuestion = onlineQuestions[slot]; + const offlineSequenceCheck = offlineQuestion.answers[':sequencecheck']; + + if (onlineQuestion) { + // We found the online data for the question, validate that the sequence check is ok. + if (!CoreQuestionDelegate.instance.validateSequenceCheck(onlineQuestion, offlineSequenceCheck)) { + // Sequence check is not valid, remove the offline data. + await AddonModQuizOffline.instance.removeQuestionAndAnswers(attemptId, slot, siteId); + + discardedData = true; + delete offlineQuestions[slot]; + } else { + // Sequence check is valid. Use the online one to prevent synchronization errors. + offlineQuestion.answers[':sequencecheck'] = String(onlineQuestion.sequencecheck); + } + } else { + // Online question not found, it can happen for 2 reasons: + // 1- It's a sequential quiz and the question is in a page already passed. + // 2- Quiz layout has changed (shouldn't happen since it's blocked if there are attempts). + await AddonModQuizOffline.instance.removeQuestionAndAnswers(attemptId, slot, siteId); + + discardedData = true; + delete offlineQuestions[slot]; + } + })); + + return discardedData; + } + +} + +export class AddonModQuizSync extends makeSingleton(AddonModQuizSyncProvider) {} + +/** + * Data returned by a quiz sync. + */ +export type AddonModQuizSyncResult = { + warnings: string[]; // List of warnings. + attemptFinished: boolean; // Whether an attempt was finished in the site due to the sync. + updated: boolean; +}; + +/** + * Options to pass to finish sync. + */ +type FinishSyncOptions = { + attemptId?: number; // Last attempt ID. + offlineAttempt?: AddonModQuizAttemptDBRecord; // Offline attempt synchronized, if any. + onlineAttempt?: AddonModQuizAttemptWSData; // Online data for the offline attempt. + removeAttempt?: boolean; // Whether the offline data should be removed. + updated?: boolean; // Whether the offline data should be removed. + onlineQuestions?: Record; // Online questions indexed by slot. +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModQuizAutoSyncData = CoreEventSiteData & { + quizId: number; + attemptFinished: boolean; + warnings: string[]; +}; diff --git a/src/core/features/course/services/sync.ts b/src/core/features/course/services/sync.ts index 4cbcd2f0c..a23a0d5f4 100644 --- a/src/core/features/course/services/sync.ts +++ b/src/core/features/course/services/sync.ts @@ -50,17 +50,17 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider { - return this.syncOnSites('courses', this.syncAllCoursesFunc.bind(this, siteId, force), siteId); + return this.syncOnSites('courses', this.syncAllCoursesFunc.bind(this, !!force), siteId); } /** * Sync all courses on a site. * - * @param siteId Site ID to sync. * @param force Wether the execution is forced (manual sync). + * @param siteId Site ID to sync. * @return Promise resolved if sync is successful, rejected if sync fails. */ - protected async syncAllCoursesFunc(siteId: string, force: boolean): Promise { + protected async syncAllCoursesFunc(force: boolean, siteId: string): Promise { await Promise.all([ CoreCourseLogHelper.instance.syncSite(siteId), this.syncCoursesCompletion(siteId, force), @@ -149,7 +149,7 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider { const db = await CoreSites.instance.getSiteDb(siteId); - return await db.getRecord(SYNC_TABLE_NAME, { component: component, id: id }); + return await db.getRecord(SYNC_TABLE_NAME, { component: component, id: String(id) }); } /** @@ -121,7 +121,7 @@ export class CoreSyncProvider { const db = await CoreSites.instance.getSiteDb(siteId); data.component = component; - data.id = id + ''; + data.id = String(id); await db.insertRecord(SYNC_TABLE_NAME, data); } From e565df9ea4c919d82ec1e013d8b88567c3c641e3 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 10 Feb 2021 14:59:22 +0100 Subject: [PATCH 04/16] MOBILE-3651 quiz: Implement entry page --- .../index/addon-mod-lesson-index.html | 2 +- .../components/menu-modal/menu-modal.html | 2 +- .../password-modal/password-modal.html | 4 +- src/addons/mod/mod.module.ts | 2 + .../mod/quiz/components/components.module.ts | 38 + .../index/addon-mod-quiz-index.html | 199 ++++++ .../mod/quiz/components/index/index.scss | 44 ++ src/addons/mod/quiz/components/index/index.ts | 658 ++++++++++++++++++ src/addons/mod/quiz/pages/index/index.html | 22 + .../mod/quiz/pages/index/index.module.ts | 40 ++ src/addons/mod/quiz/pages/index/index.ts | 69 ++ src/addons/mod/quiz/quiz-lazy.module.ts | 28 + src/addons/mod/quiz/quiz.module.ts | 56 ++ .../mod/quiz/services/handlers/module.ts | 98 +++ 14 files changed, 1258 insertions(+), 4 deletions(-) create mode 100644 src/addons/mod/quiz/components/components.module.ts create mode 100644 src/addons/mod/quiz/components/index/addon-mod-quiz-index.html create mode 100644 src/addons/mod/quiz/components/index/index.scss create mode 100644 src/addons/mod/quiz/components/index/index.ts create mode 100644 src/addons/mod/quiz/pages/index/index.html create mode 100644 src/addons/mod/quiz/pages/index/index.module.ts create mode 100644 src/addons/mod/quiz/pages/index/index.ts create mode 100644 src/addons/mod/quiz/quiz-lazy.module.ts create mode 100644 src/addons/mod/quiz/quiz.module.ts create mode 100644 src/addons/mod/quiz/services/handlers/module.ts diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index 4f4f7b741..ab10934b6 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -64,7 +64,7 @@ {{ 'addon.mod_lesson.continue' | translate }} - + diff --git a/src/addons/mod/lesson/components/menu-modal/menu-modal.html b/src/addons/mod/lesson/components/menu-modal/menu-modal.html index b442fb3b3..602349fd7 100644 --- a/src/addons/mod/lesson/components/menu-modal/menu-modal.html +++ b/src/addons/mod/lesson/components/menu-modal/menu-modal.html @@ -4,7 +4,7 @@ - + diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.html b/src/addons/mod/lesson/components/password-modal/password-modal.html index 9b9ff4658..c09d93d0f 100644 --- a/src/addons/mod/lesson/components/password-modal/password-modal.html +++ b/src/addons/mod/lesson/components/password-modal/password-modal.html @@ -4,7 +4,7 @@ - + @@ -20,7 +20,7 @@ {{ 'addon.mod_lesson.continue' | translate }} - + diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index 9a8f91414..92f8e574f 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -18,6 +18,7 @@ import { AddonModAssignModule } from './assign/assign.module'; import { AddonModBookModule } from './book/book.module'; import { AddonModLessonModule } from './lesson/lesson.module'; import { AddonModPageModule } from './page/page.module'; +import { AddonModQuizModule } from './quiz/quiz.module'; @NgModule({ declarations: [], @@ -26,6 +27,7 @@ import { AddonModPageModule } from './page/page.module'; AddonModBookModule, AddonModLessonModule, AddonModPageModule, + AddonModQuizModule, ], providers: [], exports: [], diff --git a/src/addons/mod/quiz/components/components.module.ts b/src/addons/mod/quiz/components/components.module.ts new file mode 100644 index 000000000..ef510db4b --- /dev/null +++ b/src/addons/mod/quiz/components/components.module.ts @@ -0,0 +1,38 @@ +// (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 { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error'; +import { AddonModQuizIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModQuizIndexComponent, + AddonModQuizConnectionErrorComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + ], + providers: [ + ], + exports: [ + AddonModQuizIndexComponent, + AddonModQuizConnectionErrorComponent, + ], +}) +export class AddonModQuizComponentsModule {} diff --git a/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html b/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html new file mode 100644 index 000000000..52c209fa2 --- /dev/null +++ b/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ rule }}

+
+ + +

{{ 'addon.mod_quiz.grademethod' | translate }}

+

{{ gradeMethodReadable }}

+
+
+ + +

{{ 'core.lastsync' | translate }}

+

{{ syncTime }}

+
+
+
+
+ + + + + + {{ 'addon.mod_quiz.summaryofattempts' | translate }} + + + + + + + + + {{ 'addon.mod_quiz.attemptnumber' | translate }} + + # + {{ 'addon.mod_quiz.attemptstate' | translate }} + + {{ 'addon.mod_quiz.marks' | translate }} / {{ quiz.sumGradesFormatted }} + + + {{ 'addon.mod_quiz.grade' | translate }} / {{ quiz.gradeFormatted }} + + + + + + + + + + {{ 'addon.mod_quiz.preview' | translate }} + + + {{ attempt.attempt }} + + +

{{ sentence }}

+
+ +

{{ attempt.readableMark }}

+
+

{{ attempt.readableGrade }}

+
+
+
+
+
+ + + + + + {{ gradeResult }} + + + {{ 'core.course.overriddennotice' | translate }} + + + +

{{ 'addon.mod_quiz.comment' | translate }}

+

+

+
+
+ + +

{{ 'addon.mod_quiz.overallfeedback' | translate }}

+

+

+
+
+
+
+ + + + + + +

{{ message }}

+
+ +

{{ 'addon.mod_quiz.noquestions' | translate }}

+
+ + +

{{ 'addon.mod_quiz.errorquestionsnotsupported' | translate }}

+

{{ type }}

+
+
+ + +

{{ 'addon.mod_quiz.errorrulesnotsupported' | translate }}

+

{{ name }}

+
+
+ + +

{{ 'addon.mod_quiz.errorbehaviournotsupported' | translate }}

+

{{ quiz.preferredbehaviour }}

+
+
+ + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + +

{{ 'addon.mod_quiz.canattemptbutnotsubmit' | translate }}

+

{{ 'addon.mod_quiz.warningquestionsnotsupported' | translate }}

+

{{ type }}

+
+
+ + + + {{ buttonText | translate }} + + + + + {{ 'core.openinbrowser' | translate }} + + + + + + + +
+
+
diff --git a/src/addons/mod/quiz/components/index/index.scss b/src/addons/mod/quiz/components/index/index.scss new file mode 100644 index 000000000..ead659221 --- /dev/null +++ b/src/addons/mod/quiz/components/index/index.scss @@ -0,0 +1,44 @@ +:host { + + .addon-mod_quiz-table { + .addon-mod_quiz-table-header { + --detail-icon-opacity: 0; + } + + ion-card-content { + padding-left: 0; + padding-right: 0; + } + + .item:nth-child(even) { + --background: var(--gray-lighter); + // @include darkmode() { + // background-color: $core-dark-item-divider-bg-color; + // } + } + + .addon-mod_quiz-highlighted, + .item.addon-mod_quiz-highlighted, + .addon-mod_quiz-highlighted p, + .item.addon-mod_quiz-highlighted p { + --background: var(--blue-light); + color: var(--blue-dark); + } + + // @include darkmode() { + // .addon-mod_quiz-highlighted, + // .item.addon-mod_quiz-highlighted, + // .addon-mod_quiz-highlighted p, + // .item.addon-mod_quiz-highlighted p { + // background-color: $blue-dark; + // color: $blue-light; + // } + + // .item.addon-mod_quiz-highlighted.activated, + // .item.addon-mod_quiz-highlighted.activated p { + // background-color: $blue; + // color: $blue-light; + // } + // } + } +} diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts new file mode 100644 index 000000000..19912e9bb --- /dev/null +++ b/src/addons/mod/quiz/components/index/index.ts @@ -0,0 +1,658 @@ +// (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 { CoreConstants } from '@/core/constants'; +import { Component, OnDestroy, OnInit, Optional } from '@angular/core'; + +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { IonContent } from '@ionic/angular'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { AddonModQuizPrefetchHandler } from '../../services/handlers/prefetch'; +import { + AddonModQuiz, + AddonModQuizAttemptFinishedData, + AddonModQuizAttemptWSData, + AddonModQuizCombinedReviewOptions, + AddonModQuizGetAttemptAccessInformationWSResponse, + AddonModQuizGetQuizAccessInformationWSResponse, + AddonModQuizGetUserBestGradeWSResponse, + AddonModQuizProvider, +} from '../../services/quiz'; +import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper'; +import { + AddonModQuizAutoSyncData, + AddonModQuizSync, + AddonModQuizSyncProvider, + AddonModQuizSyncResult, +} from '../../services/quiz-sync'; + +/** + * Component that displays a quiz entry page. + */ +@Component({ + selector: 'addon-mod-quiz-index', + templateUrl: 'addon-mod-quiz-index.html', + styleUrls: ['index.scss'], +}) +export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + component = AddonModQuizProvider.COMPONENT; + moduleName = 'quiz'; + quiz?: AddonModQuizQuizData; // The quiz. + now?: number; // Current time. + syncTime?: string; // Last synchronization time. + hasOffline = false; // Whether the quiz has offline data. + hasSupportedQuestions = false; // Whether the quiz has at least 1 supported question. + accessRules: string[] = []; // List of access rules of the quiz. + unsupportedRules: string[] = []; // List of unsupported access rules of the quiz. + unsupportedQuestions: string[] = []; // List of unsupported question types of the quiz. + behaviourSupported = false; // Whether the quiz behaviour is supported. + showResults = false; // Whether to show the result of the quiz (grade, etc.). + gradeOverridden = false; // Whether grade has been overridden. + gradebookFeedback?: string; // The feedback in the gradebook. + gradeResult?: string; // Message with the grade. + overallFeedback?: string; // The feedback for the grade. + buttonText?: string; // Text to display in the start/continue button. + preventMessages: string[] = []; // List of messages explaining why the quiz cannot be attempted. + showStatusSpinner = true; // Whether to show a spinner due to quiz status. + gradeMethodReadable?: string; // Grade method in a readable format. + showReviewColumn = false; // Whether to show the review column. + attempts: AddonModQuizAttempt[] = []; // List of attempts the user has made. + + protected fetchContentDefaultError = 'addon.mod_quiz.errorgetquiz'; // Default error to show when loading contents. + protected syncEventName = AddonModQuizSyncProvider.AUTO_SYNCED; + + // protected quizData: any; // Quiz instance. This variable will store the quiz instance until it's ready to be shown + protected autoReview?: AddonModQuizAttemptFinishedData; // Data to auto-review an attempt after finishing. + protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access info. + protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Last attempt access info. + protected moreAttempts = false; // Whether user can create/continue attempts. + protected options?: AddonModQuizCombinedReviewOptions; // Combined review options. + protected bestGrade?: AddonModQuizGetUserBestGradeWSResponse; // Best grade data. + protected gradebookData?: { grade?: number; feedback?: string }; // The gradebook grade and feedback. + protected overallStats = false; // Equivalent to overallstats in mod_quiz_view_object in Moodle. + protected finishedObserver?: CoreEventObserver; // It will observe attempt finished events. + protected hasPlayed = false; // Whether the user has gone to the quiz player (attempted). + protected candidateQuiz?: AddonModQuizQuizData; + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModQuizIndexComponent', content, courseContentsPage); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + // Listen for attempt finished events. + this.finishedObserver = CoreEvents.on( + AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, + (data) => { + // Go to review attempt if an attempt in this quiz was finished and synced. + if (this.quiz && data.quizId == this.quiz.id) { + this.autoReview = data; + } + }, + this.siteId, + ); + + await this.loadContent(false, true); + + if (!this.quiz) { + return; + } + + try { + await AddonModQuiz.instance.logViewQuiz(this.quiz.id, this.quiz.name); + + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + } catch { + // Ignore errors. + } + } + + /** + * Attempt the quiz. + */ + async attemptQuiz(): Promise { + if (this.showStatusSpinner || !this.quiz) { + // Quiz is being downloaded or synchronized, abort. + return; + } + + if (!AddonModQuiz.instance.isQuizOffline(this.quiz)) { + // Quiz isn't offline, just open it. + return this.openQuiz(); + } + + // Quiz supports offline, check if it needs to be downloaded. + // If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new. + const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED; + + if (isDownloaded && CoreCourseModulePrefetchDelegate.instance.canCheckUpdates()) { + // Already downloaded, open it. + return this.openQuiz(); + } + + // Prefetch the quiz. + this.showStatusSpinner = true; + + try { + await AddonModQuizPrefetchHandler.instance.prefetch(this.module!, this.courseId, true); + + // Success downloading, open quiz. + this.openQuiz(); + } catch (error) { + if (this.hasOffline || (isDownloaded && !CoreCourseModulePrefetchDelegate.instance.canCheckUpdates())) { + // Error downloading but there is something offline, allow continuing it. + // If the site doesn't support check updates, continue too because we cannot tell if there's something new. + this.openQuiz(); + } else { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } + } finally { + this.showStatusSpinner = false; + } + } + + /** + * Get the quiz data. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + try { + // First get the quiz instance. + const quiz = await AddonModQuiz.instance.getQuiz(this.courseId!, this.module!.id); + + this.gradeMethodReadable = AddonModQuiz.instance.getQuizGradeMethod(quiz.grademethod); + this.now = Date.now(); + this.dataRetrieved.emit(quiz); + this.description = quiz.intro || this.description; + this.candidateQuiz = quiz; + + // Try to get warnings from automatic sync. + const warnings = await AddonModQuizSync.instance.getSyncWarnings(quiz.id); + + if (warnings?.length) { + // Show warnings and delete them so they aren't shown again. + CoreDomUtils.instance.showErrorModal(CoreTextUtils.instance.buildMessage(warnings)); + + await AddonModQuizSync.instance.setSyncWarnings(quiz.id, []); + } + + if (AddonModQuiz.instance.isQuizOffline(quiz) && sync) { + // Try to sync the quiz. + try { + await this.syncActivity(showErrors); + } catch { + // Ignore errors, keep getting data even if sync fails. + this.autoReview = undefined; + } + } else { + this.autoReview = undefined; + this.showStatusSpinner = false; + } + + if (AddonModQuiz.instance.isQuizOffline(quiz)) { + // Handle status. + this.setStatusListener(); + + // Get last synchronization time and check if sync button should be seen. + this.syncTime = await AddonModQuizSync.instance.getReadableSyncTime(quiz.id); + this.hasOffline = await AddonModQuizSync.instance.hasDataToSync(quiz.id); + } + + // Get quiz access info. + this.quizAccessInfo = await AddonModQuiz.instance.getQuizAccessInformation(quiz.id, { cmId: this.module!.id }); + + this.showReviewColumn = this.quizAccessInfo.canreviewmyattempts; + this.accessRules = this.quizAccessInfo.accessrules; + this.unsupportedRules = AddonModQuiz.instance.getUnsupportedRules(this.quizAccessInfo.activerulenames); + + if (quiz.preferredbehaviour) { + this.behaviourSupported = CoreQuestionBehaviourDelegate.instance.isBehaviourSupported(quiz.preferredbehaviour); + } + + // Get question types in the quiz. + const types = await AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, { cmId: this.module!.id }); + + this.unsupportedQuestions = AddonModQuiz.instance.getUnsupportedQuestions(types); + this.hasSupportedQuestions = !!types.find((type) => type != 'random' && this.unsupportedQuestions.indexOf(type) == -1); + + await this.getAttempts(quiz); + + // Quiz is ready to be shown, move it to the variable that is displayed. + this.quiz = quiz; + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Get the user attempts in the quiz and the result info. + * + * @param quiz Quiz instance. + * @return Promise resolved when done. + */ + protected async getAttempts(quiz: AddonModQuizQuizData): Promise { + + // Get access information of last attempt (it also works if no attempts made). + this.attemptAccessInfo = await AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, { cmId: this.module!.id }); + + // Get attempts. + const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { cmId: this.module!.id }); + + this.attempts = await this.treatAttempts(quiz, attempts); + + // Check if user can create/continue attempts. + if (this.attempts.length) { + const last = this.attempts[this.attempts.length - 1]; + this.moreAttempts = !AddonModQuiz.instance.isAttemptFinished(last.state) || !this.attemptAccessInfo.isfinished; + } else { + this.moreAttempts = !this.attemptAccessInfo.isfinished; + } + + this.getButtonText(quiz); + + await this.getResultInfo(quiz); + } + + /** + * Get the text to show in the button. It also sets restriction messages if needed. + * + * @param quiz Quiz. + */ + protected getButtonText(quiz: AddonModQuizQuizData): void { + this.buttonText = ''; + + if (quiz.hasquestions !== 0) { + if (this.attempts.length && !AddonModQuiz.instance.isAttemptFinished(this.attempts[this.attempts.length - 1].state)) { + // Last attempt is unfinished. + if (this.quizAccessInfo?.canattempt) { + this.buttonText = 'addon.mod_quiz.continueattemptquiz'; + } else if (this.quizAccessInfo?.canpreview) { + this.buttonText = 'addon.mod_quiz.continuepreview'; + } + + } else { + // Last attempt is finished or no attempts. + if (this.quizAccessInfo?.canattempt) { + this.preventMessages = this.attemptAccessInfo?.preventnewattemptreasons || []; + if (!this.preventMessages.length) { + if (!this.attempts.length) { + this.buttonText = 'addon.mod_quiz.attemptquiznow'; + } else { + this.buttonText = 'addon.mod_quiz.reattemptquiz'; + } + } + } else if (this.quizAccessInfo?.canpreview) { + this.buttonText = 'addon.mod_quiz.previewquiznow'; + } + } + } + + if (!this.buttonText) { + return; + } + + // So far we think a button should be printed, check if they will be allowed to access it. + this.preventMessages = this.quizAccessInfo?.preventaccessreasons || []; + + if (!this.moreAttempts) { + this.buttonText = ''; + } else if (this.quizAccessInfo?.canattempt && this.preventMessages.length) { + this.buttonText = ''; + } else if (!this.hasSupportedQuestions || this.unsupportedRules.length || !this.behaviourSupported) { + this.buttonText = ''; + } + } + + /** + * Get result info to show. + * + * @param quiz Quiz. + * @return Promise resolved when done. + */ + protected async getResultInfo(quiz: AddonModQuizQuizData): Promise { + + if (!this.attempts.length || !quiz.showGradeColumn || !this.bestGrade?.hasgrade || + this.gradebookData?.grade === undefined) { + this.showResults = false; + + return; + } + + const formattedGradebookGrade = AddonModQuiz.instance.formatGrade(this.gradebookData.grade, quiz.decimalpoints); + const formattedBestGrade = AddonModQuiz.instance.formatGrade(this.bestGrade.grade, quiz.decimalpoints); + let gradeToShow = formattedGradebookGrade; // By default we show the grade in the gradebook. + + this.showResults = true; + this.gradeOverridden = formattedGradebookGrade != formattedBestGrade; + this.gradebookFeedback = this.gradebookData.feedback; + + if (this.bestGrade.grade! > this.gradebookData.grade && this.gradebookData.grade == quiz.grade) { + // The best grade is higher than the max grade for the quiz. + // We'll do like Moodle web and show the best grade instead of the gradebook grade. + this.gradeOverridden = false; + gradeToShow = formattedBestGrade; + } + + if (this.overallStats) { + // Show the quiz grade. The message shown is different if the quiz is finished. + if (this.moreAttempts) { + this.gradeResult = Translate.instance.instant('addon.mod_quiz.gradesofar', { $a: { + method: this.gradeMethodReadable, + mygrade: gradeToShow, + quizgrade: quiz.gradeFormatted, + } }); + } else { + const outOfShort = Translate.instance.instant('addon.mod_quiz.outofshort', { $a: { + grade: gradeToShow, + maxgrade: quiz.gradeFormatted, + } }); + + this.gradeResult = Translate.instance.instant('addon.mod_quiz.yourfinalgradeis', { $a: outOfShort }); + } + } + + if (quiz.showFeedbackColumn) { + // Get the quiz overall feedback. + const response = await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, this.gradebookData.grade, { + cmId: this.module!.id, + }); + + this.overallFeedback = response.feedbacktext; + } + } + + /** + * Go to review an attempt that has just been finished. + * + * @return Promise resolved when done. + */ + protected async goToAutoReview(): Promise { + if (!this.autoReview) { + return; + } + + // If we go to auto review it means an attempt was finished. Check completion status. + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + + // Verify that user can see the review. + const attemptId = this.autoReview.attemptId; + + if (this.quizAccessInfo?.canreviewmyattempts) { + try { + await AddonModQuiz.instance.getAttemptReview(attemptId, { page: -1, cmId: this.module!.id }); + + // @todo this.navCtrl.push('AddonModQuizReviewPage', { courseId: this.courseId, quizId: quiz!.id, attemptId }); + } catch { + // Ignore errors. + } + } + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModQuizSyncResult): boolean { + if (result.attemptFinished) { + // An attempt was finished, check completion status. + CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata); + } + + // If the sync call isn't rejected it means the sync was successful. + return result.updated; + } + + /** + * User entered the page that contains the component. + */ + async ionViewDidEnter(): Promise { + super.ionViewDidEnter(); + + if (!this.hasPlayed) { + this.autoReview = undefined; + + return; + } + + this.hasPlayed = false; + let promise = Promise.resolve(); + + // Update data when we come back from the player since the attempt status could have changed. + // Check if we need to go to review an attempt automatically. + if (this.autoReview && this.autoReview.synced) { + promise = this.goToAutoReview(); + this.autoReview = undefined; + } + + // Refresh data. + this.loaded = false; + this.refreshIcon = CoreConstants.ICON_LOADING; + this.syncIcon = CoreConstants.ICON_LOADING; + this.content?.scrollToTop(); + + await promise; + await CoreUtils.instance.ignoreErrors(this.refreshContent()); + + this.loaded = true; + this.refreshIcon = CoreConstants.ICON_REFRESH; + this.syncIcon = CoreConstants.ICON_SYNC; + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + super.ionViewDidLeave(); + this.autoReview = undefined; + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId!)); + + if (this.quiz) { + promises.push(AddonModQuiz.instance.invalidateUserAttemptsForUser(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateQuizAccessInformation(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateQuizRequiredQtypes(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateAttemptAccessInformation(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateUserBestGradeForUser(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateGradeFromGradebook(this.courseId!)); + } + + await Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModQuizAutoSyncData): boolean { + if (!this.courseId || !this.module) { + return false; + } + + if (syncEventData.attemptFinished) { + // An attempt was finished, check completion status. + CoreCourse.instance.checkModuleCompletion(this.courseId, this.module.completiondata); + } + + if (this.quiz && syncEventData.quizId == this.quiz.id) { + this.content?.scrollToTop(); + + return true; + } + + return false; + } + + /** + * Open a quiz to attempt it. + */ + protected openQuiz(): void { + this.hasPlayed = true; + + // @todo this.navCtrl.push('player', {courseId: this.courseId, quizId: this.quiz.id, moduleUrl: this.module.url}); + } + + /** + * Displays some data based on the current status. + * + * @param status The current status. + * @param previousStatus The previous status. If not defined, there is no previous status. + */ + protected showStatus(status: string, previousStatus?: string): void { + this.showStatusSpinner = status == CoreConstants.DOWNLOADING; + + if (status == CoreConstants.DOWNLOADED && previousStatus == CoreConstants.DOWNLOADING) { + // Quiz downloaded now, maybe a new attempt was created. Load content again. + this.loaded = false; + this.loadContent(); + } + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModQuizSync.instance.syncQuiz(this.candidateQuiz!, true); + } + + /** + * Treat user attempts. + * + * @param attempts The attempts to treat. + * @return Promise resolved when done. + */ + protected async treatAttempts( + quiz: AddonModQuizQuizData, + attempts: AddonModQuizAttemptWSData[], + ): Promise { + if (!attempts || !attempts.length) { + // There are no attempts to treat. + return []; + } + + const lastFinished = AddonModQuiz.instance.getLastFinishedAttemptFromList(attempts); + const promises: Promise[] = []; + + if (this.autoReview && lastFinished && lastFinished.id >= this.autoReview.attemptId) { + // User just finished an attempt in offline and it seems it's been synced, since it's finished in online. + // Go to the review of this attempt if the user hasn't left this view. + if (!this.isDestroyed && this.isCurrentView) { + promises.push(this.goToAutoReview()); + } + this.autoReview = undefined; + } + + // Get combined review options. + promises.push(AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, { cmId: this.module!.id }).then((options) => { + this.options = options; + + return; + })); + + // Get best grade. + promises.push(this.getQuizGrade(quiz)); + + await Promise.all(promises); + + const grade = typeof this.gradebookData?.grade != 'undefined' ? this.gradebookData.grade : this.bestGrade?.grade; + const quizGrade = AddonModQuiz.instance.formatGrade(grade, quiz.decimalpoints); + + // Calculate data to construct the header of the attempts table. + AddonModQuizHelper.instance.setQuizCalculatedData(quiz, this.options!); + + this.overallStats = !!lastFinished && this.options!.alloptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX; + + // Calculate data to show for each attempt. + const formattedAttempts = await Promise.all(attempts.map((attempt, index) => { + // Highlight the highest grade if appropriate. + const shouldHighlight = this.overallStats && quiz.grademethod == AddonModQuizProvider.GRADEHIGHEST && + attempts.length > 1; + const isLast = index == attempts.length - 1; + + return AddonModQuizHelper.instance.setAttemptCalculatedData(quiz, attempt, shouldHighlight, quizGrade, isLast); + })); + + return formattedAttempts; + } + + /** + * Get quiz grade data. + * + * @param quiz Quiz. + * @return Promise resolved when done. + */ + protected async getQuizGrade(quiz: AddonModQuizQuizData): Promise { + this.bestGrade = await AddonModQuiz.instance.getUserBestGrade(quiz.id, { cmId: this.module!.id }); + + try { + // Get gradebook grade. + const data = await AddonModQuiz.instance.getGradeFromGradebook(this.courseId!, this.module!.id); + + this.gradebookData = { + grade: data.graderaw, + feedback: data.feedback, + }; + } catch { + // Fallback to quiz best grade if failure or not found. + this.gradebookData = { + grade: this.bestGrade.grade, + }; + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.finishedObserver?.off(); + } + +} diff --git a/src/addons/mod/quiz/pages/index/index.html b/src/addons/mod/quiz/pages/index/index.html new file mode 100644 index 000000000..51d75b733 --- /dev/null +++ b/src/addons/mod/quiz/pages/index/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/quiz/pages/index/index.module.ts b/src/addons/mod/quiz/pages/index/index.module.ts new file mode 100644 index 000000000..6cf37c942 --- /dev/null +++ b/src/addons/mod/quiz/pages/index/index.module.ts @@ -0,0 +1,40 @@ +// (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 { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModQuizComponentsModule } from '../../components/components.module'; +import { AddonModQuizIndexPage } from './index'; + +const routes: Routes = [ + { + path: '', + component: AddonModQuizIndexPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModQuizComponentsModule, + ], + declarations: [ + AddonModQuizIndexPage, + ], + exports: [RouterModule], +}) +export class AddonModQuizIndexPageModule {} diff --git a/src/addons/mod/quiz/pages/index/index.ts b/src/addons/mod/quiz/pages/index/index.ts new file mode 100644 index 000000000..7bfccfd4b --- /dev/null +++ b/src/addons/mod/quiz/pages/index/index.ts @@ -0,0 +1,69 @@ +// (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, OnInit, ViewChild } from '@angular/core'; + +import { CoreCourseWSModule } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { AddonModQuizIndexComponent } from '../../components/index'; +import { AddonModQuizQuizWSData } from '../../services/quiz'; + +/** + * Page that displays the quiz entry page. + */ +@Component({ + selector: 'page-addon-mod-quiz-index', + templateUrl: 'index.html', +}) +export class AddonModQuizIndexPage implements OnInit { + + @ViewChild(AddonModQuizIndexComponent) quizComponent?: AddonModQuizIndexComponent; + + title?: string; + module?: CoreCourseWSModule; + courseId?: number; + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.module = CoreNavigator.instance.getRouteParam('module'); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + this.title = this.module?.name; + } + + /** + * Update some data based on the quiz instance. + * + * @param quiz Quiz instance. + */ + updateData(quiz: AddonModQuizQuizWSData): void { + this.title = quiz.name || this.title; + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.quizComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.quizComponent?.ionViewDidLeave(); + } + +} diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts new file mode 100644 index 000000000..b167bbd5b --- /dev/null +++ b/src/addons/mod/quiz/quiz-lazy.module.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 { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: ':courseId/:cmdId', + loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModQuizIndexPageModule), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], +}) +export class AddonModQuizLazyModule {} diff --git a/src/addons/mod/quiz/quiz.module.ts b/src/addons/mod/quiz/quiz.module.ts new file mode 100644 index 000000000..601d4f3c3 --- /dev/null +++ b/src/addons/mod/quiz/quiz.module.ts @@ -0,0 +1,56 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; + +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModQuizComponentsModule } from './components/components.module'; +import { SITE_SCHEMA } from './services/database/quiz'; +import { AddonModQuizModuleHandler, AddonModQuizModuleHandlerService } from './services/handlers/module'; +import { AddonModQuizPrefetchHandler } from './services/handlers/prefetch'; + +const routes: Routes = [ + { + path: AddonModQuizModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./quiz-lazy.module').then(m => m.AddonModQuizLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModQuizComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.instance.registerHandler(AddonModQuizModuleHandler.instance); + CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModQuizPrefetchHandler.instance); + }, + }, + ], +}) +export class AddonModQuizModule {} diff --git a/src/addons/mod/quiz/services/handlers/module.ts b/src/addons/mod/quiz/services/handlers/module.ts new file mode 100644 index 000000000..b37387a83 --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/module.ts @@ -0,0 +1,98 @@ +// (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 { CoreConstants } from '@/core/constants'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { AddonModQuizIndexComponent } from '../../components/index'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support quiz modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_quiz'; + + name = 'AddonModQuiz'; + modName = 'quiz'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_CONTROLS_GRADE_VISIBILITY]: true, + [CoreConstants.FEATURE_USES_QUESTIONS]: true, + }; + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param module The module object. + * @param courseId The course ID. + * @param sectionId The section ID. + * @return Data to render the module. + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_quiz-handler', + showDownloadButton: true, + action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) => { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = '/' + courseId + '/' + module.id; + + CoreNavigator.instance.navigateToSitePath(AddonModQuizModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @param course The course object. + * @param module The module object. + * @return The component to use, undefined if not found. + */ + async getMainComponent(): Promise> { + return AddonModQuizIndexComponent; + } + +} + +export class AddonModQuizModuleHandler extends makeSingleton(AddonModQuizModuleHandlerService) {} From 14ba4869a3621405b7ae13c713059b21748a155e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 11 Feb 2021 08:55:49 +0100 Subject: [PATCH 05/16] MOBILE-3651 quiz: Implement access rules --- .../quiz/accessrules/accessrules.module.ts | 43 ++++ .../delaybetweenattempts.module.ts | 34 +++ .../services/handlers/delaybetweenattempts.ts | 54 +++++ .../accessrules/ipaddress/ipaddress.module.ts | 34 +++ .../ipaddress/services/handlers/ipaddress.ts | 53 +++++ .../numattempts/numattempts.module.ts | 34 +++ .../services/handlers/numattempts.ts | 53 +++++ ...ddon-mod-quiz-access-offline-attempts.html | 6 + .../component/offlineattempts.ts | 56 +++++ .../offlineattempts/offlineattempts.module.ts | 43 ++++ .../services/handlers/offlineattempts.ts | 102 +++++++++ .../openclosedate/openclosedate.module.ts | 34 +++ .../services/handlers/openclosedate.ts | 76 +++++++ .../addon-mod-quiz-access-password.html | 14 ++ .../password/component/password.ts | 46 ++++ .../accessrules/password/password.module.ts | 51 +++++ .../password/services/database/password.ts | 53 +++++ .../password/services/handlers/password.ts | 198 ++++++++++++++++++ .../safebrowser/safebrowser.module.ts | 34 +++ .../services/handlers/safebrowser.ts | 53 +++++ .../securewindow/securewindow.module.ts | 34 +++ .../services/handlers/securewindow.ts | 53 +++++ .../addon-mod-quiz-access-time-limit.html | 6 + .../timelimit/component/timelimit.ts | 47 +++++ .../timelimit/services/handlers/timelimit.ts | 83 ++++++++ .../accessrules/timelimit/timelimit.module.ts | 43 ++++ src/addons/mod/quiz/quiz.module.ts | 2 + 27 files changed, 1339 insertions(+) create mode 100644 src/addons/mod/quiz/accessrules/accessrules.module.ts create mode 100644 src/addons/mod/quiz/accessrules/delaybetweenattempts/delaybetweenattempts.module.ts create mode 100644 src/addons/mod/quiz/accessrules/delaybetweenattempts/services/handlers/delaybetweenattempts.ts create mode 100644 src/addons/mod/quiz/accessrules/ipaddress/ipaddress.module.ts create mode 100644 src/addons/mod/quiz/accessrules/ipaddress/services/handlers/ipaddress.ts create mode 100644 src/addons/mod/quiz/accessrules/numattempts/numattempts.module.ts create mode 100644 src/addons/mod/quiz/accessrules/numattempts/services/handlers/numattempts.ts create mode 100644 src/addons/mod/quiz/accessrules/offlineattempts/component/addon-mod-quiz-access-offline-attempts.html create mode 100644 src/addons/mod/quiz/accessrules/offlineattempts/component/offlineattempts.ts create mode 100644 src/addons/mod/quiz/accessrules/offlineattempts/offlineattempts.module.ts create mode 100644 src/addons/mod/quiz/accessrules/offlineattempts/services/handlers/offlineattempts.ts create mode 100644 src/addons/mod/quiz/accessrules/openclosedate/openclosedate.module.ts create mode 100644 src/addons/mod/quiz/accessrules/openclosedate/services/handlers/openclosedate.ts create mode 100644 src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html create mode 100644 src/addons/mod/quiz/accessrules/password/component/password.ts create mode 100644 src/addons/mod/quiz/accessrules/password/password.module.ts create mode 100644 src/addons/mod/quiz/accessrules/password/services/database/password.ts create mode 100644 src/addons/mod/quiz/accessrules/password/services/handlers/password.ts create mode 100644 src/addons/mod/quiz/accessrules/safebrowser/safebrowser.module.ts create mode 100644 src/addons/mod/quiz/accessrules/safebrowser/services/handlers/safebrowser.ts create mode 100644 src/addons/mod/quiz/accessrules/securewindow/securewindow.module.ts create mode 100644 src/addons/mod/quiz/accessrules/securewindow/services/handlers/securewindow.ts create mode 100644 src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html create mode 100644 src/addons/mod/quiz/accessrules/timelimit/component/timelimit.ts create mode 100644 src/addons/mod/quiz/accessrules/timelimit/services/handlers/timelimit.ts create mode 100644 src/addons/mod/quiz/accessrules/timelimit/timelimit.module.ts diff --git a/src/addons/mod/quiz/accessrules/accessrules.module.ts b/src/addons/mod/quiz/accessrules/accessrules.module.ts new file mode 100644 index 000000000..efd3206b9 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/accessrules.module.ts @@ -0,0 +1,43 @@ +// (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 { AddonModQuizAccessDelayBetweenAttemptsModule } from './delaybetweenattempts/delaybetweenattempts.module'; +import { AddonModQuizAccessIpAddressModule } from './ipaddress/ipaddress.module'; +import { AddonModQuizAccessNumAttemptsModule } from './numattempts/numattempts.module'; +import { AddonModQuizAccessOfflineAttemptsModule } from './offlineattempts/offlineattempts.module'; +import { AddonModQuizAccessOpenCloseDateModule } from './openclosedate/openclosedate.module'; +import { AddonModQuizAccessPasswordModule } from './password/password.module'; +import { AddonModQuizAccessSafeBrowserModule } from './safebrowser/safebrowser.module'; +import { AddonModQuizAccessSecureWindowModule } from './securewindow/securewindow.module'; +import { AddonModQuizAccessTimeLimitModule } from './timelimit/timelimit.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonModQuizAccessDelayBetweenAttemptsModule, + AddonModQuizAccessIpAddressModule, + AddonModQuizAccessNumAttemptsModule, + AddonModQuizAccessOfflineAttemptsModule, + AddonModQuizAccessOpenCloseDateModule, + AddonModQuizAccessPasswordModule, + AddonModQuizAccessSafeBrowserModule, + AddonModQuizAccessSecureWindowModule, + AddonModQuizAccessTimeLimitModule, + ], + providers: [], + exports: [], +}) +export class AddonModQuizAccessRulesModule { } diff --git a/src/addons/mod/quiz/accessrules/delaybetweenattempts/delaybetweenattempts.module.ts b/src/addons/mod/quiz/accessrules/delaybetweenattempts/delaybetweenattempts.module.ts new file mode 100644 index 000000000..feefd7d1f --- /dev/null +++ b/src/addons/mod/quiz/accessrules/delaybetweenattempts/delaybetweenattempts.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAccessDelayBetweenAttemptsHandler } from './services/handlers/delaybetweenattempts'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessDelayBetweenAttemptsHandler.instance); + }, + }, + ], +}) +export class AddonModQuizAccessDelayBetweenAttemptsModule {} diff --git a/src/addons/mod/quiz/accessrules/delaybetweenattempts/services/handlers/delaybetweenattempts.ts b/src/addons/mod/quiz/accessrules/delaybetweenattempts/services/handlers/delaybetweenattempts.ts new file mode 100644 index 000000000..e66527e48 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/delaybetweenattempts/services/handlers/delaybetweenattempts.ts @@ -0,0 +1,54 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support delay between attempts access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessDelayBetweenAttemptsHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessDelayBetweenAttempts'; + ruleName = 'quizaccess_delaybetweenattempts'; + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired(): boolean | Promise { + return false; + } + +} + +export class AddonModQuizAccessDelayBetweenAttemptsHandler + extends makeSingleton(AddonModQuizAccessDelayBetweenAttemptsHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/ipaddress/ipaddress.module.ts b/src/addons/mod/quiz/accessrules/ipaddress/ipaddress.module.ts new file mode 100644 index 000000000..693912758 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/ipaddress/ipaddress.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAccessIpAddressHandler } from './services/handlers/ipaddress'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessIpAddressHandler.instance); + }, + }, + ], +}) +export class AddonModQuizAccessIpAddressModule {} diff --git a/src/addons/mod/quiz/accessrules/ipaddress/services/handlers/ipaddress.ts b/src/addons/mod/quiz/accessrules/ipaddress/services/handlers/ipaddress.ts new file mode 100644 index 000000000..ca96e8ef7 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/ipaddress/services/handlers/ipaddress.ts @@ -0,0 +1,53 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support IP address access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessIpAddressHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessIpAddress'; + ruleName = 'quizaccess_ipaddress'; + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired(): boolean | Promise { + return false; + } + +} + +export class AddonModQuizAccessIpAddressHandler extends makeSingleton(AddonModQuizAccessIpAddressHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/numattempts/numattempts.module.ts b/src/addons/mod/quiz/accessrules/numattempts/numattempts.module.ts new file mode 100644 index 000000000..5ce73b554 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/numattempts/numattempts.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAccessNumAttemptsHandler } from './services/handlers/numattempts'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessNumAttemptsHandler.instance); + }, + }, + ], +}) +export class AddonModQuizAccessNumAttemptsModule {} diff --git a/src/addons/mod/quiz/accessrules/numattempts/services/handlers/numattempts.ts b/src/addons/mod/quiz/accessrules/numattempts/services/handlers/numattempts.ts new file mode 100644 index 000000000..347bc6a77 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/numattempts/services/handlers/numattempts.ts @@ -0,0 +1,53 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support num attempts access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessNumAttemptsHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessNumAttempts'; + ruleName = 'quizaccess_numattempts'; + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired(): boolean | Promise { + return false; + } + +} + +export class AddonModQuizAccessNumAttemptsHandler extends makeSingleton(AddonModQuizAccessNumAttemptsHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/offlineattempts/component/addon-mod-quiz-access-offline-attempts.html b/src/addons/mod/quiz/accessrules/offlineattempts/component/addon-mod-quiz-access-offline-attempts.html new file mode 100644 index 000000000..e99c2d3b2 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/offlineattempts/component/addon-mod-quiz-access-offline-attempts.html @@ -0,0 +1,6 @@ + + +

{{ 'core.settings.synchronization' | translate }}

+

{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: syncTimeReadable} }}

+
+
diff --git a/src/addons/mod/quiz/accessrules/offlineattempts/component/offlineattempts.ts b/src/addons/mod/quiz/accessrules/offlineattempts/component/offlineattempts.ts new file mode 100644 index 000000000..d6566ebc0 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/offlineattempts/component/offlineattempts.ts @@ -0,0 +1,56 @@ +// (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 { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz'; +import { AddonModQuizSync } from '@addons/mod/quiz/services/quiz-sync'; +import { Component, OnInit, Input } from '@angular/core'; +import { FormGroup, FormBuilder } from '@angular/forms'; + +/** + * Component to render the preflight for offline attempts. + */ +@Component({ + selector: 'addon-mod-quiz-access-offline-attempts', + templateUrl: 'addon-mod-quiz-access-offline-attempts.html', +}) +export class AddonModQuizAccessOfflineAttemptsComponent implements OnInit { + + @Input() rule?: string; // The name of the rule. + @Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to. + @Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued. + @Input() prefetch?: boolean; // Whether the user is prefetching the quiz. + @Input() siteId?: string; // Site ID. + @Input() form?: FormGroup; // Form where to add the form control. + + syncTimeReadable = ''; + + constructor(private fb: FormBuilder) { } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + // Always set confirmdatasaved to 1. Sending the data means the user accepted. + this.form?.addControl('confirmdatasaved', this.fb.control(1)); + + if (!this.quiz) { + return; + } + + const time = await AddonModQuizSync.instance.getSyncTime(this.quiz.id); + + this.syncTimeReadable = AddonModQuizSync.instance.getReadableTimeFromTimestamp(time); + } + +} diff --git a/src/addons/mod/quiz/accessrules/offlineattempts/offlineattempts.module.ts b/src/addons/mod/quiz/accessrules/offlineattempts/offlineattempts.module.ts new file mode 100644 index 000000000..bfd496662 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/offlineattempts/offlineattempts.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModQuizAccessOfflineAttemptsComponent } from './component/offlineattempts'; +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAccessOfflineAttemptsHandler } from './services/handlers/offlineattempts'; + +@NgModule({ + declarations: [ + AddonModQuizAccessOfflineAttemptsComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessOfflineAttemptsHandler.instance); + }, + }, + ], + exports: [ + AddonModQuizAccessOfflineAttemptsComponent, + ], +}) +export class AddonModQuizAccessOfflineAttemptsModule {} diff --git a/src/addons/mod/quiz/accessrules/offlineattempts/services/handlers/offlineattempts.ts b/src/addons/mod/quiz/accessrules/offlineattempts/services/handlers/offlineattempts.ts new file mode 100644 index 000000000..d0d7ba2af --- /dev/null +++ b/src/addons/mod/quiz/accessrules/offlineattempts/services/handlers/offlineattempts.ts @@ -0,0 +1,102 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz'; +import { AddonModQuizAccessOfflineAttemptsComponent } from '../../component/offlineattempts'; +import { AddonModQuizSync } from '@addons/mod/quiz/services/quiz-sync'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support offline attempts access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessOfflineAttemptsHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessOfflineAttempts'; + ruleName = 'quizaccess_offlineattempts'; + + /** + * Add preflight data that doesn't require user interaction. The data should be added to the preflightData param. + * + * @param quiz The quiz the rule belongs to. + * @param preflightData Object where to add the preflight data. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + getFixedPreflightData( + quiz: AddonModQuizQuizWSData, + preflightData: Record, + ): void | Promise { + preflightData.confirmdatasaved = '1'; + } + + /** + * Return the Component to use to display the access rule preflight. + * Implement this if your access rule requires a preflight check with user interaction. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getPreflightComponent(): Type | Promise> { + return AddonModQuizAccessOfflineAttemptsComponent; + } + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + async isPreflightCheckRequired( + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + if (prefetch) { + // Don't show the warning if the user is prefetching. + return false; + } + + if (!attempt) { + // User is starting a new attempt, show the warning. + return true; + } + + const syncTime = await AddonModQuizSync.instance.getSyncTime(quiz.id); + + // Show warning if last sync was a while ago. + return Date.now() - AddonModQuizSync.instance.syncInterval > syncTime; + } + +} + +export class AddonModQuizAccessOfflineAttemptsHandler extends makeSingleton(AddonModQuizAccessOfflineAttemptsHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/openclosedate/openclosedate.module.ts b/src/addons/mod/quiz/accessrules/openclosedate/openclosedate.module.ts new file mode 100644 index 000000000..3f19048f8 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/openclosedate/openclosedate.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAccessOpenCloseDateHandler } from './services/handlers/openclosedate'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessOpenCloseDateHandler.instance); + }, + }, + ], +}) +export class AddonModQuizAccessOpenCloseDateModule {} diff --git a/src/addons/mod/quiz/accessrules/openclosedate/services/handlers/openclosedate.ts b/src/addons/mod/quiz/accessrules/openclosedate/services/handlers/openclosedate.ts new file mode 100644 index 000000000..58d2854ef --- /dev/null +++ b/src/addons/mod/quiz/accessrules/openclosedate/services/handlers/openclosedate.ts @@ -0,0 +1,76 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { AddonModQuizAttemptWSData, AddonModQuizProvider } from '@addons/mod/quiz/services/quiz'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support open/close date access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessOpenCloseDateHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessOpenCloseDate'; + ruleName = 'quizaccess_openclosedate'; + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired(): boolean | Promise { + return false; + } + + /** + * Whether or not the time left of an attempt should be displayed. + * + * @param attempt The attempt. + * @param endTime The attempt end time (in seconds). + * @param timeNow The current time in seconds. + * @return Whether it should be displayed. + */ + shouldShowTimeLeft(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean { + // If this is a teacher preview after the close date, do not show the time. + if (attempt.preview && timeNow > endTime) { + return false; + } + + // Show the time left only if it's less than QUIZ_SHOW_TIME_BEFORE_DEADLINE. + if (timeNow > endTime - AddonModQuizProvider.QUIZ_SHOW_TIME_BEFORE_DEADLINE) { + return true; + } + + return false; + } + +} + +export class AddonModQuizAccessOpenCloseDateHandler extends makeSingleton(AddonModQuizAccessOpenCloseDateHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html b/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html new file mode 100644 index 000000000..5ab743e6f --- /dev/null +++ b/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html @@ -0,0 +1,14 @@ + + +

{{ 'addon.mod_quiz.quizpassword' | translate }}

+

{{ 'addon.mod_quiz.requirepasswordmessage' | translate}}

+
+
+ + + + + + + diff --git a/src/addons/mod/quiz/accessrules/password/component/password.ts b/src/addons/mod/quiz/accessrules/password/component/password.ts new file mode 100644 index 000000000..95100a976 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/password/component/password.ts @@ -0,0 +1,46 @@ +// (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, OnInit, Input } from '@angular/core'; +import { FormGroup, FormBuilder } from '@angular/forms'; + +import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz'; + +/** + * Component to render the preflight for password. + */ +@Component({ + selector: 'addon-mod-quiz-access-password', + templateUrl: 'addon-mod-quiz-access-password.html', +}) +export class AddonModQuizAccessPasswordComponent implements OnInit { + + @Input() rule?: string; // The name of the rule. + @Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to. + @Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued. + @Input() prefetch?: boolean; // Whether the user is prefetching the quiz. + @Input() siteId?: string; // Site ID. + @Input() form?: FormGroup; // Form where to add the form control. + + constructor(private fb: FormBuilder) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // Add the control for the password. + this.form?.addControl('quizpassword', this.fb.control('')); + } + +} diff --git a/src/addons/mod/quiz/accessrules/password/password.module.ts b/src/addons/mod/quiz/accessrules/password/password.module.ts new file mode 100644 index 000000000..ebad48b4c --- /dev/null +++ b/src/addons/mod/quiz/accessrules/password/password.module.ts @@ -0,0 +1,51 @@ +// (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 { CoreSharedModule } from '@/core/shared.module'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModQuizAccessPasswordComponent } from './component/password'; +import { AddonModQuizAccessPasswordHandler } from './services/handlers/password'; +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { SITE_SCHEMA } from './services/database/password'; + +@NgModule({ + declarations: [ + AddonModQuizAccessPasswordComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessPasswordHandler.instance); + }, + }, + ], + exports: [ + AddonModQuizAccessPasswordComponent, + ], +}) +export class AddonModQuizAccessPasswordModule {} diff --git a/src/addons/mod/quiz/accessrules/password/services/database/password.ts b/src/addons/mod/quiz/accessrules/password/services/database/password.ts new file mode 100644 index 000000000..cf67bd039 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/password/services/database/password.ts @@ -0,0 +1,53 @@ +// (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 AddonModQuizAccessPasswordHandlerService. + */ +export const PASSWORD_TABLE_NAME = 'addon_mod_quiz_access_password'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModQuizAccessPasswordHandler', + version: 1, + tables: [ + { + name: PASSWORD_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'password', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + ], + }, + ], +}; + +/** + * Quiz attempt. + */ +export type AddonModQuizAccessPasswordDBRecord = { + id: number; + password: string; + timemodified: number; +}; diff --git a/src/addons/mod/quiz/accessrules/password/services/handlers/password.ts b/src/addons/mod/quiz/accessrules/password/services/handlers/password.ts new file mode 100644 index 000000000..3e37f4331 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/password/services/handlers/password.ts @@ -0,0 +1,198 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz'; +import { CoreSites } from '@services/sites'; +import { AddonModQuizAccessPasswordDBRecord, PASSWORD_TABLE_NAME } from '../database/password'; +import { AddonModQuizAccessPasswordComponent } from '../../component/password'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Handler to support password access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessPasswordHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessPassword'; + ruleName = 'quizaccess_password'; + + /** + * Add preflight data that doesn't require user interaction. The data should be added to the preflightData param. + * + * @param quiz The quiz the rule belongs to. + * @param preflightData Object where to add the preflight data. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + async getFixedPreflightData( + quiz: AddonModQuizQuizWSData, + preflightData: Record, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): Promise { + if (typeof preflightData.quizpassword != 'undefined') { + return; + } + + try { + // Try to get a password stored. If it's found, use it. + const entry = await this.getPasswordEntry(quiz.id, siteId); + + preflightData.quizpassword = entry.password; + } catch { + // No password stored. + } + } + + /** + * Get a password stored in DB. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the DB entry on success. + */ + protected async getPasswordEntry(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(PASSWORD_TABLE_NAME, { id: quizId }); + } + + /** + * Return the Component to use to display the access rule preflight. + * Implement this if your access rule requires a preflight check with user interaction. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getPreflightComponent(): Type | Promise> { + return AddonModQuizAccessPasswordComponent; + } + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + async isPreflightCheckRequired( + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + prefetch?: boolean, + siteId?: string, + ): Promise { + // If there's a password stored don't require the preflight since we'll use the stored one. + const entry = await CoreUtils.instance.ignoreErrors(this.getPasswordEntry(quiz.id, siteId)); + + return !entry; + } + + /** + * Function called when the preflight check has passed. This is a chance to record that fact in some way. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + async notifyPreflightCheckPassed( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): Promise { + // The password is right, store it to use it automatically in following executions. + if (typeof preflightData.quizpassword != 'undefined') { + return this.storePassword(quiz.id, preflightData.quizpassword, siteId); + } + } + + /** + * Function called when the preflight check fails. This is a chance to record that fact in some way. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. + * @param preflightData Preflight data gathered. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done if async, void if it's synchronous. + */ + notifyPreflightCheckFailed?( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData | undefined, + preflightData: Record, + prefetch?: boolean, + siteId?: string, + ): Promise { + // The password is wrong, remove it from DB if it's there. + return this.removePassword(quiz.id, siteId); + } + + /** + * Remove a password from DB. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected async removePassword(quizId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(PASSWORD_TABLE_NAME, { id: quizId }); + } + + /** + * Store a password in DB. + * + * @param quizId Quiz ID. + * @param password Password. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected async storePassword(quizId: number, password: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const entry: AddonModQuizAccessPasswordDBRecord = { + id: quizId, + password, + timemodified: Date.now(), + }; + + await site.getDb().insertRecord(PASSWORD_TABLE_NAME, entry); + } + +} + +export class AddonModQuizAccessPasswordHandler extends makeSingleton(AddonModQuizAccessPasswordHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/safebrowser/safebrowser.module.ts b/src/addons/mod/quiz/accessrules/safebrowser/safebrowser.module.ts new file mode 100644 index 000000000..8078e2f93 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/safebrowser/safebrowser.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAccessSafeBrowserHandler } from './services/handlers/safebrowser'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessSafeBrowserHandler.instance); + }, + }, + ], +}) +export class AddonModQuizAccessSafeBrowserModule {} diff --git a/src/addons/mod/quiz/accessrules/safebrowser/services/handlers/safebrowser.ts b/src/addons/mod/quiz/accessrules/safebrowser/services/handlers/safebrowser.ts new file mode 100644 index 000000000..f85bba085 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/safebrowser/services/handlers/safebrowser.ts @@ -0,0 +1,53 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support safe address access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessSafeBrowserHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessSafeBrowser'; + ruleName = 'quizaccess_safebrowser'; + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired(): boolean | Promise { + return false; + } + +} + +export class AddonModQuizAccessSafeBrowserHandler extends makeSingleton(AddonModQuizAccessSafeBrowserHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/securewindow/securewindow.module.ts b/src/addons/mod/quiz/accessrules/securewindow/securewindow.module.ts new file mode 100644 index 000000000..748371fb9 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/securewindow/securewindow.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAccessSecureWindowHandler } from './services/handlers/securewindow'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessSecureWindowHandler.instance); + }, + }, + ], +}) +export class AddonModQuizAccessSecureWindowModule {} diff --git a/src/addons/mod/quiz/accessrules/securewindow/services/handlers/securewindow.ts b/src/addons/mod/quiz/accessrules/securewindow/services/handlers/securewindow.ts new file mode 100644 index 000000000..e44bc366a --- /dev/null +++ b/src/addons/mod/quiz/accessrules/securewindow/services/handlers/securewindow.ts @@ -0,0 +1,53 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support secure window access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessSecureWindowHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessSecureWindow'; + ruleName = 'quizaccess_securewindow'; + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired(): boolean | Promise { + return false; + } + +} + +export class AddonModQuizAccessSecureWindowHandler extends makeSingleton(AddonModQuizAccessSecureWindowHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html b/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html new file mode 100644 index 000000000..b48075dbf --- /dev/null +++ b/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html @@ -0,0 +1,6 @@ + + +

{{ 'addon.mod_quiz.confirmstartheader' | translate }}

+

{{ 'addon.mod_quiz.confirmstart' | translate:{$a: readableTimeLimit} }}

+
+
diff --git a/src/addons/mod/quiz/accessrules/timelimit/component/timelimit.ts b/src/addons/mod/quiz/accessrules/timelimit/component/timelimit.ts new file mode 100644 index 000000000..0cf94ba9b --- /dev/null +++ b/src/addons/mod/quiz/accessrules/timelimit/component/timelimit.ts @@ -0,0 +1,47 @@ +// (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, OnInit } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz'; +import { CoreTimeUtils } from '@services/utils/time'; + +/** + * Component to render the preflight for time limit. + */ +@Component({ + selector: 'addon-mod-quiz-access-time-limit', + templateUrl: 'addon-mod-quiz-access-time-limit.html', +}) +export class AddonModQuizAccessTimeLimitComponent implements OnInit { + + @Input() rule?: string; // The name of the rule. + @Input() quiz?: AddonModQuizQuizWSData; // The quiz the rule belongs to. + @Input() attempt?: AddonModQuizAttemptWSData; // The attempt being started/continued. + @Input() prefetch?: boolean; // Whether the user is prefetching the quiz. + @Input() siteId?: string; // Site ID. + @Input() form?: FormGroup; // Form where to add the form control. + + readableTimeLimit = ''; + + ngOnInit(): void { + if (!this.quiz?.timelimit) { + return; + } + + this.readableTimeLimit = CoreTimeUtils.instance.formatTime(this.quiz?.timelimit); + } + +} diff --git a/src/addons/mod/quiz/accessrules/timelimit/services/handlers/timelimit.ts b/src/addons/mod/quiz/accessrules/timelimit/services/handlers/timelimit.ts new file mode 100644 index 000000000..ee8c7bf1a --- /dev/null +++ b/src/addons/mod/quiz/accessrules/timelimit/services/handlers/timelimit.ts @@ -0,0 +1,83 @@ +// (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 { AddonModQuizAccessRuleHandler } from '@addons/mod/quiz/services/access-rules-delegate'; +import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '@addons/mod/quiz/services/quiz'; +import { AddonModQuizAccessTimeLimitComponent } from '../../component/timelimit'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support time limit access rule. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizAccessTimeLimitHandlerService implements AddonModQuizAccessRuleHandler { + + name = 'AddonModQuizAccessTimeLimit'; + ruleName = 'quizaccess_timelimit'; + + /** + * Return the Component to use to display the access rule preflight. + * Implement this if your access rule requires a preflight check with user interaction. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getPreflightComponent(): Type | Promise> { + return AddonModQuizAccessTimeLimitComponent; + } + + /** + * 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; + } + + /** + * Whether the rule requires a preflight check when prefetch/start/continue an attempt. + * + * @param quiz The quiz the rule belongs to. + * @param attempt The attempt started/continued. If not supplied, user is starting a new attempt. + * @param prefetch Whether the user is prefetching the quiz. + * @param siteId Site ID. If not defined, current site. + * @return Whether the rule requires a preflight check. + */ + isPreflightCheckRequired( + quiz: AddonModQuizQuizWSData, + attempt?: AddonModQuizAttemptWSData, + ): boolean | Promise { + // Warning only required if the attempt is not already started. + return !attempt; + } + + /** + * Whether or not the time left of an attempt should be displayed. + * + * @param attempt The attempt. + * @param endTime The attempt end time (in seconds). + * @param timeNow The current time in seconds. + * @return Whether it should be displayed. + */ + shouldShowTimeLeft(attempt: AddonModQuizAttemptWSData, endTime: number, timeNow: number): boolean { + // If this is a teacher preview after the time limit expires, don't show the time left. + return !(attempt.preview && timeNow > endTime); + } + +} + +export class AddonModQuizAccessTimeLimitHandler extends makeSingleton(AddonModQuizAccessTimeLimitHandlerService) {} diff --git a/src/addons/mod/quiz/accessrules/timelimit/timelimit.module.ts b/src/addons/mod/quiz/accessrules/timelimit/timelimit.module.ts new file mode 100644 index 000000000..f482484f4 --- /dev/null +++ b/src/addons/mod/quiz/accessrules/timelimit/timelimit.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModQuizAccessTimeLimitComponent } from './component/timelimit'; +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAccessTimeLimitHandler } from './services/handlers/timelimit'; + +@NgModule({ + declarations: [ + AddonModQuizAccessTimeLimitComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + AddonModQuizAccessRuleDelegate.instance.registerHandler(AddonModQuizAccessTimeLimitHandler.instance); + }, + }, + ], + exports: [ + AddonModQuizAccessTimeLimitComponent, + ], +}) +export class AddonModQuizAccessTimeLimitModule {} diff --git a/src/addons/mod/quiz/quiz.module.ts b/src/addons/mod/quiz/quiz.module.ts index 601d4f3c3..3dcf54d19 100644 --- a/src/addons/mod/quiz/quiz.module.ts +++ b/src/addons/mod/quiz/quiz.module.ts @@ -19,6 +19,7 @@ import { CoreCourseModuleDelegate } from '@features/course/services/module-deleg import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModQuizAccessRulesModule } from './accessrules/accessrules.module'; import { AddonModQuizComponentsModule } from './components/components.module'; import { SITE_SCHEMA } from './services/database/quiz'; import { AddonModQuizModuleHandler, AddonModQuizModuleHandlerService } from './services/handlers/module'; @@ -35,6 +36,7 @@ const routes: Routes = [ imports: [ CoreMainMenuTabRoutingModule.forChild(routes), AddonModQuizComponentsModule, + AddonModQuizAccessRulesModule, ], providers: [ { From 4917067f8d125ca25af27e3b8f3f60f599710037 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 11 Feb 2021 11:27:03 +0100 Subject: [PATCH 06/16] MOBILE-3651 qbehaviour: Implement all question behaviours --- src/addons/addons.module.ts | 2 + .../services/block-handler.ts | 1 - .../qbehaviour/adaptive/adaptive.module.ts | 33 +++ .../adaptive/services/handlers/adaptive.ts | 56 +++++ .../adaptivenopenalty.module.ts | 34 +++ .../services/handlers/adaptivenopenalty.ts | 56 +++++ .../addon-qbehaviour-deferredcbm.html | 15 ++ .../deferredcbm/component/deferredcbm.ts | 38 +++ .../deferredcbm/deferredcbm.module.ts | 43 ++++ .../services/handlers/deferredcbm.ts | 152 ++++++++++++ .../deferredfeedback.module.ts | 33 +++ .../services/handlers/deferredfeedback.ts | 216 ++++++++++++++++++ .../immediatecbm/immediatecbm.module.ts | 34 +++ .../services/handlers/immediatecbm.ts | 61 +++++ .../immediatefeedback.module.ts | 34 +++ .../services/handlers/immediatefeedback.ts | 58 +++++ .../addon-qbehaviour-informationitem.html | 2 + .../component/informationitem.ts | 38 +++ .../informationitem/informationitem.module.ts | 43 ++++ .../services/handlers/informationitem.ts | 80 +++++++ .../interactive/interactive.module.ts | 34 +++ .../services/handlers/interactive.ts | 56 +++++ .../interactivecountback.module.ts | 34 +++ .../services/handlers/interactivecountback.ts | 56 +++++ .../manualgraded/manualgraded.module.ts | 34 +++ .../services/handlers/manualgraded.ts | 69 ++++++ src/addons/qbehaviour/qbehaviour.module.ts | 45 ++++ .../datetime/services/handlers/datetime.ts | 1 - .../textarea/services/handlers/textarea.ts | 1 - .../features/block/services/block-delegate.ts | 2 - .../handlers/singleactivity-format.ts | 1 - .../services/handlers/course-tag-area.ts | 1 - .../services/handlers/default-format.ts | 2 +- .../services/handlers/modules-tag-area.ts | 1 - .../user/services/handlers/tag-area.ts | 1 - .../services/user-profile-field-delegate.ts | 1 - 36 files changed, 1357 insertions(+), 11 deletions(-) create mode 100644 src/addons/qbehaviour/adaptive/adaptive.module.ts create mode 100644 src/addons/qbehaviour/adaptive/services/handlers/adaptive.ts create mode 100644 src/addons/qbehaviour/adaptivenopenalty/adaptivenopenalty.module.ts create mode 100644 src/addons/qbehaviour/adaptivenopenalty/services/handlers/adaptivenopenalty.ts create mode 100644 src/addons/qbehaviour/deferredcbm/component/addon-qbehaviour-deferredcbm.html create mode 100644 src/addons/qbehaviour/deferredcbm/component/deferredcbm.ts create mode 100644 src/addons/qbehaviour/deferredcbm/deferredcbm.module.ts create mode 100644 src/addons/qbehaviour/deferredcbm/services/handlers/deferredcbm.ts create mode 100644 src/addons/qbehaviour/deferredfeedback/deferredfeedback.module.ts create mode 100644 src/addons/qbehaviour/deferredfeedback/services/handlers/deferredfeedback.ts create mode 100644 src/addons/qbehaviour/immediatecbm/immediatecbm.module.ts create mode 100644 src/addons/qbehaviour/immediatecbm/services/handlers/immediatecbm.ts create mode 100644 src/addons/qbehaviour/immediatefeedback/immediatefeedback.module.ts create mode 100644 src/addons/qbehaviour/immediatefeedback/services/handlers/immediatefeedback.ts create mode 100644 src/addons/qbehaviour/informationitem/component/addon-qbehaviour-informationitem.html create mode 100644 src/addons/qbehaviour/informationitem/component/informationitem.ts create mode 100644 src/addons/qbehaviour/informationitem/informationitem.module.ts create mode 100644 src/addons/qbehaviour/informationitem/services/handlers/informationitem.ts create mode 100644 src/addons/qbehaviour/interactive/interactive.module.ts create mode 100644 src/addons/qbehaviour/interactive/services/handlers/interactive.ts create mode 100644 src/addons/qbehaviour/interactivecountback/interactivecountback.module.ts create mode 100644 src/addons/qbehaviour/interactivecountback/services/handlers/interactivecountback.ts create mode 100644 src/addons/qbehaviour/manualgraded/manualgraded.module.ts create mode 100644 src/addons/qbehaviour/manualgraded/services/handlers/manualgraded.ts create mode 100644 src/addons/qbehaviour/qbehaviour.module.ts diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index 7b2688efe..ad92c6f09 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -24,6 +24,7 @@ import { AddonNotificationsModule } from './notifications/notifications.module'; import { AddonMessageOutputModule } from './messageoutput/messageoutput.module'; import { AddonMessagesModule } from './messages/messages.module'; import { AddonModModule } from './mod/mod.module'; +import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module'; @NgModule({ imports: [ @@ -37,6 +38,7 @@ import { AddonModModule } from './mod/mod.module'; AddonNotificationsModule, AddonMessageOutputModule, AddonModModule, + AddonQbehaviourModule, ], }) export class AddonsModule {} diff --git a/src/addons/block/recentlyaccesseditems/services/block-handler.ts b/src/addons/block/recentlyaccesseditems/services/block-handler.ts index cfc9ddf00..747f672ad 100644 --- a/src/addons/block/recentlyaccesseditems/services/block-handler.ts +++ b/src/addons/block/recentlyaccesseditems/services/block-handler.ts @@ -30,7 +30,6 @@ export class AddonBlockRecentlyAccessedItemsHandlerService extends CoreBlockBase /** * Returns the data needed to render the block. * - * @param injector Injector. * @param block The block to render. * @param contextLevel The context where the block will be used. * @param instanceId The instance ID associated with the context level. diff --git a/src/addons/qbehaviour/adaptive/adaptive.module.ts b/src/addons/qbehaviour/adaptive/adaptive.module.ts new file mode 100644 index 000000000..7858d6057 --- /dev/null +++ b/src/addons/qbehaviour/adaptive/adaptive.module.ts @@ -0,0 +1,33 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourAdaptiveHandler } from './services/handlers/adaptive'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourAdaptiveHandler.instance); + }, + }, + ], +}) +export class AddonQbehaviourAdaptiveModule {} diff --git a/src/addons/qbehaviour/adaptive/services/handlers/adaptive.ts b/src/addons/qbehaviour/adaptive/services/handlers/adaptive.ts new file mode 100644 index 000000000..4c10c6023 --- /dev/null +++ b/src/addons/qbehaviour/adaptive/services/handlers/adaptive.ts @@ -0,0 +1,56 @@ +// (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 { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support adaptive question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourAdaptiveHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourAdaptive'; + type = 'adaptive'; + + /** + * 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 { + // Just extract the button, it doesn't need any specific component. + CoreQuestionHelper.instance.extractQbehaviourButtons(question); + } + + /** + * 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; + } + +} + +export class AddonQbehaviourAdaptiveHandler extends makeSingleton(AddonQbehaviourAdaptiveHandlerService) {} diff --git a/src/addons/qbehaviour/adaptivenopenalty/adaptivenopenalty.module.ts b/src/addons/qbehaviour/adaptivenopenalty/adaptivenopenalty.module.ts new file mode 100644 index 000000000..c8de10ff7 --- /dev/null +++ b/src/addons/qbehaviour/adaptivenopenalty/adaptivenopenalty.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourAdaptiveNoPenaltyHandler } from './services/handlers/adaptivenopenalty'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourAdaptiveNoPenaltyHandler.instance); + }, + }, + ], +}) +export class AddonQbehaviourAdaptiveNoPenaltyModule {} diff --git a/src/addons/qbehaviour/adaptivenopenalty/services/handlers/adaptivenopenalty.ts b/src/addons/qbehaviour/adaptivenopenalty/services/handlers/adaptivenopenalty.ts new file mode 100644 index 000000000..85af22bb1 --- /dev/null +++ b/src/addons/qbehaviour/adaptivenopenalty/services/handlers/adaptivenopenalty.ts @@ -0,0 +1,56 @@ +// (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 { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support adaptive no penalty question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourAdaptiveNoPenaltyHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourAdaptiveNoPenalty'; + type = 'adaptivenopenalty'; + + /** + * 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 { + // Just extract the button, it doesn't need any specific component. + CoreQuestionHelper.instance.extractQbehaviourButtons(question); + } + + /** + * 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; + } + +} + +export class AddonQbehaviourAdaptiveNoPenaltyHandler extends makeSingleton(AddonQbehaviourAdaptiveNoPenaltyHandlerService) {} diff --git a/src/addons/qbehaviour/deferredcbm/component/addon-qbehaviour-deferredcbm.html b/src/addons/qbehaviour/deferredcbm/component/addon-qbehaviour-deferredcbm.html new file mode 100644 index 000000000..76dfebf56 --- /dev/null +++ b/src/addons/qbehaviour/deferredcbm/component/addon-qbehaviour-deferredcbm.html @@ -0,0 +1,15 @@ +
+ +

{{ 'core.question.certainty' | translate }}

+
+ + + + {{ option.text }} + + + + + + +
diff --git a/src/addons/qbehaviour/deferredcbm/component/deferredcbm.ts b/src/addons/qbehaviour/deferredcbm/component/deferredcbm.ts new file mode 100644 index 000000000..88b47fcf2 --- /dev/null +++ b/src/addons/qbehaviour/deferredcbm/component/deferredcbm.ts @@ -0,0 +1,38 @@ +// (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, EventEmitter } from '@angular/core'; + +import { CoreQuestionBehaviourButton, CoreQuestionQuestion } from '@features/question/services/question-helper'; + +/** + * Component to render the deferred CBM in a question. + */ +@Component({ + selector: 'addon-qbehaviour-deferredcbm', + templateUrl: 'addon-qbehaviour-deferredcbm.html', +}) +export class AddonQbehaviourDeferredCBMComponent { + + @Input() question?: CoreQuestionQuestion; // The question. + @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() 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. + @Output() buttonClicked = new EventEmitter(); // Will emit when a behaviour button is clicked. + @Output() onAbort = new EventEmitter(); // Should emit an event if the question should be aborted. + +} diff --git a/src/addons/qbehaviour/deferredcbm/deferredcbm.module.ts b/src/addons/qbehaviour/deferredcbm/deferredcbm.module.ts new file mode 100644 index 000000000..112c32e42 --- /dev/null +++ b/src/addons/qbehaviour/deferredcbm/deferredcbm.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourDeferredCBMComponent } from './component/deferredcbm'; +import { AddonQbehaviourDeferredCBMHandler } from './services/handlers/deferredcbm'; + +@NgModule({ + declarations: [ + AddonQbehaviourDeferredCBMComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourDeferredCBMHandler.instance); + }, + }, + ], + exports: [ + AddonQbehaviourDeferredCBMComponent, + ], +}) +export class AddonQbehaviourDeferredCBMModule {} diff --git a/src/addons/qbehaviour/deferredcbm/services/handlers/deferredcbm.ts b/src/addons/qbehaviour/deferredcbm/services/handlers/deferredcbm.ts new file mode 100644 index 000000000..a2a2c0bb9 --- /dev/null +++ b/src/addons/qbehaviour/deferredcbm/services/handlers/deferredcbm.ts @@ -0,0 +1,152 @@ +// (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 { AddonQbehaviourDeferredFeedbackHandler } from '@addons/qbehaviour/deferredfeedback/services/handlers/deferredfeedback'; +import { CoreQuestionBehaviourHandler, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate'; +import { makeSingleton } from '@singletons'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers, CoreQuestionState } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { AddonQbehaviourDeferredCBMComponent } from '../../component/deferredcbm'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; + +/** + * Handler to support deferred CBM question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourDeferredCBMHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourDeferredCBM'; + type = 'deferredcbm'; + + /** + * 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: CoreQuestionQuestionWithAnswers, + componentId: string | number, + siteId?: string, + ): CoreQuestionState | Promise { + // Depends on deferredfeedback. + return AddonQbehaviourDeferredFeedbackHandler.instance.determineNewStateDeferred( + component, + attemptId, + question, + componentId, + siteId, + this.isCompleteResponse.bind(this), + this.isSameResponse.bind(this), + ); + } + + /** + * 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[] { + if (CoreQuestionHelper.instance.extractQbehaviourCBM(question)) { + return [AddonQbehaviourDeferredCBMComponent]; + } + } + + /** + * 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. + */ + protected isCompleteResponse( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): number { + // First check if the question answer is complete. + const complete = CoreQuestionDelegate.instance.isCompleteResponse(question, answers, component, componentId); + if (complete > 0) { + // Answer is complete, check the user answered CBM too. + return answers['-certainty'] ? 1 : 0; + } + + return complete; + } + + /** + * 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; + } + + /** + * Check if two responses are the same. + * + * @param question Question. + * @param prevAnswers Object with the previous question answers. + * @param prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). + * @param newAnswers Object with the new question answers. + * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + protected isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + prevBasicAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + newBasicAnswers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): boolean { + // First check if the question answer is the same. + const sameResponse = CoreQuestionDelegate.instance.isSameResponse( + question, + prevBasicAnswers, + newBasicAnswers, + component, + componentId, + ); + + if (sameResponse) { + // Same response, check the CBM is the same too. + return prevAnswers['-certainty'] == newAnswers['-certainty']; + } + + return sameResponse; + } + +} + +export class AddonQbehaviourDeferredCBMHandler extends makeSingleton(AddonQbehaviourDeferredCBMHandlerService) {} diff --git a/src/addons/qbehaviour/deferredfeedback/deferredfeedback.module.ts b/src/addons/qbehaviour/deferredfeedback/deferredfeedback.module.ts new file mode 100644 index 000000000..811f8dd00 --- /dev/null +++ b/src/addons/qbehaviour/deferredfeedback/deferredfeedback.module.ts @@ -0,0 +1,33 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourDeferredFeedbackHandler } from './services/handlers/deferredfeedback'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourDeferredFeedbackHandler.instance); + }, + }, + ], +}) +export class AddonQbehaviourDeferredFeedbackModule {} diff --git a/src/addons/qbehaviour/deferredfeedback/services/handlers/deferredfeedback.ts b/src/addons/qbehaviour/deferredfeedback/services/handlers/deferredfeedback.ts new file mode 100644 index 000000000..6f536532a --- /dev/null +++ b/src/addons/qbehaviour/deferredfeedback/services/handlers/deferredfeedback.ts @@ -0,0 +1,216 @@ +// (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 { CoreQuestionBehaviourHandler, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionDBRecord } from '@features/question/services/database/question'; +import { + CoreQuestion, + CoreQuestionQuestionParsed, + CoreQuestionsAnswers, + CoreQuestionState, +} from '@features/question/services/question'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support deferred feedback question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourDeferredFeedbackHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourDeferredFeedback'; + type = 'deferredfeedback'; + + /** + * 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: CoreQuestionQuestionWithAnswers, + componentId: string | number, + siteId?: string, + ): CoreQuestionState | Promise { + return this.determineNewStateDeferred(component, attemptId, question, componentId, siteId); + } + + /** + * Determine a question new state based on its answer(s) for deferred question behaviour. + * + * @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. + * @param isCompleteFn Function to override the default isCompleteResponse check. + * @param isSameFn Function to override the default isSameResponse check. + * @return Promise resolved with state. + */ + async determineNewStateDeferred( + component: string, + attemptId: number, + question: CoreQuestionQuestionWithAnswers, + componentId: string | number, + siteId?: string, + isCompleteFn?: isCompleteResponseFunction, + isSameFn?: isSameResponseFunction, + ): Promise { + + // Check if we have local data for the question. + let dbQuestion: CoreQuestionDBRecord | CoreQuestionQuestionWithAnswers = question; + try { + dbQuestion = await CoreQuestion.instance.getQuestion(component, attemptId, question.slot, siteId); + } catch (error) { + // No entry found, use the original data. + } + + const state = CoreQuestion.instance.getState(dbQuestion.state); + + if (state.finished || !state.active) { + // Question is finished, it cannot change. + return state; + } + + const newBasicAnswers = CoreQuestion.instance.getBasicAnswers(question.answers || {}); + + if (dbQuestion.state) { + // Question already has a state stored. Check if answer has changed. + const prevAnswersList = await CoreQuestion.instance.getQuestionAnswers( + component, + attemptId, + question.slot, + false, + siteId, + ); + const prevAnswers = CoreQuestion.instance.convertAnswersArrayToObject(prevAnswersList, true); + const prevBasicAnswers = CoreQuestion.instance.getBasicAnswers(prevAnswers); + + // If answers haven't changed the state is the same. + let sameResponse = false; + + if (isSameFn) { + sameResponse = isSameFn( + question, + prevAnswers, + prevBasicAnswers, + question.answers || {}, + newBasicAnswers, + component, + componentId, + ); + } else { + sameResponse = CoreQuestionDelegate.instance.isSameResponse( + question, + prevBasicAnswers, + newBasicAnswers, + component, + componentId, + ); + } + + if (sameResponse) { + return state; + } + } + + // Answers have changed. Now check if the response is complete and calculate the new state. + let complete: number; + let newState: string; + + if (isCompleteFn) { + // Pass all the answers since some behaviours might need the extra data. + complete = isCompleteFn(question, question.answers || {}, component, componentId); + } else { + // Only pass the basic answers since questions should be independent of extra data. + complete = CoreQuestionDelegate.instance.isCompleteResponse(question, newBasicAnswers, component, componentId); + } + + if (complete < 0) { + newState = 'cannotdeterminestatus'; + } else if (complete > 0) { + newState = 'complete'; + } else { + const gradable = CoreQuestionDelegate.instance.isGradableResponse(question, newBasicAnswers, component, componentId); + if (gradable < 0) { + newState = 'cannotdeterminestatus'; + } else if (gradable > 0) { + newState = 'invalid'; + } else { + newState = 'todo'; + } + } + + return CoreQuestion.instance.getState(newState); + } + + /** + * 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; + } + +} + +export class AddonQbehaviourDeferredFeedbackHandler extends makeSingleton(AddonQbehaviourDeferredFeedbackHandlerService) {} + + +/** + * 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. + */ +export type isCompleteResponseFunction = ( + 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 prevBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). + * @param newAnswers Object with the new question answers. + * @param newBasicAnswers Object with the previous basic" answers (without sequencecheck, certainty, ...). + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ +export type isSameResponseFunction = ( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + prevBasicAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + newBasicAnswers: CoreQuestionsAnswers, + component: string, + componentId: string | number, +) => boolean; diff --git a/src/addons/qbehaviour/immediatecbm/immediatecbm.module.ts b/src/addons/qbehaviour/immediatecbm/immediatecbm.module.ts new file mode 100644 index 000000000..adcc19d47 --- /dev/null +++ b/src/addons/qbehaviour/immediatecbm/immediatecbm.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourImmediateCBMHandler } from './services/handlers/immediatecbm'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourImmediateCBMHandler.instance); + }, + }, + ], +}) +export class AddonQbehaviourImmediateCBMModule {} diff --git a/src/addons/qbehaviour/immediatecbm/services/handlers/immediatecbm.ts b/src/addons/qbehaviour/immediatecbm/services/handlers/immediatecbm.ts new file mode 100644 index 000000000..243e2c42c --- /dev/null +++ b/src/addons/qbehaviour/immediatecbm/services/handlers/immediatecbm.ts @@ -0,0 +1,61 @@ +// (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 { AddonQbehaviourDeferredCBMComponent } from '@addons/qbehaviour/deferredcbm/component/deferredcbm'; +import { Injectable, Type } from '@angular/core'; + +import { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support immediate CBM question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourImmediateCBMHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourImmediateCBM'; + type = 'immediatecbm'; + + /** + * 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[] { + CoreQuestionHelper.instance.extractQbehaviourButtons(question); + + if (CoreQuestionHelper.instance.extractQbehaviourCBM(question)) { + // Depends on deferredcbm. + return [AddonQbehaviourDeferredCBMComponent]; + } + } + + /** + * 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; + } + +} + +export class AddonQbehaviourImmediateCBMHandler extends makeSingleton(AddonQbehaviourImmediateCBMHandlerService) {} diff --git a/src/addons/qbehaviour/immediatefeedback/immediatefeedback.module.ts b/src/addons/qbehaviour/immediatefeedback/immediatefeedback.module.ts new file mode 100644 index 000000000..617fd6b1c --- /dev/null +++ b/src/addons/qbehaviour/immediatefeedback/immediatefeedback.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourImmediateFeedbackHandler } from './services/handlers/immediatefeedback'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourImmediateFeedbackHandler.instance); + }, + }, + ], +}) +export class AddonQbehaviourImmediateFeedbackModule {} diff --git a/src/addons/qbehaviour/immediatefeedback/services/handlers/immediatefeedback.ts b/src/addons/qbehaviour/immediatefeedback/services/handlers/immediatefeedback.ts new file mode 100644 index 000000000..c3d542c48 --- /dev/null +++ b/src/addons/qbehaviour/immediatefeedback/services/handlers/immediatefeedback.ts @@ -0,0 +1,58 @@ +// (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 { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support immediate feedback question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourImmediateFeedbackHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourImmediateFeedback'; + type = 'immediatefeedback'; + + /** + * 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 { + // Just extract the button, it doesn't need any specific component. + CoreQuestionHelper.instance.extractQbehaviourButtons(question); + + 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; + } + +} + +export class AddonQbehaviourImmediateFeedbackHandler extends makeSingleton(AddonQbehaviourImmediateFeedbackHandlerService) {} diff --git a/src/addons/qbehaviour/informationitem/component/addon-qbehaviour-informationitem.html b/src/addons/qbehaviour/informationitem/component/addon-qbehaviour-informationitem.html new file mode 100644 index 000000000..8f720da0f --- /dev/null +++ b/src/addons/qbehaviour/informationitem/component/addon-qbehaviour-informationitem.html @@ -0,0 +1,2 @@ + diff --git a/src/addons/qbehaviour/informationitem/component/informationitem.ts b/src/addons/qbehaviour/informationitem/component/informationitem.ts new file mode 100644 index 000000000..bbc0a243f --- /dev/null +++ b/src/addons/qbehaviour/informationitem/component/informationitem.ts @@ -0,0 +1,38 @@ +// (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, EventEmitter } from '@angular/core'; + +import { CoreQuestionBehaviourButton, CoreQuestionQuestion } from '@features/question/services/question-helper'; + +/** + * Component to render a "seen" hidden input for informationitem question behaviour. + */ +@Component({ + selector: 'addon-qbehaviour-informationitem', + templateUrl: 'addon-qbehaviour-informationitem.html', +}) +export class AddonQbehaviourInformationItemComponent { + + @Input() question?: CoreQuestionQuestion; // The question. + @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() 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. + @Output() buttonClicked = new EventEmitter(); // Will emit when a behaviour button is clicked. + @Output() onAbort = new EventEmitter(); // Should emit an event if the question should be aborted. + +} diff --git a/src/addons/qbehaviour/informationitem/informationitem.module.ts b/src/addons/qbehaviour/informationitem/informationitem.module.ts new file mode 100644 index 000000000..eab461bc9 --- /dev/null +++ b/src/addons/qbehaviour/informationitem/informationitem.module.ts @@ -0,0 +1,43 @@ +// (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 { CoreSharedModule } from '@/core/shared.module'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourInformationItemComponent } from './component/informationitem'; +import { AddonQbehaviourInformationItemHandler } from './services/handlers/informationitem'; + +@NgModule({ + declarations: [ + AddonQbehaviourInformationItemComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourInformationItemHandler.instance); + }, + }, + ], + exports: [ + AddonQbehaviourInformationItemComponent, + ], +}) +export class AddonQbehaviourInformationItemModule {} diff --git a/src/addons/qbehaviour/informationitem/services/handlers/informationitem.ts b/src/addons/qbehaviour/informationitem/services/handlers/informationitem.ts new file mode 100644 index 000000000..6ae1aabc8 --- /dev/null +++ b/src/addons/qbehaviour/informationitem/services/handlers/informationitem.ts @@ -0,0 +1,80 @@ +// (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 { CoreQuestionBehaviourHandler, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate'; +import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionState } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { makeSingleton } from '@singletons'; +import { AddonQbehaviourInformationItemComponent } from '../../component/informationitem'; + +/** + * Handler to support information item question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourInformationItemHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourInformationItem'; + type = 'informationitem'; + + /** + * 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: CoreQuestionQuestionWithAnswers, + ): CoreQuestionState | Promise { + if (question.answers?.['-seen']) { + return CoreQuestion.instance.getState('complete'); + } + + return CoreQuestion.instance.getState(question.state || 'todo'); + } + + /** + * 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[] { + if (CoreQuestionHelper.instance.extractQbehaviourSeenInput(question)) { + return [AddonQbehaviourInformationItemComponent]; + } + } + + /** + * 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; + } + +} + +export class AddonQbehaviourInformationItemHandler extends makeSingleton(AddonQbehaviourInformationItemHandlerService) {} diff --git a/src/addons/qbehaviour/interactive/interactive.module.ts b/src/addons/qbehaviour/interactive/interactive.module.ts new file mode 100644 index 000000000..4f79ea766 --- /dev/null +++ b/src/addons/qbehaviour/interactive/interactive.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourInteractiveHandler } from './services/handlers/interactive'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourInteractiveHandler.instance); + }, + }, + ], +}) +export class AddonQbehaviourInteractiveModule {} diff --git a/src/addons/qbehaviour/interactive/services/handlers/interactive.ts b/src/addons/qbehaviour/interactive/services/handlers/interactive.ts new file mode 100644 index 000000000..aa934d7b5 --- /dev/null +++ b/src/addons/qbehaviour/interactive/services/handlers/interactive.ts @@ -0,0 +1,56 @@ +// (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 { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support interactive question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourInteractiveHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourInteractive'; + type = 'interactive'; + + /** + * 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 { + // Just extract the button, it doesn't need any specific component. + CoreQuestionHelper.instance.extractQbehaviourButtons(question); + } + + /** + * 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; + } + +} + +export class AddonQbehaviourInteractiveHandler extends makeSingleton(AddonQbehaviourInteractiveHandlerService) {} diff --git a/src/addons/qbehaviour/interactivecountback/interactivecountback.module.ts b/src/addons/qbehaviour/interactivecountback/interactivecountback.module.ts new file mode 100644 index 000000000..1b74410ff --- /dev/null +++ b/src/addons/qbehaviour/interactivecountback/interactivecountback.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourInteractiveCountbackHandler } from './services/handlers/interactivecountback'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourInteractiveCountbackHandler.instance); + }, + }, + ], +}) +export class AddonQbehaviourInteractiveCountbackModule {} diff --git a/src/addons/qbehaviour/interactivecountback/services/handlers/interactivecountback.ts b/src/addons/qbehaviour/interactivecountback/services/handlers/interactivecountback.ts new file mode 100644 index 000000000..9baebd181 --- /dev/null +++ b/src/addons/qbehaviour/interactivecountback/services/handlers/interactivecountback.ts @@ -0,0 +1,56 @@ +// (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 { CoreQuestionBehaviourHandler } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support interactive countback question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourInteractiveCountbackHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourInteractiveCountback'; + type = 'interactivecountback'; + + /** + * 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 { + // Just extract the button, it doesn't need any specific component. + CoreQuestionHelper.instance.extractQbehaviourButtons(question); + } + + /** + * 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; + } + +} + +export class AddonQbehaviourInteractiveCountbackHandler extends makeSingleton(AddonQbehaviourInteractiveCountbackHandlerService) {} diff --git a/src/addons/qbehaviour/manualgraded/manualgraded.module.ts b/src/addons/qbehaviour/manualgraded/manualgraded.module.ts new file mode 100644 index 000000000..a1a2e5deb --- /dev/null +++ b/src/addons/qbehaviour/manualgraded/manualgraded.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { AddonQbehaviourManualGradedHandler } from './services/handlers/manualgraded'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionBehaviourDelegate.instance.registerHandler(AddonQbehaviourManualGradedHandler.instance); + }, + }, + ], +}) +export class AddonQbehaviourManualGradedModule {} diff --git a/src/addons/qbehaviour/manualgraded/services/handlers/manualgraded.ts b/src/addons/qbehaviour/manualgraded/services/handlers/manualgraded.ts new file mode 100644 index 000000000..44ec70460 --- /dev/null +++ b/src/addons/qbehaviour/manualgraded/services/handlers/manualgraded.ts @@ -0,0 +1,69 @@ +// (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 { AddonQbehaviourDeferredFeedbackHandler } from '@addons/qbehaviour/deferredfeedback/services/handlers/deferredfeedback'; +import { CoreQuestionBehaviourHandler, CoreQuestionQuestionWithAnswers } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionState } from '@features/question/services/question'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support manual graded question behaviour. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQbehaviourManualGradedHandlerService implements CoreQuestionBehaviourHandler { + + name = 'AddonQbehaviourManualGraded'; + type = 'manualgraded'; + + /** + * 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: CoreQuestionQuestionWithAnswers, + componentId: string | number, + siteId?: string, + ): CoreQuestionState | Promise { + // Same implementation as the deferred feedback. Use that function instead of replicating it. + return AddonQbehaviourDeferredFeedbackHandler.instance.determineNewStateDeferred( + component, + attemptId, + question, + componentId, + siteId, + ); + } + + /** + * 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; + } + +} + +export class AddonQbehaviourManualGradedHandler extends makeSingleton(AddonQbehaviourManualGradedHandlerService) {} diff --git a/src/addons/qbehaviour/qbehaviour.module.ts b/src/addons/qbehaviour/qbehaviour.module.ts new file mode 100644 index 000000000..499ce1334 --- /dev/null +++ b/src/addons/qbehaviour/qbehaviour.module.ts @@ -0,0 +1,45 @@ +// (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 { AddonQbehaviourAdaptiveModule } from './adaptive/adaptive.module'; +import { AddonQbehaviourAdaptiveNoPenaltyModule } from './adaptivenopenalty/adaptivenopenalty.module'; +import { AddonQbehaviourDeferredCBMModule } from './deferredcbm/deferredcbm.module'; +import { AddonQbehaviourDeferredFeedbackModule } from './deferredfeedback/deferredfeedback.module'; +import { AddonQbehaviourImmediateCBMModule } from './immediatecbm/immediatecbm.module'; +import { AddonQbehaviourImmediateFeedbackModule } from './immediatefeedback/immediatefeedback.module'; +import { AddonQbehaviourInformationItemModule } from './informationitem/informationitem.module'; +import { AddonQbehaviourInteractiveModule } from './interactive/interactive.module'; +import { AddonQbehaviourInteractiveCountbackModule } from './interactivecountback/interactivecountback.module'; +import { AddonQbehaviourManualGradedModule } from './manualgraded/manualgraded.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonQbehaviourAdaptiveModule, + AddonQbehaviourAdaptiveNoPenaltyModule, + AddonQbehaviourDeferredCBMModule, + AddonQbehaviourDeferredFeedbackModule, + AddonQbehaviourImmediateCBMModule, + AddonQbehaviourImmediateFeedbackModule, + AddonQbehaviourInformationItemModule, + AddonQbehaviourInteractiveModule, + AddonQbehaviourInteractiveCountbackModule, + AddonQbehaviourManualGradedModule, + ], + providers: [ + ], + exports: [], +}) +export class AddonQbehaviourModule { } diff --git a/src/addons/userprofilefield/datetime/services/handlers/datetime.ts b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts index 5a1ab635f..cc16bc6ea 100644 --- a/src/addons/userprofilefield/datetime/services/handlers/datetime.ts +++ b/src/addons/userprofilefield/datetime/services/handlers/datetime.ts @@ -69,7 +69,6 @@ export class AddonUserProfileFieldDatetimeHandlerService implements CoreUserProf * Return the Component to use to display the user profile field. * It's recommended to return the class of the component, but you can also return an instance of the component. * - * @param injector Injector. * @return The component (or promise resolved with component) to use, undefined if not found. */ getComponent(): Type | Promise> { diff --git a/src/addons/userprofilefield/textarea/services/handlers/textarea.ts b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts index e1a233652..6ad1ba8bb 100644 --- a/src/addons/userprofilefield/textarea/services/handlers/textarea.ts +++ b/src/addons/userprofilefield/textarea/services/handlers/textarea.ts @@ -76,7 +76,6 @@ export class AddonUserProfileFieldTextareaHandlerService implements CoreUserProf * Return the Component to use to display the user profile field. * It's recommended to return the class of the component, but you can also return an instance of the component. * - * @param injector Injector. * @return The component (or promise resolved with component) to use, undefined if not found. */ getComponent(): Type | Promise> { diff --git a/src/core/features/block/services/block-delegate.ts b/src/core/features/block/services/block-delegate.ts index d46f34b1d..bd80bbc25 100644 --- a/src/core/features/block/services/block-delegate.ts +++ b/src/core/features/block/services/block-delegate.ts @@ -138,7 +138,6 @@ export class CoreBlockDelegateService extends CoreDelegate { /** * Get the display data for a certain block. * - * @param injector Injector. * @param block The block to render. * @param contextLevel The context where the block will be used. * @param instanceId The instance ID associated with the context level. @@ -200,4 +199,3 @@ export class CoreBlockDelegateService extends CoreDelegate { } export class CoreBlockDelegate extends makeSingleton(CoreBlockDelegateService) {} - diff --git a/src/core/features/course/format/singleactivity/services/handlers/singleactivity-format.ts b/src/core/features/course/format/singleactivity/services/handlers/singleactivity-format.ts index 4cc6d6189..a23f09060 100644 --- a/src/core/features/course/format/singleactivity/services/handlers/singleactivity-format.ts +++ b/src/core/features/course/format/singleactivity/services/handlers/singleactivity-format.ts @@ -127,7 +127,6 @@ export class CoreCourseFormatSingleActivityHandlerService implements CoreCourseF * If you want to customize the default format there are several methods to customize parts of it. * It's recommended to return the class of the component, but you can also return an instance of the component. * - * @param injector Injector. * @param course The course to render. * @return The component (or promise resolved with component) to use, undefined if not found. */ diff --git a/src/core/features/course/services/handlers/course-tag-area.ts b/src/core/features/course/services/handlers/course-tag-area.ts index 216bf0479..4815096fb 100644 --- a/src/core/features/course/services/handlers/course-tag-area.ts +++ b/src/core/features/course/services/handlers/course-tag-area.ts @@ -67,7 +67,6 @@ export class CoreCourseTagAreaHandlerService implements CoreTagAreaHandler { /** * Get the component to use to display items. * - * @param injector Injector. * @return The component (or promise resolved with component) to use, undefined if not found. */ getComponent(): Type | Promise> { diff --git a/src/core/features/course/services/handlers/default-format.ts b/src/core/features/course/services/handlers/default-format.ts index f77d8f7df..115ca52be 100644 --- a/src/core/features/course/services/handlers/default-format.ts +++ b/src/core/features/course/services/handlers/default-format.ts @@ -109,7 +109,7 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { * @return Whether the refresher should be displayed. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - displayRefresher?(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): boolean { + displayRefresher(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): boolean { return true; } diff --git a/src/core/features/course/services/handlers/modules-tag-area.ts b/src/core/features/course/services/handlers/modules-tag-area.ts index 21a786cc9..fa488ebf7 100644 --- a/src/core/features/course/services/handlers/modules-tag-area.ts +++ b/src/core/features/course/services/handlers/modules-tag-area.ts @@ -50,7 +50,6 @@ export class CoreCourseModulesTagAreaHandlerService implements CoreTagAreaHandle /** * Get the component to use to display items. * - * @param injector Injector. * @return The component (or promise resolved with component) to use, undefined if not found. */ getComponent(): Type | Promise> { diff --git a/src/core/features/user/services/handlers/tag-area.ts b/src/core/features/user/services/handlers/tag-area.ts index 384466574..5b51db223 100644 --- a/src/core/features/user/services/handlers/tag-area.ts +++ b/src/core/features/user/services/handlers/tag-area.ts @@ -82,7 +82,6 @@ export class CoreUserTagAreaHandlerService implements CoreTagAreaHandler { /** * Get the component to use to display items. * - * @param injector Injector. * @return The component (or promise resolved with component) to use, undefined if not found. */ getComponent(): Type | Promise> { diff --git a/src/core/features/user/services/user-profile-field-delegate.ts b/src/core/features/user/services/user-profile-field-delegate.ts index 833686c4f..7743bf540 100644 --- a/src/core/features/user/services/user-profile-field-delegate.ts +++ b/src/core/features/user/services/user-profile-field-delegate.ts @@ -96,7 +96,6 @@ export class CoreUserProfileFieldDelegateService extends CoreDelegate Date: Mon, 15 Feb 2021 11:35:41 +0100 Subject: [PATCH 07/16] MOBILE-3651 qtype: Implement all question types --- src/addons/addons.module.ts | 2 + .../qtype/calculated/calculated.module.ts | 43 + .../component/addon-qtype-calculated.html | 75 ++ .../qtype/calculated/component/calculated.ts | 43 + .../services/handlers/calculated.ts | 237 +++++ .../calculatedmulti/calculatedmulti.module.ts | 34 + .../services/handlers/calculatedmulti.ts | 109 ++ .../calculatedsimple.module.ts | 34 + .../services/handlers/calculatedsimple.ts | 115 +++ .../ddimageortext/classes/ddimageortext.ts | 848 +++++++++++++++ .../component/addon-qtype-ddimageortext.html | 24 + .../component/ddimageortext.scss | 114 ++ .../ddimageortext/component/ddimageortext.ts | 145 +++ .../ddimageortext/ddimageortext.module.ts | 43 + .../services/handlers/ddimageortext.ts | 136 +++ src/addons/qtype/ddmarker/classes/ddmarker.ts | 972 ++++++++++++++++++ .../qtype/ddmarker/classes/graphics_api.ts | 96 ++ .../component/addon-qtype-ddmarker.html | 22 + .../qtype/ddmarker/component/ddmarker.scss | 150 +++ .../qtype/ddmarker/component/ddmarker.ts | 188 ++++ src/addons/qtype/ddmarker/ddmarker.module.ts | 43 + .../ddmarker/services/handlers/ddmarker.ts | 155 +++ src/addons/qtype/ddwtos/classes/ddwtos.ts | 585 +++++++++++ .../ddwtos/component/addon-qtype-ddwtos.html | 23 + src/addons/qtype/ddwtos/component/ddwtos.scss | 132 +++ src/addons/qtype/ddwtos/component/ddwtos.ts | 167 +++ src/addons/qtype/ddwtos/ddwtos.module.ts | 43 + .../qtype/ddwtos/services/handlers/ddwtos.ts | 134 +++ .../component/addon-qtype-description.html | 12 + .../description/component/description.ts | 53 + .../qtype/description/description.module.ts | 43 + .../services/handlers/description.ts | 77 ++ .../essay/component/addon-qtype-essay.html | 87 ++ src/addons/qtype/essay/component/essay.ts | 96 ++ src/addons/qtype/essay/essay.module.ts | 45 + .../qtype/essay/services/handlers/essay.ts | 464 +++++++++ .../component/addon-qtype-gapselect.html | 10 + .../qtype/gapselect/component/gapselect.scss | 23 + .../qtype/gapselect/component/gapselect.ts | 55 + .../qtype/gapselect/gapselect.module.ts | 43 + .../gapselect/services/handlers/gapselect.ts | 136 +++ .../match/component/addon-qtype-match.html | 29 + src/addons/qtype/match/component/match.scss | 13 + src/addons/qtype/match/component/match.ts | 43 + src/addons/qtype/match/match.module.ts | 43 + .../qtype/match/services/handlers/match.ts | 136 +++ .../component/addon-qtype-multianswer.html | 10 + .../multianswer/component/multianswer.scss | 38 + .../multianswer/component/multianswer.ts | 54 + .../qtype/multianswer/multianswer.module.ts | 43 + .../services/handlers/multianswer.ts | 162 +++ .../component/addon-qtype-multichoice.html | 67 ++ .../multichoice/component/multichoice.scss | 8 + .../multichoice/component/multichoice.ts | 50 + .../qtype/multichoice/multichoice.module.ts | 43 + .../services/handlers/multichoice.ts | 201 ++++ .../qtype/numerical/numerical.module.ts | 35 + .../numerical/services/handlers/numerical.ts | 32 + src/addons/qtype/qtype.module.ts | 57 + .../randomsamatch/randomsamatch.module.ts | 34 + .../services/handlers/randomsamatch.ts | 31 + .../component/addon-qtype-shortanswer.html | 20 + .../shortanswer/component/shortanswer.scss | 5 + .../shortanswer/component/shortanswer.ts | 43 + .../services/handlers/shortanswer.ts | 109 ++ .../qtype/shortanswer/shortanswer.module.ts | 43 + .../truefalse/services/handlers/truefalse.ts | 132 +++ .../qtype/truefalse/truefalse.module.ts | 34 + .../fileuploader/services/fileuploader.ts | 119 +++ .../classes/base-question-component.ts | 784 ++++++++++++++ src/core/services/file.ts | 43 +- src/core/services/utils/dom.ts | 2 + src/core/services/utils/text.ts | 88 ++ 73 files changed, 8392 insertions(+), 15 deletions(-) create mode 100644 src/addons/qtype/calculated/calculated.module.ts create mode 100644 src/addons/qtype/calculated/component/addon-qtype-calculated.html create mode 100644 src/addons/qtype/calculated/component/calculated.ts create mode 100644 src/addons/qtype/calculated/services/handlers/calculated.ts create mode 100644 src/addons/qtype/calculatedmulti/calculatedmulti.module.ts create mode 100644 src/addons/qtype/calculatedmulti/services/handlers/calculatedmulti.ts create mode 100644 src/addons/qtype/calculatedsimple/calculatedsimple.module.ts create mode 100644 src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts create mode 100644 src/addons/qtype/ddimageortext/classes/ddimageortext.ts create mode 100644 src/addons/qtype/ddimageortext/component/addon-qtype-ddimageortext.html create mode 100644 src/addons/qtype/ddimageortext/component/ddimageortext.scss create mode 100644 src/addons/qtype/ddimageortext/component/ddimageortext.ts create mode 100644 src/addons/qtype/ddimageortext/ddimageortext.module.ts create mode 100644 src/addons/qtype/ddimageortext/services/handlers/ddimageortext.ts create mode 100644 src/addons/qtype/ddmarker/classes/ddmarker.ts create mode 100644 src/addons/qtype/ddmarker/classes/graphics_api.ts create mode 100644 src/addons/qtype/ddmarker/component/addon-qtype-ddmarker.html create mode 100644 src/addons/qtype/ddmarker/component/ddmarker.scss create mode 100644 src/addons/qtype/ddmarker/component/ddmarker.ts create mode 100644 src/addons/qtype/ddmarker/ddmarker.module.ts create mode 100644 src/addons/qtype/ddmarker/services/handlers/ddmarker.ts create mode 100644 src/addons/qtype/ddwtos/classes/ddwtos.ts create mode 100644 src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html create mode 100644 src/addons/qtype/ddwtos/component/ddwtos.scss create mode 100644 src/addons/qtype/ddwtos/component/ddwtos.ts create mode 100644 src/addons/qtype/ddwtos/ddwtos.module.ts create mode 100644 src/addons/qtype/ddwtos/services/handlers/ddwtos.ts create mode 100644 src/addons/qtype/description/component/addon-qtype-description.html create mode 100644 src/addons/qtype/description/component/description.ts create mode 100644 src/addons/qtype/description/description.module.ts create mode 100644 src/addons/qtype/description/services/handlers/description.ts create mode 100644 src/addons/qtype/essay/component/addon-qtype-essay.html create mode 100644 src/addons/qtype/essay/component/essay.ts create mode 100644 src/addons/qtype/essay/essay.module.ts create mode 100644 src/addons/qtype/essay/services/handlers/essay.ts create mode 100644 src/addons/qtype/gapselect/component/addon-qtype-gapselect.html create mode 100644 src/addons/qtype/gapselect/component/gapselect.scss create mode 100644 src/addons/qtype/gapselect/component/gapselect.ts create mode 100644 src/addons/qtype/gapselect/gapselect.module.ts create mode 100644 src/addons/qtype/gapselect/services/handlers/gapselect.ts create mode 100644 src/addons/qtype/match/component/addon-qtype-match.html create mode 100644 src/addons/qtype/match/component/match.scss create mode 100644 src/addons/qtype/match/component/match.ts create mode 100644 src/addons/qtype/match/match.module.ts create mode 100644 src/addons/qtype/match/services/handlers/match.ts create mode 100644 src/addons/qtype/multianswer/component/addon-qtype-multianswer.html create mode 100644 src/addons/qtype/multianswer/component/multianswer.scss create mode 100644 src/addons/qtype/multianswer/component/multianswer.ts create mode 100644 src/addons/qtype/multianswer/multianswer.module.ts create mode 100644 src/addons/qtype/multianswer/services/handlers/multianswer.ts create mode 100644 src/addons/qtype/multichoice/component/addon-qtype-multichoice.html create mode 100644 src/addons/qtype/multichoice/component/multichoice.scss create mode 100644 src/addons/qtype/multichoice/component/multichoice.ts create mode 100644 src/addons/qtype/multichoice/multichoice.module.ts create mode 100644 src/addons/qtype/multichoice/services/handlers/multichoice.ts create mode 100644 src/addons/qtype/numerical/numerical.module.ts create mode 100644 src/addons/qtype/numerical/services/handlers/numerical.ts create mode 100644 src/addons/qtype/qtype.module.ts create mode 100644 src/addons/qtype/randomsamatch/randomsamatch.module.ts create mode 100644 src/addons/qtype/randomsamatch/services/handlers/randomsamatch.ts create mode 100644 src/addons/qtype/shortanswer/component/addon-qtype-shortanswer.html create mode 100644 src/addons/qtype/shortanswer/component/shortanswer.scss create mode 100644 src/addons/qtype/shortanswer/component/shortanswer.ts create mode 100644 src/addons/qtype/shortanswer/services/handlers/shortanswer.ts create mode 100644 src/addons/qtype/shortanswer/shortanswer.module.ts create mode 100644 src/addons/qtype/truefalse/services/handlers/truefalse.ts create mode 100644 src/addons/qtype/truefalse/truefalse.module.ts create mode 100644 src/core/features/question/classes/base-question-component.ts diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts index ad92c6f09..408a1d3a6 100644 --- a/src/addons/addons.module.ts +++ b/src/addons/addons.module.ts @@ -25,6 +25,7 @@ import { AddonMessageOutputModule } from './messageoutput/messageoutput.module'; import { AddonMessagesModule } from './messages/messages.module'; import { AddonModModule } from './mod/mod.module'; import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module'; +import { AddonQtypeModule } from './qtype/qtype.module'; @NgModule({ imports: [ @@ -39,6 +40,7 @@ import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module'; AddonMessageOutputModule, AddonModModule, AddonQbehaviourModule, + AddonQtypeModule, ], }) export class AddonsModule {} diff --git a/src/addons/qtype/calculated/calculated.module.ts b/src/addons/qtype/calculated/calculated.module.ts new file mode 100644 index 000000000..ce8213b38 --- /dev/null +++ b/src/addons/qtype/calculated/calculated.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeCalculatedComponent } from './component/calculated'; +import { AddonQtypeCalculatedHandler } from './services/handlers/calculated'; + +@NgModule({ + declarations: [ + AddonQtypeCalculatedComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeCalculatedComponent, + ], +}) +export class AddonQtypeCalculatedModule {} diff --git a/src/addons/qtype/calculated/component/addon-qtype-calculated.html b/src/addons/qtype/calculated/component/addon-qtype-calculated.html new file mode 100644 index 000000000..06421260d --- /dev/null +++ b/src/addons/qtype/calculated/component/addon-qtype-calculated.html @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + {{ 'addon.mod_quiz.answercolon' | translate }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{option.label}} + + + + + + + + + + {{ option.text }} + + + + + + + + diff --git a/src/addons/qtype/calculated/component/calculated.ts b/src/addons/qtype/calculated/component/calculated.ts new file mode 100644 index 000000000..5a3aff42d --- /dev/null +++ b/src/addons/qtype/calculated/component/calculated.ts @@ -0,0 +1,43 @@ +// (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, OnInit, ElementRef } from '@angular/core'; + +import { AddonModQuizCalculatedQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; + +/** + * Component to render a calculated question. + */ +@Component({ + selector: 'addon-qtype-calculated', + templateUrl: 'addon-qtype-calculated.html', +}) +export class AddonQtypeCalculatedComponent extends CoreQuestionBaseComponent implements OnInit { + + calcQuestion?: AddonModQuizCalculatedQuestion; + + constructor(elementRef: ElementRef) { + super('AddonQtypeCalculatedComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initCalculatedComponent(); + + this.calcQuestion = this.question; + } + +} diff --git a/src/addons/qtype/calculated/services/handlers/calculated.ts b/src/addons/qtype/calculated/services/handlers/calculated.ts new file mode 100644 index 000000000..6115b425f --- /dev/null +++ b/src/addons/qtype/calculated/services/handlers/calculated.ts @@ -0,0 +1,237 @@ +// (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 { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeCalculatedComponent } from '../../component/calculated'; + +/** + * Handler to support calculated question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeCalculatedHandlerService implements CoreQuestionHandler { + + static readonly UNITINPUT = '0'; + static readonly UNITRADIO = '1'; + static readonly UNITSELECT = '2'; + static readonly UNITNONE = '3'; + + static readonly UNITGRADED = '1'; + static readonly UNITOPTIONAL = '0'; + + name = 'AddonQtypeCalculated'; + type = 'qtype_calculated'; + + /** + * 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(): Type { + return AddonQtypeCalculatedComponent; + } + + /** + * Check if the units are in a separate field for the question. + * + * @param question Question. + * @return Whether units are in a separate field. + */ + hasSeparateUnitField(question: CoreQuestionQuestionParsed): boolean { + if (!question.parsedSettings) { + const element = CoreDomUtils.instance.convertToElement(question.html); + + return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]')); + } + + return question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITRADIO || + question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITSELECT; + } + + /** + * 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 { + if (!this.isGradableResponse(question, answers, component, componentId)) { + return 0; + } + + const { answer, unit } = this.parseAnswer(question, answers.answer); + if (answer === null) { + return 0; + } + + if (!question.parsedSettings) { + if (this.hasSeparateUnitField(question)) { + return this.isValidValue( answers.unit) ? 1 : 0; + } + + // We cannot know if the answer should contain units or not. + return -1; + } + + if (question.parsedSettings.unitdisplay != AddonQtypeCalculatedHandlerService.UNITINPUT && unit) { + // There should be no units or be outside of the input, not valid. + return 0; + } + + if (this.hasSeparateUnitField(question) && !this.isValidValue( answers.unit)) { + // Unit not supplied as a separate field and it's required. + return 0; + } + + if (question.parsedSettings.unitdisplay == AddonQtypeCalculatedHandlerService.UNITINPUT && + question.parsedSettings.unitgradingtype == AddonQtypeCalculatedHandlerService.UNITGRADED && + !this.isValidValue(unit)) { + // Unit not supplied inside the input and it's required. + return 0; + } + + return 1; + } + + /** + * 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; + } + + /** + * 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, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): number { + return this.isValidValue( answers.answer) ? 1 : 0; + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + component: string, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): boolean { + return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') && + CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit'); + } + + /** + * Check if a value is valid (not empty). + * + * @param value Value to check. + * @return Whether the value is valid. + */ + isValidValue(value: string | number | null): boolean { + return !!value || value === '0' || value === 0; + } + + /** + * Parse an answer string. + * + * @param question Question. + * @param answer Answer. + * @return Answer and unit. + */ + parseAnswer(question: CoreQuestionQuestionParsed, answer: string): { answer: number | null; unit: string | null } { + if (!answer) { + return { answer: null, unit: null }; + } + + let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?'; + + // Strip spaces (which may be thousands separators) and change other forms of writing e to e. + answer = answer.replace(/ /g, ''); + answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1'); + + // If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it. + // Else assume it is a decimal separator, and change it to '.'. + if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) { + answer = answer.replace(',', ''); + } else { + answer = answer.replace(',', '.'); + } + + let unitsLeft = false; + let match: RegExpMatchArray | null = null; + + if (!question.parsedSettings || question.parsedSettings.unitsleft === null) { + // We don't know if units should be before or after so we check both. + match = answer.match(new RegExp('^' + regexString)); + if (!match) { + unitsLeft = true; + match = answer.match(new RegExp(regexString + '$')); + } + } else { + unitsLeft = question.parsedSettings.unitsleft == '1'; + regexString = unitsLeft ? regexString + '$' : '^' + regexString; + + match = answer.match(new RegExp(regexString)); + } + + if (!match) { + return { answer: null, unit: null }; + } + + const numberString = match[0]; + const unit = unitsLeft ? answer.substr(0, answer.length - match[0].length) : answer.substr(match[0].length); + + // No need to calculate the multiplier. + return { answer: Number(numberString), unit }; + } + +} + +export class AddonQtypeCalculatedHandler extends makeSingleton(AddonQtypeCalculatedHandlerService) {} diff --git a/src/addons/qtype/calculatedmulti/calculatedmulti.module.ts b/src/addons/qtype/calculatedmulti/calculatedmulti.module.ts new file mode 100644 index 000000000..3ae73755f --- /dev/null +++ b/src/addons/qtype/calculatedmulti/calculatedmulti.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeCalculatedMultiHandler } from './services/handlers/calculatedmulti'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedMultiHandler.instance); + }, + }, + ], +}) +export class AddonQtypeCalculatedMultiModule {} diff --git a/src/addons/qtype/calculatedmulti/services/handlers/calculatedmulti.ts b/src/addons/qtype/calculatedmulti/services/handlers/calculatedmulti.ts new file mode 100644 index 000000000..832271cde --- /dev/null +++ b/src/addons/qtype/calculatedmulti/services/handlers/calculatedmulti.ts @@ -0,0 +1,109 @@ +// (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 { AddonQtypeMultichoiceComponent } from '@addons/qtype/multichoice/component/multichoice'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeMultichoiceHandler } from '@addons/qtype/multichoice/services/handlers/multichoice'; + +/** + * Handler to support calculated multi question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeCalculatedMultiHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeCalculatedMulti'; + type = 'qtype_calculatedmulti'; + + /** + * 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(): Type { + // Calculated multi behaves like a multichoice, use the same component. + return AddonQtypeMultichoiceComponent; + } + + /** + * 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, + ): number { + // This question type depends on multichoice. + return AddonQtypeMultichoiceHandler.instance.isCompleteResponseSingle(answers); + } + + /** + * 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; + } + + /** + * 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, + ): number { + // This question type depends on multichoice. + return AddonQtypeMultichoiceHandler.instance.isGradableResponseSingle(answers); + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + // This question type depends on multichoice. + return AddonQtypeMultichoiceHandler.instance.isSameResponseSingle(prevAnswers, newAnswers); + } + +} + +export class AddonQtypeCalculatedMultiHandler extends makeSingleton(AddonQtypeCalculatedMultiHandlerService) {} diff --git a/src/addons/qtype/calculatedsimple/calculatedsimple.module.ts b/src/addons/qtype/calculatedsimple/calculatedsimple.module.ts new file mode 100644 index 000000000..c467476d9 --- /dev/null +++ b/src/addons/qtype/calculatedsimple/calculatedsimple.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeCalculatedSimpleHandler } from './services/handlers/calculatedsimple'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedSimpleHandler.instance); + }, + }, + ], +}) +export class AddonQtypeCalculatedSimpleModule {} diff --git a/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts b/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts new file mode 100644 index 000000000..ddc86597e --- /dev/null +++ b/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts @@ -0,0 +1,115 @@ +// (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 { AddonQtypeCalculatedComponent } from '@addons/qtype/calculated/component/calculated'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { AddonQtypeCalculatedHandler } from '@addons/qtype/calculated/services/handlers/calculated'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support calculated simple question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeCalculatedSimpleHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeCalculatedSimple'; + type = 'qtype_calculatedsimple'; + + /** + * 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(): Type { + // Calculated simple behaves like a calculated, use the same component. + return AddonQtypeCalculatedComponent; + } + + /** + * 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 { + // This question type depends on calculated. + return AddonQtypeCalculatedHandler.instance.isCompleteResponse(question, answers, component, componentId); + } + + /** + * 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; + } + + /** + * 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 { + // This question type depends on calculated. + return AddonQtypeCalculatedHandler.instance.isGradableResponse(question, answers, component, componentId); + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): boolean { + // This question type depends on calculated. + return AddonQtypeCalculatedHandler.instance.isSameResponse(question, prevAnswers, newAnswers, component, componentId); + } + +} + +export class AddonQtypeCalculatedSimpleHandler extends makeSingleton(AddonQtypeCalculatedSimpleHandlerService) {} diff --git a/src/addons/qtype/ddimageortext/classes/ddimageortext.ts b/src/addons/qtype/ddimageortext/classes/ddimageortext.ts new file mode 100644 index 000000000..2f829f107 --- /dev/null +++ b/src/addons/qtype/ddimageortext/classes/ddimageortext.ts @@ -0,0 +1,848 @@ +// (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 { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLogger } from '@singletons/logger'; +import { AddonModQuizDdImageOrTextQuestionData } from '../component/ddimageortext'; + +/** + * Class to make a question of ddimageortext type work. + */ +export class AddonQtypeDdImageOrTextQuestion { + + protected logger: CoreLogger; + protected toLoad = 0; + protected doc!: AddonQtypeDdImageOrTextQuestionDocStructure; + protected afterImageLoadDone = false; + protected proportion = 1; + protected selected?: HTMLElement | null; // Selected element (being "dragged"). + protected resizeFunction?: (ev?: Event) => void; + + /** + * Create the this. + * + * @param container The container HTMLElement of the question. + * @param question The question. + * @param readOnly Whether it's read only. + * @param drops The drop zones received in the init object of the question. + */ + constructor( + protected container: HTMLElement, + protected question: AddonModQuizDdImageOrTextQuestionData, + protected readOnly: boolean, + protected drops?: unknown[], + ) { + this.logger = CoreLogger.getInstance('AddonQtypeDdImageOrTextQuestion'); + + this.initializer(); + } + + /** + * Calculate image proportion to make easy conversions. + */ + calculateImgProportion(): void { + const bgImg = this.doc.bgImg(); + if (!bgImg) { + return; + } + + // Render the position related to the current image dimensions. + this.proportion = 1; + if (bgImg.width != bgImg.naturalWidth) { + this.proportion = bgImg.width / bgImg.naturalWidth; + } + } + + /** + * Convert the X and Y position of the BG IMG to a position relative to the window. + * + * @param bgImgXY X and Y of the BG IMG relative position. + * @return Position relative to the window. + */ + convertToWindowXY(bgImgXY: number[]): number[] { + const bgImg = this.doc.bgImg(); + if (!bgImg) { + return bgImgXY; + } + + const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea'); + + // Render the position related to the current image dimensions. + bgImgXY[0] *= this.proportion; + bgImgXY[1] *= this.proportion; + + return [Number(bgImgXY[0]) + position[0] + 1, Number(bgImgXY[1]) + position[1] + 1]; + } + + /** + * Create and initialize all draggable elements and drop zones. + */ + async createAllDragAndDrops(): Promise { + // Initialize drop zones. + this.initDrops(); + + // Initialize drag items area. + this.doc.dragItemsArea?.classList.add('clearfix'); + this.makeDragAreaClickable(); + + const dragItemHomes = this.doc.dragItemHomes(); + let i = 0; + + // Create the draggable items. + for (let x = 0; x < dragItemHomes.length; x++) { + + const dragItemHome = dragItemHomes[x]; + const dragItemNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'dragitemhomes') ?? -1; + const choice = this.doc.getClassnameNumericSuffix(dragItemHome, 'choice') ?? -1; + const group = this.doc.getClassnameNumericSuffix(dragItemHome, 'group') ?? -1; + + // Images need to be inside a div element to admit padding with width and height. + if (dragItemHome.tagName == 'IMG') { + const wrap = document.createElement('div'); + wrap.className = dragItemHome.className; + dragItemHome.className = ''; + + // Insert wrapper before the image in the DOM tree. + dragItemHome.parentNode?.insertBefore(wrap, dragItemHome); + // Move the image into wrapper. + wrap.appendChild(dragItemHome); + } + + // Create a new drag item for this home. + const dragNode = this.doc.cloneNewDragItem(i, dragItemNo); + i++; + + // Make the item draggable. + this.draggableForQuestion(dragNode, group, choice); + + // If the draggable item needs to be created more than once, create the rest of copies. + if (dragNode?.classList.contains('infinite')) { + const groupSize = this.doc.dropZoneGroup(group).length; + let dragsToCreate = groupSize - 1; + + while (dragsToCreate > 0) { + const newDragNode = this.doc.cloneNewDragItem(i, dragItemNo); + i++; + this.draggableForQuestion(newDragNode, group, choice); + + dragsToCreate--; + } + } + } + + await CoreUtils.instance.nextTick(); + + // All drag items have been created, position them. + this.repositionDragsForQuestion(); + + if (!this.readOnly) { + const dropZones = this.doc.dropZones(); + dropZones.forEach((dropZone) => { + dropZone.setAttribute('tabIndex', '0'); + }); + } + } + + /** + * Deselect all drags. + */ + deselectDrags(): void { + const drags = this.doc.dragItems(); + + drags.forEach((drag) => { + drag.classList.remove('beingdragged'); + }); + + this.selected = null; + } + + /** + * Function to call when the instance is no longer needed. + */ + destroy(): void { + this.stopPolling(); + + if (this.resizeFunction) { + window.removeEventListener('resize', this.resizeFunction); + } + } + + /** + * Make an element draggable. + * + * @param drag Element to make draggable. + * @param group Group the element belongs to. + * @param choice Choice the element belongs to. + */ + draggableForQuestion(drag: HTMLElement | null, group: number, choice: number): void { + if (!drag) { + return; + } + + // Set attributes. + drag.setAttribute('group', String(group)); + drag.setAttribute('choice', String(choice)); + + if (!this.readOnly) { + // Listen to click events. + drag.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (drag.classList.contains('beingdragged')) { + this.deselectDrags(); + } else { + this.selectDrag(drag); + } + }); + } + } + + /** + * Function called when a drop zone is clicked. + * + * @param dropNode Drop element. + */ + dropClick(dropNode: HTMLElement): void { + const drag = this.selected; + if (!drag) { + // No selected item, nothing to do. + return; + } + + // Deselect the drag and place it in the position of this drop zone if it belongs to the same group. + this.deselectDrags(); + + if (Number(dropNode.getAttribute('group')) === Number(drag.getAttribute('group'))) { + this.placeDragInDrop(drag, dropNode); + } + } + + /** + * Get all the draggable elements for a choice and a drop zone. + * + * @param choice Choice number. + * @param drop Drop zone. + * @return Draggable elements. + */ + getChoicesForDrop(choice: number, drop: HTMLElement): HTMLElement[] { + if (!this.doc.topNode) { + return []; + } + + return Array.from( + this.doc.topNode.querySelectorAll('div.dragitemgroup' + drop.getAttribute('group') + ` .choice${choice}.drag`), + ); + } + + /** + * Get an unplaced draggable element that belongs to a certain choice and drop zone. + * + * @param choice Choice number. + * @param drop Drop zone. + * @return Unplaced draggable element. + */ + getUnplacedChoiceForDrop(choice: number, drop: HTMLElement): HTMLElement | null { + const dragItems = this.getChoicesForDrop(choice, drop); + + const foundItem = dragItems.find((dragItem) => + !dragItem.classList.contains('placed') && !dragItem.classList.contains('beingdragged')); + + return foundItem || null; + } + + /** + * Initialize drop zones. + */ + initDrops(): void { + const dropAreas = this.doc.topNode?.querySelector('div.dropzones'); + if (!dropAreas) { + return; + } + + const groupNodes: Record = {}; + + // Create all group nodes and add them to the drop area. + for (let groupNo = 1; groupNo <= 8; groupNo++) { + const groupNode = document.createElement('div'); + groupNode.className = `dropzonegroup${groupNo}`; + + dropAreas.appendChild(groupNode); + groupNodes[groupNo] = groupNode; + } + + // Create the drops specified by the init object. + for (const dropNo in this.drops) { + const drop = this.drops[dropNo]; + const nodeClass = `dropzone group${drop.group} place${dropNo}`; + const title = drop.text.replace('"', '"'); + const dropNode = document.createElement('div'); + + dropNode.setAttribute('title', title); + dropNode.className = nodeClass; + + groupNodes[drop.group].appendChild(dropNode); + dropNode.style.opacity = '0.5'; + dropNode.setAttribute('xy', drop.xy); + dropNode.setAttribute('aria-label', drop.text); + dropNode.setAttribute('place', dropNo); + dropNode.setAttribute('inputid', drop.fieldname.replace(':', '_')); + dropNode.setAttribute('group', drop.group); + + dropNode.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.dropClick(dropNode); + }); + } + } + + /** + * Initialize the question. + * + * @param question Question. + */ + initializer(): void { + this.doc = new AddonQtypeDdImageOrTextQuestionDocStructure(this.container, this.question.slot); + + if (this.readOnly) { + this.doc.topNode?.classList.add('readonly'); + } + + // Wait the DOM to be rendered. + setTimeout(() => { + const bgImg = this.doc.bgImg(); + if (!bgImg) { + this.logger.error('Background image not found'); + + return; + } + + // Wait for background image to be loaded. + // On iOS, complete is mistakenly true, check also naturalWidth for compatibility. + if (!bgImg.complete || !bgImg.naturalWidth) { + this.toLoad++; + bgImg.addEventListener('load', () => { + this.toLoad--; + }); + } + + const itemHomes = this.doc.dragItemHomes(); + itemHomes.forEach((item) => { + if (item.tagName != 'IMG') { + return; + } + // Wait for drag images to be loaded. + // On iOS, complete is mistakenly true, check also naturalWidth for compatibility. + const itemImg = item; + + if (!itemImg.complete || !itemImg.naturalWidth) { + this.toLoad++; + itemImg.addEventListener('load', () => { + this.toLoad--; + }); + } + }); + + this.pollForImageLoad(); + }); + + this.resizeFunction = this.repositionDragsForQuestion.bind(this); + window.addEventListener('resize', this.resizeFunction!); + } + + /** + * Make the drag items area clickable. + */ + makeDragAreaClickable(): void { + if (this.readOnly) { + return; + } + + const home = this.doc.dragItemsArea; + home?.addEventListener('click', (e) => { + const drag = this.selected; + if (!drag) { + // No element selected, nothing to do. + return false; + } + + // An element was selected. Deselect it and move it back to the area if needed. + this.deselectDrags(); + this.removeDragFromDrop(drag); + + e.preventDefault(); + e.stopPropagation(); + }); + } + + /** + * Place a draggable element into a certain drop zone. + * + * @param drag Draggable element. + * @param drop Drop zone element. + */ + placeDragInDrop(drag: HTMLElement, drop: HTMLElement): void { + // Search the input related to the drop zone. + const targetInputId = drop.getAttribute('inputid') || ''; + const inputNode = this.doc.topNode?.querySelector(`input#${targetInputId}`); + + // Check if the draggable item is already assigned to an input and if it's the same as the one of the drop zone. + const originInputId = drag.getAttribute('inputid'); + if (originInputId && originInputId != targetInputId) { + // Remove it from the previous place. + const originInputNode = this.doc.topNode?.querySelector(`input#${originInputId}`); + originInputNode?.setAttribute('value', '0'); + } + + // Now position the draggable and set it to the input. + const position = CoreDomUtils.instance.getElementXY(drop, undefined, 'ddarea'); + const choice = drag.getAttribute('choice'); + drag.style.left = position[0] - 1 + 'px'; + drag.style.top = position[1] - 1 + 'px'; + drag.classList.add('placed'); + + if (choice) { + inputNode?.setAttribute('value', choice); + } + + drag.setAttribute('inputid', targetInputId); + } + + /** + * Wait for images to be loaded. + */ + pollForImageLoad(): void { + if (this.afterImageLoadDone) { + // Already done, stop. + return; + } + + if (this.toLoad <= 0) { + // All images loaded. + this.createAllDragAndDrops(); + this.afterImageLoadDone = true; + this.question.loaded = true; + } + + // Try again after a while. + setTimeout(() => { + this.pollForImageLoad(); + }, 1000); + } + + /** + * Remove a draggable element from the drop zone where it is. + * + * @param drag Draggable element to remove. + */ + removeDragFromDrop(drag: HTMLElement): void { + // Check if the draggable element is assigned to an input. If so, empty the input's value. + const inputId = drag.getAttribute('inputid'); + if (inputId) { + this.doc.topNode?.querySelector(`input#${inputId}`)?.setAttribute('value', '0'); + } + + // Move the element to its original position. + const dragItemHome = this.doc.dragItemHome(Number(drag.getAttribute('dragitemno'))); + if (!dragItemHome) { + return; + } + + const position = CoreDomUtils.instance.getElementXY(dragItemHome, undefined, 'ddarea'); + drag.style.left = position[0] + 'px'; + drag.style.top = position[1] + 'px'; + drag.classList.remove('placed'); + + drag.setAttribute('inputid', ''); + } + + /** + * Reposition all the draggable elements and drop zones. + */ + repositionDragsForQuestion(): void { + const dragItems = this.doc.dragItems(); + + // Mark all draggable items as "unplaced", they will be placed again later. + dragItems.forEach((dragItem) => { + dragItem.classList.remove('placed'); + dragItem.setAttribute('inputid', ''); + }); + + // Calculate the proportion to apply to images. + this.calculateImgProportion(); + + // Apply the proportion to all images in drag item homes. + const dragItemHomes = this.doc.dragItemHomes(); + for (let x = 0; x < dragItemHomes.length; x++) { + const dragItemHome = dragItemHomes[x]; + const dragItemHomeImg = dragItemHome.querySelector('img'); + + if (!dragItemHomeImg || dragItemHomeImg.naturalWidth <= 0) { + continue; + } + + const widthHeight = [Math.round(dragItemHomeImg.naturalWidth * this.proportion), + Math.round(dragItemHomeImg.naturalHeight * this.proportion)]; + + dragItemHomeImg.style.width = widthHeight[0] + 'px'; + dragItemHomeImg.style.height = widthHeight[1] + 'px'; + + // Apply the proportion to all the images cloned from this home. + const dragItemNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'dragitemhomes'); + const groupNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'group'); + const dragsImg = this.doc.topNode ? + Array.from(this.doc.topNode.querySelectorAll(`.drag.group${groupNo}.dragitems${dragItemNo} img`)) : []; + + dragsImg.forEach((dragImg) => { + dragImg.style.width = widthHeight[0] + 'px'; + dragImg.style.height = widthHeight[1] + 'px'; + }); + } + + // Update the padding of all draggable elements. + this.updatePaddingSizesAll(); + + const dropZones = this.doc.dropZones(); + for (let x = 0; x < dropZones.length; x++) { + // Re-position the drop zone based on the proportion. + const dropZone = dropZones[x]; + const dropZoneXY = dropZone.getAttribute('xy')?.split(',').map((i) => Number(i)); + const relativeXY = this.convertToWindowXY(dropZoneXY || []); + + dropZone.style.left = relativeXY[0] + 'px'; + dropZone.style.top = relativeXY[1] + 'px'; + + // Re-place items got from the inputs. + const inputCss = 'input#' + dropZone.getAttribute('inputid'); + const input = this.doc.topNode?.querySelector(inputCss); + const choice = input ? Number(input.value) : -1; + + if (choice > 0) { + const dragItem = this.getUnplacedChoiceForDrop(choice, dropZone); + + if (dragItem !== null) { + this.placeDragInDrop(dragItem, dropZone); + } + } + } + + // Re-place draggable items not placed drop zones (they will be placed in the original position). + for (let x = 0; x < dragItems.length; x++) { + const dragItem = dragItems[x]; + if (!dragItem.classList.contains('placed') && !dragItem.classList.contains('beingdragged')) { + this.removeDragFromDrop(dragItem); + } + } + } + + /** + * Mark a draggable element as selected. + * + * @param drag Element to select. + */ + selectDrag(drag: HTMLElement): void { + // Deselect previous ones. + this.deselectDrags(); + + this.selected = drag; + drag.classList.add('beingdragged'); + } + + /** + * Stop waiting for images to be loaded. + */ + stopPolling(): void { + this.afterImageLoadDone = true; + } + + /** + * Update the padding of all items in a group to make them all have the same width and height. + * + * @param groupNo The group number. + */ + updatePaddingSizeForGroup(groupNo: number): void { + + // Get all the items for this group. + const groupItems = this.doc.topNode ? + Array.from(this.doc.topNode.querySelectorAll(`.draghome.group${groupNo}`)) : []; + + if (groupItems.length == 0) { + return; + } + + // Get the max width and height of the items. + let maxWidth = 0; + let maxHeight = 0; + + for (let x = 0; x < groupItems.length; x++) { + // Check if the item has an img. + const item = groupItems[x]; + const img = item.querySelector('img'); + + if (img) { + maxWidth = Math.max(maxWidth, Math.round(this.proportion * img.naturalWidth)); + maxHeight = Math.max(maxHeight, Math.round(this.proportion * img.naturalHeight)); + } else { + // Remove the padding to calculate the size. + const originalPadding = item.style.padding; + item.style.padding = ''; + + // Text is not affected by the proportion. + maxWidth = Math.max(maxWidth, Math.round(item.clientWidth)); + maxHeight = Math.max(maxHeight, Math.round(item.clientHeight)); + + // Restore the padding. + item.style.padding = originalPadding; + } + } + + if (maxWidth <= 0 || maxHeight <= 0) { + return; + } + + // Add a variable padding to the image or text. + maxWidth = Math.round(maxWidth + this.proportion * 8); + maxHeight = Math.round(maxHeight + this.proportion * 8); + + for (let x = 0; x < groupItems.length; x++) { + // Check if the item has an img and calculate its width and height. + const item = groupItems[x]; + const img = item.querySelector('img'); + let width: number | undefined; + let height: number | undefined; + + if (img) { + width = Math.round(img.naturalWidth * this.proportion); + height = Math.round(img.naturalHeight * this.proportion); + } else { + // Remove the padding to calculate the size. + const originalPadding = item.style.padding; + item.style.padding = ''; + + // Text is not affected by the proportion. + width = Math.round(item.clientWidth); + height = Math.round(item.clientHeight); + + // Restore the padding. + item.style.padding = originalPadding; + } + + // Now set the right padding to make this item have the max height and width. + const marginTopBottom = Math.round((maxHeight - height) / 2); + const marginLeftRight = Math.round((maxWidth - width) / 2); + + // Correction for the roundings. + const widthCorrection = maxWidth - (width + marginLeftRight * 2); + const heightCorrection = maxHeight - (height + marginTopBottom * 2); + + item.style.padding = marginTopBottom + 'px ' + marginLeftRight + 'px ' + + (marginTopBottom + heightCorrection) + 'px ' + (marginLeftRight + widthCorrection) + 'px'; + + const dragItemNo = this.doc.getClassnameNumericSuffix(item, 'dragitemhomes'); + const drags = this.doc.topNode ? + Array.from(this.doc.topNode.querySelectorAll(`.drag.group${groupNo}.dragitems${dragItemNo}`)) : []; + + drags.forEach((drag) => { + drag.style.padding = marginTopBottom + 'px ' + marginLeftRight + 'px ' + + (marginTopBottom + heightCorrection) + 'px ' + (marginLeftRight + widthCorrection) + 'px'; + }); + } + + // It adds the border of 1px to the width. + const zoneGroups = this.doc.dropZoneGroup(groupNo); + zoneGroups.forEach((zone) => { + zone.style.width = maxWidth + 2 + 'px '; + zone.style.height = maxHeight + 2 + 'px '; + }); + } + + /** + * Update the padding of all items in all groups. + */ + updatePaddingSizesAll(): void { + for (let groupNo = 1; groupNo <= 8; groupNo++) { + this.updatePaddingSizeForGroup(groupNo); + } + } + +} + +/** + * Encapsulates operations on dd area. + */ +export class AddonQtypeDdImageOrTextQuestionDocStructure { + + topNode: HTMLElement | null; + dragItemsArea: HTMLElement | null; + + protected logger: CoreLogger; + + constructor( + protected container: HTMLElement, + protected slot: number, + ) { + this.logger = CoreLogger.getInstance('AddonQtypeDdImageOrTextQuestionDocStructure'); + this.topNode = this.container.querySelector('.addon-qtype-ddimageortext-container'); + this.dragItemsArea = this.topNode?.querySelector('div.draghomes') || null; + + if (this.dragItemsArea) { + // On 3.9+ dragitems were removed. + const dragItems = this.topNode!.querySelector('div.dragitems'); + + if (dragItems) { + // Remove empty div.dragitems. + dragItems.remove(); + } + + // 3.6+ site, transform HTML so it has the same structure as in Moodle 3.5. + const ddArea = this.topNode!.querySelector('div.ddarea'); + if (ddArea) { + // Move div.dropzones to div.ddarea. + const dropZones = this.topNode!.querySelector('div.dropzones'); + if (dropZones) { + ddArea.appendChild(dropZones); + } + + // Move div.draghomes to div.ddarea and rename the class to .dragitems. + ddArea?.appendChild(this.dragItemsArea); + } + + this.dragItemsArea.classList.remove('draghomes'); + this.dragItemsArea.classList.add('dragitems'); + + // Add .dragitemhomesNNN class to drag items. + Array.from(this.dragItemsArea.querySelectorAll('.draghome')).forEach((draghome, index) => { + draghome.classList.add(`dragitemhomes${index}`); + }); + } else { + this.dragItemsArea = this.topNode!.querySelector('div.dragitems'); + } + } + + querySelector(element: HTMLElement | null, selector: string): T | null { + if (!element) { + return null; + } + + return element.querySelector(selector); + } + + querySelectorAll(element: HTMLElement | null, selector: string): HTMLElement[] { + if (!element) { + return []; + } + + return Array.from(element.querySelectorAll(selector)); + } + + dragItems(): HTMLElement[] { + return this.querySelectorAll(this.dragItemsArea, '.drag'); + } + + dropZones(): HTMLElement[] { + return this.querySelectorAll(this.topNode, 'div.dropzones div.dropzone'); + } + + dropZoneGroup(groupNo: number): HTMLElement[] { + return this.querySelectorAll(this.topNode, `div.dropzones div.group${groupNo}`); + } + + dragItemsClonedFrom(dragItemNo: number): HTMLElement[] { + return this.querySelectorAll(this.dragItemsArea, `.dragitems${dragItemNo}`); + } + + dragItem(dragInstanceNo: number): HTMLElement | null { + return this.querySelector(this.dragItemsArea, `.draginstance${dragInstanceNo}`); + } + + dragItemsInGroup(groupNo: number): HTMLElement[] { + return this.querySelectorAll(this.dragItemsArea, `.drag.group${groupNo}`); + } + + dragItemHomes(): HTMLElement[] { + return this.querySelectorAll(this.dragItemsArea, '.draghome'); + } + + bgImg(): HTMLImageElement | null { + return this.querySelector(this.topNode, '.dropbackground'); + } + + dragItemHome(dragItemNo: number): HTMLElement | null { + return this.querySelector(this.dragItemsArea, `.dragitemhomes${dragItemNo}`); + } + + getClassnameNumericSuffix(node: HTMLElement, prefix: string): number | undefined { + if (node.classList && node.classList.length) { + const patt1 = new RegExp(`^${prefix}([0-9])+$`); + const patt2 = new RegExp('([0-9])+$'); + + for (let index = 0; index < node.classList.length; index++) { + if (patt1.test(node.classList[index])) { + const match = patt2.exec(node.classList[index]); + + return Number(match![0]); + } + } + } + + this.logger.warn(`Prefix "${prefix}" not found in class names.`); + } + + cloneNewDragItem(dragInstanceNo: number, dragItemNo: number): HTMLElement | null { + const dragHome = this.dragItemHome(dragItemNo); + if (dragHome === null) { + return null; + } + + const dragHomeImg = dragHome.querySelector('img'); + let divDrag: HTMLElement | undefined = undefined; + + // Images need to be inside a div element to admit padding with width and height. + if (dragHomeImg) { + // Clone the image. + const drag = dragHomeImg.cloneNode(true); + + // Create a div and put the image in it. + divDrag = document.createElement('div'); + divDrag.appendChild(drag); + divDrag.className = dragHome.className; + drag.className = ''; + } else { + // The drag item doesn't have an image, just clone it. + divDrag = dragHome.cloneNode(true); + } + + // Set the right classes and styles. + divDrag.classList.remove(`dragitemhomes${dragItemNo}`); + divDrag.classList.remove('draghome'); + divDrag.classList.add(`dragitems${dragItemNo}`); + divDrag.classList.add(`draginstance${dragInstanceNo}`); + divDrag.classList.add('drag'); + + divDrag.style.visibility = 'inherit'; + divDrag.style.position = 'absolute'; + divDrag.setAttribute('draginstanceno', String(dragInstanceNo)); + divDrag.setAttribute('dragitemno', String(dragItemNo)); + divDrag.setAttribute('tabindex', '0'); + + // Insert the new drag after the dragHome. + dragHome.parentElement?.insertBefore(divDrag, dragHome.nextSibling); + + return divDrag; + } + +}; diff --git a/src/addons/qtype/ddimageortext/component/addon-qtype-ddimageortext.html b/src/addons/qtype/ddimageortext/component/addon-qtype-ddimageortext.html new file mode 100644 index 000000000..1b3c7ad50 --- /dev/null +++ b/src/addons/qtype/ddimageortext/component/addon-qtype-ddimageortext.html @@ -0,0 +1,24 @@ + + + + + + + + + + {{ 'core.question.howtodraganddrop' | translate }} + + + + + + + + + + + diff --git a/src/addons/qtype/ddimageortext/component/ddimageortext.scss b/src/addons/qtype/ddimageortext/component/ddimageortext.scss new file mode 100644 index 000000000..8487bbcdf --- /dev/null +++ b/src/addons/qtype/ddimageortext/component/ddimageortext.scss @@ -0,0 +1,114 @@ +@import "~core/features/question/question"; + +// Style ddimageortext content a bit. Almost all these styles are copied from Moodle. +:host { + .addon-qtype-ddimageortext-container { + min-height: 80px; // To display the loading. + } + + core-format-text ::ng-deep { + .qtext { + margin-bottom: 0.5em; + display: block; + } + + div.droparea img { + border: 1px solid var(--gray-darker); + max-width: 100%; + } + + .draghome { + vertical-align: top; + margin: 5px; + visibility : hidden; + } + .draghome img { + display: block; + } + + div.draghome { + border: 1px solid var(--gray-darker); + cursor: pointer; + background-color: #B0C4DE; + display: inline-block; + height: auto; + width: auto; + zoom: 1; + } + + @for $i from 0 to length($core-dd-question-colors) { + .group#{$i + 1} { + background: nth($core-dd-question-colors, $i + 1); + } + } + + .group2 { + border-radius: 10px 0 0 0; + } + .group3 { + border-radius: 0 10px 0 0; + } + .group4 { + border-radius: 0 0 10px 0; + } + .group5 { + border-radius: 0 0 0 10px; + } + .group6 { + border-radius: 0 10px 10px 0; + } + .group7 { + border-radius: 10px 0 0 10px; + } + .group8 { + border-radius: 10px 10px 10px 10px; + } + + .drag { + border: 1px solid var(--gray-darker); + color: var(--ion-text-color); + cursor: pointer; + z-index: 2; + } + .dragitems.readonly .drag { + cursor: auto; + } + .dragitems>div { + clear: both; + } + .dragitems { + cursor: pointer; + } + .dragitems.readonly { + cursor: auto; + } + .drag img { + display: block; + } + + div.ddarea { + text-align : center; + position: relative; + } + .dropbackground { + margin:0 auto; + } + .dropzone { + border: 1px solid var(--gray-darker); + position: absolute; + z-index: 1; + cursor: pointer; + } + .readonly .dropzone { + cursor: auto; + } + + div.dragitems div.draghome, div.dragitems div.drag { + font:13px/1.231 arial,helvetica,clean,sans-serif; + } + .drag.beingdragged { + z-index: 3; + box-shadow: var(--core-dd-question-selected-shadow); + } + } +} diff --git a/src/addons/qtype/ddimageortext/component/ddimageortext.ts b/src/addons/qtype/ddimageortext/component/ddimageortext.ts new file mode 100644 index 000000000..72a69ea38 --- /dev/null +++ b/src/addons/qtype/ddimageortext/component/ddimageortext.ts @@ -0,0 +1,145 @@ +// (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, OnInit, OnDestroy, ElementRef } from '@angular/core'; + +import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreDomUtils } from '@services/utils/dom'; +import { AddonQtypeDdImageOrTextQuestion } from '../classes/ddimageortext'; + +/** + * Component to render a drag-and-drop onto image question. + */ +@Component({ + selector: 'addon-qtype-ddimageortext', + templateUrl: 'addon-qtype-ddimageortext.html', + styleUrls: ['ddimageortext.scss'], +}) +export class AddonQtypeDdImageOrTextComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy { + + ddQuestion?: AddonModQuizDdImageOrTextQuestionData; + + protected questionInstance?: AddonQtypeDdImageOrTextQuestion; + protected drops?: unknown[]; // The drop zones received in the init object of the question. + protected destroyed = false; + protected textIsRendered = false; + protected ddAreaisRendered = false; + + constructor(elementRef: ElementRef) { + super('AddonQtypeDdImageOrTextComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.question) { + this.logger.warn('Aborting because of no question received.'); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + this.ddQuestion = this.question; + + const element = CoreDomUtils.instance.convertToElement(this.ddQuestion.html); + + // Get D&D area and question text. + const ddArea = element.querySelector('.ddarea'); + + this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext'); + if (!ddArea || typeof this.ddQuestion.text == 'undefined') { + this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + // Set the D&D area HTML. + this.ddQuestion.ddArea = ddArea.outerHTML; + this.ddQuestion.readOnly = false; + + if (this.ddQuestion.initObjects) { + // Moodle version <= 3.5. + if (typeof this.ddQuestion.initObjects.drops != 'undefined') { + this.drops = this.ddQuestion.initObjects.drops; + } + if (typeof this.ddQuestion.initObjects.readonly != 'undefined') { + this.ddQuestion.readOnly = !!this.ddQuestion.initObjects.readonly; + } + } else if (this.ddQuestion.amdArgs) { + // Moodle version >= 3.6. + if (typeof this.ddQuestion.amdArgs[1] != 'undefined') { + this.ddQuestion.readOnly = !!this.ddQuestion.amdArgs[1]; + } + if (typeof this.ddQuestion.amdArgs[2] != 'undefined') { + this.drops = this.ddQuestion.amdArgs[2]; + } + } + + this.ddQuestion.loaded = false; + } + + /** + * The question ddArea has been rendered. + */ + ddAreaRendered(): void { + this.ddAreaisRendered = true; + if (this.textIsRendered) { + this.questionRendered(); + } + } + + /** + * The question text has been rendered. + */ + textRendered(): void { + this.textIsRendered = true; + if (this.ddAreaisRendered) { + this.questionRendered(); + } + } + + /** + * The question has been rendered. + */ + protected questionRendered(): void { + if (!this.destroyed && this.ddQuestion) { + // Create the instance. + this.questionInstance = new AddonQtypeDdImageOrTextQuestion( + this.hostElement, + this.ddQuestion, + !!this.ddQuestion.readOnly, + this.drops, + ); + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.destroyed = true; + this.questionInstance?.destroy(); + } + +} + +/** + * Data for DD Image or Text question. + */ +export type AddonModQuizDdImageOrTextQuestionData = AddonModQuizQuestionBasicData & { + loaded?: boolean; + readOnly?: boolean; + ddArea?: string; +}; diff --git a/src/addons/qtype/ddimageortext/ddimageortext.module.ts b/src/addons/qtype/ddimageortext/ddimageortext.module.ts new file mode 100644 index 000000000..77562e67d --- /dev/null +++ b/src/addons/qtype/ddimageortext/ddimageortext.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeDdImageOrTextComponent } from './component/ddimageortext'; +import { AddonQtypeDdImageOrTextHandler } from './services/handlers/ddimageortext'; + +@NgModule({ + declarations: [ + AddonQtypeDdImageOrTextComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeDdImageOrTextHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeDdImageOrTextComponent, + ], +}) +export class AddonQtypeDdImageOrTextModule {} diff --git a/src/addons/qtype/ddimageortext/services/handlers/ddimageortext.ts b/src/addons/qtype/ddimageortext/services/handlers/ddimageortext.ts new file mode 100644 index 000000000..cceff37ce --- /dev/null +++ b/src/addons/qtype/ddimageortext/services/handlers/ddimageortext.ts @@ -0,0 +1,136 @@ +// (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 { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeDdImageOrTextComponent } from '../../component/ddimageortext'; + +/** + * Handler to support drag-and-drop onto image question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeDdImageOrTextHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeDdImageOrText'; + type = 'qtype_ddimageortext'; + + /** + * 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 { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param question The question to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonQtypeDdImageOrTextComponent; + } + + /** + * 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, + ): number { + // An answer is complete if all drop zones have an answer. + // We should always receive all the drop zones with their value ('' if not answered). + for (const name in answers) { + const value = answers[name]; + if (!value || value === '0') { + return 0; + } + } + + return 1; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param 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, + ): number { + for (const name in answers) { + const value = answers[name]; + if (value && value !== '0') { + return 1; + } + } + + return 0; + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers); + } + +} + +export class AddonQtypeDdImageOrTextHandler extends makeSingleton(AddonQtypeDdImageOrTextHandlerService) {} diff --git a/src/addons/qtype/ddmarker/classes/ddmarker.ts b/src/addons/qtype/ddmarker/classes/ddmarker.ts new file mode 100644 index 000000000..88d2ebafb --- /dev/null +++ b/src/addons/qtype/ddmarker/classes/ddmarker.ts @@ -0,0 +1,972 @@ +// (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 { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreLogger } from '@singletons/logger'; +import { AddonQtypeDdMarkerQuestionData } from '../component/ddmarker'; +import { AddonQtypeDdMarkerGraphicsApi } from './graphics_api'; + +/** + * Point type. + */ +export type AddonQtypeDdMarkerQuestionPoint = { + x: number; // X axis coordinates. + y: number; // Y axis coordinates. +}; + +/** + * Class to make a question of ddmarker type work. + */ +export class AddonQtypeDdMarkerQuestion { + + protected readonly COLOURS = ['#FFFFFF', '#B0C4DE', '#DCDCDC', '#D8BFD8', '#87CEFA', '#DAA520', '#FFD700', '#F0E68C']; + + protected logger: CoreLogger; + protected afterImageLoadDone = false; + protected drops; + protected topNode; + protected nextColourIndex = 0; + protected proportion = 1; + protected selected?: HTMLElement; // Selected element (being "dragged"). + protected graphics: AddonQtypeDdMarkerGraphicsApi; + protected resizeFunction?: () => void; + + doc!: AddonQtypeDdMarkerQuestionDocStructure; + shapes: SVGElement[] = []; + + /** + * Create the instance. + * + * @param container The container HTMLElement of the question. + * @param question The question instance. + * @param readOnly Whether it's read only. + * @param dropZones The drop zones received in the init object of the question. + * @param imgSrc Background image source (3.6+ sites). + */ + constructor( + protected container: HTMLElement, + protected question: AddonQtypeDdMarkerQuestionData, + protected readOnly: boolean, + protected dropZones: any[], // eslint-disable-line @typescript-eslint/no-explicit-any + protected imgSrc?: string, + ) { + this.logger = CoreLogger.getInstance('AddonQtypeDdMarkerQuestion'); + + this.graphics = new AddonQtypeDdMarkerGraphicsApi(this); + + this.initializer(); + } + + /** + * Calculate image proportion to make easy conversions. + */ + calculateImgProportion(): void { + const bgImg = this.doc.bgImg(); + if (!bgImg) { + return; + } + + // Render the position related to the current image dimensions. + this.proportion = 1; + if (bgImg.width != bgImg.naturalWidth) { + this.proportion = bgImg.width / bgImg.naturalWidth; + } + } + + /** + * Create a new draggable element cloning a certain element. + * + * @param dragHome The element to clone. + * @param itemNo The number of the new item. + * @return The new element. + */ + cloneNewDragItem(dragHome: HTMLElement, itemNo: number): HTMLElement { + // Clone the element and add the right classes. + const drag = dragHome.cloneNode(true); + drag.classList.remove('draghome'); + drag.classList.add('dragitem'); + drag.classList.add('item' + itemNo); + drag.classList.remove('dragplaceholder'); // In case it has it. + dragHome.classList.add('dragplaceholder'); + + // Insert the new drag after the dragHome. + dragHome.parentElement?.insertBefore(drag, dragHome.nextSibling); + if (!this.readOnly) { + this.draggable(drag); + } + + return drag; + } + + /** + * Convert the X and Y position of the BG IMG to a position relative to the window. + * + * @param bgImgXY X and Y of the BG IMG relative position. + * @return Position relative to the window. + */ + convertToWindowXY(bgImgXY: string): number[] { + const bgImg = this.doc.bgImg(); + if (!bgImg) { + return []; + } + + const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea'); + let coordsNumbers = this.parsePoint(bgImgXY); + + coordsNumbers = this.makePointProportional(coordsNumbers); + + return [coordsNumbers.x + position[0], coordsNumbers.y + position[1]]; + } + + /** + * Check if some coordinates (X, Y) are inside the background image. + * + * @param coords Coordinates to check. + * @return Whether they're inside the background image. + */ + coordsInImg(coords: AddonQtypeDdMarkerQuestionPoint): boolean { + const bgImg = this.doc.bgImg(); + if (!bgImg) { + return false; + } + + return (coords.x * this.proportion <= bgImg.width + 1) && (coords.y * this.proportion <= bgImg.height + 1); + } + + /** + * Deselect all draggable items. + */ + deselectDrags(): void { + const drags = this.doc.dragItems(); + drags.forEach((drag) => { + drag.classList.remove('beingdragged'); + }); + this.selected = undefined; + } + + /** + * Function to call when the instance is no longer needed. + */ + destroy(): void { + if (this.resizeFunction) { + window.removeEventListener('resize', this.resizeFunction); + } + } + + /** + * Make an element "draggable". In the mobile app, items are "dragged" using tap and drop. + * + * @param drag Element. + */ + draggable(drag: HTMLElement): void { + drag.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const dragging = this.selected; + if (dragging && !drag.classList.contains('unplaced')) { + + const position = CoreDomUtils.instance.getElementXY(drag, undefined, 'ddarea'); + const bgImg = this.doc.bgImg(); + if (!bgImg) { + return; + } + + const bgImgPos = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea'); + + position[0] = position[0] - bgImgPos[0] + e.offsetX; + position[1] = position[1] - bgImgPos[1] + e.offsetY; + + // Ensure the we click on a placed dragitem. + if (position[0] <= bgImg.width && position[1] <= bgImg.height) { + this.deselectDrags(); + this.dropDrag(dragging, position); + + return; + } + } + + if (drag.classList.contains('beingdragged')) { + this.deselectDrags(); + } else { + this.selectDrag(drag); + } + }); + } + + /** + * Get the coordinates of the drag home of a certain choice. + * + * @param choiceNo Choice number. + * @return Coordinates. + */ + dragHomeXY(choiceNo: number): number[] { + const dragItemHome = this.doc.dragItemHome(choiceNo); + if (!dragItemHome) { + return []; + } + + const position = CoreDomUtils.instance.getElementXY(dragItemHome, undefined, 'ddarea'); + + return [position[0], position[1]]; + } + + /** + * Draw a drop zone. + * + * @param dropZoneNo Number of the drop zone. + * @param markerText The marker text to set. + * @param shape Name of the shape of the drop zone (circle, rectangle, polygon). + * @param coords Coordinates of the shape. + * @param colour Colour of the shape. + */ + drawDropZone(dropZoneNo: number, markerText: string, shape: string, coords: string, colour: string): void { + const markerTexts = this.doc.markerTexts(); + // Check if there is already a marker text for this drop zone. + const existingMarkerText = markerTexts?.querySelector('span.markertext' + dropZoneNo); + + if (existingMarkerText) { + // Marker text already exists. Update it or remove it if empty. + if (markerText !== '') { + existingMarkerText.innerHTML = markerText; + } else { + existingMarkerText.remove(); + } + } else if (markerText !== '' && markerTexts) { + // Create and add the marker text. + const classNames = 'markertext markertext' + dropZoneNo; + const span = document.createElement('span'); + + span.className = classNames; + span.innerHTML = markerText; + + markerTexts.appendChild(span); + } + + // Check that a function to draw this shape exists. + const drawFunc = 'drawShape' + CoreTextUtils.instance.ucFirst(shape); + if (!(this[drawFunc] instanceof Function)) { + return; + } + + // Call the function. + const xyForText = this[drawFunc](dropZoneNo, coords, colour); + if (xyForText === null || xyForText === undefined) { + return; + } + + // Search the marker for the drop zone. + const markerSpan = this.doc.topNode?.querySelector(`div.ddarea div.markertexts span.markertext${dropZoneNo}`); + if (!markerSpan) { + return; + } + + const width = CoreDomUtils.instance.getElementMeasure(markerSpan, true, true, false, true); + const height = CoreDomUtils.instance.getElementMeasure(markerSpan, false, true, false, true); + markerSpan.style.opacity = '0.6'; + markerSpan.style.left = (xyForText.x - (width / 2)) + 'px'; + markerSpan.style.top = (xyForText.y - (height / 2)) + 'px'; + + const markerSpanAnchor = markerSpan.querySelector('a'); + if (markerSpanAnchor !== null) { + + markerSpanAnchor.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.shapes.forEach((elem) => { + elem.style.fillOpacity = '0.5'; + }); + + this.shapes[dropZoneNo].style.fillOpacity = '1'; + setTimeout(() => { + this.shapes[dropZoneNo].style.fillOpacity = '0.5'; + }, 2000); + }); + + markerSpanAnchor.setAttribute('tabIndex', '0'); + } + } + + /** + * Draw a circle in a drop zone. + * + * @param dropZoneNo Number of the drop zone. + * @param coordinates Coordinates of the circle. + * @param colour Colour of the circle. + * @return X and Y position of the center of the circle. + */ + drawShapeCircle(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null { + if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?$/)) { + return null; + } + + const bits = coordinates.split(';'); + let centre = this.parsePoint(bits[0]); + const radius = Number(bits[1]); + + // Calculate circle limits and check it's inside the background image. + const circleLimit = { x: centre.x - radius, y: centre.y - radius }; + if (this.coordsInImg(circleLimit)) { + centre = this.makePointProportional(centre); + + // All good, create the shape. + this.shapes[dropZoneNo] = this.graphics.addShape({ + type: 'circle', + color: colour, + }, { + cx: centre.x, + cy: centre.y, + r: Math.round(radius * this.proportion), + }); + + // Return the centre. + return centre; + } + + return null; + } + + /** + * Draw a rectangle in a drop zone. + * + * @param dropZoneNo Number of the drop zone. + * @param coordinates Coordinates of the rectangle. + * @param colour Colour of the rectangle. + * @return X and Y position of the center of the rectangle. + */ + drawShapeRectangle(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null { + if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?,\d+(\.\d+)?$/)) { + return null; + } + + const bits = coordinates.split(';'); + const startPoint = this.parsePoint(bits[0]); + const size = this.parsePoint(bits[1]); + + // Calculate rectangle limits and check it's inside the background image. + const rectLimits = { x: startPoint.x + size.x, y: startPoint.y + size.y }; + if (this.coordsInImg(rectLimits)) { + const startPointProp = this.makePointProportional(startPoint); + const sizeProp = this.makePointProportional(size); + + // All good, create the shape. + this.shapes[dropZoneNo] = this.graphics.addShape({ + type: 'rect', + color: colour, + }, { + x: startPointProp.x, + y: startPointProp.y, + width: sizeProp.x, + height: sizeProp.y, + }); + + const centre = { x: startPoint.x + (size.x / 2) , y: startPoint.y + (size.y / 2) }; + + // Return the centre. + return this.makePointProportional(centre); + } + + return null; + } + + /** + * Draw a polygon in a drop zone. + * + * @param dropZoneNo Number of the drop zone. + * @param coordinates Coordinates of the polygon. + * @param colour Colour of the polygon. + * @return X and Y position of the center of the polygon. + */ + drawShapePolygon(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null { + if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?(?:;\d+(\.\d+)?,\d+(\.\d+)?)*$/)) { + return null; + } + + const bits = coordinates.split(';'); + const centre = { x: 0, y: 0 }; + const points = bits.map((bit) => { + const point = this.parsePoint(bit); + centre.x += point.x; + centre.y += point.y; + + return point; + }); + + if (points.length > 0) { + centre.x = Math.round(centre.x / points.length); + centre.y = Math.round(centre.y / points.length); + } + + const pointsOnImg: string[] = []; + points.forEach((point) => { + if (this.coordsInImg(point)) { + point = this.makePointProportional(point); + + pointsOnImg.push(point.x + ',' + point.y); + } + }); + + if (pointsOnImg.length > 2) { + this.shapes[dropZoneNo] = this.graphics.addShape({ + type: 'polygon', + color: colour, + }, { + points: pointsOnImg.join(' '), + }); + + // Return the centre. + return this.makePointProportional(centre); + } + + return null; + } + + /** + * Make a point from the string representation. + * + * @param coordinates "x,y". + * @return Coordinates to the point. + */ + parsePoint(coordinates: string): AddonQtypeDdMarkerQuestionPoint { + const bits = coordinates.split(','); + if (bits.length !== 2) { + throw coordinates + ' is not a valid point'; + } + + return { x: Number(bits[0]), y: Number(bits[1]) }; + } + + /** + * Make proportional position of the point. + * + * @param point Point coordinates. + * @return Converted point. + */ + makePointProportional(point: AddonQtypeDdMarkerQuestionPoint): AddonQtypeDdMarkerQuestionPoint { + return { + x: Math.round(point.x * this.proportion), + y: Math.round(point.y * this.proportion), + + }; + } + + /** + * Drop a drag element into a certain position. + * + * @param drag The element to drop. + * @param position Position to drop to (X, Y). + */ + dropDrag(drag: HTMLElement, position: number[] | null): void { + const choiceNo = this.getChoiceNoForNode(drag); + + if (position) { + // Set the position related to the natural image dimensions. + if (this.proportion < 1) { + position[0] = Math.round(position[0] / this.proportion); + position[1] = Math.round(position[1] / this.proportion); + } + } + + this.saveAllXYForChoice(choiceNo, drag, position); + this.redrawDragsAndDrops(); + } + + /** + * Determine which drag items need to be shown and return coords of all drag items except any that are currently being + * dragged based on contents of hidden inputs and whether drags are 'infinite' or how many drags should be shown. + * + * @param input The input element. + * @return List of coordinates. + */ + getCoords(input: HTMLElement): number[][] { + const choiceNo = this.getChoiceNoForNode(input); + const fv = input.getAttribute('value'); + const infinite = input.classList.contains('infinite'); + const noOfDrags = this.getNoOfDragsForNode(input); + const dragging = !!this.doc.dragItemBeingDragged(choiceNo); + const coords: number[][] = []; + + if (fv !== '' && typeof fv != 'undefined' && fv !== null) { + // Get all the coordinates in the input and add them to the coords list. + const coordsStrings = fv.split(';'); + + for (let i = 0; i < coordsStrings.length; i++) { + coords[coords.length] = this.convertToWindowXY(coordsStrings[i]); + } + } + + const displayedDrags = coords.length + (dragging ? 1 : 0); + if (infinite || (displayedDrags < noOfDrags)) { + coords[coords.length] = this.dragHomeXY(choiceNo); + } + + return coords; + } + + /** + * Get the choice number from an HTML element. + * + * @param node Element to check. + * @return Choice number. + */ + getChoiceNoForNode(node: HTMLElement): number { + return Number(this.doc.getClassnameNumericSuffix(node, 'choice')); + } + + /** + * Get the coordinates (X, Y) of a draggable element. + * + * @param dragItem The draggable item. + * @return Coordinates. + */ + getDragXY(dragItem: HTMLElement): number[] { + const position = CoreDomUtils.instance.getElementXY(dragItem, undefined, 'ddarea'); + const bgImg = this.doc.bgImg(); + if (bgImg) { + const bgImgXY = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea'); + + position[0] -= bgImgXY[0]; + position[1] -= bgImgXY[1]; + } + + // Set the position related to the natural image dimensions. + if (this.proportion < 1) { + position[0] = Math.round(position[0] / this.proportion); + position[1] = Math.round(position[1] / this.proportion); + } + + return position; + } + + /** + * Get the item number from an HTML element. + * + * @param node Element to check. + * @return Choice number. + */ + getItemNoForNode(node: HTMLElement): number { + return Number(this.doc.getClassnameNumericSuffix(node, 'item')); + } + + /** + * Get the next colour. + * + * @return Colour. + */ + getNextColour(): string { + const colour = this.COLOURS[this.nextColourIndex]; + this.nextColourIndex++; + + // If we reached the end of the list, start again. + if (this.nextColourIndex === this.COLOURS.length) { + this.nextColourIndex = 0; + } + + return colour; + } + + /** + * Get the number of drags from an HTML element. + * + * @param node Element to check. + * @return Choice number. + */ + getNoOfDragsForNode(node: HTMLElement): number { + return Number(this.doc.getClassnameNumericSuffix(node, 'noofdrags')); + } + + /** + * Initialize the question. + * + * @param question Question. + */ + initializer(): void { + this.doc = new AddonQtypeDdMarkerQuestionDocStructure(this.container); + + // Wait the DOM to be rendered. + setTimeout(() => { + this.pollForImageLoad(); + }); + + this.resizeFunction = this.redrawDragsAndDrops.bind(this); + window.addEventListener('resize', this.resizeFunction!); + } + + /** + * Make background image and home zone dropable. + */ + makeImageDropable(): void { + if (this.readOnly) { + return; + } + + // Listen for click events in the background image to make it dropable. + const bgImg = this.doc.bgImg(); + bgImg?.addEventListener('click', (e) => { + + const drag = this.selected; + if (!drag) { + // No draggable element selected, nothing to do. + return false; + } + + // There's an element being dragged. Deselect it and drop it in the position. + const position = [e.offsetX, e.offsetY]; + this.deselectDrags(); + this.dropDrag(drag, position); + + e.preventDefault(); + e.stopPropagation(); + }); + + const home = this.doc.dragItemsArea; + home?.addEventListener('click', (e) => { + + const drag = this.selected; + if (!drag) { + // No draggable element selected, nothing to do. + return false; + } + + // There's an element being dragged but it's not placed yet, deselect. + if (drag.classList.contains('unplaced')) { + this.deselectDrags(); + + return false; + } + + // There's an element being dragged and it's placed somewhere. Move it back to the home area. + this.deselectDrags(); + this.dropDrag(drag, null); + + e.preventDefault(); + e.stopPropagation(); + }); + } + + /** + * Wait for the background image to be loaded. + */ + pollForImageLoad(): void { + if (this.afterImageLoadDone) { + // Already treated. + return; + } + + const bgImg = this.doc.bgImg(); + if (!bgImg) { + return; + } + + if (!bgImg.src && this.imgSrc) { + bgImg.src = this.imgSrc; + } + + const imgLoaded = (): void => { + bgImg.removeEventListener('load', imgLoaded); + + this.makeImageDropable(); + + setTimeout(() => { + this.redrawDragsAndDrops(); + }); + + this.afterImageLoadDone = true; + this.question.loaded = true; + }; + + if (!bgImg.src || (bgImg.complete && bgImg.naturalWidth)) { + imgLoaded(); + + return; + } + + bgImg.addEventListener('load', imgLoaded); + + // Try again after a while. + setTimeout(() => { + this.pollForImageLoad(); + }, 500); + } + + /** + * Redraw all draggables and drop zones. + */ + redrawDragsAndDrops(): void { + // Mark all the draggable items as not placed. + const drags = this.doc.dragItems(); + drags.forEach((drag) => { + drag.classList.add('unneeded', 'unplaced'); + }); + + // Re-calculate the image proportion. + this.calculateImgProportion(); + + // Get all the inputs. + const inputs = this.doc.inputsForChoices(); + for (let x = 0; x < inputs.length; x++) { + + // Get all the drag items for the choice. + const input = inputs[x]; + const choiceNo = this.getChoiceNoForNode(input); + const coords = this.getCoords(input); + const dragItemHome = this.doc.dragItemHome(choiceNo); + const homePosition = this.dragHomeXY(choiceNo); + if (!dragItemHome) { + continue; + } + + for (let i = 0; i < coords.length; i++) { + let dragItem = this.doc.dragItemForChoice(choiceNo, i); + + if (!dragItem || dragItem.classList.contains('beingdragged')) { + dragItem = this.cloneNewDragItem(dragItemHome, i); + } else { + dragItem.classList.remove('unneeded'); + } + + const placeholder = this.doc.dragItemPlaceholder(choiceNo); + + // Remove the class only if is placed on the image. + if (homePosition[0] != coords[i][0] || homePosition[1] != coords[i][1]) { + dragItem.classList.remove('unplaced'); + dragItem.classList.add('placed'); + + const computedStyle = getComputedStyle(dragItem); + const left = coords[i][0] - CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'marginLeft'); + const top = coords[i][1] - CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'marginTop'); + + dragItem.style.left = left + 'px'; + dragItem.style.top = top + 'px'; + placeholder?.classList.add('active'); + } else { + dragItem.classList.remove('placed'); + dragItem.classList.add('unplaced'); + placeholder?.classList.remove('active'); + } + } + } + + // Remove unneeded draggable items. + for (let y = 0; y < drags.length; y++) { + const item = drags[y]; + if (item.classList.contains('unneeded') && !item.classList.contains('beingdragged')) { + item.remove(); + } + } + + // Re-draw drop zones. + if (this.dropZones && this.dropZones.length !== 0) { + this.graphics.clear(); + this.restartColours(); + + for (const dropZoneNo in this.dropZones) { + const colourForDropZone = this.getNextColour(); + const dropZone = this.dropZones[dropZoneNo]; + const dzNo = Number(dropZoneNo); + + this.drawDropZone(dzNo, dropZone.markertext, dropZone.shape, dropZone.coords, colourForDropZone); + } + } + } + + /** + * Reset the coordinates stored for a choice. + * + * @param choiceNo Choice number. + */ + resetDragXY(choiceNo: number): void { + this.setFormValue(choiceNo, ''); + } + + /** + * Restart the colour index. + */ + restartColours(): void { + this.nextColourIndex = 0; + } + + /** + * Save all the coordinates of a choice into the right input. + * + * @param choiceNo Number of the choice. + * @param dropped Element being dropped. + * @param position Position where the element is dropped. + */ + saveAllXYForChoice(choiceNo: number, dropped: HTMLElement, position: number[] | null): void { + const coords: number[][] = []; + + // Calculate the coords for the choice. + const dragItemsChoice = this.doc.dragItemsForChoice(choiceNo); + for (let i = 0; i < dragItemsChoice.length; i++) { + + const dragItem = this.doc.dragItemForChoice(choiceNo, i); + if (dragItem) { + const bgImgXY = this.getDragXY(dragItem); + dragItem.classList.remove('item' + i); + dragItem.classList.add('item' + coords.length); + coords.push(bgImgXY); + } + } + + if (position !== null) { + // Element dropped into a certain position. Mark it as placed and save the position. + dropped.classList.remove('unplaced'); + dropped.classList.add('item' + coords.length); + coords.push(position); + } else { + // Element back at home, mark it as unplaced. + dropped.classList.add('unplaced'); + } + + if (coords.length > 0) { + // Save the coordinates in the input. + this.setFormValue(choiceNo, coords.join(';')); + } else { + // Empty the input. + this.resetDragXY(choiceNo); + } + } + + /** + * Save a certain value in the input of a choice. + * + * @param choiceNo Choice number. + * @param value The value to set. + */ + setFormValue(choiceNo: number, value: string): void { + this.doc.inputForChoice(choiceNo)?.setAttribute('value', value); + } + + /** + * Select a draggable element. + * + * @param drag Element. + */ + selectDrag(drag: HTMLElement): void { + // Deselect previous drags. + this.deselectDrags(); + + this.selected = drag; + drag.classList.add('beingdragged'); + + const itemNo = this.getItemNoForNode(drag); + if (itemNo !== null) { + drag.classList.remove('item' + itemNo); + } + } + +} + + +/** + * Encapsulates operations on dd area. + */ +export class AddonQtypeDdMarkerQuestionDocStructure { + + topNode: HTMLElement | null; + dragItemsArea: HTMLElement | null; + + protected logger: CoreLogger; + + constructor( + protected container: HTMLElement, + ) { + this.logger = CoreLogger.getInstance('AddonQtypeDdMarkerQuestionDocStructure'); + + this.topNode = this.container.querySelector('.addon-qtype-ddmarker-container'); + this.dragItemsArea = this.topNode?.querySelector('div.dragitems, div.draghomes') || null; + } + + querySelector(element: HTMLElement | null, selector: string): T | null { + if (!element) { + return null; + } + + return element.querySelector(selector); + } + + querySelectorAll(element: HTMLElement | null, selector: string): HTMLElement[] { + if (!element) { + return []; + } + + return Array.from(element.querySelectorAll(selector)); + } + + bgImg(): HTMLImageElement | null { + return this.querySelector(this.topNode, '.dropbackground'); + } + + dragItems(): HTMLElement[] { + return this.querySelectorAll(this.dragItemsArea, '.dragitem'); + } + + dragItemsForChoice(choiceNo: number): HTMLElement[] { + return this.querySelectorAll(this.dragItemsArea, `span.dragitem.choice${choiceNo}`); + } + + dragItemForChoice(choiceNo: number, itemNo: number): HTMLElement | null { + return this.querySelector(this.dragItemsArea, `span.dragitem.choice${choiceNo}.item${itemNo}`); + } + + dragItemPlaceholder(choiceNo: number): HTMLElement | null { + return this.querySelector(this.dragItemsArea, `span.dragplaceholder.choice${choiceNo}`); + } + + dragItemBeingDragged(choiceNo: number): HTMLElement | null { + return this.querySelector(this.dragItemsArea, `span.dragitem.beingdragged.choice${choiceNo}`); + + } + + dragItemHome(choiceNo: number): HTMLElement | null { + return this.querySelector(this.dragItemsArea, `span.draghome.choice${choiceNo}, span.marker.choice${choiceNo}`); + } + + dragItemHomes(): HTMLElement[] { + return this.querySelectorAll(this.dragItemsArea, 'span.draghome, span.marker'); + } + + getClassnameNumericSuffix(node: HTMLElement, prefix: string): number | undefined { + if (node.classList.length) { + const patt1 = new RegExp('^' + prefix + '([0-9])+$'); + const patt2 = new RegExp('([0-9])+$'); + + for (let index = 0; index < node.classList.length; index++) { + if (patt1.test(node.classList[index])) { + const match = patt2.exec(node.classList[index]); + + return Number(match?.[0]); + } + } + } + + this.logger.warn('Prefix "' + prefix + '" not found in class names.'); + } + + inputsForChoices(): HTMLElement[] { + return this.querySelectorAll(this.topNode, 'input.choices'); + } + + inputForChoice(choiceNo: number): HTMLElement | null { + return this.querySelector(this.topNode, `input.choice${choiceNo}`); + } + + markerTexts(): HTMLElement | null { + return this.querySelector(this.topNode, 'div.markertexts'); + } + +} diff --git a/src/addons/qtype/ddmarker/classes/graphics_api.ts b/src/addons/qtype/ddmarker/classes/graphics_api.ts new file mode 100644 index 000000000..4d69d0108 --- /dev/null +++ b/src/addons/qtype/ddmarker/classes/graphics_api.ts @@ -0,0 +1,96 @@ +// (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 { CoreDomUtils } from '@services/utils/dom'; +import { AddonQtypeDdMarkerQuestion } from './ddmarker'; + +/** + * Graphics API for drag-and-drop markers question type. + */ +export class AddonQtypeDdMarkerGraphicsApi { + + protected readonly NS = 'http://www.w3.org/2000/svg'; + protected dropZone?: SVGSVGElement; + + /** + * Create the instance. + * + * @param instance Question instance. + * @param domUtils Dom Utils provider. + */ + constructor(protected instance: AddonQtypeDdMarkerQuestion) { } + + /** + * Add a shape. + * + * @param shapeAttribs Attributes for the shape: type and color. + * @param styles Object with the styles for the shape (name -> value). + * @return The new shape. + */ + addShape(shapeAttribs: {type: string; color: string}, styles: {[name: string]: number | string}): SVGElement { + const shape = document.createElementNS(this.NS, shapeAttribs.type); + shape.setAttribute('fill', shapeAttribs.color); + shape.setAttribute('fill-opacity', '0.5'); + shape.setAttribute('stroke', 'black'); + + for (const x in styles) { + shape.setAttribute(x, String(styles[x])); + } + + this.dropZone?.appendChild(shape); + + return shape; + } + + /** + * Clear the shapes. + */ + clear(): void { + const bgImg = this.instance.doc?.bgImg(); + const dropZones = this.instance.doc?.topNode?.querySelector('div.ddarea div.dropzones'); + const markerTexts = this.instance.doc?.markerTexts(); + + if (!bgImg || !dropZones || !markerTexts) { + return; + } + + const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea'); + + dropZones.style.left = position[0] + 'px'; + dropZones.style.top = position[1] + 'px'; + dropZones.style.width = bgImg.width + 'px'; + dropZones.style.height = bgImg.height + 'px'; + + markerTexts.style.left = position[0] + 'px'; + markerTexts.style.top = position[1] + 'px'; + markerTexts.style.width = bgImg.width + 'px'; + markerTexts.style.height = bgImg.height + 'px'; + + if (!this.dropZone) { + this.dropZone = document.createElementNS(this.NS, 'svg'); + dropZones.appendChild(this.dropZone); + } else { + // Remove all children. + while (this.dropZone.firstChild) { + this.dropZone.removeChild(this.dropZone.firstChild); + } + } + + this.dropZone.style.width = bgImg.width + 'px'; + this.dropZone.style.height = bgImg.height + 'px'; + + this.instance.shapes = []; + } + +} diff --git a/src/addons/qtype/ddmarker/component/addon-qtype-ddmarker.html b/src/addons/qtype/ddmarker/component/addon-qtype-ddmarker.html new file mode 100644 index 000000000..eec783f34 --- /dev/null +++ b/src/addons/qtype/ddmarker/component/addon-qtype-ddmarker.html @@ -0,0 +1,22 @@ + + + + + + + + + + {{ 'core.question.howtodraganddrop' | translate }} + + + + + + + + + diff --git a/src/addons/qtype/ddmarker/component/ddmarker.scss b/src/addons/qtype/ddmarker/component/ddmarker.scss new file mode 100644 index 000000000..5a71d8cc2 --- /dev/null +++ b/src/addons/qtype/ddmarker/component/ddmarker.scss @@ -0,0 +1,150 @@ +// Style ddmarker content a bit. Almost all these styles are copied from Moodle. +:host { + .addon-qtype-ddmarker-container { + min-height: 80px; // To display the loading. + } + + core-format-text ::ng-deep { + .ddarea, .ddform { + user-select: none; + } + + .qtext { + margin-bottom: 0.5em; + display: block; + } + + .droparea { + display: inline-block; + } + + div.droparea img { + border: 1px solid var(--gray-darker); + max-width: 100%; + } + + .dropzones svg { + z-index: 3; + } + + .dragitem.beingdragged .markertext { + z-index: 5; + box-shadow: var(--core-dd-question-selected-shadow); + } + + .dragitems, // Previous to 3.9. + .draghomes { + &.readonly { + .dragitem, + .marker { + cursor: auto; + } + } + + .dragitem, // Previous to 3.9. + .draghome, + .marker { + vertical-align: top; + cursor: pointer; + position: relative; + margin: 10px; + display: inline-block; + &.dragplaceholder { + display: none; + visibility: hidden; + + &.active { + display: inline-block; + } + } + + &.unplaced { + position: relative; + } + &.placed { + position: absolute; + opacity: 0.6; + } + } + } + + .droparea { + .dragitem, + .marker { + cursor: pointer; + position: absolute; + vertical-align: top; + z-index: 2; + } + } + + div.ddarea { + text-align: center; + position: relative; + } + div.ddarea .dropzones, + div.ddarea .markertexts { + top: 0; + left: 0; + min-height: 80px; + position: absolute; + text-align: start; + } + + .dropbackground { + margin: 0 auto; + } + + div.dragitems div.draghome, + div.dragitems div.dragitem, + div.draghome, + div.drag, + div.draghomes div.marker, + div.marker, + div.drag { + font: 13px/1.231 arial,helvetica,clean,sans-serif; + } + div.dragitems span.markertext, + div.draghomes span.markertext, + div.markertexts span.markertext { + margin: 0 5px; + z-index: 2; + background-color: var(--white); + border: 2px solid var(--gray-darker); + padding: 5px; + display: inline-block; + zoom: 1; + border-radius: 10px; + color: var(--ion-text-color); + } + div.markertexts span.markertext { + z-index: 3; + background-color: var(--yellow-light); + border-style: solid; + border-width: 2px; + border-color: var(--yellow); + position: absolute; + } + span.wrongpart { + background-color: var(--yellow-light); + border-style: solid; + border-width: 2px; + border-color: var(--yellow); + padding: 5px; + border-radius: 10px; + opacity: 0.6; + margin: 5px; + display: inline-block; + } + div.dragitems img.target, + div.draghomes img.target { + position: absolute; + left: -7px; /* This must be half the size of the target image, minus 0.5. */ + top: -7px; /* In other words, this works for a 15x15 cross-hair. */ + } + div.dragitems div.draghome img.target, + div.draghomes div.marker img.target { + display: none; + } + } +} diff --git a/src/addons/qtype/ddmarker/component/ddmarker.ts b/src/addons/qtype/ddmarker/component/ddmarker.ts new file mode 100644 index 000000000..cb61f6d1a --- /dev/null +++ b/src/addons/qtype/ddmarker/component/ddmarker.ts @@ -0,0 +1,188 @@ +// (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, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core'; + +import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { AddonQtypeDdMarkerQuestion } from '../classes/ddmarker'; + +/** + * Component to render a drag-and-drop markers question. + */ +@Component({ + selector: 'addon-qtype-ddmarker', + templateUrl: 'addon-qtype-ddmarker.html', + styleUrls: ['ddmarker.scss'], +}) +export class AddonQtypeDdMarkerComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy { + + @ViewChild('questiontext') questionTextEl?: ElementRef; + + ddQuestion?: AddonQtypeDdMarkerQuestionData; + + protected questionInstance?: AddonQtypeDdMarkerQuestion; + protected dropZones: unknown[] = []; // The drop zones received in the init object of the question. + protected imgSrc?: string; // Background image URL. + protected destroyed = false; + protected textIsRendered = false; + protected ddAreaisRendered = false; + + constructor(elementRef: ElementRef) { + super('AddonQtypeDdMarkerComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.question) { + this.logger.warn('Aborting because of no question received.'); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + this.ddQuestion = this.question; + const element = CoreDomUtils.instance.convertToElement(this.question.html); + + // Get D&D area, form and question text. + const ddArea = element.querySelector('.ddarea'); + const ddForm = element.querySelector('.ddform'); + + this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext'); + if (!ddArea || !ddForm || typeof this.ddQuestion.text == 'undefined') { + this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + // Build the D&D area HTML. + this.ddQuestion.ddArea = ddArea.outerHTML; + + const wrongParts = element.querySelector('.wrongparts'); + if (wrongParts) { + this.ddQuestion.ddArea += wrongParts.outerHTML; + } + this.ddQuestion.ddArea += ddForm.outerHTML; + this.ddQuestion.readOnly = false; + + if (this.ddQuestion.initObjects) { + // Moodle version <= 3.5. + if (typeof this.ddQuestion.initObjects.dropzones != 'undefined') { + this.dropZones = this.ddQuestion.initObjects.dropzones; + } + if (typeof this.ddQuestion.initObjects.readonly != 'undefined') { + this.ddQuestion.readOnly = !!this.ddQuestion.initObjects.readonly; + } + } else if (this.ddQuestion.amdArgs) { + // Moodle version >= 3.6. + let nextIndex = 1; + // Moodle version >= 3.9, imgSrc is not specified, do not advance index. + if (this.ddQuestion.amdArgs[nextIndex] !== undefined && typeof this.ddQuestion.amdArgs[nextIndex] != 'boolean') { + this.imgSrc = this.ddQuestion.amdArgs[nextIndex]; + nextIndex++; + } + + if (typeof this.ddQuestion.amdArgs[nextIndex] != 'undefined') { + this.ddQuestion.readOnly = !!this.ddQuestion.amdArgs[nextIndex]; + } + nextIndex++; + + if (typeof this.ddQuestion.amdArgs[nextIndex] != 'undefined') { + this.dropZones = this.ddQuestion.amdArgs[nextIndex]; + } + } + + this.ddQuestion.loaded = false; + } + + /** + * The question ddArea has been rendered. + */ + ddAreaRendered(): void { + this.ddAreaisRendered = true; + if (this.textIsRendered) { + this.questionRendered(); + } + } + + /** + * The question text has been rendered. + */ + textRendered(): void { + this.textIsRendered = true; + if (this.ddAreaisRendered) { + this.questionRendered(); + } + } + + /** + * The question has been rendered. + */ + protected async questionRendered(): Promise { + if (this.destroyed) { + return; + } + // Download background image (3.6+ sites). + let imgSrc = this.imgSrc; + const site = CoreSites.instance.getCurrentSite(); + + if (this.imgSrc && site?.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(this.imgSrc)) { + imgSrc = await CoreFilepool.instance.getSrcByUrl( + site.id!, + this.imgSrc, + this.component, + this.componentId, + 0, + true, + true, + ); + } + + if (this.questionTextEl) { + await CoreDomUtils.instance.waitForImages(this.questionTextEl.nativeElement); + } + + // Create the instance. + this.questionInstance = new AddonQtypeDdMarkerQuestion( + this.hostElement, + this.ddQuestion!, + !!this.ddQuestion!.readOnly, + this.dropZones, + imgSrc, + ); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.destroyed = true; + this.questionInstance?.destroy(); + } + +} + +/** + * Data for DD Marker question. + */ +export type AddonQtypeDdMarkerQuestionData = AddonModQuizQuestionBasicData & { + loaded?: boolean; + readOnly?: boolean; + ddArea?: string; +}; diff --git a/src/addons/qtype/ddmarker/ddmarker.module.ts b/src/addons/qtype/ddmarker/ddmarker.module.ts new file mode 100644 index 000000000..fed857dfd --- /dev/null +++ b/src/addons/qtype/ddmarker/ddmarker.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeDdMarkerComponent } from './component/ddmarker'; +import { AddonQtypeDdMarkerHandler } from './services/handlers/ddmarker'; + +@NgModule({ + declarations: [ + AddonQtypeDdMarkerComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeDdMarkerHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeDdMarkerComponent, + ], +}) +export class AddonQtypeDdMarkerModule {} diff --git a/src/addons/qtype/ddmarker/services/handlers/ddmarker.ts b/src/addons/qtype/ddmarker/services/handlers/ddmarker.ts new file mode 100644 index 000000000..b602dc2c2 --- /dev/null +++ b/src/addons/qtype/ddmarker/services/handlers/ddmarker.ts @@ -0,0 +1,155 @@ +// (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 { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { CoreQuestionHelper, CoreQuestionQuestion } from '@features/question/services/question-helper'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeDdMarkerComponent } from '../../component/ddmarker'; + +/** + * Handler to support drag-and-drop markers question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeDdMarkerHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeDdMarker'; + type = 'qtype_ddmarker'; + + /** + * 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 { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param question The question to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonQtypeDdMarkerComponent; + } + + /** + * 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, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): number { + // If 1 dragitem is set we assume the answer is complete (like Moodle does). + for (const name in answers) { + if (answers[name]) { + return 1; + } + } + + return 0; + } + + /** + * 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; + } + + /** + * 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 { + return this.isCompleteResponse(question, answers, component, componentId); + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers); + } + + /** + * 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 treatedQuestion: CoreQuestionQuestion = question; + + CoreQuestionHelper.instance.extractQuestionScripts(treatedQuestion, usageId); + + if (treatedQuestion.amdArgs && typeof treatedQuestion.amdArgs[1] == 'string') { + // Moodle 3.6+. + return [{ + fileurl: treatedQuestion.amdArgs[1], + }]; + } + + return []; + } + +} + +export class AddonQtypeDdMarkerHandler extends makeSingleton(AddonQtypeDdMarkerHandlerService) {} diff --git a/src/addons/qtype/ddwtos/classes/ddwtos.ts b/src/addons/qtype/ddwtos/classes/ddwtos.ts new file mode 100644 index 000000000..93ffb9d5d --- /dev/null +++ b/src/addons/qtype/ddwtos/classes/ddwtos.ts @@ -0,0 +1,585 @@ +// (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 { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLogger } from '@singletons/logger'; +import { AddonModQuizDdwtosQuestionData } from '../component/ddwtos'; + +/** + * Class to make a question of ddwtos type work. + */ +export class AddonQtypeDdwtosQuestion { + + protected logger: CoreLogger; + protected nextDragItemNo = 1; + protected selectors!: AddonQtypeDdwtosQuestionCSSSelectors; // Result of cssSelectors. + protected placed: {[no: number]: number} = {}; // Map that relates drag elements numbers with drop zones numbers. + protected selected?: HTMLElement; // Selected element (being "dragged"). + protected resizeFunction?: () => void; + + /** + * Create the instance. + * + * @param logger Logger provider. + * @param domUtils Dom Utils provider. + * @param container The container HTMLElement of the question. + * @param question The question instance. + * @param readOnly Whether it's read only. + * @param inputIds Ids of the inputs of the question (where the answers will be stored). + */ + constructor( + protected container: HTMLElement, + protected question: AddonModQuizDdwtosQuestionData, + protected readOnly: boolean, + protected inputIds: string[], + ) { + this.logger = CoreLogger.getInstance('AddonQtypeDdwtosQuestion'); + + this.initializer(); + } + + /** + * Clone a drag item and add it to the drag container. + * + * @param dragHome Item to clone + */ + cloneDragItem(dragHome: HTMLElement): void { + const drag = dragHome.cloneNode(true); + + drag.classList.remove('draghome'); + drag.classList.add('drag'); + drag.classList.add('no' + this.nextDragItemNo); + this.nextDragItemNo++; + drag.setAttribute('tabindex', '0'); + + drag.style.visibility = 'visible'; + drag.style.position = 'absolute'; + + const container = this.container.querySelector(this.selectors.dragContainer()); + container?.appendChild(drag); + + if (!this.readOnly) { + this.makeDraggable(drag); + } + } + + /** + * Clone the 'drag homes'. + * Invisible 'drag homes' are output in the question. These have the same properties as the drag items but are invisible. + * We clone these invisible elements to make the actual drag items. + */ + cloneDragItems(): void { + const dragHomes = Array.from(this.container.querySelectorAll(this.selectors.dragHomes())); + for (let x = 0; x < dragHomes.length; x++) { + this.cloneDragItemsForOneChoice(dragHomes[x]); + } + } + + /** + * Clone a certain 'drag home'. If it's an "infinite" drag, clone it several times. + * + * @param dragHome Element to clone. + */ + cloneDragItemsForOneChoice(dragHome: HTMLElement): void { + if (dragHome.classList.contains('infinite')) { + const groupNo = this.getGroup(dragHome) ?? -1; + const noOfDrags = this.container.querySelectorAll(this.selectors.dropsInGroup(groupNo)).length; + + for (let x = 0; x < noOfDrags; x++) { + this.cloneDragItem(dragHome); + } + } else { + this.cloneDragItem(dragHome); + } + } + + /** + * Deselect all drags. + */ + deselectDrags(): void { + // Remove the selected class from all drags. + const drags = Array.from(this.container.querySelectorAll(this.selectors.drags())); + drags.forEach((drag) => { + drag.classList.remove('selected'); + }); + this.selected = undefined; + } + + /** + * Function to call when the instance is no longer needed. + */ + destroy(): void { + if (this.resizeFunction) { + window.removeEventListener('resize', this.resizeFunction); + } + } + + /** + * Get the choice number of an element. It is extracted from the classes. + * + * @param node Element to check. + * @return Choice number. + */ + getChoice(node: HTMLElement | null): number | undefined { + return this.getClassnameNumericSuffix(node, 'choice'); + } + + /** + * Get the number in a certain class name of an element. + * + * @param node The element to check. + * @param prefix Prefix of the class to check. + * @return The number in the class. + */ + getClassnameNumericSuffix(node: HTMLElement | null, prefix: string): number | undefined { + if (node?.classList.length) { + const patt1 = new RegExp('^' + prefix + '([0-9])+$'); + const patt2 = new RegExp('([0-9])+$'); + + for (let index = 0; index < node.classList.length; index++) { + if (patt1.test(node.classList[index])) { + const match = patt2.exec(node.classList[index]); + + return Number(match?.[0]); + } + } + } + + this.logger.warn('Prefix "' + prefix + '" not found in class names.'); + } + + /** + * Get the group number of an element. It is extracted from the classes. + * + * @param node Element to check. + * @return Group number. + */ + getGroup(node: HTMLElement | null): number | undefined { + return this.getClassnameNumericSuffix(node, 'group'); + } + + /** + * Get the number of an element ('no'). It is extracted from the classes. + * + * @param node Element to check. + * @return Number. + */ + getNo(node: HTMLElement | null): number | undefined { + return this.getClassnameNumericSuffix(node, 'no'); + } + + /** + * Get the place number of an element. It is extracted from the classes. + * + * @param node Element to check. + * @return Place number. + */ + getPlace(node: HTMLElement | null): number | undefined { + return this.getClassnameNumericSuffix(node, 'place'); + } + + /** + * Initialize the question. + */ + async initializer(): Promise { + this.selectors = new AddonQtypeDdwtosQuestionCSSSelectors(); + + const container = this.container.querySelector(this.selectors.topNode()); + if (this.readOnly) { + container.classList.add('readonly'); + } else { + container.classList.add('notreadonly'); + } + + // Wait for the elements to be ready. + await this.waitForReady(); + + this.setPaddingSizesAll(); + this.cloneDragItems(); + this.initialPlaceOfDragItems(); + this.makeDropZones(); + + this.positionDragItems(); + + this.resizeFunction = this.positionDragItems.bind(this); + window.addEventListener('resize', this.resizeFunction!); + } + + /** + * Initialize drag items, putting them in their initial place. + */ + initialPlaceOfDragItems(): void { + const drags = Array.from(this.container.querySelectorAll(this.selectors.drags())); + + // Add the class 'unplaced' to all elements. + drags.forEach((drag) => { + drag.classList.add('unplaced'); + }); + + this.placed = {}; + for (const placeNo in this.inputIds) { + const inputId = this.inputIds[placeNo]; + const inputNode = this.container.querySelector('input#' + inputId); + const choiceNo = Number(inputNode?.getAttribute('value')); + + if (choiceNo !== 0 && !isNaN(choiceNo)) { + const drop = this.container.querySelector(this.selectors.dropForPlace(parseInt(placeNo, 10) + 1)); + const groupNo = this.getGroup(drop) ?? -1; + const drag = this.container.querySelector( + this.selectors.unplacedDragsForChoiceInGroup(choiceNo, groupNo), + ); + + this.placeDragInDrop(drag, drop); + this.positionDragItem(drag); + } + } + } + + /** + * Make an element "draggable". In the mobile app, items are "dragged" using tap and drop. + * + * @param drag Element. + */ + makeDraggable(drag: HTMLElement): void { + drag.addEventListener('click', () => { + if (drag.classList.contains('selected')) { + this.deselectDrags(); + } else { + this.selectDrag(drag); + } + }); + } + + /** + * Convert an element into a drop zone. + * + * @param drop Element. + */ + makeDropZone(drop: HTMLElement): void { + drop.addEventListener('click', () => { + const drag = this.selected; + if (!drag) { + // No element selected, nothing to do. + return false; + } + + // Place it only if the same group is selected. + if (this.getGroup(drag) === this.getGroup(drop)) { + this.placeDragInDrop(drag, drop); + this.deselectDrags(); + this.positionDragItem(drag); + } + }); + } + + /** + * Create all drop zones. + */ + makeDropZones(): void { + if (this.readOnly) { + return; + } + + // Create all the drop zones. + const drops = Array.from(this.container.querySelectorAll(this.selectors.drops())); + drops.forEach((drop) => { + this.makeDropZone(drop); + }); + + // If home answer zone is clicked, return drag home. + const home = this.container.querySelector(this.selectors.topNode() + ' .answercontainer'); + + home.addEventListener('click', () => { + const drag = this.selected; + if (!drag) { + // No element selected, nothing to do. + return; + } + + // Not placed yet, deselect. + if (drag.classList.contains('unplaced')) { + this.deselectDrags(); + + return; + } + + // Remove, deselect and move back home in this order. + this.removeDragFromDrop(drag); + this.deselectDrags(); + this.positionDragItem(drag); + }); + } + + /** + * Set the width and height of an element. + * + * @param node Element. + * @param width Width to set. + * @param height Height to set. + */ + protected padToWidthHeight(node: HTMLElement, width: number, height: number): void { + node.style.width = width + 'px'; + node.style.height = height + 'px'; + // Originally lineHeight was set as height to center the text but it comes on too height lines on multiline elements. + } + + /** + * Place a draggable element inside a drop zone. + * + * @param drag Draggable element. + * @param drop Drop zone. + */ + placeDragInDrop(drag: HTMLElement | null, drop: HTMLElement | null): void { + if (!drop) { + return; + } + + const placeNo = this.getPlace(drop) ?? -1; + const inputId = this.inputIds[placeNo - 1]; + const inputNode = this.container.querySelector('input#' + inputId); + + // Set the value of the drag element in the input of the drop zone. + if (drag !== null) { + inputNode?.setAttribute('value', String(this.getChoice(drag))); + } else { + inputNode?.setAttribute('value', '0'); + } + + // Remove the element from the "placed" map if it's there. + for (const alreadyThereDragNo in this.placed) { + if (this.placed[alreadyThereDragNo] === placeNo) { + delete this.placed[alreadyThereDragNo]; + } + } + + if (drag !== null) { + // Add the element in the "placed" map. + this.placed[this.getNo(drag) ?? -1] = placeNo; + } + } + + /** + * Position a drag element in the right drop zone or in the home zone. + * + * @param drag Drag element. + */ + positionDragItem(drag: HTMLElement | null): void { + if (!drag) { + return; + } + + let position; + + const placeNo = this.placed[this.getNo(drag) ?? -1]; + if (!placeNo) { + // Not placed, put it in home zone. + const groupNo = this.getGroup(drag) ?? -1; + const choiceNo = this.getChoice(drag) ?? -1; + + position = CoreDomUtils.instance.getElementXY( + this.container, + this.selectors.dragHome(groupNo, choiceNo), + 'answercontainer', + ); + drag.classList.add('unplaced'); + } else { + // Get the drop zone position. + position = CoreDomUtils.instance.getElementXY( + this.container, + this.selectors.dropForPlace(placeNo), + 'addon-qtype-ddwtos-container', + ); + drag.classList.remove('unplaced'); + } + + if (position) { + drag.style.left = position[0] + 'px'; + drag.style.top = position[1] + 'px'; + } + } + + /** + * Postition, or reposition, all the drag items. They're placed in the right drop zone or in the home zone. + */ + positionDragItems(): void { + const drags = Array.from(this.container.querySelectorAll(this.selectors.drags())); + drags.forEach((drag) => { + this.positionDragItem(drag); + }); + } + + /** + * Wait for the drag items to have an offsetParent. For some reason it takes a while. + * + * @param retries Number of times this has been retried. + * @return Promise resolved when ready or if it took too long to load. + */ + protected async waitForReady(retries: number = 0): Promise { + const drag = Array.from(this.container.querySelectorAll(this.selectors.drags()))[0]; + if (drag?.offsetParent || retries >= 10) { + // Ready or too many retries, stop. + return; + } + + const deferred = CoreUtils.instance.promiseDefer(); + + setTimeout(async () => { + try { + await this.waitForReady(retries + 1); + } finally { + deferred.resolve(); + } + }, 20); + + return deferred.promise; + } + + /** + * Remove a draggable element from a drop zone. + * + * @param drag The draggable element. + */ + removeDragFromDrop(drag: HTMLElement): void { + const placeNo = this.placed[this.getNo(drag) ?? -1]; + const drop = this.container.querySelector(this.selectors.dropForPlace(placeNo)); + + this.placeDragInDrop(null, drop); + } + + /** + * Select a certain element as being "dragged". + * + * @param drag Element. + */ + selectDrag(drag: HTMLElement): void { + // Deselect previous drags, only 1 can be selected. + this.deselectDrags(); + + this.selected = drag; + drag.classList.add('selected'); + } + + /** + * Set the padding size for all groups. + */ + setPaddingSizesAll(): void { + for (let groupNo = 1; groupNo <= 8; groupNo++) { + this.setPaddingSizeForGroup(groupNo); + } + } + + /** + * Set the padding size for a certain group. + * + * @param groupNo Group number. + */ + setPaddingSizeForGroup(groupNo: number): void { + const groupItems = Array.from(this.container.querySelectorAll(this.selectors.dragHomesGroup(groupNo))); + + if (!groupItems.length) { + return; + } + + let maxWidth = 0; + let maxHeight = 0; + + // Find max height and width. + groupItems.forEach((item) => { + item.innerHTML = CoreTextUtils.instance.decodeHTML(item.innerHTML); + maxWidth = Math.max(maxWidth, Math.ceil(item.offsetWidth)); + maxHeight = Math.max(maxHeight, Math.ceil(item.offsetHeight)); + }); + + maxWidth += 8; + maxHeight += 5; + groupItems.forEach((item) => { + this.padToWidthHeight(item, maxWidth, maxHeight); + }); + + const dropsGroup = Array.from(this.container.querySelectorAll(this.selectors.dropsGroup(groupNo))); + dropsGroup.forEach((item) => { + this.padToWidthHeight(item, maxWidth + 2, maxHeight + 2); + }); + } + +} + +/** + * Set of functions to get the CSS selectors. + */ +export class AddonQtypeDdwtosQuestionCSSSelectors { + + topNode(): string { + return '.addon-qtype-ddwtos-container'; + } + + dragContainer(): string { + return this.topNode() + ' div.drags'; + } + + drags(): string { + return this.dragContainer() + ' span.drag'; + } + + drag(no: number): string { + return this.drags() + `.no${no}`; + } + + dragsInGroup(groupNo: number): string { + return this.drags() + `.group${groupNo}`; + } + + unplacedDragsInGroup(groupNo: number): string { + return this.dragsInGroup(groupNo) + '.unplaced'; + } + + dragsForChoiceInGroup(choiceNo: number, groupNo: number): string { + return this.dragsInGroup(groupNo) + `.choice${choiceNo}`; + } + + unplacedDragsForChoiceInGroup(choiceNo: number, groupNo: number): string { + return this.unplacedDragsInGroup(groupNo) + `.choice${choiceNo}`; + } + + drops(): string { + return this.topNode() + ' span.drop'; + } + + dropForPlace(placeNo: number): string { + return this.drops() + `.place${placeNo}`; + } + + dropsInGroup(groupNo: number): string { + return this.drops() + `.group${groupNo}`; + } + + dragHomes(): string { + return this.topNode() + ' span.draghome'; + } + + dragHomesGroup(groupNo: number): string { + return this.topNode() + ` .draggrouphomes${groupNo} span.draghome`; + } + + dragHome(groupNo: number, choiceNo: number): string { + return this.topNode() + ` .draggrouphomes${groupNo} span.draghome.choice${choiceNo}`; + } + + dropsGroup(groupNo: number): string { + return this.topNode() + ` span.drop.group${groupNo}`; + } + +} diff --git a/src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html b/src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html new file mode 100644 index 000000000..113ff3572 --- /dev/null +++ b/src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html @@ -0,0 +1,23 @@ + + + + + + + + + + {{ 'core.question.howtodraganddrop' | translate }} + + + + + + +
+
+
+
diff --git a/src/addons/qtype/ddwtos/component/ddwtos.scss b/src/addons/qtype/ddwtos/component/ddwtos.scss new file mode 100644 index 000000000..a1643e2e9 --- /dev/null +++ b/src/addons/qtype/ddwtos/component/ddwtos.scss @@ -0,0 +1,132 @@ +@import "~core/features/question/question"; + +// Style ddwtos content a bit. Almost all these styles are copied from Moodle. +:host { + .addon-qtype-ddwtos-container { + min-height: 80px; // To display the loading. + } + + core-format-text ::ng-deep, .drags ::ng-deep { + .qtext { + margin-bottom: 0.5em; + display: block; + } + + .draghome { + margin-bottom: 1em; + max-width: calc(100%); + } + + .answertext { + margin-bottom: 0.5em; + } + + .drop { + display: inline-block; + text-align: center; + border: 1px solid var(--gray-darker); + margin-bottom: 2px; + border-radius: 5px; + cursor: pointer; + } + .draghome, .drag { + display: inline-block; + text-align: center; + background: transparent; + border: 0; + white-space: normal; + overflow: visible; + word-wrap: break-word; + } + .draghome, .drag.unplaced{ + border: 1px solid var(--gray-darker); + } + .draghome { + visibility: hidden; + } + .drag { + z-index: 2; + border-radius: 5px; + line-height: 25px; + cursor: pointer; + } + .drag.selected { + z-index: 3; + box-shadow: var(--core-dd-question-selected-shadow); + } + + .drop.selected { + border-color: var(--yellow-light); + box-shadow: 0 0 5px 5px var(--yellow-light); + } + + &.notreadonly .drag, + &.notreadonly .draghome, + &.notreadonly .drop, + &.notreadonly .answercontainer { + cursor: pointer; + border-radius: 5px; + } + + &.readonly .drag, + &.readonly .draghome, + &.readonly .drop, + &.readonly .answercontainer { + cursor: default; + } + + span.incorrect { + background-color: var(--red-light); + // @include darkmode() { + // background-color: $red-dark; + // } + } + span.correct { + background-color: var(--green-light); + // @include darkmode() { + // background-color: $green-dark; + // } + } + + @for $i from 0 to length($core-dd-question-colors) { + .group#{$i + 1} { + background: nth($core-dd-question-colors, $i + 1); + color: var(--ion-text-color); + } + } + + .group2 { + border-radius: 10px 0 0 0; + } + .group3 { + border-radius: 0 10px 0 0; + } + .group4 { + border-radius: 0 0 10px 0; + } + .group5 { + border-radius: 0 0 0 10px; + } + .group6 { + border-radius: 0 10px 10px 0; + } + .group7 { + border-radius: 10px 0 0 10px; + } + .group8 { + border-radius: 10px 10px 10px 10px; + } + + sub, sup { + font-size: 80%; + position: relative; + vertical-align: baseline; + } + sup { + top: -0.4em; + } + sub { + bottom: -0.2em; + } + } +} diff --git a/src/addons/qtype/ddwtos/component/ddwtos.ts b/src/addons/qtype/ddwtos/component/ddwtos.ts new file mode 100644 index 000000000..3d6f8b3a4 --- /dev/null +++ b/src/addons/qtype/ddwtos/component/ddwtos.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 { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core'; + +import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreDomUtils } from '@services/utils/dom'; +import { AddonQtypeDdwtosQuestion } from '../classes/ddwtos'; + +/** + * Component to render a drag-and-drop words into sentences question. + */ +@Component({ + selector: 'addon-qtype-ddwtos', + templateUrl: 'addon-qtype-ddwtos.html', + styleUrls: ['ddwtos.scss'], +}) +export class AddonQtypeDdwtosComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy { + + @ViewChild('questiontext') questionTextEl?: ElementRef; + + ddQuestion?: AddonModQuizDdwtosQuestionData; + + protected questionInstance?: AddonQtypeDdwtosQuestion; + protected inputIds: string[] = []; // Ids of the inputs of the question (where the answers will be stored). + protected destroyed = false; + protected textIsRendered = false; + protected answerAreRendered = false; + + constructor(elementRef: ElementRef) { + super('AddonQtypeDdwtosComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.question) { + this.logger.warn('Aborting because of no question received.'); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + this.ddQuestion = this.question; + const element = CoreDomUtils.instance.convertToElement(this.ddQuestion.html); + + // Replace Moodle's correct/incorrect and feedback classes with our own. + CoreQuestionHelper.instance.replaceCorrectnessClasses(element); + CoreQuestionHelper.instance.replaceFeedbackClasses(element); + + // Treat the correct/incorrect icons. + CoreQuestionHelper.instance.treatCorrectnessIcons(element); + + const answerContainer = element.querySelector('.answercontainer'); + if (!answerContainer) { + this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + this.ddQuestion.readOnly = answerContainer.classList.contains('readonly'); + this.ddQuestion.answers = answerContainer.outerHTML; + + this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext'); + if (typeof this.ddQuestion.text == 'undefined') { + this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + // Get the inputs where the answers will be stored and add them to the question text. + const inputEls = Array.from(element.querySelectorAll('input[type="hidden"]:not([name*=sequencecheck])')); + + inputEls.forEach((inputEl) => { + this.ddQuestion!.text += inputEl.outerHTML; + const id = inputEl.getAttribute('id'); + if (id) { + this.inputIds.push(id); + } + }); + + this.ddQuestion.loaded = false; + } + + /** + * The question answers have been rendered. + */ + answersRendered(): void { + this.answerAreRendered = true; + if (this.textIsRendered) { + this.questionRendered(); + } + } + + /** + * The question text has been rendered. + */ + textRendered(): void { + this.textIsRendered = true; + if (this.answerAreRendered) { + this.questionRendered(); + } + } + + /** + * The question has been rendered. + */ + protected async questionRendered(): Promise { + if (this.destroyed) { + return; + } + + if (this.questionTextEl) { + await CoreDomUtils.instance.waitForImages(this.questionTextEl.nativeElement); + } + + // Create the instance. + this.questionInstance = new AddonQtypeDdwtosQuestion( + this.hostElement, + this.ddQuestion!, + !!this.ddQuestion!.readOnly, + this.inputIds, + ); + + CoreQuestionHelper.instance.treatCorrectnessIconsClicks( + this.hostElement, + this.component, + this.componentId, + this.contextLevel, + this.contextInstanceId, + this.courseId, + ); + + this.ddQuestion!.loaded = true; + + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.destroyed = true; + this.questionInstance?.destroy(); + } + +} + +/** + * Data for DD WtoS question. + */ +export type AddonModQuizDdwtosQuestionData = AddonModQuizQuestionBasicData & { + loaded?: boolean; + readOnly?: boolean; + answers?: string; +}; diff --git a/src/addons/qtype/ddwtos/ddwtos.module.ts b/src/addons/qtype/ddwtos/ddwtos.module.ts new file mode 100644 index 000000000..2aeddc069 --- /dev/null +++ b/src/addons/qtype/ddwtos/ddwtos.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeDdwtosComponent } from './component/ddwtos'; +import { AddonQtypeDdwtosHandler } from './services/handlers/ddwtos'; + +@NgModule({ + declarations: [ + AddonQtypeDdwtosComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeDdwtosHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeDdwtosComponent, + ], +}) +export class AddonQtypeDdwtosModule {} diff --git a/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts b/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts new file mode 100644 index 000000000..20999b302 --- /dev/null +++ b/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts @@ -0,0 +1,134 @@ +// (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 { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeDdwtosComponent } from '../../component/ddwtos'; + +/** + * Handler to support drag-and-drop words into sentences question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeDdwtosHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeDdwtos'; + type = 'qtype_ddwtos'; + + /** + * 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 { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param question The question to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonQtypeDdwtosComponent; + } + + /** + * 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, + ): number { + for (const name in answers) { + const value = answers[name]; + if (!value || value === '0') { + return 0; + } + } + + return 1; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param 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, + ): number { + for (const name in answers) { + const value = answers[name]; + if (value && value !== '0') { + return 1; + } + } + + return 0; + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers); + } + +} + +export class AddonQtypeDdwtosHandler extends makeSingleton(AddonQtypeDdwtosHandlerService) {} diff --git a/src/addons/qtype/description/component/addon-qtype-description.html b/src/addons/qtype/description/component/addon-qtype-description.html new file mode 100644 index 000000000..d323be103 --- /dev/null +++ b/src/addons/qtype/description/component/addon-qtype-description.html @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/src/addons/qtype/description/component/description.ts b/src/addons/qtype/description/component/description.ts new file mode 100644 index 000000000..5422b1ff5 --- /dev/null +++ b/src/addons/qtype/description/component/description.ts @@ -0,0 +1,53 @@ +// (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, OnInit, ElementRef } from '@angular/core'; + +import { CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; + +/** + * Component to render a description question. + */ +@Component({ + selector: 'addon-qtype-description', + templateUrl: 'addon-qtype-description.html', +}) +export class AddonQtypeDescriptionComponent extends CoreQuestionBaseComponent implements OnInit { + + seenInput?: { name: string; value: string }; + + constructor(elementRef: ElementRef) { + super('AddonQtypeDescriptionComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + const questionEl = this.initComponent(); + if (!questionEl) { + return; + } + + // Get the "seen" hidden input. + const input = questionEl.querySelector('input[type="hidden"][name*=seen]'); + if (input) { + this.seenInput = { + name: input.name, + value: input.value, + }; + } + } + +} diff --git a/src/addons/qtype/description/description.module.ts b/src/addons/qtype/description/description.module.ts new file mode 100644 index 000000000..f03cf74c8 --- /dev/null +++ b/src/addons/qtype/description/description.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeDescriptionComponent } from './component/description'; +import { AddonQtypeDescriptionHandler } from './services/handlers/description'; + +@NgModule({ + declarations: [ + AddonQtypeDescriptionComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeDescriptionHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeDescriptionComponent, + ], +}) +export class AddonQtypeDescriptionModule {} diff --git a/src/addons/qtype/description/services/handlers/description.ts b/src/addons/qtype/description/services/handlers/description.ts new file mode 100644 index 000000000..52b359f24 --- /dev/null +++ b/src/addons/qtype/description/services/handlers/description.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 { Injectable, Type } from '@angular/core'; + +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeDescriptionComponent } from '../../component/description'; + +/** + * Handler to support description question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeDescriptionHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeDescription'; + type = 'qtype_description'; + + /** + * 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(): string { + return 'informationitem'; + } + + /** + * 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(): Type { + return AddonQtypeDescriptionComponent; + } + + /** + * 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; + } + + /** + * 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(): boolean { + // Descriptions don't have any answer so we'll always treat them as valid. + return true; + } + +} + +export class AddonQtypeDescriptionHandler extends makeSingleton(AddonQtypeDescriptionHandlerService) {} diff --git a/src/addons/qtype/essay/component/addon-qtype-essay.html b/src/addons/qtype/essay/component/addon-qtype-essay.html new file mode 100644 index 000000000..85cf617cc --- /dev/null +++ b/src/addons/qtype/essay/component/addon-qtype-essay.html @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.question.errorembeddedfilesnotsupportedinsite' | translate }} + + + + + + + + + + + + + + + + + + + + + {{ 'core.question.errorattachmentsnotsupportedinsite' | translate }} + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/qtype/essay/component/essay.ts b/src/addons/qtype/essay/component/essay.ts new file mode 100644 index 000000000..a3c0d3b4d --- /dev/null +++ b/src/addons/qtype/essay/component/essay.ts @@ -0,0 +1,96 @@ +// (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, OnInit, ElementRef } from '@angular/core'; +import { FormBuilder, FormControl } from '@angular/forms'; +import { FileEntry } from '@ionic-native/file/ngx'; + +import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { AddonModQuizEssayQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreWSExternalFile } from '@services/ws'; +import { CoreFileSession } from '@services/file-session'; +import { CoreQuestion } from '@features/question/services/question'; +/** + * Component to render an essay question. + */ +@Component({ + selector: 'addon-qtype-essay', + templateUrl: 'addon-qtype-essay.html', +}) +export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implements OnInit { + + formControl?: FormControl; + attachments?: (CoreWSExternalFile | FileEntry)[]; + uploadFilesSupported = false; + essayQuestion?: AddonModQuizEssayQuestion; + + constructor(elementRef: ElementRef, protected fb: FormBuilder) { + super('AddonQtypeEssayComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.uploadFilesSupported = typeof this.question?.responsefileareas != 'undefined'; + this.initEssayComponent(this.review); + this.essayQuestion = this.question; + + this.formControl = this.fb.control(this.essayQuestion?.textarea?.text); + + if (this.essayQuestion?.allowsAttachments && this.uploadFilesSupported && !this.review) { + this.loadAttachments(); + } + } + + /** + * Load attachments. + * + * @return Promise resolved when done. + */ + async loadAttachments(): Promise { + if (this.offlineEnabled && this.essayQuestion?.localAnswers?.attachments_offline) { + + const attachmentsData: CoreFileUploaderStoreFilesResult = CoreTextUtils.instance.parseJSON( + this.essayQuestion.localAnswers.attachments_offline, + { + online: [], + offline: 0, + }, + ); + let offlineFiles: FileEntry[] = []; + + if (attachmentsData.offline) { + offlineFiles = await CoreQuestionHelper.instance.getStoredQuestionFiles( + this.essayQuestion, + this.component || '', + this.componentId || -1, + ); + } + + this.attachments = [...attachmentsData.online, ...offlineFiles]; + } else { + this.attachments = Array.from(CoreQuestionHelper.instance.getResponseFileAreaFiles(this.question!, 'attachments')); + } + + CoreFileSession.instance.setFiles( + this.component || '', + CoreQuestion.instance.getQuestionComponentId(this.question!, this.componentId || -1), + this.attachments, + ); + } + +} diff --git a/src/addons/qtype/essay/essay.module.ts b/src/addons/qtype/essay/essay.module.ts new file mode 100644 index 000000000..7e394279f --- /dev/null +++ b/src/addons/qtype/essay/essay.module.ts @@ -0,0 +1,45 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeEssayHandler } from './services/handlers/essay'; +import { AddonQtypeEssayComponent } from './component/essay'; + +@NgModule({ + declarations: [ + AddonQtypeEssayComponent, + ], + imports: [ + CoreSharedModule, + CoreEditorComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeEssayHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeEssayComponent, + ], +}) +export class AddonQtypeEssayModule {} diff --git a/src/addons/qtype/essay/services/handlers/essay.ts b/src/addons/qtype/essay/services/handlers/essay.ts new file mode 100644 index 000000000..bc333e5bd --- /dev/null +++ b/src/addons/qtype/essay/services/handlers/essay.ts @@ -0,0 +1,464 @@ +// (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 { FileEntry } from '@ionic-native/file/ngx'; + +import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; +import { AddonModQuizEssayQuestion } from '@features/question/classes/base-question-component'; +import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreFileSession } from '@services/file-session'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeEssayComponent } from '../../component/essay'; + +/** + * Handler to support essay question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeEssayHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeEssay'; + type = 'qtype_essay'; + + /** + * 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. + */ + clearTmpData(question: CoreQuestionQuestionParsed, component: string, componentId: string | number): void { + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const files = CoreFileSession.instance.getFiles(component, questionComponentId); + + // Clear the files in session for this question. + CoreFileSession.instance.clearFiles(component, questionComponentId); + + // Now delete the local files from the tmp folder. + CoreFileUploader.instance.clearTmpFiles(files); + } + + /** + * 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 Promise resolved when done. + */ + deleteOfflineData( + question: CoreQuestionQuestionParsed, + component: string, + componentId: string | number, + siteId?: string, + ): Promise { + return CoreQuestionHelper.instance.deleteStoredQuestionFiles(question, component, componentId, siteId); + } + + /** + * 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): CoreWSExternalFile[] { + if (!question.responsefileareas) { + return []; + } + + return question.responsefileareas.reduce((urlsList, area) => urlsList.concat(area.files || []), []); + } + + /** + * Check whether the question allows text and/or attachments. + * + * @param question Question to check. + * @return Allowed options. + */ + protected getAllowedOptions(question: CoreQuestionQuestionParsed): { text: boolean; attachments: boolean } { + if (question.parsedSettings) { + return { + text: question.parsedSettings.responseformat != 'noinline', + attachments: question.parsedSettings.attachments != '0', + }; + } + + const element = CoreDomUtils.instance.convertToElement(question.html); + + return { + text: !!element.querySelector('textarea[name*=_answer]'), + attachments: !!element.querySelector('div[id*=filemanager]'), + }; + } + + /** + * 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(): string { + return 'manualgraded'; + } + + /** + * 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(): Type { + return AddonQtypeEssayComponent; + } + + /** + * 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 { + const element = CoreDomUtils.instance.convertToElement(question.html); + const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; + + if (!uploadFilesSupported && element.querySelector('div[id*=filemanager]')) { + // The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question. + return 'core.question.errorattachmentsnotsupportedinsite'; + } + + if (!uploadFilesSupported && CoreQuestionHelper.instance.hasDraftFileUrls(element.innerHTML)) { + return 'core.question.errorembeddedfilesnotsupportedinsite'; + } + } + + /** + * 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 hasTextAnswer = !!answers.answer; + const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; + const allowedOptions = this.getAllowedOptions(question); + + if (!allowedOptions.attachments) { + return hasTextAnswer ? 1 : 0; + } + + if (!uploadFilesSupported || !question.parsedSettings) { + // We can't know if the attachments are required or if the user added any in web. + return -1; + } + + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + + if (!allowedOptions.text) { + return attachments && attachments.length >= Number(question.parsedSettings.attachmentsrequired) ? 1 : 0; + } + + return ((hasTextAnswer || question.parsedSettings.responserequired == '0') && + (attachments && attachments.length >= Number(question.parsedSettings.attachmentsrequired))) ? 1 : 0; + } + + /** + * 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; + } + + /** + * 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 { + if (typeof question.responsefileareas == 'undefined') { + return -1; + } + + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + + // Determine if the given response has online text or attachments. + return (answers.answer && answers.answer !== '') || (attachments && attachments.length > 0) ? 1 : 0; + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + ): boolean { + const uploadFilesSupported = typeof question.responsefileareas != 'undefined'; + const allowedOptions = this.getAllowedOptions(question); + + // First check the inline text. + const answerIsEqual = allowedOptions.text ? + CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true; + + if (!allowedOptions.attachments || !uploadFilesSupported || !answerIsEqual) { + // No need to check attachments. + return answerIsEqual; + } + + // Check attachments now. + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments'); + + return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments); + } + + /** + * Prepare and add to answers the data to send to server based in the input. + * + * @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. + */ + async prepareAnswers( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + offline: boolean, + component: string, + componentId: string | number, + siteId?: string, + ): Promise { + + const element = CoreDomUtils.instance.convertToElement(question.html); + const attachmentsInput = element.querySelector('.attachments input[name*=_attachments]'); + + // Search the textarea to get its name. + const textarea = element.querySelector('textarea[name*=_answer]'); + + if (textarea && typeof answers[textarea.name] != 'undefined') { + await this.prepareTextAnswer(question, answers, textarea, siteId); + } + + if (attachmentsInput) { + await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId); + } + } + + /** + * Prepare attachments. + * + * @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 attachmentsInput The HTML input containing the draft ID for attachments. + * @param siteId Site ID. If not defined, current site. + * @return Return a promise resolved when done if async, void if sync. + */ + async prepareAttachments( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + offline: boolean, + component: string, + componentId: string | number, + attachmentsInput: HTMLInputElement, + siteId?: string, + ): Promise { + + // Treat attachments if any. + const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId); + const attachments = CoreFileSession.instance.getFiles(component, questionComponentId); + const draftId = Number(attachmentsInput.value); + + if (offline) { + // Get the folder where to store the files. + const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId); + + const result = await CoreFileUploader.instance.storeFilesToUpload(folderPath, attachments); + + // Store the files in the answers. + answers[attachmentsInput.name + '_offline'] = JSON.stringify(result); + } else { + // Check if any attachment was deleted. + const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments'); + const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachments); + + if (filesToDelete.length > 0) { + // Delete files. + await CoreFileUploader.instance.deleteDraftFiles(draftId, filesToDelete, siteId); + } + + await CoreFileUploader.instance.uploadFiles(draftId, attachments, 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 Promise resolved when done. + */ + async prepareSyncData( + question: CoreQuestionQuestionParsed, + answers: CoreQuestionsAnswers, + component: string, + componentId: string | number, + siteId?: string, + ): Promise { + + const element = CoreDomUtils.instance.convertToElement(question.html); + const attachmentsInput = element.querySelector('.attachments input[name*=_attachments]'); + + if (attachmentsInput) { + // Update the draft ID, the stored one could no longer be valid. + answers.attachments = attachmentsInput.value; + } + + if (!answers || !answers.attachments_offline) { + return; + } + + const attachmentsData: CoreFileUploaderStoreFilesResult = CoreTextUtils.instance.parseJSON( + answers.attachments_offline, + { + online: [], + offline: 0, + }, + ); + delete answers.attachments_offline; + + // Check if any attachment was deleted. + const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments'); + const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachmentsData.online); + + if (filesToDelete.length > 0) { + // Delete files. + await CoreFileUploader.instance.deleteDraftFiles(Number(answers.attachments), filesToDelete, siteId); + } + + if (!attachmentsData.offline) { + return; + } + + // Upload the offline files. + const offlineFiles = + await CoreQuestionHelper.instance.getStoredQuestionFiles(question, component, componentId, siteId); + + await CoreFileUploader.instance.uploadFiles( + Number(answers.attachments), + [...attachmentsData.online, ...offlineFiles], + siteId, + ); + } + + /** + * Prepare the text answer. + * + * @param question Question. + * @param answers The answers retrieved from the form. Prepared answers must be stored in this object. + * @param textarea The textarea HTML element of the question. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prepareTextAnswer( + question: AddonModQuizEssayQuestion, + answers: CoreQuestionsAnswers, + textarea: HTMLTextAreaElement, + siteId?: string, + ): Promise { + if (CoreQuestionHelper.instance.hasDraftFileUrls(question.html) && question.responsefileareas) { + // Restore draftfile URLs. + const site = await CoreSites.instance.getSite(siteId); + + answers[textarea.name] = CoreTextUtils.instance.restoreDraftfileUrls( + site.getURL(), + answers[textarea.name], + question.html, + CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'answer'), + ); + } + + let isPlainText = false; + if (question.isPlainText !== undefined) { + isPlainText = question.isPlainText; + } else if (question.parsedSettings) { + isPlainText = question.parsedSettings.responseformat == 'monospaced' || + question.parsedSettings.responseformat == 'plain'; + } else { + const questionEl = CoreDomUtils.instance.convertToElement(question.html); + isPlainText = !!questionEl.querySelector('.qtype_essay_monospaced') || !!questionEl.querySelector('.qtype_essay_plain'); + } + + if (!isPlainText) { + // Add some HTML to the text if needed. + answers[textarea.name] = CoreTextUtils.instance.formatHtmlLines( answers[textarea.name]); + } + } + +} + +export class AddonQtypeEssayHandler extends makeSingleton(AddonQtypeEssayHandlerService) {} diff --git a/src/addons/qtype/gapselect/component/addon-qtype-gapselect.html b/src/addons/qtype/gapselect/component/addon-qtype-gapselect.html new file mode 100644 index 000000000..b6ebd47da --- /dev/null +++ b/src/addons/qtype/gapselect/component/addon-qtype-gapselect.html @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/addons/qtype/gapselect/component/gapselect.scss b/src/addons/qtype/gapselect/component/gapselect.scss new file mode 100644 index 000000000..5060bcf32 --- /dev/null +++ b/src/addons/qtype/gapselect/component/gapselect.scss @@ -0,0 +1,23 @@ +// Style gapselect content a bit. Most of these styles are copied from Moodle. +:host ::ng-deep { + p { + margin: 0 0 .5em; + } + + select { + height: 30px; + line-height: 30px; + display: inline-block; + border: 1px solid var(--gray-dark); + padding: 4px 6px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + margin-bottom: 10px; + background: var(--gray-lighter); + + // @include darkmode() { + // background: $gray-dark; + // } + } +} diff --git a/src/addons/qtype/gapselect/component/gapselect.ts b/src/addons/qtype/gapselect/component/gapselect.ts new file mode 100644 index 000000000..8c1872bae --- /dev/null +++ b/src/addons/qtype/gapselect/component/gapselect.ts @@ -0,0 +1,55 @@ +// (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, OnInit, ElementRef } from '@angular/core'; + +import { CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; + +/** + * Component to render a gap select question. + */ +@Component({ + selector: 'addon-qtype-gapselect', + templateUrl: 'addon-qtype-gapselect.html', + styleUrls: ['gapselect.scss'], +}) +export class AddonQtypeGapSelectComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(elementRef: ElementRef) { + super('AddonQtypeGapSelectComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initOriginalTextComponent('.qtext'); + } + + /** + * The question has been rendered. + */ + questionRendered(): void { + CoreQuestionHelper.instance.treatCorrectnessIconsClicks( + this.hostElement, + this.component, + this.componentId, + this.contextLevel, + this.contextInstanceId, + this.courseId, + ); + } + +} diff --git a/src/addons/qtype/gapselect/gapselect.module.ts b/src/addons/qtype/gapselect/gapselect.module.ts new file mode 100644 index 000000000..1dd6c49ca --- /dev/null +++ b/src/addons/qtype/gapselect/gapselect.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeGapSelectComponent } from './component/gapselect'; +import { AddonQtypeGapSelectHandler } from './services/handlers/gapselect'; + +@NgModule({ + declarations: [ + AddonQtypeGapSelectComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeGapSelectHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeGapSelectComponent, + ], +}) +export class AddonQtypeGapSelectModule {} diff --git a/src/addons/qtype/gapselect/services/handlers/gapselect.ts b/src/addons/qtype/gapselect/services/handlers/gapselect.ts new file mode 100644 index 000000000..a817375d8 --- /dev/null +++ b/src/addons/qtype/gapselect/services/handlers/gapselect.ts @@ -0,0 +1,136 @@ +// (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 { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeGapSelectComponent } from '../../component/gapselect'; + +/** + * Handler to support gapselect question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeGapSelectHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeGapSelect'; + type = 'qtype_gapselect'; + + /** + * 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 { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param question The question to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonQtypeGapSelectComponent; + } + + /** + * 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, + ): number { + // We should always get a value for each select so we can assume we receive all the possible answers. + for (const name in answers) { + const value = answers[name]; + if (!value || value === '0') { + return 0; + } + } + + return 1; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param 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, + ): number { + // We should always get a value for each select so we can assume we receive all the possible answers. + for (const name in answers) { + const value = answers[name]; + if (value) { + return 1; + } + } + + return 0; + } + + /** + * Check if two responses are the same. + * + * @param question Question. + * @param prevAnswers Object with the previous question answers. + * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers); + } + +} + +export class AddonQtypeGapSelectHandler extends makeSingleton(AddonQtypeGapSelectHandlerService) {} diff --git a/src/addons/qtype/match/component/addon-qtype-match.html b/src/addons/qtype/match/component/addon-qtype-match.html new file mode 100644 index 000000000..f388fff84 --- /dev/null +++ b/src/addons/qtype/match/component/addon-qtype-match.html @@ -0,0 +1,29 @@ +
+ + + + + + + + + + + + + + + {{option.label}} + + + + + +
diff --git a/src/addons/qtype/match/component/match.scss b/src/addons/qtype/match/component/match.scss new file mode 100644 index 000000000..da749c46f --- /dev/null +++ b/src/addons/qtype/match/component/match.scss @@ -0,0 +1,13 @@ +:host { + .core-correct-icon { + margin-left: 0; + } + + .addon-qtype-match-correct { + color: var(--success); + } + + .addon-qtype-match-incorrect { + color: var(--danger); + } +} diff --git a/src/addons/qtype/match/component/match.ts b/src/addons/qtype/match/component/match.ts new file mode 100644 index 000000000..8a15cb210 --- /dev/null +++ b/src/addons/qtype/match/component/match.ts @@ -0,0 +1,43 @@ +// (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, OnInit, ElementRef } from '@angular/core'; + +import { AddonModQuizMatchQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; + +/** + * Component to render a match question. + */ +@Component({ + selector: 'addon-qtype-match', + templateUrl: 'addon-qtype-match.html', + styleUrls: ['match.scss'], +}) +export class AddonQtypeMatchComponent extends CoreQuestionBaseComponent implements OnInit { + + matchQuestion?: AddonModQuizMatchQuestion; + + constructor(elementRef: ElementRef) { + super('AddonQtypeMatchComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initMatchComponent(); + this.matchQuestion = this.question; + } + +} diff --git a/src/addons/qtype/match/match.module.ts b/src/addons/qtype/match/match.module.ts new file mode 100644 index 000000000..543968a91 --- /dev/null +++ b/src/addons/qtype/match/match.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeMatchComponent } from './component/match'; +import { AddonQtypeMatchHandler } from './services/handlers/match'; + +@NgModule({ + declarations: [ + AddonQtypeMatchComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeMatchHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeMatchComponent, + ], +}) +export class AddonQtypeMatchModule {} diff --git a/src/addons/qtype/match/services/handlers/match.ts b/src/addons/qtype/match/services/handlers/match.ts new file mode 100644 index 000000000..69ad099f1 --- /dev/null +++ b/src/addons/qtype/match/services/handlers/match.ts @@ -0,0 +1,136 @@ +// (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 { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeMatchComponent } from '../../component/match'; + +/** + * Handler to support match question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeMatchHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeMatch'; + type = 'qtype_match'; + + /** + * 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 { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param question The question to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonQtypeMatchComponent; + } + + /** + * 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, + ): number { + // We should always get a value for each select so we can assume we receive all the possible answers. + for (const name in answers) { + const value = answers[name]; + if (!value || value === '0') { + return 0; + } + } + + return 1; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param 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, + ): number { + // We should always get a value for each select so we can assume we receive all the possible answers. + for (const name in answers) { + const value = answers[name]; + if (value && value !== '0') { + return 1; + } + } + + return 0; + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers); + } + +} + +export class AddonQtypeMatchHandler extends makeSingleton(AddonQtypeMatchHandlerService) {} diff --git a/src/addons/qtype/multianswer/component/addon-qtype-multianswer.html b/src/addons/qtype/multianswer/component/addon-qtype-multianswer.html new file mode 100644 index 000000000..a8e0b18df --- /dev/null +++ b/src/addons/qtype/multianswer/component/addon-qtype-multianswer.html @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/addons/qtype/multianswer/component/multianswer.scss b/src/addons/qtype/multianswer/component/multianswer.scss new file mode 100644 index 000000000..ef8b92cff --- /dev/null +++ b/src/addons/qtype/multianswer/component/multianswer.scss @@ -0,0 +1,38 @@ +// Style multianswer content a bit. Most of these styles are copied from Moodle. +:host ::ng-deep { + p { + margin: 0 0 .5em; + } + + .answer div.r0, .answer div.r1, .answer td.r0, .answer td.r1 { + padding: 0.3em; + } + + table { + width: 100%; + display: table; + } + + tr { + display: table-row; + } + + td { + display: table-cell; + } + + input, select { + border-radius: 4px; + display: inline-block; + border: 1px solid var(--gray-dark); + padding: 6px 8px; + margin-left: 2px; + margin-right: 2px; + margin-bottom: 10px; + } + + select { + height: 30px; + line-height: 30px; + } +} diff --git a/src/addons/qtype/multianswer/component/multianswer.ts b/src/addons/qtype/multianswer/component/multianswer.ts new file mode 100644 index 000000000..19e7e567b --- /dev/null +++ b/src/addons/qtype/multianswer/component/multianswer.ts @@ -0,0 +1,54 @@ +// (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, OnInit, ElementRef } from '@angular/core'; +import { CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; + +/** + * Component to render a multianswer question. + */ +@Component({ + selector: 'addon-qtype-multianswer', + templateUrl: 'addon-qtype-multianswer.html', + styleUrls: ['multianswer.scss'], +}) +export class AddonQtypeMultiAnswerComponent extends CoreQuestionBaseComponent implements OnInit { + + constructor(elementRef: ElementRef) { + super('AddonQtypeMultiAnswerComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initOriginalTextComponent('.formulation'); + } + + /** + * The question has been rendered. + */ + questionRendered(): void { + CoreQuestionHelper.instance.treatCorrectnessIconsClicks( + this.hostElement, + this.component, + this.componentId, + this.contextLevel, + this.contextInstanceId, + this.courseId, + ); + } + +} diff --git a/src/addons/qtype/multianswer/multianswer.module.ts b/src/addons/qtype/multianswer/multianswer.module.ts new file mode 100644 index 000000000..b66445d56 --- /dev/null +++ b/src/addons/qtype/multianswer/multianswer.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeMultiAnswerComponent } from './component/multianswer'; +import { AddonQtypeMultiAnswerHandler } from './services/handlers/multianswer'; + +@NgModule({ + declarations: [ + AddonQtypeMultiAnswerComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeMultiAnswerHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeMultiAnswerComponent, + ], +}) +export class AddonQtypeMultiAnswerModule {} diff --git a/src/addons/qtype/multianswer/services/handlers/multianswer.ts b/src/addons/qtype/multianswer/services/handlers/multianswer.ts new file mode 100644 index 000000000..0c0fd3271 --- /dev/null +++ b/src/addons/qtype/multianswer/services/handlers/multianswer.ts @@ -0,0 +1,162 @@ +// (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 { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeMultiAnswerComponent } from '../../component/multianswer'; + +/** + * Handler to support multianswer question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeMultiAnswerHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeMultiAnswer'; + type = 'qtype_multianswer'; + + /** + * 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 { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param question The question to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type { + return AddonQtypeMultiAnswerComponent; + } + + /** + * 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, + ): number { + // Get all the inputs in the question to check if they've all been answered. + const names = CoreQuestion.instance.getBasicAnswers( + CoreQuestionHelper.instance.getAllInputNamesFromHtml(question.html || ''), + ); + for (const name in names) { + const value = answers[name]; + if (!value) { + return 0; + } + } + + return 1; + } + + /** + * 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; + } + + /** + * 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, + ): number { + // We should always get a value for each select so we can assume we receive all the possible answers. + for (const name in answers) { + const value = answers[name]; + if (value || value === false) { + return 1; + } + } + + return 0; + } + + /** + * Check if two responses are the same. + * + * @param question Question. + * @param prevAnswers Object with the previous question answers. + * @param newAnswers Object with the new question answers. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers); + } + + /** + * Validate if an offline sequencecheck is valid compared with the online one. + * This function only needs to be implemented if a specific compare is required. + * + * @param question The question. + * @param offlineSequenceCheck Sequence check stored in offline. + * @return Whether sequencecheck is valid. + */ + validateSequenceCheck(question: CoreQuestionQuestionParsed, offlineSequenceCheck: string): boolean { + if (question.sequencecheck == Number(offlineSequenceCheck)) { + return true; + } + + // For some reason, viewing a multianswer for the first time without answering it creates a new step "todo". + // We'll treat this case as valid. + if (question.sequencecheck == 2 && question.state == 'todo' && offlineSequenceCheck == '1') { + return true; + } + + return false; + } + +} + +export class AddonQtypeMultiAnswerHandler extends makeSingleton(AddonQtypeMultiAnswerHandlerService) {} diff --git a/src/addons/qtype/multichoice/component/addon-qtype-multichoice.html b/src/addons/qtype/multichoice/component/addon-qtype-multichoice.html new file mode 100644 index 000000000..0abd17a77 --- /dev/null +++ b/src/addons/qtype/multichoice/component/addon-qtype-multichoice.html @@ -0,0 +1,67 @@ + + + + +

+

+

{{ multiQuestion.prompt }}

+
+
+ + + + + + + +
+ + +
+
+ + + + + + + + + +
+
+ + + + + + + +
+ + +
+
+ + + + + + +
+ + {{ 'addon.mod_quiz.clearchoice' | translate }} + + + + +
+
diff --git a/src/addons/qtype/multichoice/component/multichoice.scss b/src/addons/qtype/multichoice/component/multichoice.scss new file mode 100644 index 000000000..17f1f2577 --- /dev/null +++ b/src/addons/qtype/multichoice/component/multichoice.scss @@ -0,0 +1,8 @@ +:host { + .specificfeedback { + background-color: var(--core-question-feedback-color-bg); + color: var(--core-question-feedback-color); + display: inline; + padding: 0 .7em; + } +} diff --git a/src/addons/qtype/multichoice/component/multichoice.ts b/src/addons/qtype/multichoice/component/multichoice.ts new file mode 100644 index 000000000..db3a3d139 --- /dev/null +++ b/src/addons/qtype/multichoice/component/multichoice.ts @@ -0,0 +1,50 @@ +// (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, OnInit, ElementRef } from '@angular/core'; + +import { AddonModQuizMultichoiceQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; + +/** + * Component to render a multichoice question. + */ +@Component({ + selector: 'addon-qtype-multichoice', + templateUrl: 'addon-qtype-multichoice.html', + styleUrls: ['multichoice.scss'], +}) +export class AddonQtypeMultichoiceComponent extends CoreQuestionBaseComponent implements OnInit { + + multiQuestion?: AddonModQuizMultichoiceQuestion; + + constructor(elementRef: ElementRef) { + super('AddonQtypeMultichoiceComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initMultichoiceComponent(); + this.multiQuestion = this.question; + } + + /** + * Clear selected choices. + */ + clear(): void { + this.multiQuestion!.singleChoiceModel = undefined; + } + +} diff --git a/src/addons/qtype/multichoice/multichoice.module.ts b/src/addons/qtype/multichoice/multichoice.module.ts new file mode 100644 index 000000000..e04a66ae1 --- /dev/null +++ b/src/addons/qtype/multichoice/multichoice.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonQtypeMultichoiceComponent } from './component/multichoice'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeMultichoiceHandler } from './services/handlers/multichoice'; + +@NgModule({ + declarations: [ + AddonQtypeMultichoiceComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeMultichoiceHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeMultichoiceComponent, + ], +}) +export class AddonQtypeMultichoiceModule {} diff --git a/src/addons/qtype/multichoice/services/handlers/multichoice.ts b/src/addons/qtype/multichoice/services/handlers/multichoice.ts new file mode 100644 index 000000000..c185959c7 --- /dev/null +++ b/src/addons/qtype/multichoice/services/handlers/multichoice.ts @@ -0,0 +1,201 @@ +// (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 { AddonModQuizMultichoiceQuestion } from '@features/question/classes/base-question-component'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeMultichoiceComponent } from '../../component/multichoice'; + +/** + * Handler to support multichoice question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeMultichoiceHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeMultichoice'; + type = 'qtype_multichoice'; + + /** + * 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(): Type { + return AddonQtypeMultichoiceComponent; + } + + /** + * 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, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): number { + let isSingle = true; + let isMultiComplete = false; + + // To know if it's single or multi answer we need to search for answers with "choice" in the name. + for (const name in answers) { + if (name.indexOf('choice') != -1) { + isSingle = false; + if (answers[name]) { + isMultiComplete = true; + } + } + } + + if (isSingle) { + // Single. + return this.isCompleteResponseSingle(answers); + } else { + // Multi. + return isMultiComplete ? 1 : 0; + } + } + + /** + * Check if a response is complete. Only for single answer. + * + * @param question The question.uestion answers (without prefix). + * @return 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponseSingle(answers: CoreQuestionsAnswers): number { + return (answers.answer && answers.answer !== '') ? 1 : 0; + } + + /** + * 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; + } + + /** + * 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 { + return this.isCompleteResponse(question, answers, component, componentId); + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. Only for single answer. + * + * @param answers Object with the question answers (without prefix). + * @return 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponseSingle(answers: CoreQuestionsAnswers): number { + return this.isCompleteResponseSingle(answers); + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + let isSingle = true; + let isMultiSame = true; + + // To know if it's single or multi answer we need to search for answers with "choice" in the name. + for (const name in newAnswers) { + if (name.indexOf('choice') != -1) { + isSingle = false; + if (!CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, name)) { + isMultiSame = false; + break; + } + } + } + + if (isSingle) { + return this.isSameResponseSingle(prevAnswers, newAnswers); + } else { + return isMultiSame; + } + } + + /** + * Check if two responses are the same. Only for single answer. + * + * @param prevAnswers Object with the previous question answers. + * @param newAnswers Object with the new question answers. + * @return Whether they're the same. + */ + isSameResponseSingle(prevAnswers: CoreQuestionsAnswers, newAnswers: CoreQuestionsAnswers): boolean { + return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); + } + + /** + * 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: AddonModQuizMultichoiceQuestion, + answers: CoreQuestionsAnswers, + ): void { + if (question && !question.multi && answers[question.optionsName!] !== undefined && !answers[question.optionsName!]) { + /* It's a single choice and the user hasn't answered. Delete the answer because + sending an empty string (default value) will mark the first option as selected. */ + delete answers[question.optionsName!]; + } + } + +} + +export class AddonQtypeMultichoiceHandler extends makeSingleton(AddonQtypeMultichoiceHandlerService) {} diff --git a/src/addons/qtype/numerical/numerical.module.ts b/src/addons/qtype/numerical/numerical.module.ts new file mode 100644 index 000000000..6099c867e --- /dev/null +++ b/src/addons/qtype/numerical/numerical.module.ts @@ -0,0 +1,35 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeNumericalHandler } from './services/handlers/numerical'; + + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeNumericalHandler.instance); + }, + }, + ], +}) +export class AddonQtypeNumericalModule {} diff --git a/src/addons/qtype/numerical/services/handlers/numerical.ts b/src/addons/qtype/numerical/services/handlers/numerical.ts new file mode 100644 index 000000000..de5c79477 --- /dev/null +++ b/src/addons/qtype/numerical/services/handlers/numerical.ts @@ -0,0 +1,32 @@ +// (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 { AddonQtypeCalculatedHandlerService } from '@addons/qtype/calculated/services/handlers/calculated'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support numerical question type. + * This question type depends on calculated question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeNumericalHandlerService extends AddonQtypeCalculatedHandlerService { + + name = 'AddonQtypeNumerical'; + type = 'qtype_numerical'; + +} + +export class AddonQtypeNumericalHandler extends makeSingleton(AddonQtypeNumericalHandlerService) {} diff --git a/src/addons/qtype/qtype.module.ts b/src/addons/qtype/qtype.module.ts new file mode 100644 index 000000000..716b5b586 --- /dev/null +++ b/src/addons/qtype/qtype.module.ts @@ -0,0 +1,57 @@ +// (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 { AddonQtypeCalculatedModule } from './calculated/calculated.module'; +import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module'; +import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module'; +import { AddonQtypeDdImageOrTextModule } from './ddimageortext/ddimageortext.module'; +import { AddonQtypeDdMarkerModule } from './ddmarker/ddmarker.module'; +import { AddonQtypeDdwtosModule } from './ddwtos/ddwtos.module'; +import { AddonQtypeDescriptionModule } from './description/description.module'; +import { AddonQtypeEssayModule } from './essay/essay.module'; +import { AddonQtypeGapSelectModule } from './gapselect/gapselect.module'; +import { AddonQtypeMatchModule } from './match/match.module'; +import { AddonQtypeMultiAnswerModule } from './multianswer/multianswer.module'; +import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module'; +import { AddonQtypeNumericalModule } from './numerical/numerical.module'; +import { AddonQtypeRandomSaMatchModule } from './randomsamatch/randomsamatch.module'; +import { AddonQtypeShortAnswerModule } from './shortanswer/shortanswer.module'; +import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; + +@NgModule({ + declarations: [], + imports: [ + AddonQtypeCalculatedModule, + AddonQtypeCalculatedMultiModule, + AddonQtypeCalculatedSimpleModule, + AddonQtypeDdImageOrTextModule, + AddonQtypeDdMarkerModule, + AddonQtypeDdwtosModule, + AddonQtypeDescriptionModule, + AddonQtypeEssayModule, + AddonQtypeGapSelectModule, + AddonQtypeMatchModule, + AddonQtypeMultiAnswerModule, + AddonQtypeMultichoiceModule, + AddonQtypeNumericalModule, + AddonQtypeRandomSaMatchModule, + AddonQtypeShortAnswerModule, + AddonQtypeTrueFalseModule, + ], + providers: [ + ], + exports: [], +}) +export class AddonQtypeModule { } diff --git a/src/addons/qtype/randomsamatch/randomsamatch.module.ts b/src/addons/qtype/randomsamatch/randomsamatch.module.ts new file mode 100644 index 000000000..004d8b82a --- /dev/null +++ b/src/addons/qtype/randomsamatch/randomsamatch.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeRandomSaMatchHandler } from './services/handlers/randomsamatch'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeRandomSaMatchHandler.instance); + }, + }, + ], +}) +export class AddonQtypeRandomSaMatchModule {} diff --git a/src/addons/qtype/randomsamatch/services/handlers/randomsamatch.ts b/src/addons/qtype/randomsamatch/services/handlers/randomsamatch.ts new file mode 100644 index 000000000..18470962d --- /dev/null +++ b/src/addons/qtype/randomsamatch/services/handlers/randomsamatch.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 { Injectable } from '@angular/core'; + +import { AddonQtypeMatchHandlerService } from '@addons/qtype/match/services/handlers/match'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support random short-answer matching question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeRandomSaMatchHandlerService extends AddonQtypeMatchHandlerService { + + name = 'AddonQtypeRandomSaMatch'; + type = 'qtype_randomsamatch'; + +} + +export class AddonQtypeRandomSaMatchHandler extends makeSingleton(AddonQtypeRandomSaMatchHandlerService) {} diff --git a/src/addons/qtype/shortanswer/component/addon-qtype-shortanswer.html b/src/addons/qtype/shortanswer/component/addon-qtype-shortanswer.html new file mode 100644 index 000000000..d4c2b2a9f --- /dev/null +++ b/src/addons/qtype/shortanswer/component/addon-qtype-shortanswer.html @@ -0,0 +1,20 @@ + + + + + + + + + {{ 'addon.mod_quiz.answercolon' | translate }} + + + + + + diff --git a/src/addons/qtype/shortanswer/component/shortanswer.scss b/src/addons/qtype/shortanswer/component/shortanswer.scss new file mode 100644 index 000000000..bac1b66f7 --- /dev/null +++ b/src/addons/qtype/shortanswer/component/shortanswer.scss @@ -0,0 +1,5 @@ +:host { + .core-correct-icon { + margin-top: 14px; + } +} diff --git a/src/addons/qtype/shortanswer/component/shortanswer.ts b/src/addons/qtype/shortanswer/component/shortanswer.ts new file mode 100644 index 000000000..77cfcb83e --- /dev/null +++ b/src/addons/qtype/shortanswer/component/shortanswer.ts @@ -0,0 +1,43 @@ +// (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, OnInit, ElementRef } from '@angular/core'; + +import { AddonModQuizTextQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component'; + +/** + * Component to render a short answer question. + */ +@Component({ + selector: 'addon-qtype-shortanswer', + templateUrl: 'addon-qtype-shortanswer.html', + styleUrls: ['shortanswer.scss'], +}) +export class AddonQtypeShortAnswerComponent extends CoreQuestionBaseComponent implements OnInit { + + textQuestion?: AddonModQuizTextQuestion; + + constructor(elementRef: ElementRef) { + super('AddonQtypeShortAnswerComponent', elementRef); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.initInputTextComponent(); + this.textQuestion = this.question; + } + +} diff --git a/src/addons/qtype/shortanswer/services/handlers/shortanswer.ts b/src/addons/qtype/shortanswer/services/handlers/shortanswer.ts new file mode 100644 index 000000000..5cc0398f5 --- /dev/null +++ b/src/addons/qtype/shortanswer/services/handlers/shortanswer.ts @@ -0,0 +1,109 @@ +// (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 { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonQtypeShortAnswerComponent } from '../../component/shortanswer'; + +/** + * Handler to support short answer question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeShortAnswerHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeShortAnswer'; + type = 'qtype_shortanswer'; + + /** + * 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(): Type { + return AddonQtypeShortAnswerComponent; + } + + /** + * 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, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): number { + return answers.answer ? 1 : 0; + } + + /** + * 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; + } + + /** + * 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 { + return this.isCompleteResponse(question, answers, component, componentId); + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); + } + +} + +export class AddonQtypeShortAnswerHandler extends makeSingleton(AddonQtypeShortAnswerHandlerService) {} diff --git a/src/addons/qtype/shortanswer/shortanswer.module.ts b/src/addons/qtype/shortanswer/shortanswer.module.ts new file mode 100644 index 000000000..8faf8f6aa --- /dev/null +++ b/src/addons/qtype/shortanswer/shortanswer.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeShortAnswerComponent } from './component/shortanswer'; +import { AddonQtypeShortAnswerHandler } from './services/handlers/shortanswer'; + +@NgModule({ + declarations: [ + AddonQtypeShortAnswerComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeShortAnswerHandler.instance); + }, + }, + ], + exports: [ + AddonQtypeShortAnswerComponent, + ], +}) +export class AddonQtypeShortAnswerModule {} diff --git a/src/addons/qtype/truefalse/services/handlers/truefalse.ts b/src/addons/qtype/truefalse/services/handlers/truefalse.ts new file mode 100644 index 000000000..33a00db9d --- /dev/null +++ b/src/addons/qtype/truefalse/services/handlers/truefalse.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 { Injectable, Type } from '@angular/core'; + +import { AddonQtypeMultichoiceComponent } from '@addons/qtype/multichoice/component/multichoice'; +import { CoreQuestionHandler } from '@features/question/services/question-delegate'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreUtils } from '@services/utils/utils'; +import { AddonModQuizMultichoiceQuestion } from '@features/question/classes/base-question-component'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support true/false question type. + */ +@Injectable({ providedIn: 'root' }) +export class AddonQtypeTrueFalseHandlerService implements CoreQuestionHandler { + + name = 'AddonQtypeTrueFalse'; + type = 'qtype_truefalse'; + + /** + * 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(): Type { + // True/false behaves like a multichoice, use the same component. + return AddonQtypeMultichoiceComponent; + } + + /** + * 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, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): number { + return answers.answer ? 1 : 0; + } + + /** + * 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; + } + + /** + * 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 { + return this.isCompleteResponse(question, answers, component, componentId); + } + + /** + * 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. + * @param component The component the question is related to. + * @param componentId Component ID. + * @return Whether they're the same. + */ + isSameResponse( + question: CoreQuestionQuestionParsed, + prevAnswers: CoreQuestionsAnswers, + newAnswers: CoreQuestionsAnswers, + ): boolean { + return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer'); + } + + /** + * 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: AddonModQuizMultichoiceQuestion, + answers: CoreQuestionsAnswers, + ): void | Promise { + if (question && answers[question.optionsName!] !== undefined && !answers[question.optionsName!]) { + // The user hasn't answered. Delete the answer to prevent marking one of the answers automatically. + delete answers[question.optionsName!]; + } + } + +} + +export class AddonQtypeTrueFalseHandler extends makeSingleton(AddonQtypeTrueFalseHandlerService) {} diff --git a/src/addons/qtype/truefalse/truefalse.module.ts b/src/addons/qtype/truefalse/truefalse.module.ts new file mode 100644 index 000000000..2599299c0 --- /dev/null +++ b/src/addons/qtype/truefalse/truefalse.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { AddonQtypeTrueFalseHandler } from './services/handlers/truefalse'; + +@NgModule({ + declarations: [ + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreQuestionDelegate.instance.registerHandler(AddonQtypeTrueFalseHandler.instance); + }, + }, + ], +}) +export class AddonQtypeTrueFalseModule {} diff --git a/src/core/features/fileuploader/services/fileuploader.ts b/src/core/features/fileuploader/services/fileuploader.ts index 6cbc4bb91..e9bcdcc5a 100644 --- a/src/core/features/fileuploader/services/fileuploader.ts +++ b/src/core/features/fileuploader/services/fileuploader.ts @@ -31,6 +31,7 @@ import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from import { CoreLogger } from '@singletons/logger'; import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media'; import { CoreError } from '@classes/errors/error'; +import { CoreSite } from '@classes/site'; /** * File upload options. @@ -97,6 +98,36 @@ export class CoreFileUploaderProvider { return false; } + /** + * Check if a certain site allows deleting draft files. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if can delete. + * @since 3.10 + */ + async canDeleteDraftFiles(siteId?: string): Promise { + try { + const site = await CoreSites.instance.getSite(siteId); + + return this.canDeleteDraftFilesInSite(site); + } catch (error) { + return false; + } + } + + /** + * Check if a certain site allows deleting draft files. + * + * @param site Site. If not defined, use current site. + * @return Whether draft files can be deleted. + * @since 3.10 + */ + canDeleteDraftFilesInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!(site?.wsAvailable('core_files_delete_draft_files')); + } + /** * Start the audio recorder application and return information about captured audio clip files. * @@ -175,6 +206,25 @@ export class CoreFileUploaderProvider { }); } + /** + * Delete draft files. + * + * @param draftId Draft ID. + * @param files Files to delete. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteDraftFiles(draftId: number, files: { filepath: string; filename: string }[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params = { + draftitemid: draftId, + files: files, + }; + + return site.write('core_files_delete_draft_files', params); + } + /** * Get the upload options for a file taken with the Camera Cordova plugin. * @@ -217,6 +267,35 @@ export class CoreFileUploaderProvider { return options; } + /** + * Given a list of original files and a list of current files, return the list of files to delete. + * + * @param originalFiles Original files. + * @param currentFiles Current files. + * @return List of files to delete. + */ + getFilesToDelete( + originalFiles: CoreWSExternalFile[], + currentFiles: (CoreWSExternalFile | FileEntry)[], + ): { filepath: string; filename: string }[] { + + const filesToDelete: { filepath: string; filename: string }[] = []; + currentFiles = currentFiles || []; + + originalFiles.forEach((file) => { + const stillInList = currentFiles.some((currentFile) => ( currentFile).fileurl == file.fileurl); + + if (!stillInList) { + filesToDelete.push({ + filepath: file.filepath!, + filename: file.filename!, + }); + } + }); + + return filesToDelete; + } + /** * Get the upload options for a file of any type. * @@ -541,6 +620,46 @@ export class CoreFileUploaderProvider { return result; } + /** + * Given a list of files (either online files or local files), upload the local files to the draft area. + * Local files are not deleted from the device after upload. + * + * @param itemId Draft ID. + * @param files List of files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the itemId. + */ + async uploadFiles(itemId: number, files: (CoreWSExternalFile | FileEntry)[], siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!files || !files.length) { + return; + } + + // Index the online files by name. + const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {}; + const filesToUpload: FileEntry[] = []; + files.forEach((file) => { + if (CoreUtils.instance.isFileEntry(file)) { + filesToUpload.push( file); + } else { + // It's an online file. + usedNames[file.filename!.toLowerCase()] = file; + } + }); + + await Promise.all(filesToUpload.map(async (file) => { + // Make sure the file name is unique in the area. + const name = CoreFile.instance.calculateUniqueName(usedNames, file.name); + usedNames[name] = file; + + // Now upload the file. + const options = this.getFileUploadOptions(file.toURL(), name, undefined, false, 'draft', itemId); + + await this.uploadFile(file.toURL(), options, undefined, siteId); + })); + } + /** * Upload a file to a draft area and return the draft ID. * diff --git a/src/core/features/question/classes/base-question-component.ts b/src/core/features/question/classes/base-question-component.ts new file mode 100644 index 000000000..b6f21aef4 --- /dev/null +++ b/src/core/features/question/classes/base-question-component.ts @@ -0,0 +1,784 @@ +// (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 { Input, Output, EventEmitter, Component, Optional, Inject, ElementRef } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreWSExternalFile } from '@services/ws'; +import { CoreLogger } from '@singletons/logger'; +import { CoreQuestionBehaviourButton, CoreQuestionHelper, CoreQuestionQuestion } from '../services/question-helper'; + +/** + * Base class for components to render a question. + */ +@Component({ + template: '', +}) +export class CoreQuestionBaseComponent { + + @Input() question?: AddonModQuizQuestion; // 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() 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; // The course the question belongs to (if any). + @Input() review?: boolean; // Whether the user is in review mode. + @Output() buttonClicked = new EventEmitter(); // Will emit when a behaviour button is clicked. + @Output() onAbort = new EventEmitter(); // Should emit an event if the question should be aborted. + + protected logger: CoreLogger; + protected hostElement: HTMLElement; + + constructor(@Optional() @Inject('') logName: string, elementRef: ElementRef) { + this.logger = CoreLogger.getInstance(logName); + this.hostElement = elementRef.nativeElement; + } + + /** + * Initialize a question component of type calculated or calculated simple. + * + * @return Element containing the question HTML, void if the data is not valid. + */ + initCalculatedComponent(): void | HTMLElement { + // Treat the input text first. + const questionEl = this.initInputTextComponent(); + if (!questionEl) { + return; + } + + // Check if the question has a select for units. + if (this.treatCalculatedSelectUnits(questionEl)) { + return questionEl; + } + + // Check if the question has radio buttons for units. + if (this.treatCalculatedRadioUnits(questionEl)) { + return questionEl; + } + + return questionEl; + } + + /** + * Treat a calculated question units in case they use radio buttons. + * + * @param questionEl Question HTML element. + * @return True if question has units using radio buttons. + */ + protected treatCalculatedRadioUnits(questionEl: HTMLElement): boolean { + // Check if the question has radio buttons for units. + const radios = Array.from(questionEl.querySelectorAll('input[type="radio"]')); + if (!radios.length) { + return false; + } + + const question = this.question!; + question.options = []; + + for (const i in radios) { + const radioEl = radios[i]; + const option: AddonModQuizQuestionRadioOption = { + id: radioEl.id, + name: radioEl.name, + value: radioEl.value, + checked: radioEl.checked, + disabled: radioEl.disabled, + }; + // Get the label with the question text. + const label = questionEl.querySelector('label[for="' + option.id + '"]'); + + question.optionsName = option.name; + + if (!label || option.name === undefined || option.value === undefined) { + // Something went wrong when extracting the questions data. Abort. + this.logger.warn('Aborting because of an error parsing options.', question.slot, option.name); + CoreQuestionHelper.instance.showComponentError(this.onAbort); + + return true; + } + + option.text = label.innerText; + if (radioEl.checked) { + // If the option is checked we use the model to select the one. + question.unit = option.value; + } + + question.options.push(option); + } + + // Check which one should be displayed first: the options or the input. + if (question.parsedSettings && question.parsedSettings.unitsleft !== null) { + question.optionsFirst = question.parsedSettings.unitsleft == '1'; + } else { + const input = questionEl.querySelector('input[type="text"][name*=answer]'); + question.optionsFirst = + questionEl.innerHTML.indexOf(input?.outerHTML || '') > questionEl.innerHTML.indexOf(radios[0].outerHTML); + } + + return true; + } + + /** + * Treat a calculated question units in case they use a select. + * + * @param questionEl Question HTML element. + * @return True if question has units using a select. + */ + protected treatCalculatedSelectUnits(questionEl: HTMLElement): boolean { + // Check if the question has a select for units. + const select = questionEl.querySelector('select[name*=unit]'); + const options = select && Array.from(select.querySelectorAll('option')); + + if (!select || !options?.length) { + return false; + } + + const question = this.question!; + const selectModel: AddonModQuizQuestionSelect = { + id: select.id, + name: select.name, + disabled: select.disabled, + options: [], + }; + + // Treat each option. + for (const i in options) { + const optionEl = options[i]; + + if (typeof optionEl.value == 'undefined') { + this.logger.warn('Aborting because couldn\'t find input.', this.question?.slot); + CoreQuestionHelper.instance.showComponentError(this.onAbort); + + return true; + } + + const option: AddonModQuizQuestionSelectOption = { + value: optionEl.value, + label: optionEl.innerHTML, + selected: optionEl.selected, + }; + + if (optionEl.selected) { + selectModel.selected = option.value; + } + + selectModel.options.push(option); + } + + if (!selectModel.selected) { + // No selected option, select the first one. + selectModel.selected = selectModel.options[0].value; + } + + // Get the accessibility label. + const accessibilityLabel = questionEl.querySelector('label[for="' + select.id + '"]'); + selectModel.accessibilityLabel = accessibilityLabel?.innerHTML; + + question.select = selectModel; + + // Check which one should be displayed first: the select or the input. + if (question.parsedSettings && question.parsedSettings.unitsleft !== null) { + question.selectFirst = question.parsedSettings.unitsleft == '1'; + } else { + const input = questionEl.querySelector('input[type="text"][name*=answer]'); + question.selectFirst = + questionEl.innerHTML.indexOf(input?.outerHTML || '') > questionEl.innerHTML.indexOf(select.outerHTML); + } + + return true; + } + + /** + * Initialize the component and the question text. + * + * @return Element containing the question HTML, void if the data is not valid. + */ + initComponent(): void | HTMLElement { + if (!this.question) { + this.logger.warn('Aborting because of no question received.'); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + this.hostElement.classList.add('core-question-container'); + + const element = CoreDomUtils.instance.convertToElement(this.question.html); + + // Extract question text. + this.question.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext'); + if (typeof this.question.text == 'undefined') { + this.logger.warn('Aborting because of an error parsing question.', this.question.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + return element; + } + + /** + * Initialize a question component of type essay. + * + * @param review Whether we're in review mode. + * @return Element containing the question HTML, void if the data is not valid. + */ + initEssayComponent(review?: boolean): void | HTMLElement { + const questionEl = this.initComponent(); + if (!questionEl) { + return; + } + + const question = this.question!; + const answerDraftIdInput = questionEl.querySelector('input[name*="_answer:itemid"]'); + + if (question.parsedSettings) { + question.allowsAttachments = question.parsedSettings.attachments != '0'; + question.allowsAnswerFiles = question.parsedSettings.responseformat == 'editorfilepicker'; + question.isMonospaced = question.parsedSettings.responseformat == 'monospaced'; + question.isPlainText = question.isMonospaced || question.parsedSettings.responseformat == 'plain'; + question.hasInlineText = question.parsedSettings.responseformat != 'noinline'; + } else { + question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]'); + question.allowsAnswerFiles = !!answerDraftIdInput; + question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced'); + question.isPlainText = question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain'); + } + + if (review) { + // Search the answer and the attachments. + question.answer = CoreDomUtils.instance.getContentsOfElement(questionEl, '.qtype_essay_response'); + + if (question.parsedSettings) { + question.attachments = Array.from( + CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments'), + ); + } else { + question.attachments = CoreQuestionHelper.instance.getQuestionAttachmentsFromHtml( + CoreDomUtils.instance.getContentsOfElement(questionEl, '.attachments') || '', + ); + } + + return questionEl; + } + + const textarea = questionEl.querySelector('textarea[name*=_answer]'); + question.hasDraftFiles = question.allowsAnswerFiles && CoreQuestionHelper.instance.hasDraftFileUrls(questionEl.innerHTML); + + if (!textarea && (question.hasInlineText || !question.allowsAttachments)) { + // Textarea not found, we might be in review. Search the answer and the attachments. + question.answer = CoreDomUtils.instance.getContentsOfElement(questionEl, '.qtype_essay_response'); + question.attachments = CoreQuestionHelper.instance.getQuestionAttachmentsFromHtml( + CoreDomUtils.instance.getContentsOfElement(questionEl, '.attachments') || '', + ); + + return questionEl; + } + + if (textarea) { + const input = questionEl.querySelector('input[type="hidden"][name*=answerformat]'); + let content = CoreTextUtils.instance.decodeHTML(textarea.innerHTML || ''); + + if (question.hasDraftFiles && question.responsefileareas) { + content = CoreTextUtils.instance.replaceDraftfileUrls( + CoreSites.instance.getCurrentSite()!.getURL(), + content, + CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'answer'), + ).text; + } + + question.textarea = { + id: textarea.id, + name: textarea.name, + text: content, + }; + + if (input) { + question.formatInput = { + name: input.name, + value: input.value, + }; + } + } + + if (answerDraftIdInput) { + question.answerDraftIdInput = { + name: answerDraftIdInput.name, + value: Number(answerDraftIdInput.value), + }; + } + + if (question.allowsAttachments) { + const attachmentsInput = questionEl.querySelector('.attachments input[name*=_attachments]'); + const objectElement = questionEl.querySelector('.attachments object'); + const fileManagerUrl = objectElement && objectElement.data; + + if (attachmentsInput) { + question.attachmentsDraftIdInput = { + name: attachmentsInput.name, + value: Number(attachmentsInput.value), + }; + } + + if (question.parsedSettings) { + question.attachmentsMaxFiles = Number(question.parsedSettings.attachments); + question.attachmentsAcceptedTypes = ( question.parsedSettings.filetypeslist)?.join(','); + } + + if (fileManagerUrl) { + const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl); + const maxBytes = Number(params.maxbytes); + const areaMaxBytes = Number(params.areamaxbytes); + + question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ? + Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes); + } + } + + return questionEl; + } + + /** + * Initialize a question component that uses the original question text with some basic treatment. + * + * @param contentSelector The selector to find the question content (text). + * @return Element containing the question HTML, void if the data is not valid. + */ + initOriginalTextComponent(contentSelector: string): void | HTMLElement { + if (!this.question) { + this.logger.warn('Aborting because of no question received.'); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + const element = CoreDomUtils.instance.convertToElement(this.question.html); + + // Get question content. + const content = element.querySelector(contentSelector); + if (!content) { + this.logger.warn('Aborting because of an error parsing question.', this.question.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + // Remove sequencecheck and validation error. + CoreDomUtils.instance.removeElement(content, 'input[name*=sequencecheck]'); + CoreDomUtils.instance.removeElement(content, '.validationerror'); + + // Replace Moodle's correct/incorrect and feedback classes with our own. + CoreQuestionHelper.instance.replaceCorrectnessClasses(element); + CoreQuestionHelper.instance.replaceFeedbackClasses(element); + + // Treat the correct/incorrect icons. + CoreQuestionHelper.instance.treatCorrectnessIcons(element); + + // Set the question text. + this.question.text = content.innerHTML; + + return element; + } + + /** + * Initialize a question component that has an input of type "text". + * + * @return Element containing the question HTML, void if the data is not valid. + */ + initInputTextComponent(): void | HTMLElement { + const questionEl = this.initComponent(); + if (!questionEl) { + return; + } + + // Get the input element. + const question = this.question!; + const input = questionEl.querySelector('input[type="text"][name*=answer]'); + if (!input) { + this.logger.warn('Aborting because couldn\'t find input.', this.question!.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + question.input = { + id: input.id, + name: input.name, + value: input.value, + readOnly: input.readOnly, + isInline: !!CoreDomUtils.instance.closest(input, '.qtext'), // The answer can be inside the question text. + }; + + // Check if question is marked as correct. + if (input.classList.contains('incorrect')) { + question.input.correctClass = 'core-question-incorrect'; + question.input.correctIcon = 'fa-remove'; + question.input.correctIconColor = 'danger'; + } else if (input.classList.contains('correct')) { + question.input.correctClass = 'core-question-correct'; + question.input.correctIcon = 'fa-check'; + question.input.correctIconColor = 'success'; + } else if (input.classList.contains('partiallycorrect')) { + question.input.correctClass = 'core-question-partiallycorrect'; + question.input.correctIcon = 'fa-check-square'; + question.input.correctIconColor = 'warning'; + } else { + question.input.correctClass = ''; + question.input.correctIcon = ''; + question.input.correctIconColor = ''; + } + + if (question.input.isInline) { + // Handle correct/incorrect classes and icons. + const content = questionEl.querySelector('.qtext'); + + CoreQuestionHelper.instance.replaceCorrectnessClasses(content); + CoreQuestionHelper.instance.treatCorrectnessIcons(content); + + question.text = content.innerHTML; + } + + return questionEl; + } + + /** + * Initialize a question component with a "match" behaviour. + * + * @return Element containing the question HTML, void if the data is not valid. + */ + initMatchComponent(): void | HTMLElement { + const questionEl = this.initComponent(); + if (!questionEl) { + return; + } + + // Find rows. + const question = this.question!; + const rows = Array.from(questionEl.querySelectorAll('table.answer tr')); + if (!rows || !rows.length) { + this.logger.warn('Aborting because couldn\'t find any row.', question.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + question.rows = []; + + for (const i in rows) { + const row = rows[i]; + const columns = Array.from(row.querySelectorAll('td')); + + if (!columns || columns.length < 2) { + this.logger.warn('Aborting because couldn\'t the right columns.', question.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + // Get the select and the options. + const select = columns[1].querySelector('select'); + const options = Array.from(columns[1].querySelectorAll('option')); + + if (!select || !options || !options.length) { + this.logger.warn('Aborting because couldn\'t find select or options.', question.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + const rowModel: AddonModQuizQuestionMatchSelect = { + id: select.id.replace(/:/g, '\\:'), + name: select.name, + disabled: select.disabled, + options: [], + text: columns[0].innerHTML, // Row's text should be in the first column. + }; + + // Check if answer is correct. + if (columns[1].className.indexOf('incorrect') >= 0) { + rowModel.isCorrect = 0; + } else if (columns[1].className.indexOf('correct') >= 0) { + rowModel.isCorrect = 1; + } + + // Treat each option. + for (const j in options) { + const optionEl = options[j]; + + if (typeof optionEl.value == 'undefined') { + this.logger.warn('Aborting because couldn\'t find the value of an option.', question.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + const option: AddonModQuizQuestionSelectOption = { + value: optionEl.value, + label: optionEl.innerHTML, + selected: optionEl.selected, + }; + + if (option.selected) { + rowModel.selected = option.value; + } + + rowModel.options.push(option); + } + + // Get the accessibility label. + const accessibilityLabel = columns[1].querySelector('label.accesshide'); + rowModel.accessibilityLabel = accessibilityLabel?.innerHTML; + + question.rows.push(rowModel); + } + + question.loaded = true; + + return questionEl; + } + + /** + * Initialize a question component with a multiple choice (checkbox) or single choice (radio). + * + * @return Element containing the question HTML, void if the data is not valid. + */ + initMultichoiceComponent(): void | HTMLElement { + const questionEl = this.initComponent(); + if (!questionEl) { + return; + } + + // Get the prompt. + const question = this.question!; + question.prompt = CoreDomUtils.instance.getContentsOfElement(questionEl, '.prompt'); + + // Search radio buttons first (single choice). + let options = Array.from(questionEl.querySelectorAll('input[type="radio"]')); + if (!options || !options.length) { + // Radio buttons not found, it should be a multi answer. Search for checkbox. + question.multi = true; + options = Array.from(questionEl.querySelectorAll('input[type="checkbox"]')); + + if (!options || !options.length) { + // No checkbox found either. Abort. + this.logger.warn('Aborting because of no radio and checkbox found.', question.slot); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + } + + question.options = []; + question.disabled = true; + + for (const i in options) { + const element = options[i]; + const option: AddonModQuizQuestionRadioOption = { + id: element.id, + name: element.name, + value: element.value, + checked: element.checked, + disabled: element.disabled, + }; + const parent = element.parentElement; + + if (option.value == '-1') { + // It's the clear choice option, ignore it. + continue; + } + + question.optionsName = option.name; + question.disabled = question.disabled && element.disabled; + + // Get the label with the question text. Try the new format first. + const labelId = element.getAttribute('aria-labelledby'); + let label = labelId ? questionEl.querySelector('#' + labelId.replace(/:/g, '\\:')) : undefined; + if (!label) { + // Not found, use the old format. + label = questionEl.querySelector('label[for="' + option.id + '"]'); + } + + // Check that we were able to successfully extract options required data. + if (!label || option.name === undefined || option.value === undefined) { + // Something went wrong when extracting the questions data. Abort. + this.logger.warn('Aborting because of an error parsing options.', question.slot, option.name); + + return CoreQuestionHelper.instance.showComponentError(this.onAbort); + } + + option.text = label.innerHTML; + + if (element.checked) { + // If the option is checked and it's a single choice we use the model to select the one. + if (!question.multi) { + question.singleChoiceModel = option.value; + } + + if (parent) { + // Check if answer is correct. + if (parent && parent.className.indexOf('incorrect') >= 0) { + option.isCorrect = 0; + } else if (parent && parent.className.indexOf('correct') >= 0) { + option.isCorrect = 1; + } + + // Search the feedback. + const feedback = parent.querySelector('.specificfeedback'); + if (feedback) { + option.feedback = feedback.innerHTML; + } + } + } + + question.options.push(option); + } + + return questionEl; + } + +} + +/** + * Any possible types of question. + */ +export type AddonModQuizQuestion = AddonModQuizCalculatedQuestion | AddonModQuizEssayQuestion | AddonModQuizTextQuestion | +AddonModQuizMatchQuestion | AddonModQuizMultichoiceQuestion; + +/** + * Basic data for question. + */ +export type AddonModQuizQuestionBasicData = CoreQuestionQuestion & { + text?: string; +}; + +/** + * Data for calculated question. + */ +export type AddonModQuizCalculatedQuestion = AddonModQuizTextQuestion & { + select?: AddonModQuizQuestionSelect; // Select data if units use a select. + selectFirst?: boolean; // Whether the select is first or after the input. + options?: AddonModQuizQuestionRadioOption[]; // Options if units use radio buttons. + optionsName?: string; // Options name (for radio buttons). + unit?: string; // Option selected (for radio buttons). + optionsFirst?: boolean; // Whether the radio buttons are first or after the input. +}; + +/** + * Data for a select. + */ +export type AddonModQuizQuestionSelect = { + id: string; + name: string; + disabled: boolean; + options: AddonModQuizQuestionSelectOption[]; + selected?: string; + accessibilityLabel?: string; +}; + +/** + * Data for each option in a select. + */ +export type AddonModQuizQuestionSelectOption = { + value: string; + label: string; + selected: boolean; +}; + +/** + * Data for radio button. + */ +export type AddonModQuizQuestionRadioOption = { + id: string; + name: string; + value: string; + disabled: boolean; + checked: boolean; + text?: string; + isCorrect?: number; + feedback?: string; +}; + +/** + * Data for essay question. + */ +export type AddonModQuizEssayQuestion = AddonModQuizQuestionBasicData & { + allowsAttachments?: boolean; // Whether the question allows attachments. + allowsAnswerFiles?: boolean; // Whether the question allows adding files in the answer. + isMonospaced?: boolean; // Whether the answer is monospaced. + isPlainText?: boolean; // Whether the answer is plain text. + hasInlineText?: boolean; // // Whether the answer has inline text + answer?: string; // Question answer text. + attachments?: CoreWSExternalFile[]; // Question answer attachments. + hasDraftFiles?: boolean; // Whether the question has draft files. + textarea?: AddonModQuizQuestionTextarea; // Textarea data. + formatInput?: { name: string; value: string }; // Format input data. + answerDraftIdInput?: { name: string; value: number }; // Answer draft id input data. + attachmentsDraftIdInput?: { name: string; value: number }; // Attachments draft id input data. + attachmentsMaxFiles?: number; // Max number of attachments. + attachmentsAcceptedTypes?: string; // Attachments accepted file types. + attachmentsMaxBytes?: number; // Max bytes for attachments. +}; + +/** + * Data for textarea. + */ +export type AddonModQuizQuestionTextarea = { + id: string; + name: string; + text: string; +}; + +/** + * Data for text question. + */ +export type AddonModQuizTextQuestion = AddonModQuizQuestionBasicData & { + input?: AddonModQuizQuestionTextInput; +}; + +/** + * Data for text input. + */ +export type AddonModQuizQuestionTextInput = { + id: string; + name: string; + value: string; + readOnly: boolean; + isInline: boolean; + correctClass?: string; + correctIcon?: string; + correctIconColor?: string; +}; + +/** + * Data for match question. + */ +export type AddonModQuizMatchQuestion = AddonModQuizQuestionBasicData & { + loaded?: boolean; // Whether the question is loaded. + rows?: AddonModQuizQuestionMatchSelect[]; // Data for each row. +}; + +/** + * Each select data for match questions. + */ +export type AddonModQuizQuestionMatchSelect = AddonModQuizQuestionSelect & { + text: string; + isCorrect?: number; +}; + +/** + * Data for multichoice question. + */ +export type AddonModQuizMultichoiceQuestion = AddonModQuizQuestionBasicData & { + prompt?: string; // Question prompt. + multi?: boolean; // Whether the question allows more than one selected answer. + options?: AddonModQuizQuestionRadioOption[]; // List of options. + disabled?: boolean; // Whether the question is disabled. + optionsName?: string; // Name to use for the options in single choice. + singleChoiceModel?: string; // Model for single choice. +}; diff --git a/src/core/services/file.ts b/src/core/services/file.ts index 539aea31e..529b96334 100644 --- a/src/core/services/file.ts +++ b/src/core/services/file.ts @@ -1095,7 +1095,6 @@ export class CoreFileProvider { const entries = await this.getDirectoryContents(dirPath); const files = {}; - let num = 1; let fileNameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(fileName); let extension = CoreMimetypeUtils.instance.getFileExtension(fileName) || defaultExt; @@ -1116,26 +1115,40 @@ export class CoreFileProvider { extension = ''; } - let newName = fileNameWithoutExtension + extension; - if (typeof files[newName.toLowerCase()] == 'undefined') { - // No file with the same name. - return newName; - } else { - // Repeated name. Add a number until we find a free name. - do { - newName = fileNameWithoutExtension + '(' + num + ')' + extension; - num++; - } while (typeof files[newName.toLowerCase()] != 'undefined'); - - // Ask the user what he wants to do. - return newName; - } + return this.calculateUniqueName(files, fileNameWithoutExtension + extension); } catch (error) { // Folder doesn't exist, name is unique. Clean it and return it. return CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName)); } } + /** + * Given a file name and a set of already used names, calculate a unique name. + * + * @param usedNames Object with names already used as keys. + * @param name Name to check. + * @return Unique name. + */ + calculateUniqueName(usedNames: Record, name: string): string { + if (typeof usedNames[name.toLowerCase()] == 'undefined') { + // No file with the same name. + return name; + } + + // Repeated name. Add a number until we find a free name. + const nameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(name); + let extension = CoreMimetypeUtils.instance.getFileExtension(name); + let num = 1; + extension = extension ? '.' + extension : ''; + + do { + name = nameWithoutExtension + '(' + num + ')' + extension; + num++; + } while (typeof usedNames[name.toLowerCase()] != 'undefined'); + + return name; + } + /** * Remove app temporary folder. * diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 6d75cdd1c..f09b70c54 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -600,6 +600,8 @@ export class CoreDomUtilsProvider { * @param positionParentClass Parent Class where to stop calculating the position. Default inner-scroll. * @return positionLeft, positionTop of the element relative to. */ + getElementXY(container: HTMLElement, selector: undefined, positionParentClass?: string): number[]; + getElementXY(container: HTMLElement, selector: string, positionParentClass?: string): number[] | null; getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] | null { let element: HTMLElement | null = (selector ? container.querySelector(selector) : container); let positionTop = 0; diff --git a/src/core/services/utils/text.ts b/src/core/services/utils/text.ts index 247749821..7063dec43 100644 --- a/src/core/services/utils/text.ts +++ b/src/core/services/utils/text.ts @@ -740,6 +740,63 @@ export class CoreTextUtilsProvider { return text.replace(/(?:\r\n|\r|\n)/g, newValue); } + /** + * Replace draftfile URLs with the equivalent pluginfile URL. + * + * @param siteUrl URL of the site. + * @param text Text to treat, including draftfile URLs. + * @param files List of files of the area, using pluginfile URLs. + * @return Treated text and map with the replacements. + */ + replaceDraftfileUrls( + siteUrl: string, + text: string, + files: CoreWSExternalFile[], + ): { text: string; replaceMap?: {[url: string]: string} } { + + if (!text || !files || !files.length) { + return { text }; + } + + const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php'); + const matches = text.match(new RegExp(this.escapeForRegex(draftfileUrl) + '[^\'" ]+', 'ig')); + + if (!matches || !matches.length) { + return { text }; + } + + // Index the pluginfile URLs by file name. + const pluginfileMap: {[name: string]: string} = {}; + files.forEach((file) => { + pluginfileMap[file.filename!] = file.fileurl; + }); + + // Replace each draftfile with the corresponding pluginfile URL. + const replaceMap: {[url: string]: string} = {}; + matches.forEach((url) => { + if (replaceMap[url]) { + // URL already treated, same file embedded more than once. + return; + } + + // Get the filename from the URL. + let filename = url.substr(url.lastIndexOf('/') + 1); + if (filename.indexOf('?') != -1) { + filename = filename.substr(0, filename.indexOf('?')); + } + + if (pluginfileMap[filename]) { + replaceMap[url] = pluginfileMap[filename]; + text = text.replace(new RegExp(this.escapeForRegex(url), 'g'), pluginfileMap[filename]); + } + }); + + return { + text, + replaceMap, + }; + } + /** * Replace @@PLUGINFILE@@ wildcards with the real URL in a text. * @@ -758,6 +815,37 @@ export class CoreTextUtilsProvider { return text; } + /** + * Restore original draftfile URLs. + * + * @param text Text to treat, including pluginfile URLs. + * @param replaceMap Map of the replacements that were done. + * @return Treated text. + */ + restoreDraftfileUrls(siteUrl: string, treatedText: string, originalText: string, files: CoreWSExternalFile[]): string { + if (!treatedText || !files || !files.length) { + return treatedText; + } + + const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php'); + const draftfileUrlRegexPrefix = this.escapeForRegex(draftfileUrl) + '/[^/]+/[^/]+/[^/]+/[^/]+/'; + + files.forEach((file) => { + // Search the draftfile URL in the original text. + const matches = originalText.match( + new RegExp(draftfileUrlRegexPrefix + this.escapeForRegex(file.filename!) + '[^\'" ]*', 'i'), + ); + + if (!matches || !matches[0]) { + return; // Original URL not found, skip. + } + + treatedText = treatedText.replace(new RegExp(this.escapeForRegex(file.fileurl), 'g'), matches[0]); + }); + + return treatedText; + } + /** * Replace pluginfile URLs with @@PLUGINFILE@@ wildcards. * From 1c443b183bfdc0e6a29d580ac63a2e3d07ebcc12 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 18 Feb 2021 09:19:38 +0100 Subject: [PATCH 08/16] MOBILE-3651 quiz: Implement player page --- .../mod/lesson/pages/player/player.html | 4 +- src/addons/mod/lesson/pages/player/player.ts | 4 +- src/addons/mod/quiz/classes/auto-save.ts | 254 ++++++ .../mod/quiz/components/components.module.ts | 6 + .../connection-error/connection-error.html | 3 + .../connection-error/connection-error.scss | 7 + .../connection-error/connection-error.ts | 27 + src/addons/mod/quiz/components/index/index.ts | 9 +- .../navigation-modal/navigation-modal.html | 67 ++ .../navigation-modal/navigation-modal.ts | 76 ++ .../preflight-modal/preflight-modal.html | 32 + .../preflight-modal/preflight-modal.ts | 138 ++++ src/addons/mod/quiz/pages/player/player.html | 189 +++++ .../mod/quiz/pages/player/player.module.ts | 42 + src/addons/mod/quiz/pages/player/player.scss | 10 + src/addons/mod/quiz/pages/player/player.ts | 782 ++++++++++++++++++ src/addons/mod/quiz/quiz-lazy.module.ts | 4 + .../addon-user-profile-field-datetime.html | 4 +- .../addon-user-profile-field-menu.html | 2 +- .../addon-user-profile-field-text.html | 2 +- .../addon-user-profile-field-textarea.html | 4 +- src/core/classes/site.ts | 17 +- .../components/input-errors/input-errors.ts | 2 +- .../self-enrol-password.html | 25 +- .../services/fileuploader-helper.ts | 9 + .../login/pages/credentials/credentials.html | 15 +- .../login/pages/reconnect/reconnect.html | 15 +- src/core/features/login/pages/site/site.html | 6 +- src/core/lang.json | 1 + src/core/services/filepool.ts | 11 +- src/theme/globals.variables.scss | 2 + src/theme/theme.base.scss | 5 + src/theme/theme.light.scss | 20 +- 33 files changed, 1735 insertions(+), 59 deletions(-) create mode 100644 src/addons/mod/quiz/classes/auto-save.ts create mode 100644 src/addons/mod/quiz/components/connection-error/connection-error.html create mode 100644 src/addons/mod/quiz/components/connection-error/connection-error.scss create mode 100644 src/addons/mod/quiz/components/connection-error/connection-error.ts create mode 100644 src/addons/mod/quiz/components/navigation-modal/navigation-modal.html create mode 100644 src/addons/mod/quiz/components/navigation-modal/navigation-modal.ts create mode 100644 src/addons/mod/quiz/components/preflight-modal/preflight-modal.html create mode 100644 src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts create mode 100644 src/addons/mod/quiz/pages/player/player.html create mode 100644 src/addons/mod/quiz/pages/player/player.module.ts create mode 100644 src/addons/mod/quiz/pages/player/player.scss create mode 100644 src/addons/mod/quiz/pages/player/player.ts diff --git a/src/addons/mod/lesson/pages/player/player.html b/src/addons/mod/lesson/pages/player/player.html index a3ad6de00..e534fbc27 100644 --- a/src/addons/mod/lesson/pages/player/player.html +++ b/src/addons/mod/lesson/pages/player/player.html @@ -99,7 +99,7 @@ - + - + (CoreEvents.ACTIVITY_DATA_SENT, { module: 'lesson' }); // Format activity link if present. if (this.eolData.activitylink) { diff --git a/src/addons/mod/quiz/classes/auto-save.ts b/src/addons/mod/quiz/classes/auto-save.ts new file mode 100644 index 000000000..981d4bd89 --- /dev/null +++ b/src/addons/mod/quiz/classes/auto-save.ts @@ -0,0 +1,254 @@ +// (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 { BehaviorSubject } from 'rxjs'; + +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreQuestionsAnswers } from '@features/question/services/question'; +import { PopoverController } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { AddonModQuizConnectionErrorComponent } from '../components/connection-error/connection-error'; +import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../services/quiz'; + +/** + * Class to support auto-save in quiz. Every certain seconds, it will check if there are changes in the current page answers + * and, if so, it will save them automatically. + */ +export class AddonModQuizAutoSave { + + protected readonly CHECK_CHANGES_INTERVAL = 5000; + + protected logger: CoreLogger; + protected checkChangesInterval?: number; // Interval to check if there are changes in the answers. + protected loadPreviousAnswersTimeout?: number; // Timeout to load previous answers. + protected autoSaveTimeout?: number; // Timeout to auto-save the answers. + protected popover?: HTMLIonPopoverElement; // Popover to display there's been an error. + protected popoverShown = false; // Whether the popover is shown. + protected previousAnswers?: CoreQuestionsAnswers; // The previous answers, to check if answers have changed. + protected errorObservable: BehaviorSubject; // An observable to notify if there's been an error. + + /** + * Constructor. + * + * @param formName Name of the form where the answers are stored. + * @param buttonSelector Selector to find the button to show the connection error. + */ + constructor( + protected formName: string, + protected buttonSelector: string, + ) { + this.logger = CoreLogger.getInstance('AddonModQuizAutoSave'); + + // Create the observable to notify if an error happened. + this.errorObservable = new BehaviorSubject(false); + } + + /** + * Cancel a pending auto save. + */ + cancelAutoSave(): void { + clearTimeout(this.autoSaveTimeout); + this.autoSaveTimeout = undefined; + } + + /** + * Check if the answers have changed in a page. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight data. + * @param offline Whether the quiz is being attempted in offline mode. + */ + checkChanges( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + preflightData: Record, + offline?: boolean, + ): void { + if (this.autoSaveTimeout) { + // We already have an auto save pending, no need to check changes. + return; + } + + const answers = this.getAnswers(); + + if (!this.previousAnswers) { + // Previous answers isn't set, set it now. + this.previousAnswers = answers; + + return; + } + + // Check if answers have changed. + let equal = true; + + for (const name in answers) { + if (this.previousAnswers[name] != answers[name]) { + equal = false; + break; + } + } + + if (!equal) { + this.setAutoSaveTimer(quiz, attempt, preflightData, offline); + } + + this.previousAnswers = answers; + } + + /** + * Get answers from a form. + * + * @return Answers. + */ + protected getAnswers(): CoreQuestionsAnswers { + return CoreQuestionHelper.instance.getAnswersFromForm(document.forms[this.formName]); + } + + /** + * Hide the auto save error. + */ + hideAutoSaveError(): void { + this.errorObservable.next(false); + this.popover?.dismiss(); + } + + /** + * Returns an observable that will notify when an error happens or stops. + * It will send true when there's an error, and false when the error has been ammended. + * + * @return Observable. + */ + onError(): BehaviorSubject { + return this.errorObservable; + } + + /** + * Schedule an auto save process if it's not scheduled already. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight data. + * @param offline Whether the quiz is being attempted in offline mode. + */ + setAutoSaveTimer( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + preflightData: Record, + offline?: boolean, + ): void { + // Don't schedule if already shceduled or quiz is almost closed. + if (!quiz.autosaveperiod || this.autoSaveTimeout || AddonModQuiz.instance.isAttemptTimeNearlyOver(quiz, attempt)) { + return; + } + + // Schedule save. + this.autoSaveTimeout = window.setTimeout(async () => { + const answers = this.getAnswers(); + this.cancelAutoSave(); + this.previousAnswers = answers; // Update previous answers to match what we're sending to the server. + + try { + await AddonModQuiz.instance.saveAttempt(quiz, attempt, answers, preflightData, offline); + + // Save successful, we can hide the connection error if it was shown. + this.hideAutoSaveError(); + } catch (error) { + // Error auto-saving. Show error and set timer again. + this.logger.warn('Error auto-saving data.', error); + + // If there was no error already, show the error message. + if (!this.errorObservable.getValue()) { + this.errorObservable.next(true); + this.showAutoSaveError(); + } + + // Try again. + this.setAutoSaveTimer(quiz, attempt, preflightData, offline); + } + }, quiz.autosaveperiod * 1000); + } + + /** + * Show an error popover due to an auto save error. + */ + async showAutoSaveError(ev?: Event): Promise { + // Don't show popover if it was already shown. + if (this.popoverShown) { + return; + } + + const event: unknown = ev || { + // Cannot use new Event() because event's target property is readonly + target: document.querySelector(this.buttonSelector), + stopPropagation: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + preventDefault: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + }; + this.popoverShown = true; + + this.popover = await PopoverController.instance.create({ + component: AddonModQuizConnectionErrorComponent, + event: event, + }); + await this.popover.present(); + + await this.popover.onDidDismiss(); + + this.popoverShown = false; + } + + /** + * Start a process to periodically check changes in answers. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight data. + * @param offline Whether the quiz is being attempted in offline mode. + */ + startCheckChangesProcess( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + preflightData: Record, + offline?: boolean, + ): void { + if (this.checkChangesInterval || !quiz.autosaveperiod) { + // We already have the interval in place or the quiz has autosave disabled. + return; + } + + this.previousAnswers = undefined; + + // Load initial answers in 2.5 seconds so the first check interval finds them already loaded. + this.loadPreviousAnswersTimeout = window.setTimeout(() => { + this.checkChanges(quiz, attempt, preflightData, offline); + }, 2500); + + // Check changes every certain time. + this.checkChangesInterval = window.setInterval(() => { + this.checkChanges(quiz, attempt, preflightData, offline); + }, this.CHECK_CHANGES_INTERVAL); + } + + /** + * Stops the periodical check for changes. + */ + stopCheckChangesProcess(): void { + clearTimeout(this.loadPreviousAnswersTimeout); + clearInterval(this.checkChangesInterval); + + this.loadPreviousAnswersTimeout = undefined; + this.checkChangesInterval = undefined; + } + +} diff --git a/src/addons/mod/quiz/components/components.module.ts b/src/addons/mod/quiz/components/components.module.ts index ef510db4b..9b167b237 100644 --- a/src/addons/mod/quiz/components/components.module.ts +++ b/src/addons/mod/quiz/components/components.module.ts @@ -18,11 +18,15 @@ import { CoreSharedModule } from '@/core/shared.module'; import { CoreCourseComponentsModule } from '@features/course/components/components.module'; import { AddonModQuizConnectionErrorComponent } from './connection-error/connection-error'; import { AddonModQuizIndexComponent } from './index/index'; +import { AddonModQuizNavigationModalComponent } from './navigation-modal/navigation-modal'; +import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight-modal'; @NgModule({ declarations: [ AddonModQuizIndexComponent, AddonModQuizConnectionErrorComponent, + AddonModQuizNavigationModalComponent, + AddonModQuizPreflightModalComponent, ], imports: [ CoreSharedModule, @@ -33,6 +37,8 @@ import { AddonModQuizIndexComponent } from './index/index'; exports: [ AddonModQuizIndexComponent, AddonModQuizConnectionErrorComponent, + AddonModQuizNavigationModalComponent, + AddonModQuizPreflightModalComponent, ], }) export class AddonModQuizComponentsModule {} diff --git a/src/addons/mod/quiz/components/connection-error/connection-error.html b/src/addons/mod/quiz/components/connection-error/connection-error.html new file mode 100644 index 000000000..97d6d770b --- /dev/null +++ b/src/addons/mod/quiz/components/connection-error/connection-error.html @@ -0,0 +1,3 @@ + + {{ "addon.mod_quiz.connectionerror" | translate }} + diff --git a/src/addons/mod/quiz/components/connection-error/connection-error.scss b/src/addons/mod/quiz/components/connection-error/connection-error.scss new file mode 100644 index 000000000..7658c1e7e --- /dev/null +++ b/src/addons/mod/quiz/components/connection-error/connection-error.scss @@ -0,0 +1,7 @@ +:host { + background-color: var(--red-light); + + .item { + --background: var(--red-light); + } +} diff --git a/src/addons/mod/quiz/components/connection-error/connection-error.ts b/src/addons/mod/quiz/components/connection-error/connection-error.ts new file mode 100644 index 000000000..eedc9dcf0 --- /dev/null +++ b/src/addons/mod/quiz/components/connection-error/connection-error.ts @@ -0,0 +1,27 @@ +// (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 } from '@angular/core'; + +/** + * Component that displays a quiz entry page. + */ +@Component({ + selector: 'addon-mod-quiz-connection-error', + templateUrl: 'connection-error.html', + styleUrls: ['connection-error.scss'], +}) +export class AddonModQuizConnectionErrorComponent { + +} diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index 19912e9bb..d7535b99b 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -21,6 +21,7 @@ import { CoreCourse } from '@features/course/services/course'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; import { IonContent } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; @@ -464,7 +465,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp this.content?.scrollToTop(); await promise; - await CoreUtils.instance.ignoreErrors(this.refreshContent()); + await CoreUtils.instance.ignoreErrors(this.refreshContent(true)); this.loaded = true; this.refreshIcon = CoreConstants.ICON_REFRESH; @@ -533,7 +534,11 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp protected openQuiz(): void { this.hasPlayed = true; - // @todo this.navCtrl.push('player', {courseId: this.courseId, quizId: this.quiz.id, moduleUrl: this.module.url}); + CoreNavigator.instance.navigate(`../../player/${this.courseId}/${this.quiz!.id}`, { + params: { + moduleUrl: this.module?.url, + }, + }); } /** diff --git a/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html new file mode 100644 index 000000000..6b9e116af --- /dev/null +++ b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html @@ -0,0 +1,67 @@ + + + {{ 'addon.mod_quiz.quiznavigation' | translate }} + + + + + + + + + + + diff --git a/src/addons/mod/quiz/components/navigation-modal/navigation-modal.ts b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.ts new file mode 100644 index 000000000..d081e47c1 --- /dev/null +++ b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.ts @@ -0,0 +1,76 @@ +// (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 } from '@angular/core'; + +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { ModalController } from '@singletons'; + +/** + * Modal that renders the quiz navigation. + */ +@Component({ + selector: 'addon-mod-quiz-navigation-modal', + templateUrl: 'navigation-modal.html', +}) +export class AddonModQuizNavigationModalComponent { + + static readonly CHANGE_PAGE = 1; + static readonly SWITCH_MODE = 2; + + @Input() navigation?: AddonModQuizNavigationQuestion[]; // Whether the user is reviewing the attempt. + @Input() summaryShown?: boolean; // Whether summary is currently being shown. + @Input() currentPage?: number; // Current page. + @Input() isReview?: boolean; // Whether the user is reviewing the attempt. + @Input() numPages = 0; // Num of pages for review mode. + @Input() showAll?: boolean; // Whether to show all questions in same page or not for review mode. + + /** + * Close modal. + */ + closeModal(): void { + ModalController.instance.dismiss(); + } + + /** + * Load a certain page. + * + * @param page The page to load. + * @param slot Slot of the question to scroll to. + */ + loadPage(page: number, slot?: number): void { + ModalController.instance.dismiss({ + action: AddonModQuizNavigationModalComponent.CHANGE_PAGE, + page, + slot, + }); + } + + /** + * Switch mode in review. + */ + switchMode(): void { + ModalController.instance.dismiss({ + action: AddonModQuizNavigationModalComponent.SWITCH_MODE, + }); + } + +} + +/** + * Question for the navigation menu with some calculated data. + */ +export type AddonModQuizNavigationQuestion = CoreQuestionQuestionParsed & { + stateClass?: string; +}; diff --git a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html new file mode 100644 index 000000000..959cf7d7e --- /dev/null +++ b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html @@ -0,0 +1,32 @@ + + + {{ title | translate }} + + + + + + + + + + +
+ + + + +

Couldn't find the directive to render this access rule.

+
+ +
+ + + {{ title | translate }} + + + +
+
+
+
diff --git a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts new file mode 100644 index 000000000..f15da32c7 --- /dev/null +++ b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts @@ -0,0 +1,138 @@ +// (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, OnInit, ViewChild, ElementRef, Input, Type } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { IonContent } from '@ionic/angular'; +import { CoreSites } from '@services/sites'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { ModalController, Translate } from '@singletons'; +import { AddonModQuizAccessRuleDelegate } from '../../services/access-rules-delegate'; +import { AddonModQuizAttemptWSData, AddonModQuizQuizWSData } from '../../services/quiz'; + +/** + * Modal that renders the access rules for a quiz. + */ +@Component({ + selector: 'page-addon-mod-quiz-preflight-modal', + templateUrl: 'preflight-modal.html', +}) +export class AddonModQuizPreflightModalComponent implements OnInit { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild('preflightFormEl') formElement?: ElementRef; + + @Input() title!: string; + @Input() quiz?: AddonModQuizQuizWSData; + @Input() attempt?: AddonModQuizAttemptWSData; + @Input() prefetch?: boolean; + @Input() siteId!: string; + @Input() rules!: string[]; + + preflightForm: FormGroup; + accessRulesData: { component: Type; data: Record}[] = []; // Component and data for each access rule. + loaded = false; + + constructor( + formBuilder: FormBuilder, + ) { + // Create an empty form group. The controls will be added by the access rules components. + this.preflightForm = formBuilder.group({}); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.title = this.title || Translate.instance.instant('addon.mod_quiz.startattempt'); + this.siteId = this.siteId || CoreSites.instance.getCurrentSiteId(); + this.rules = this.rules || []; + + if (!this.quiz) { + return; + } + + try { + await Promise.all(this.rules.map(async (rule) => { + // Check if preflight is required for rule and, if so, get the component to render it. + const required = await AddonModQuizAccessRuleDelegate.instance.isPreflightCheckRequiredForRule( + rule, + this.quiz!, + this.attempt, + this.prefetch, + this.siteId, + ); + + if (!required) { + return; + } + + const component = await AddonModQuizAccessRuleDelegate.instance.getPreflightComponent(rule); + if (!component) { + return; + } + + this.accessRulesData.push({ + component: component, + data: { + rule: rule, + quiz: this.quiz, + attempt: this.attempt, + prefetch: this.prefetch, + form: this.preflightForm, + siteId: this.siteId, + }, + }); + })); + + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading rules'); + } finally { + this.loaded = true; + } + } + + /** + * Check that the data is valid and send it back. + * + * @param e Event. + */ + sendData(e: Event): void { + e.preventDefault(); + e.stopPropagation(); + + if (!this.preflightForm.valid) { + // Form not valid. Scroll to the first element with errors. + if (!CoreDomUtils.instance.scrollToInputError(this.content)) { + // Input not found, show an error modal. + CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true); + } + } else { + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, this.siteId); + + ModalController.instance.dismiss(this.preflightForm.value); + } + } + + /** + * Close modal. + */ + closeModal(): void { + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, this.siteId); + + ModalController.instance.dismiss(); + } + +} diff --git a/src/addons/mod/quiz/pages/player/player.html b/src/addons/mod/quiz/pages/player/player.html new file mode 100644 index 000000000..8c8f39b12 --- /dev/null +++ b/src/addons/mod/quiz/pages/player/player.html @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'addon.mod_quiz.startattempt' | translate }} + + + +
+
+ + + + +

+ {{ 'core.question.questionno' | translate:{$a: question.number} }} +

+

{{ 'core.question.information' | translate }}

+
+
+

{{question.status}}

+

{{ question.readableMark }}

+
+
+ + + + +
+
+
+ + + + + + + + {{ 'core.previous' | translate }} + + + + + {{ 'core.next' | translate }} + + + + + + + + + + {{ 'addon.mod_quiz.summaryofattempt' | translate }} + + + + + + + + {{ 'addon.mod_quiz.question' | translate }} + + # + + {{ 'addon.mod_quiz.status' | translate }} + + + + + + + + + + + {{ question.number }} + {{ question.status }} + + + + + + + + {{ 'addon.mod_quiz.returnattempt' | translate }} + + + + + {{ dueDateWarning }} + + + + + + + + + +

{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}

+

{{message}}

+
+
+ + + {{ 'core.openinbrowser' | translate }} + + + + + + {{ 'addon.mod_quiz.submitallandfinish' | translate }} + +
+ + + + + {{ 'addon.mod_quiz.errorparsequestions' | translate }} + + + {{ 'core.openinbrowser' | translate }} + + + +
+
diff --git a/src/addons/mod/quiz/pages/player/player.module.ts b/src/addons/mod/quiz/pages/player/player.module.ts new file mode 100644 index 000000000..603555962 --- /dev/null +++ b/src/addons/mod/quiz/pages/player/player.module.ts @@ -0,0 +1,42 @@ +// (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 { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { CoreQuestionComponentsModule } from '@features/question/components/components.module'; +import { CanLeaveGuard } from '@guards/can-leave'; +import { AddonModQuizPlayerPage } from './player'; + +const routes: Routes = [ + { + path: '', + component: AddonModQuizPlayerPage, + canDeactivate: [CanLeaveGuard], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + CoreQuestionComponentsModule, + ], + declarations: [ + AddonModQuizPlayerPage, + ], + exports: [RouterModule], +}) +export class AddonModQuizPlayerPageModule {} diff --git a/src/addons/mod/quiz/pages/player/player.scss b/src/addons/mod/quiz/pages/player/player.scss new file mode 100644 index 000000000..117b78119 --- /dev/null +++ b/src/addons/mod/quiz/pages/player/player.scss @@ -0,0 +1,10 @@ +:host { + .addon-mod_quiz-question-note p { + margin-top: 2px; + margin-bottom: 2px; + } + + ion-toolbar { + border-bottom: 1px solid var(--gray); + } +} diff --git a/src/addons/mod/quiz/pages/player/player.ts b/src/addons/mod/quiz/pages/player/player.ts new file mode 100644 index 000000000..f6124b557 --- /dev/null +++ b/src/addons/mod/quiz/pages/player/player.ts @@ -0,0 +1,782 @@ +// (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, OnInit, OnDestroy, ViewChild, ChangeDetectorRef, ViewChildren, QueryList, ElementRef } from '@angular/core'; +import { IonContent } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreQuestionComponent } from '@features/question/components/question/question'; +import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question'; +import { CoreQuestionBehaviourButton, CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Translate } from '@singletons'; +import { CoreEventActivityDataSentData, CoreEvents } from '@singletons/events'; +import { AddonModQuizAutoSave } from '../../classes/auto-save'; +import { + AddonModQuizNavigationModalComponent, + AddonModQuizNavigationQuestion, +} from '../../components/navigation-modal/navigation-modal'; +import { + AddonModQuiz, + AddonModQuizAttemptFinishedData, + AddonModQuizAttemptWSData, + AddonModQuizGetAttemptAccessInformationWSResponse, + AddonModQuizGetQuizAccessInformationWSResponse, + AddonModQuizProvider, + AddonModQuizQuizWSData, +} from '../../services/quiz'; +import { AddonModQuizAttempt, AddonModQuizHelper } from '../../services/quiz-helper'; +import { AddonModQuizSync } from '../../services/quiz-sync'; + +/** + * Page that allows attempting a quiz. + */ +@Component({ + selector: 'page-addon-mod-quiz-player', + templateUrl: 'player.html', + styleUrls: ['player.scss'], +}) +export class AddonModQuizPlayerPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; + @ViewChildren(CoreQuestionComponent) questionComponents?: QueryList; + @ViewChild('quizForm') formElement?: ElementRef; + + quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to. + attempt?: AddonModQuizAttempt; // The attempt being attempted. + moduleUrl?: string; // URL to the module in the site. + component = AddonModQuizProvider.COMPONENT; // Component to link the files to. + loaded = false; // Whether data has been loaded. + quizAborted = false; // Whether the quiz was aborted due to an error. + offline = false; // Whether the quiz is being attempted in offline mode. + navigation: AddonModQuizNavigationQuestion[] = []; // List of questions to navigate them. + questions: QuizQuestion[] = []; // Questions of the current page. + nextPage = -2; // Next page. + previousPage = -1; // Previous page. + showSummary = false; // Whether the attempt summary should be displayed. + summaryQuestions: CoreQuestionQuestionParsed[] = []; // The questions to display in the summary. + canReturn = false; // Whether the user can return to a page after seeing the summary. + preventSubmitMessages: string[] = []; // List of messages explaining why the quiz cannot be submitted. + endTime?: number; // The time when the attempt must be finished. + autoSaveError = false; // Whether there's been an error in auto-save. + isSequential = false; // Whether quiz navigation is sequential. + readableTimeLimit?: string; // Time limit in a readable format. + dueDateWarning?: string; // Warning about due date. + courseId!: number; // The course ID the quiz belongs to. + + protected quizId!: number; // Quiz ID to attempt. + protected preflightData: Record = {}; // Preflight data to attempt the quiz. + protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access information. + protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Attempt access info. + protected lastAttempt?: AddonModQuizAttemptWSData; // Last user attempt before a new one is created (if needed). + protected newAttempt = false; // Whether the user is starting a new attempt. + protected quizDataLoaded = false; // Whether the quiz data has been loaded. + protected timeUpCalled = false; // Whether the time up function has been called. + protected autoSave!: AddonModQuizAutoSave; // Class to auto-save answers every certain time. + protected autoSaveErrorSubscription?: Subscription; // To be notified when an error happens in auto-save. + protected forceLeave = false; // If true, don't perform any check when leaving the view. + protected reloadNavigation = false; // Whether navigation needs to be reloaded because some data was sent to server. + + constructor( + protected changeDetector: ChangeDetectorRef, + protected elementRef: ElementRef, + ) { + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.quizId = CoreNavigator.instance.getRouteNumberParam('quizId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.moduleUrl = CoreNavigator.instance.getRouteParam('moduleUrl'); + + // Block the quiz so it cannot be synced. + CoreSync.instance.blockOperation(AddonModQuizProvider.COMPONENT, this.quizId); + + // Create the auto save instance. + this.autoSave = new AddonModQuizAutoSave( + 'addon-mod_quiz-player-form', + '#addon-mod_quiz-connection-error-button', + ); + + // Start the player when the page is loaded. + this.start(); + + // Listen for errors on auto-save. + this.autoSaveErrorSubscription = this.autoSave.onError().subscribe((error) => { + this.autoSaveError = error; + this.changeDetector.detectChanges(); + }); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + // Stop auto save. + this.autoSave.cancelAutoSave(); + this.autoSave.stopCheckChangesProcess(); + this.autoSaveErrorSubscription?.unsubscribe(); + + // Unblock the quiz so it can be synced. + CoreSync.instance.unblockOperation(AddonModQuizProvider.COMPONENT, this.quizId); + } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + async ionViewCanLeave(): Promise { + if (this.forceLeave || this.quizAborted || !this.questions.length || this.showSummary) { + return; + } + + // Save answers. + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await this.processAttempt(false, false); + } catch (error) { + // Save attempt failed. Show confirmation. + modal.dismiss(); + + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('addon.mod_quiz.confirmleavequizonerror')); + + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + } finally { + modal.dismiss(); + } + } + + /** + * Runs when the page is about to leave and no longer be the active page. + */ + async ionViewWillLeave(): Promise { + // Close any modal if present. + const modal = await ModalController.instance.getTop(); + + modal?.dismiss(); + } + + /** + * Abort the quiz. + */ + abortQuiz(): void { + this.quizAborted = true; + } + + /** + * A behaviour button in a question was clicked (Check, Redo, ...). + * + * @param button Clicked button. + */ + async behaviourButtonClicked(button: CoreQuestionBehaviourButton): Promise { + let modal: CoreIonLoadingElement | undefined; + + try { + // Confirm that the user really wants to do it. + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.areyousure')); + + modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + // Get the answers. + const answers = await this.prepareAnswers(); + + // Add the clicked button data. + answers[button.name] = button.value; + + // Behaviour checks are always in online. + await AddonModQuiz.instance.processAttempt(this.quiz!, this.attempt!, answers, this.preflightData); + + this.reloadNavigation = true; // Data sent to server, navigation should be reloaded. + + // Reload the current page. + const scrollElement = await this.content?.getScrollElement(); + const scrollTop = scrollElement?.scrollTop || -1; + const scrollLeft = scrollElement?.scrollLeft || -1; + + this.loaded = false; + this.content?.scrollToTop(); // Scroll top so the spinner is seen. + + try { + await this.loadPage(this.attempt!.currentpage!); + } finally { + this.loaded = true; + if (scrollTop != -1 && scrollLeft != -1) { + this.content?.scrollToPoint(scrollLeft, scrollTop); + } + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error performing action.'); + } finally { + modal?.dismiss(); + } + } + + /** + * Change the current page. If slot is supplied, try to scroll to that question. + * + * @param page Page to load. -1 means summary. + * @param fromModal Whether the page was selected using the navigation modal. + * @param slot Slot of the question to scroll to. + * @return Promise resolved when done. + */ + async changePage(page: number, fromModal?: boolean, slot?: number): Promise { + if (!this.attempt) { + return; + } + + if (page != -1 && (this.attempt.state == AddonModQuizProvider.ATTEMPT_OVERDUE || this.attempt.finishedOffline)) { + // We can't load a page if overdue or the local attempt is finished. + return; + } else if (page == this.attempt.currentpage && !this.showSummary && typeof slot != 'undefined') { + // Navigating to a question in the current page. + this.scrollToQuestion(slot); + + return; + } else if ((page == this.attempt.currentpage && !this.showSummary) || (fromModal && this.isSequential && page != -1)) { + // If the user is navigating to the current page we do nothing. + // Also, in sequential quizzes we don't allow navigating using the modal except for finishing the quiz (summary). + return; + } else if (page === -1 && this.showSummary) { + // Summary already shown. + return; + } + + this.content?.scrollToTop(); + + // First try to save the attempt data. We only save it if we're not seeing the summary. + if (!this.showSummary) { + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await this.processAttempt(false, false); + + modal.dismiss(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true); + modal.dismiss(); + + return; + } + + this.reloadNavigation = true; // Data sent to server, navigation should be reloaded. + } + + this.loaded = false; + + try { + // Attempt data successfully saved, load the page or summary. + // Stop checking for changes during page change. + this.autoSave.stopCheckChangesProcess(); + + if (page === -1) { + await this.loadSummary(); + } else { + await this.loadPage(page); + } + } catch (error) { + // If the user isn't seeing the summary, start the check again. + if (!this.showSummary) { + this.autoSave.startCheckChangesProcess(this.quiz!, this.attempt, this.preflightData, this.offline); + } + + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true); + } finally { + this.loaded = true; + + if (typeof slot != 'undefined') { + // Scroll to the question. Give some time to the questions to render. + setTimeout(() => { + this.scrollToQuestion(slot); + }, 2000); + } + } + } + + /** + * Convenience function to get the quiz data. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + try { + // Wait for any ongoing sync to finish. We won't sync a quiz while it's being played. + await AddonModQuizSync.instance.waitForSync(this.quizId); + + // Sync finished, now get the quiz. + this.quiz = await AddonModQuiz.instance.getQuizById(this.courseId, this.quizId); + + this.isSequential = AddonModQuiz.instance.isNavigationSequential(this.quiz); + + if (AddonModQuiz.instance.isQuizOffline(this.quiz)) { + // Quiz supports offline. + this.offline = true; + } else { + // Quiz doesn't support offline right now, but maybe it did and then the setting was changed. + // If we have an unfinished offline attempt then we'll use offline mode. + this.offline = await AddonModQuiz.instance.isLastAttemptOfflineUnfinished(this.quiz); + } + + if (this.quiz!.timelimit && this.quiz!.timelimit > 0) { + this.readableTimeLimit = CoreTimeUtils.instance.formatTime(this.quiz.timelimit); + } + + // Get access information for the quiz. + this.quizAccessInfo = await AddonModQuiz.instance.getQuizAccessInformation(this.quiz.id, { + cmId: this.quiz.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }); + + // Get user attempts to determine last attempt. + const attempts = await AddonModQuiz.instance.getUserAttempts(this.quiz.id, { + cmId: this.quiz.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }); + + if (!attempts.length) { + // There are no attempts, start a new one. + this.newAttempt = true; + + return; + } + + // Get the last attempt. If it's finished, start a new one. + this.lastAttempt = await AddonModQuizHelper.instance.setAttemptCalculatedData( + this.quiz, + attempts[attempts.length - 1], + false, + undefined, + true, + ); + + this.newAttempt = AddonModQuiz.instance.isAttemptFinished(this.lastAttempt.state); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true); + } + } + + /** + * Finish an attempt, either by timeup or because the user clicked to finish it. + * + * @param userFinish Whether the user clicked to finish the attempt. + * @param timeUp Whether the quiz time is up. + * @return Promise resolved when done. + */ + async finishAttempt(userFinish?: boolean, timeUp?: boolean): Promise { + let modal: CoreIonLoadingElement | undefined; + + try { + // Show confirm if the user clicked the finish button and the quiz is in progress. + if (!timeUp && this.attempt!.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('addon.mod_quiz.confirmclose')); + } + + modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + await this.processAttempt(userFinish, timeUp); + + // Trigger an event to notify the attempt was finished. + CoreEvents.trigger(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, { + quizId: this.quizId, + attemptId: this.attempt!.id, + synced: !this.offline, + }, CoreSites.instance.getCurrentSiteId()); + + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'quiz' }); + + // Leave the player. + this.forceLeave = true; + CoreNavigator.instance.back(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorsaveattempt', true); + } finally { + modal?.dismiss(); + } + } + + /** + * Fix sequence checks of current page. + * + * @return Promise resolved when done. + */ + protected async fixSequenceChecks(): Promise { + // Get current page data again to get the latest sequencechecks. + const data = await AddonModQuiz.instance.getAttemptData(this.attempt!.id, this.attempt!.currentpage!, this.preflightData, { + cmId: this.quiz!.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }); + + const newSequenceChecks: Record = {}; + + data.questions.forEach((question) => { + const sequenceCheck = CoreQuestionHelper.instance.getQuestionSequenceCheckFromHtml(question.html); + if (sequenceCheck) { + newSequenceChecks[question.slot] = sequenceCheck; + } + }); + + // Notify the new sequence checks to the components. + this.questionComponents?.forEach((component) => { + component.updateSequenceCheck(newSequenceChecks); + }); + } + + /** + * Get the input answers. + * + * @return Object with the answers. + */ + protected getAnswers(): CoreQuestionsAnswers { + return CoreQuestionHelper.instance.getAnswersFromForm(document.forms['addon-mod_quiz-player-form']); + } + + /** + * Initializes the timer if enabled. + */ + protected initTimer(): void { + if (!this.attemptAccessInfo?.endtime || this.attemptAccessInfo.endtime < 0) { + return; + } + + // Quiz has an end time. Check if time left should be shown. + const shouldShowTime = AddonModQuiz.instance.shouldShowTimeLeft( + this.quizAccessInfo!.activerulenames, + this.attempt!, + this.attemptAccessInfo.endtime, + ); + + if (shouldShowTime) { + this.endTime = this.attemptAccessInfo.endtime; + } else { + delete this.endTime; + } + } + + /** + * Load a page questions. + * + * @param page The page to load. + * @return Promise resolved when done. + */ + protected async loadPage(page: number): Promise { + const data = await AddonModQuiz.instance.getAttemptData(this.attempt!.id, page, this.preflightData, { + cmId: this.quiz!.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }); + + // Update attempt, status could change during the execution. + this.attempt = data.attempt; + this.attempt.currentpage = page; + + this.questions = data.questions; + this.nextPage = data.nextpage; + this.previousPage = this.isSequential ? -1 : page - 1; + this.showSummary = false; + + this.questions.forEach((question) => { + // Get the readable mark for each question. + question.readableMark = AddonModQuizHelper.instance.getQuestionMarkFromHtml(question.html); + + // Extract the question info box. + CoreQuestionHelper.instance.extractQuestionInfoBox(question, '.info'); + + // Check if the question is blocked. If it is, treat it as a description question. + if (AddonModQuiz.instance.isQuestionBlocked(question)) { + question.type = 'description'; + } + }); + + // Mark the page as viewed. + CoreUtils.instance.ignoreErrors( + AddonModQuiz.instance.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline, this.quiz), + ); + + // Start looking for changes. + this.autoSave.startCheckChangesProcess(this.quiz!, this.attempt, this.preflightData, this.offline); + } + + /** + * Load attempt summary. + * + * @return Promise resolved when done. + */ + protected async loadSummary(): Promise { + this.summaryQuestions = []; + + this.summaryQuestions = await AddonModQuiz.instance.getAttemptSummary(this.attempt!.id, this.preflightData, { + cmId: this.quiz!.coursemodule, + loadLocal: this.offline, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }); + + this.showSummary = true; + this.canReturn = this.attempt!.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS && !this.attempt!.finishedOffline; + this.preventSubmitMessages = AddonModQuiz.instance.getPreventSubmitMessages(this.summaryQuestions); + + this.dueDateWarning = AddonModQuiz.instance.getAttemptDueDateWarning(this.quiz!, this.attempt!); + + // Log summary as viewed. + CoreUtils.instance.ignoreErrors( + AddonModQuiz.instance.logViewAttemptSummary(this.attempt!.id, this.preflightData, this.quizId, this.quiz!.name), + ); + } + + /** + * Load data to navigate the questions using the navigation modal. + * + * @return Promise resolved when done. + */ + protected async loadNavigation(): Promise { + // We use the attempt summary to build the navigation because it contains all the questions. + this.navigation = await AddonModQuiz.instance.getAttemptSummary(this.attempt!.id, this.preflightData, { + cmId: this.quiz!.coursemodule, + loadLocal: this.offline, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }); + + this.navigation.forEach((question) => { + question.stateClass = CoreQuestionHelper.instance.getQuestionStateClass(question.state || ''); + }); + } + + /** + * Open the navigation modal. + * + * @return Promise resolved when done. + */ + async openNavigation(): Promise { + + if (this.reloadNavigation) { + // Some data has changed, reload the navigation. + const modal = await CoreDomUtils.instance.showModalLoading(); + + await CoreUtils.instance.ignoreErrors(this.loadNavigation()); + + modal.dismiss(); + this.reloadNavigation = false; + } + + // Create the navigation modal. + const modal = await ModalController.instance.create({ + component: AddonModQuizNavigationModalComponent, + componentProps: { + navigation: this.navigation, + summaryShown: this.showSummary, + currentPage: this.attempt?.currentpage, + isReview: false, + }, + cssClass: 'core-modal-lateral', + showBackdrop: true, + backdropDismiss: true, + // @todo enterAnimation: 'core-modal-lateral-transition', + // @todo leaveAnimation: 'core-modal-lateral-transition', + }); + + await modal.present(); + + const result = await modal.onWillDismiss(); + + if (result.data && result.data.action == AddonModQuizNavigationModalComponent.CHANGE_PAGE) { + this.changePage(result.data.page, true, result.data.slot); + } + } + + /** + * Prepare the answers to be sent for the attempt. + * + * @return Promise resolved with the answers. + */ + protected prepareAnswers(): Promise { + return CoreQuestionHelper.instance.prepareAnswers( + this.questions, + this.getAnswers(), + this.offline, + this.component, + this.quiz!.coursemodule, + ); + } + + /** + * Process attempt. + * + * @param userFinish Whether the user clicked to finish the attempt. + * @param timeUp Whether the quiz time is up. + * @param retrying Whether we're retrying the change. + * @return Promise resolved when done. + */ + protected async processAttempt(userFinish?: boolean, timeUp?: boolean, retrying?: boolean): Promise { + // Get the answers to send. + let answers: CoreQuestionsAnswers = {}; + + if (!this.showSummary) { + answers = await this.prepareAnswers(); + } + + try { + // Send the answers. + await AddonModQuiz.instance.processAttempt( + this.quiz!, + this.attempt!, + answers, + this.preflightData, + userFinish, + timeUp, + this.offline, + ); + } catch (error) { + if (!error || error.errorcode != 'submissionoutofsequencefriendlymessage') { + throw error; + } + + try { + // There was an error with the sequence check. Try to ammend it. + await this.fixSequenceChecks(); + } catch { + throw error; + } + + if (retrying) { + // We're already retrying, don't send the data again because it could cause an infinite loop. + throw error; + } + + // Sequence checks updated, try to send the data again. + return this.processAttempt(userFinish, timeUp, true); + } + + // Answers saved, cancel auto save. + this.autoSave.cancelAutoSave(); + this.autoSave.hideAutoSaveError(); + + if (this.formElement) { + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, !this.offline, CoreSites.instance.getCurrentSiteId()); + } + + return CoreQuestionHelper.instance.clearTmpData(this.questions, this.component, this.quiz!.coursemodule); + } + + /** + * Scroll to a certain question. + * + * @param slot Slot of the question to scroll to. + */ + protected scrollToQuestion(slot: number): void { + if (this.content) { + CoreDomUtils.instance.scrollToElementBySelector(this.content, '#addon-mod_quiz-question-' + slot); + } + } + + /** + * Show connection error. + * + * @param ev Click event. + */ + showConnectionError(ev: Event): void { + this.autoSave.showAutoSaveError(ev); + } + + /** + * Convenience function to start the player. + */ + async start(): Promise { + try { + this.loaded = false; + + if (!this.quizDataLoaded) { + // Fetch data. + await this.fetchData(); + + this.quizDataLoaded = true; + } + + // Quiz data has been loaded, try to start or continue. + await this.startOrContinueAttempt(); + } finally { + this.loaded = true; + } + } + + /** + * Start or continue an attempt. + * + * @return Promise resolved when done. + */ + protected async startOrContinueAttempt(): Promise { + try { + let attempt = this.newAttempt ? undefined : this.lastAttempt; + + // Get the preflight data and start attempt if needed. + attempt = await AddonModQuizHelper.instance.getAndCheckPreflightData( + this.quiz!, + this.quizAccessInfo!, + this.preflightData, + attempt, + this.offline, + false, + 'addon.mod_quiz.startattempt', + ); + + // Re-fetch attempt access information with the right attempt (might have changed because a new attempt was created). + this.attemptAccessInfo = await AddonModQuiz.instance.getAttemptAccessInformation(this.quiz!.id, attempt.id, { + cmId: this.quiz!.coursemodule, + readingStrategy: this.offline ? CoreSitesReadingStrategy.PreferCache : CoreSitesReadingStrategy.OnlyNetwork, + }); + + this.attempt = attempt; + + await this.loadNavigation(); + + if (this.attempt.state != AddonModQuizProvider.ATTEMPT_OVERDUE && !this.attempt.finishedOffline) { + // Attempt not overdue and not finished in offline, load page. + await this.loadPage(this.attempt.currentpage!); + + this.initTimer(); + } else { + // Attempt is overdue or finished in offline, we can only load the summary. + await this.loadSummary(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true); + } + } + + /** + * Quiz time has finished. + */ + timeUp(): void { + if (this.timeUpCalled) { + return; + } + + this.timeUpCalled = true; + this.finishAttempt(false, true); + } + +} + +/** + * Question with some calculated data for the view. + */ +type QuizQuestion = CoreQuestionQuestionParsed & { + readableMark?: string; +}; diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts index b167bbd5b..e48b0f823 100644 --- a/src/addons/mod/quiz/quiz-lazy.module.ts +++ b/src/addons/mod/quiz/quiz-lazy.module.ts @@ -20,6 +20,10 @@ const routes: Routes = [ path: ':courseId/:cmdId', loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModQuizIndexPageModule), }, + { + path: 'player/:courseId/:quizId', + loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModQuizPlayerPageModule), + }, ]; @NgModule({ diff --git a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html index 7d75067a0..fb8f85417 100644 --- a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html +++ b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html @@ -7,7 +7,7 @@
- + {{ field.name }} @@ -15,4 +15,4 @@ [max]="max" [min]="min"> - \ No newline at end of file + diff --git a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html index ce2e84f68..74524fc97 100644 --- a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html +++ b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html @@ -9,7 +9,7 @@
- + {{ field.name }} diff --git a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html index 51d52320d..618ad2253 100644 --- a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html +++ b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html @@ -9,7 +9,7 @@ - + {{ field.name }} diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html index 6903fb29f..0286d08ec 100644 --- a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -9,7 +9,7 @@ - + {{ field.name }} @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 3f1d67d64..2548ccda4 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -69,13 +69,16 @@ export class CoreSite { // Versions of Moodle releases. protected readonly MOODLE_RELEASES = { - 3.1: 2016052300, - 3.2: 2016120500, - 3.3: 2017051503, - 3.4: 2017111300, - 3.5: 2018051700, - 3.6: 2018120300, - 3.7: 2019052000, + '3.1': 2016052300, + '3.2': 2016120500, + '3.3': 2017051503, + '3.4': 2017111300, + '3.5': 2018051700, + '3.6': 2018120300, + '3.7': 2019052000, + '3.8': 2019111800, + '3.9': 2020061500, + '3.10': 2020110900, }; // Possible cache update frequencies. diff --git a/src/core/components/input-errors/input-errors.ts b/src/core/components/input-errors/input-errors.ts index 7928e6e18..a3db6a307 100644 --- a/src/core/components/input-errors/input-errors.ts +++ b/src/core/components/input-errors/input-errors.ts @@ -32,7 +32,7 @@ import { Translate } from '@singletons'; * * Example usage: * - * + * * {{ 'core.login.username' | translate }} * * diff --git a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html index 470dc5d13..bbecd202a 100644 --- a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html +++ b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html @@ -15,19 +15,18 @@
- - - - - - + + + + +
{{ 'core.courses.enrolme' | translate }} diff --git a/src/core/features/fileuploader/services/fileuploader-helper.ts b/src/core/features/fileuploader/services/fileuploader-helper.ts index a303ee0bd..28b63a751 100644 --- a/src/core/features/fileuploader/services/fileuploader-helper.ts +++ b/src/core/features/fileuploader/services/fileuploader-helper.ts @@ -34,6 +34,7 @@ import { CoreFileUploaderDelegate } from './fileuploader-delegate'; import { CoreCaptureError } from '@classes/errors/captureerror'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreWSUploadFileResult } from '@services/ws'; +import { CoreSites } from '@services/sites'; /** * Helper service to upload files. @@ -738,6 +739,14 @@ export class CoreFileUploaderHelperProvider { allowOffline?: boolean, name?: string, ): Promise { + if (maxSize === 0) { + const siteInfo = CoreSites.instance.getCurrentSite()?.getInfo(); + + if (siteInfo && siteInfo.usermaxuploadfilesize) { + maxSize = siteInfo.usermaxuploadfilesize; + } + } + if (maxSize !== undefined && maxSize != -1 && file.size > maxSize) { throw this.createMaxBytesError(maxSize, file.name); } diff --git a/src/core/features/login/pages/credentials/credentials.html b/src/core/features/login/pages/credentials/credentials.html index 5c0e22e74..428ab492e 100644 --- a/src/core/features/login/pages/credentials/credentials.html +++ b/src/core/features/login/pages/credentials/credentials.html @@ -38,14 +38,13 @@ - - - - - - + + + + +
+ [class.core-course-more-than-title]="course.progress! >= 0"> - + diff --git a/src/core/features/courses/services/courses.ts b/src/core/features/courses/services/courses.ts index 84ce04a4f..28cdf1e72 100644 --- a/src/core/features/courses/services/courses.ts +++ b/src/core/features/courses/services/courses.ts @@ -1227,7 +1227,7 @@ export type CoreEnrolledCourseData = CoreEnrolledCourseBasicData & { enrolledusercount?: number; // Number of enrolled users in this course. completionhascriteria?: boolean; // If completion criteria is set. completionusertracked?: boolean; // If the user is completion tracked. - progress?: number; // Progress percentage. + progress?: number | null; // Progress percentage. completed?: boolean; // Whether the course is completed. marker?: number; // Course section marker. lastaccess?: number; // Last access to the course (timestamp). diff --git a/src/core/features/settings/pages/space-usage/space-usage.html b/src/core/features/settings/pages/space-usage/space-usage.html index 8d9294c40..582fa1773 100644 --- a/src/core/features/settings/pages/space-usage/space-usage.html +++ b/src/core/features/settings/pages/space-usage/space-usage.html @@ -26,7 +26,7 @@

{{ site.fullName }}

{{ site.siteUrl }}

-

+

{{ site.spaceUsage | coreBytesToSize }}

{{ participant.fullname }}

{{ 'core.lastaccess' | translate }}: {{ participant.lastcourseaccess | coreTimeAgo }}

-

{{ 'core.lastaccess' | translate }}: {{ participant.lastaccess | coreTimeAgo }}

+

{{ 'core.lastaccess' | translate }}: {{ participant.lastaccess | coreTimeAgo }}

diff --git a/src/core/features/user/services/user.ts b/src/core/features/user/services/user.ts index 4ed4e8ee3..0d34f14d2 100644 --- a/src/core/features/user/services/user.ts +++ b/src/core/features/user/services/user.ts @@ -964,7 +964,7 @@ export type CoreUserParticipant = CoreUserBasicData & { interests?: string; // User interests (separated by commas). firstaccess?: number; // First access to the site (0 if never). lastaccess?: number; // Last access to the site (0 if never). - lastcourseaccess?: number; // Last access to the course (0 if never). + lastcourseaccess?: number | null; // Last access to the course (0 if never). description?: string; // User profile description. descriptionformat?: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). city?: string; // Home city of the user. @@ -1114,4 +1114,3 @@ type CoreEnrolSearchUsersWSParams = { * Data returned by core_enrol_search_users WS. */ type CoreEnrolSearchUsersWSResponse = CoreUserData[]; - From 1620dd47ea92262534d5c6bc07b4486ab3eb8c63 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 18 Feb 2021 15:17:54 +0100 Subject: [PATCH 10/16] MOBILE-3651 quiz: Implement attempt page --- .../index/addon-mod-quiz-index.html | 2 +- src/addons/mod/quiz/components/index/index.ts | 9 + .../mod/quiz/pages/attempt/attempt.html | 65 ++++++ .../mod/quiz/pages/attempt/attempt.module.ts | 38 ++++ src/addons/mod/quiz/pages/attempt/attempt.ts | 197 ++++++++++++++++++ src/addons/mod/quiz/quiz-lazy.module.ts | 4 + 6 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 src/addons/mod/quiz/pages/attempt/attempt.html create mode 100644 src/addons/mod/quiz/pages/attempt/attempt.module.ts create mode 100644 src/addons/mod/quiz/pages/attempt/attempt.ts diff --git a/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html b/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html index 52c209fa2..87dd74a24 100644 --- a/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html +++ b/src/addons/mod/quiz/components/index/addon-mod-quiz-index.html @@ -81,7 +81,7 @@ + [attr.aria-label]="'core.seemoredetail' | translate" (click)="viewAttempt(attempt.id)"> diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index d7535b99b..fb50b8ebf 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -651,6 +651,15 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp } } + /** + * Go to page to view the attempt details. + * + * @return Promise resolved when done. + */ + async viewAttempt(attemptId: number): Promise { + CoreNavigator.instance.navigate(`../../attempt/${this.courseId}/${this.quiz!.id}/${attemptId}`); + } + /** * Component being destroyed. */ diff --git a/src/addons/mod/quiz/pages/attempt/attempt.html b/src/addons/mod/quiz/pages/attempt/attempt.html new file mode 100644 index 000000000..872803363 --- /dev/null +++ b/src/addons/mod/quiz/pages/attempt/attempt.html @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + +

{{ 'addon.mod_quiz.attemptnumber' | translate }}

+

{{ 'addon.mod_quiz.preview' | translate }}

+

{{ attempt.attempt }}

+
+
+ + +

{{ 'addon.mod_quiz.attemptstate' | translate }}

+

{{ sentence }}

+
+
+ + +

{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}

+

{{ attempt.readableMark }}

+
+
+ + +

{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}

+

{{ attempt.readableGrade }}

+
+
+ + +

{{ 'addon.mod_quiz.feedback' | translate }}

+

+ + +

+
+
+ + + {{ 'addon.mod_quiz.review' | translate }} + + + +

{{ 'addon.mod_quiz.noreviewattempt' | translate }}

+
+
+
+
+
diff --git a/src/addons/mod/quiz/pages/attempt/attempt.module.ts b/src/addons/mod/quiz/pages/attempt/attempt.module.ts new file mode 100644 index 000000000..0e03e43f8 --- /dev/null +++ b/src/addons/mod/quiz/pages/attempt/attempt.module.ts @@ -0,0 +1,38 @@ +// (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 { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModQuizAttemptPage } from './attempt'; + +const routes: Routes = [ + { + path: '', + component: AddonModQuizAttemptPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + ], + declarations: [ + AddonModQuizAttemptPage, + ], + exports: [RouterModule], +}) +export class AddonModQuizAttemptPageModule {} diff --git a/src/addons/mod/quiz/pages/attempt/attempt.ts b/src/addons/mod/quiz/pages/attempt/attempt.ts new file mode 100644 index 000000000..ede88d001 --- /dev/null +++ b/src/addons/mod/quiz/pages/attempt/attempt.ts @@ -0,0 +1,197 @@ +// (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, OnInit } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { + AddonModQuiz, + AddonModQuizAttemptWSData, + AddonModQuizGetQuizAccessInformationWSResponse, + AddonModQuizProvider, +} from '../../services/quiz'; +import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper'; + +/** + * Page that displays some summary data about an attempt. + */ +@Component({ + selector: 'page-addon-mod-quiz-attempt', + templateUrl: 'attempt.html', +}) +export class AddonModQuizAttemptPage implements OnInit { + + courseId!: number; // The course ID the quiz belongs to. + quiz?: AddonModQuizQuizData; // The quiz the attempt belongs to. + attempt?: AddonModQuizAttempt; // The attempt to view. + component = AddonModQuizProvider.COMPONENT; // Component to link the files to. + componentId?: number; // Component ID to use in conjunction with the component. + loaded = false; // Whether data has been loaded. + feedback?: string; // Attempt feedback. + showReviewColumn = false; + + protected attemptId!: number; // Attempt to view. + protected quizId!: number; // ID of the quiz the attempt belongs to. + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.quizId = CoreNavigator.instance.getRouteNumberParam('quizId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.attemptId = CoreNavigator.instance.getRouteNumberParam('attemptId')!; + + this.fetchQuizData().finally(() => { + this.loaded = true; + }); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + */ + doRefresh(refresher: IonRefresher): void { + this.refreshData().finally(() => { + refresher.complete(); + }); + } + + /** + * Get quiz data and attempt data. + * + * @return Promise resolved when done. + */ + protected async fetchQuizData(): Promise { + try { + this.quiz = await AddonModQuiz.instance.getQuizById(this.courseId, this.quizId); + + this.componentId = this.quiz.coursemodule; + + // Load attempt data. + const [options, accessInfo, attempt] = await Promise.all([ + AddonModQuiz.instance.getCombinedReviewOptions(this.quiz.id, { cmId: this.quiz.coursemodule }), + this.fetchAccessInfo(), + this.fetchAttempt(), + ]); + + // Set calculated data. + this.showReviewColumn = accessInfo.canreviewmyattempts; + AddonModQuizHelper.instance.setQuizCalculatedData(this.quiz, options); + + this.attempt = await AddonModQuizHelper.instance.setAttemptCalculatedData(this.quiz!, attempt, false, undefined, true); + + // Check if the feedback should be displayed. + const grade = Number(this.attempt!.rescaledGrade); + + if (this.quiz.showFeedbackColumn && AddonModQuiz.instance.isAttemptFinished(this.attempt!.state) && + options.someoptions.overallfeedback && !isNaN(grade)) { + + // Feedback should be displayed, get the feedback for the grade. + const response = await AddonModQuiz.instance.getFeedbackForGrade(this.quiz.id, grade, { + cmId: this.quiz.coursemodule, + }); + + this.feedback = response.feedbacktext; + } else { + delete this.feedback; + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetattempt', true); + } + } + + /** + * Get the attempt. + * + * @return Promise resolved when done. + */ + protected async fetchAttempt(): Promise { + // Get all the attempts and search the one we want. + const attempts = await AddonModQuiz.instance.getUserAttempts(this.quizId, { cmId: this.quiz!.coursemodule }); + + const attempt = attempts.find(attempt => attempt.id == this.attemptId); + + if (!attempt) { + // Attempt not found, error. + this.attempt = undefined; + + throw new CoreError(Translate.instance.instant('addon.mod_quiz.errorgetattempt')); + } + + return attempt; + } + + /** + * Get the access info. + * + * @return Promise resolved when done. + */ + protected async fetchAccessInfo(): Promise { + const accessInfo = await AddonModQuiz.instance.getQuizAccessInformation(this.quizId, { cmId: this.quiz!.coursemodule }); + + if (!accessInfo.canreviewmyattempts) { + return accessInfo; + } + + // Check if the user can review the attempt. + await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.invalidateAttemptReviewForPage(this.attemptId, -1)); + + try { + await AddonModQuiz.instance.getAttemptReview(this.attemptId, { page: -1, cmId: this.quiz!.coursemodule }); + } catch { + // Error getting the review, assume the user cannot review the attempt. + accessInfo.canreviewmyattempts = false; + } + + return accessInfo; + } + + /** + * Refresh the data. + * + * @return Promise resolved when done. + */ + protected async refreshData(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId)); + promises.push(AddonModQuiz.instance.invalidateUserAttemptsForUser(this.quizId)); + promises.push(AddonModQuiz.instance.invalidateQuizAccessInformation(this.quizId)); + promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quizId)); + promises.push(AddonModQuiz.instance.invalidateAttemptReview(this.attemptId)); + + if (this.attempt && typeof this.feedback != 'undefined') { + promises.push(AddonModQuiz.instance.invalidateFeedback(this.quizId)); + } + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); + + await this.fetchQuizData(); + } + + /** + * Go to the page to review the attempt. + * + * @return Promise resolved when done. + */ + async reviewAttempt(): Promise { + // @todo navPush="AddonModQuizReviewPage" [navParams]="{courseId: courseId, quizId: quiz.id, attemptId: attempt.id}" + } + +} diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts index e48b0f823..1b44928d9 100644 --- a/src/addons/mod/quiz/quiz-lazy.module.ts +++ b/src/addons/mod/quiz/quiz-lazy.module.ts @@ -24,6 +24,10 @@ const routes: Routes = [ path: 'player/:courseId/:quizId', loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModQuizPlayerPageModule), }, + { + path: 'attempt/:courseId/:quizId/:attemptId', + loadChildren: () => import('./pages/attempt/attempt.module').then( m => m.AddonModQuizAttemptPageModule), + }, ]; @NgModule({ From f682d89e67f54194fd63c8b644003105ced24072 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 19 Feb 2021 11:47:22 +0100 Subject: [PATCH 11/16] MOBILE-3651 core: Fix scroll to element function --- .../preflight-modal/preflight-modal.ts | 8 +++++- src/addons/mod/quiz/pages/player/player.ts | 8 +++--- src/core/directives/link.ts | 6 ++++- .../course/components/format/format.ts | 8 +++++- .../login/pages/email-signup/email-signup.ts | 17 +++++++++--- src/core/services/utils/dom.ts | 27 +++++++++---------- 6 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts index f15da32c7..ae2632ced 100644 --- a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts +++ b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.ts @@ -47,6 +47,7 @@ export class AddonModQuizPreflightModalComponent implements OnInit { constructor( formBuilder: FormBuilder, + protected elementRef: ElementRef, ) { // Create an empty form group. The controls will be added by the access rules components. this.preflightForm = formBuilder.group({}); @@ -115,7 +116,12 @@ export class AddonModQuizPreflightModalComponent implements OnInit { if (!this.preflightForm.valid) { // Form not valid. Scroll to the first element with errors. - if (!CoreDomUtils.instance.scrollToInputError(this.content)) { + const hasScrolled = CoreDomUtils.instance.scrollToInputError( + this.elementRef.nativeElement, + this.content, + ); + + if (!hasScrolled) { // Input not found, show an error modal. CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true); } diff --git a/src/addons/mod/quiz/pages/player/player.ts b/src/addons/mod/quiz/pages/player/player.ts index f6124b557..046db21de 100644 --- a/src/addons/mod/quiz/pages/player/player.ts +++ b/src/addons/mod/quiz/pages/player/player.ts @@ -681,9 +681,11 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { * @param slot Slot of the question to scroll to. */ protected scrollToQuestion(slot: number): void { - if (this.content) { - CoreDomUtils.instance.scrollToElementBySelector(this.content, '#addon-mod_quiz-question-' + slot); - } + CoreDomUtils.instance.scrollToElementBySelector( + this.elementRef.nativeElement, + this.content, + '#addon-mod_quiz-question-' + slot, + ); } /** diff --git a/src/core/directives/link.ts b/src/core/directives/link.ts index 9d31dfdda..c5c7597d4 100644 --- a/src/core/directives/link.ts +++ b/src/core/directives/link.ts @@ -104,7 +104,11 @@ export class CoreLinkDirective implements OnInit { if (href.charAt(0) == '#') { // Look for id or name. href = href.substr(1); - CoreDomUtils.instance.scrollToElementBySelector(this.content, '#' + href + ', [name=\'' + href + '\']'); + CoreDomUtils.instance.scrollToElementBySelector( + this.element.closest('ion-content'), + this.content, + `#${href}, [name='${href}']`, + ); return; } diff --git a/src/core/features/course/components/format/format.ts b/src/core/features/course/components/format/format.ts index 060906f96..3ca97684b 100644 --- a/src/core/features/course/components/format/format.ts +++ b/src/core/features/course/components/format/format.ts @@ -25,6 +25,7 @@ import { QueryList, Type, ViewChild, + ElementRef, } from '@angular/core'; import { CoreSites } from '@services/sites'; @@ -111,6 +112,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { constructor( protected content: IonContent, + protected elementRef: ElementRef, ) { // Pass this instance to all components so they can use its methods and properties. this.data.coreCourseFormatComponent = this; @@ -402,7 +404,11 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { if (this.moduleId && typeof previousValue == 'undefined') { setTimeout(() => { - CoreDomUtils.instance.scrollToElementBySelector(this.content, '#core-course-module-' + this.moduleId); + CoreDomUtils.instance.scrollToElementBySelector( + this.elementRef.nativeElement, + this.content, + '#core-course-module-' + this.moduleId, + ); }, 200); } else { this.content.scrollToTop(0); diff --git a/src/core/features/login/pages/email-signup/email-signup.ts b/src/core/features/login/pages/email-signup/email-signup.ts index 67cdcf4eb..973862b9c 100644 --- a/src/core/features/login/pages/email-signup/email-signup.ts +++ b/src/core/features/login/pages/email-signup/email-signup.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild, ElementRef, OnInit } from '@angular/core'; +import { Component, ViewChild, ElementRef, OnInit, ChangeDetectorRef } from '@angular/core'; import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; import { IonContent, IonRefresher } from '@ionic/angular'; @@ -81,6 +81,8 @@ export class CoreLoginEmailSignupPage implements OnInit { constructor( protected fb: FormBuilder, + protected elementRef: ElementRef, + protected changeDetector: ChangeDetectorRef, ) { // Create the ageVerificationForm. this.ageVerificationForm = this.fb.group({ @@ -272,8 +274,17 @@ export class CoreLoginEmailSignupPage implements OnInit { e.stopPropagation(); if (!this.signupForm.valid || (this.settings?.recaptchapublickey && !this.captcha.recaptcharesponse)) { - // Form not valid. Scroll to the first element with errors. - const errorFound = await CoreDomUtils.instance.scrollToInputError(this.content); + // Form not valid. Mark all controls as dirty to display errors. + for (const name in this.signupForm.controls) { + this.signupForm.controls[name].markAsDirty(); + } + this.changeDetector.detectChanges(); + + // Scroll to the first element with errors. + const errorFound = CoreDomUtils.instance.scrollToInputError( + this.elementRef.nativeElement, + this.content, + ); if (!errorFound) { // Input not found, show an error modal. diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index f09b70c54..813078fde 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -1119,24 +1119,26 @@ export class CoreDomUtilsProvider { /** * Scroll to a certain element using a selector to find it. * + * @param container The element that contains the element that must be scrolled. * @param content The content that must be scrolled. * @param selector Selector to find the element to scroll to. * @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll. * @param duration Duration of the scroll animation in milliseconds. * @return True if the element is found, false otherwise. */ - async scrollToElementBySelector( - content: IonContent, + scrollToElementBySelector( + container: HTMLElement | null, + content: IonContent | undefined, selector: string, scrollParentClass?: string, duration?: number, - ): Promise { - // @todo: This function is broken. Scroll element cannot be used because it uses shadow DOM so querySelector returns null. - // Also, traversing using parentElement doesn't work either, offsetParent isn't part of the parentElement tree. - try { - const scrollElement = await content.getScrollElement(); + ): boolean { + if (!container || !content) { + return false; + } - const position = this.getElementXY(scrollElement, selector, scrollParentClass); + try { + const position = this.getElementXY(container, selector, scrollParentClass); if (!position) { return false; } @@ -1152,16 +1154,13 @@ export class CoreDomUtilsProvider { /** * Search for an input with error (core-input-error directive) and scrolls to it if found. * + * @param container The element that contains the element that must be scrolled. * @param content The content that must be scrolled. * @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll. * @return True if the element is found, false otherwise. */ - async scrollToInputError(content?: IonContent, scrollParentClass?: string): Promise { - if (!content) { - return false; - } - - return this.scrollToElementBySelector(content, '.core-input-error', scrollParentClass); + scrollToInputError(container: HTMLElement | null, content?: IonContent, scrollParentClass?: string): boolean { + return this.scrollToElementBySelector(container, content, '.core-input-error', scrollParentClass); } /** From b405614cb65042bfcbcfae4fbb2e46d4147ef376 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 19 Feb 2021 11:47:38 +0100 Subject: [PATCH 12/16] MOBILE-3651 quiz: Implement attempt review page --- src/addons/mod/quiz/components/index/index.ts | 2 +- src/addons/mod/quiz/pages/attempt/attempt.ts | 2 +- src/addons/mod/quiz/pages/review/review.html | 137 +++++++ .../mod/quiz/pages/review/review.module.ts | 40 ++ src/addons/mod/quiz/pages/review/review.scss | 6 + src/addons/mod/quiz/pages/review/review.ts | 366 ++++++++++++++++++ src/addons/mod/quiz/quiz-lazy.module.ts | 4 + .../classes/base-question-component.ts | 6 +- src/core/features/question/question.scss | 78 +--- 9 files changed, 579 insertions(+), 62 deletions(-) create mode 100644 src/addons/mod/quiz/pages/review/review.html create mode 100644 src/addons/mod/quiz/pages/review/review.module.ts create mode 100644 src/addons/mod/quiz/pages/review/review.scss create mode 100644 src/addons/mod/quiz/pages/review/review.ts diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index fb50b8ebf..c08c5e18f 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -413,7 +413,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp try { await AddonModQuiz.instance.getAttemptReview(attemptId, { page: -1, cmId: this.module!.id }); - // @todo this.navCtrl.push('AddonModQuizReviewPage', { courseId: this.courseId, quizId: quiz!.id, attemptId }); + CoreNavigator.instance.navigate(`../../review/${this.courseId}/${this.quiz!.id}/${attemptId}`); } catch { // Ignore errors. } diff --git a/src/addons/mod/quiz/pages/attempt/attempt.ts b/src/addons/mod/quiz/pages/attempt/attempt.ts index ede88d001..ccf7ebd30 100644 --- a/src/addons/mod/quiz/pages/attempt/attempt.ts +++ b/src/addons/mod/quiz/pages/attempt/attempt.ts @@ -191,7 +191,7 @@ export class AddonModQuizAttemptPage implements OnInit { * @return Promise resolved when done. */ async reviewAttempt(): Promise { - // @todo navPush="AddonModQuizReviewPage" [navParams]="{courseId: courseId, quizId: quiz.id, attemptId: attempt.id}" + CoreNavigator.instance.navigate(`../../../../review/${this.courseId}/${this.quiz!.id}/${this.attempt!.id}`); } } diff --git a/src/addons/mod/quiz/pages/review/review.html b/src/addons/mod/quiz/pages/review/review.html new file mode 100644 index 000000000..b740c07c9 --- /dev/null +++ b/src/addons/mod/quiz/pages/review/review.html @@ -0,0 +1,137 @@ + + + + + + {{ 'addon.mod_quiz.review' | translate }} + + + + + + + + + + + + + + + + + + + {{ 'addon.mod_quiz.reviewofpreview' | translate }} + {{ 'addon.mod_quiz.reviewofattempt' | translate:{$a: attempt.attempt} }} + + + + + +

{{ 'addon.mod_quiz.startedon' | translate }}

+

{{ attempt.timestart! * 1000 | coreFormatDate }}

+
+
+ + +

{{ 'addon.mod_quiz.attemptstate' | translate }}

+

{{ readableState }}

+
+
+ + +

{{ 'addon.mod_quiz.completedon' | translate }}

+

{{ attempt.timefinish! * 1000 | coreFormatDate }}

+
+
+ + +

{{ 'addon.mod_quiz.timetaken' | translate }}

+

{{ timeTaken }}

+
+
+ + +

{{ 'addon.mod_quiz.overdue' | translate }}

+

{{ overTime }}

+
+
+ + +

{{ 'addon.mod_quiz.marks' | translate }}

+

{{ readableMark }}

+
+
+ + +

{{ 'addon.mod_quiz.grade' | translate }}

+

{{ readableGrade }}

+
+
+ + +

{{ data.title }}

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

{{ 'core.question.questionno' | translate:{$a: question.number} }}

+

{{ 'core.question.information' | translate }}

+
+
+

{{question.status}}

+

{{question.readableMark}}

+
+
+ + + + +
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/quiz/pages/review/review.module.ts b/src/addons/mod/quiz/pages/review/review.module.ts new file mode 100644 index 000000000..afb547520 --- /dev/null +++ b/src/addons/mod/quiz/pages/review/review.module.ts @@ -0,0 +1,40 @@ +// (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 { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreQuestionComponentsModule } from '@features/question/components/components.module'; +import { AddonModQuizReviewPage } from './review'; + +const routes: Routes = [ + { + path: '', + component: AddonModQuizReviewPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + CoreQuestionComponentsModule, + ], + declarations: [ + AddonModQuizReviewPage, + ], + exports: [RouterModule], +}) +export class AddonModQuizReviewPageModule {} diff --git a/src/addons/mod/quiz/pages/review/review.scss b/src/addons/mod/quiz/pages/review/review.scss new file mode 100644 index 000000000..506efd71b --- /dev/null +++ b/src/addons/mod/quiz/pages/review/review.scss @@ -0,0 +1,6 @@ +:host { + .addon-mod_quiz-question-note p { + margin-top: 2px; + margin-bottom: 2px; + } +} diff --git a/src/addons/mod/quiz/pages/review/review.ts b/src/addons/mod/quiz/pages/review/review.ts new file mode 100644 index 000000000..f02a67fcd --- /dev/null +++ b/src/addons/mod/quiz/pages/review/review.ts @@ -0,0 +1,366 @@ +// (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, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Translate } from '@singletons'; +import { + AddonModQuizNavigationModalComponent, + AddonModQuizNavigationQuestion, +} from '../../components/navigation-modal/navigation-modal'; +import { + AddonModQuiz, + AddonModQuizAttemptWSData, + AddonModQuizCombinedReviewOptions, + AddonModQuizGetAttemptReviewResponse, + AddonModQuizProvider, + AddonModQuizQuizWSData, + AddonModQuizWSAdditionalData, +} from '../../services/quiz'; +import { AddonModQuizHelper } from '../../services/quiz-helper'; + +/** + * Page that allows reviewing a quiz attempt. + */ +@Component({ + selector: 'page-addon-mod-quiz-review', + templateUrl: 'review.html', + styleUrls: ['review.scss'], +}) +export class AddonModQuizReviewPage implements OnInit { + + @ViewChild(IonContent) content?: IonContent; + + attempt?: AddonModQuizAttemptWSData; // The attempt being reviewed. + component = AddonModQuizProvider.COMPONENT; // Component to link the files to. + componentId?: number; // ID to use in conjunction with the component. + showAll = false; // Whether to view all questions in the same page. + numPages?: number; // Number of pages. + showCompleted = false; // Whether to show completed time. + additionalData?: AddonModQuizWSAdditionalData[]; // Additional data to display for the attempt. + loaded = false; // Whether data has been loaded. + navigation: AddonModQuizNavigationQuestion[] = []; // List of questions to navigate them. + questions: QuizQuestion[] = []; // Questions of the current page. + nextPage = -2; // Next page. + previousPage = -2; // Previous page. + readableState?: string; + readableGrade?: string; + readableMark?: string; + timeTaken?: string; + overTime?: string; + quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to. + courseId!: number; // The course ID the quiz belongs to. + + protected quizId!: number; // Quiz ID the attempt belongs to. + protected attemptId!: number; // The attempt being reviewed. + protected currentPage!: number; // The current page being reviewed. + protected options?: AddonModQuizCombinedReviewOptions; // Review options. + + constructor( + protected elementRef: ElementRef, + ) { + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.quizId = CoreNavigator.instance.getRouteNumberParam('quizId')!; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; + this.attemptId = CoreNavigator.instance.getRouteNumberParam('attemptId')!; + this.currentPage = CoreNavigator.instance.getRouteNumberParam('page') || -1; + this.showAll = this.currentPage == -1; + + try { + await this.fetchData(); + + CoreUtils.instance.ignoreErrors( + AddonModQuiz.instance.logViewAttemptReview(this.attemptId, this.quizId, this.quiz!.name), + ); + } finally { + this.loaded = true; + } + } + + /** + * Change the current page. If slot is supplied, try to scroll to that question. + * + * @param page Page to load. -1 means all questions in same page. + * @param fromModal Whether the page was selected using the navigation modal. + * @param slot Slot of the question to scroll to. + */ + async changePage(page: number, fromModal?: boolean, slot?: number): Promise { + if (typeof slot != 'undefined' && (this.attempt!.currentpage == -1 || page == this.currentPage)) { + // Scrol to a certain question in the current page. + this.scrollToQuestion(slot); + + return; + } else if (page == this.currentPage) { + // If the user is navigating to the current page and no question specified, we do nothing. + return; + } + + this.loaded = false; + this.content?.scrollToTop(); + + try { + await this.loadPage(page); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquestions', true); + } finally { + this.loaded = true; + + if (typeof slot != 'undefined') { + // Scroll to the question. Give some time to the questions to render. + setTimeout(() => { + this.scrollToQuestion(slot); + }, 2000); + } + } + } + + /** + * Convenience function to get the quiz data. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + try { + this.quiz = await AddonModQuiz.instance.getQuizById(this.courseId, this.quizId); + + this.componentId = this.quiz.coursemodule; + + this.options = await AddonModQuiz.instance.getCombinedReviewOptions(this.quizId, { cmId: this.quiz.coursemodule }); + + // Load the navigation data. + await this.loadNavigation(); + + // Load questions. + await this.loadPage(this.currentPage); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_quiz.errorgetquiz', true); + } + } + + /** + * Load a page questions. + * + * @param page The page to load. + * @return Promise resolved when done. + */ + protected async loadPage(page: number): Promise { + const data = await AddonModQuiz.instance.getAttemptReview(this.attemptId, { page, cmId: this.quiz!.coursemodule }); + + this.attempt = data.attempt; + this.attempt.currentpage = page; + this.currentPage = page; + + // Set the summary data. + this.setSummaryCalculatedData(data); + + this.questions = data.questions; + this.nextPage = page == -1 ? -2 : page + 1; + this.previousPage = page - 1; + + this.questions.forEach((question) => { + // Get the readable mark for each question. + question.readableMark = AddonModQuizHelper.instance.getQuestionMarkFromHtml(question.html); + + // Extract the question info box. + CoreQuestionHelper.instance.extractQuestionInfoBox(question, '.info'); + }); + } + + /** + * Load data to navigate the questions using the navigation modal. + * + * @return Promise resolved when done. + */ + protected async loadNavigation(): Promise { + // Get all questions in single page to retrieve all the questions. + const data = await AddonModQuiz.instance.getAttemptReview(this.attemptId, { page: -1, cmId: this.quiz!.coursemodule }); + + this.navigation = data.questions; + + this.navigation.forEach((question) => { + question.stateClass = CoreQuestionHelper.instance.getQuestionStateClass(question.state || ''); + }); + + const lastQuestion = data.questions[data.questions.length - 1]; + this.numPages = lastQuestion ? lastQuestion.page + 1 : 0; + } + + /** + * Refreshes data. + * + * @param refresher Refresher + */ + async refreshData(refresher: IonRefresher): Promise { + await CoreUtils.instance.ignoreErrors(Promise.all([ + AddonModQuiz.instance.invalidateQuizData(this.courseId), + AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quizId), + AddonModQuiz.instance.invalidateAttemptReview(this.attemptId), + ])); + + try { + await this.fetchData(); + } finally { + refresher.complete(); + } + } + + /** + * Scroll to a certain question. + * + * @param slot Slot of the question to scroll to. + */ + protected scrollToQuestion(slot: number): void { + CoreDomUtils.instance.scrollToElementBySelector( + this.elementRef.nativeElement, + this.content, + `#addon-mod_quiz-question-${slot}`, + ); + } + + /** + * Calculate review summary data. + * + * @param data Result of getAttemptReview. + */ + protected setSummaryCalculatedData(data: AddonModQuizGetAttemptReviewResponse): void { + if (!this.attempt || !this.quiz) { + return; + } + + this.readableState = AddonModQuiz.instance.getAttemptReadableStateName(this.attempt!.state || ''); + + if (this.attempt.state != AddonModQuizProvider.ATTEMPT_FINISHED) { + return; + } + + this.showCompleted = true; + this.additionalData = data.additionaldata; + + const timeTaken = (this.attempt.timefinish || 0) - (this.attempt.timestart || 0); + if (timeTaken > 0) { + // Format time taken. + this.timeTaken = CoreTimeUtils.instance.formatTime(timeTaken); + + // Calculate overdue time. + if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) { + this.overTime = CoreTimeUtils.instance.formatTime(timeTaken - this.quiz.timelimit); + } + } else { + this.timeTaken = undefined; + } + + // Treat grade. + if (this.options!.someoptions.marks >= AddonModQuizProvider.QUESTION_OPTIONS_MARK_AND_MAX && + AddonModQuiz.instance.quizHasGrades(this.quiz)) { + + if (data.grade === null || typeof data.grade == 'undefined') { + this.readableGrade = AddonModQuiz.instance.formatGrade(data.grade, this.quiz.decimalpoints); + } else { + // Show raw marks only if they are different from the grade (like on the entry page). + if (this.quiz.grade != this.quiz.sumgrades) { + this.readableMark = Translate.instance.instant('addon.mod_quiz.outofshort', { $a: { + grade: AddonModQuiz.instance.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints), + maxgrade: AddonModQuiz.instance.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints), + } }); + } + + // Now the scaled grade. + const gradeObject: Record = { + grade: AddonModQuiz.instance.formatGrade(Number(data.grade), this.quiz.decimalpoints), + maxgrade: AddonModQuiz.instance.formatGrade(this.quiz.grade, this.quiz.decimalpoints), + }; + + if (this.quiz.grade != 100) { + gradeObject.percent = CoreTextUtils.instance.roundToDecimals( + this.attempt.sumgrades! * 100 / this.quiz.sumgrades!, + 0, + ); + this.readableGrade = Translate.instance.instant('addon.mod_quiz.outofpercent', { $a: gradeObject }); + } else { + this.readableGrade = Translate.instance.instant('addon.mod_quiz.outof', { $a: gradeObject }); + } + } + } + + // Treat additional data. + this.additionalData.forEach((data) => { + // Remove help links from additional data. + data.content = CoreDomUtils.instance.removeElementFromHtml(data.content, '.helptooltip'); + }); + } + + /** + * Switch mode: all questions in same page OR one page at a time. + */ + switchMode(): void { + this.showAll = !this.showAll; + + // Load all questions or first page, depending on the mode. + this.loadPage(this.showAll ? -1 : 0); + } + + async openNavigation(): Promise { + // Create the navigation modal. + const modal = await ModalController.instance.create({ + component: AddonModQuizNavigationModalComponent, + componentProps: { + navigation: this.navigation, + summaryShown: false, + currentPage: this.attempt?.currentpage, + isReview: true, + numPages: this.numPages, + showAll: this.showAll, + }, + cssClass: 'core-modal-lateral', + showBackdrop: true, + backdropDismiss: true, + // @todo enterAnimation: 'core-modal-lateral-transition', + // @todo leaveAnimation: 'core-modal-lateral-transition', + }); + + await modal.present(); + + const result = await modal.onWillDismiss(); + + if (!result.data) { + return; + } + + if (result.data.action == AddonModQuizNavigationModalComponent.CHANGE_PAGE) { + this.changePage(result.data.page, true, result.data.slot); + } else if (result.data.action == AddonModQuizNavigationModalComponent.SWITCH_MODE) { + this.switchMode(); + } + } + +} + +/** + * Question with some calculated data for the view. + */ +type QuizQuestion = CoreQuestionQuestionParsed & { + readableMark?: string; +}; diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts index 1b44928d9..1cd5e8998 100644 --- a/src/addons/mod/quiz/quiz-lazy.module.ts +++ b/src/addons/mod/quiz/quiz-lazy.module.ts @@ -28,6 +28,10 @@ const routes: Routes = [ path: 'attempt/:courseId/:quizId/:attemptId', loadChildren: () => import('./pages/attempt/attempt.module').then( m => m.AddonModQuizAttemptPageModule), }, + { + path: 'review/:courseId/:quizId/:attemptId', + loadChildren: () => import('./pages/review/review.module').then( m => m.AddonModQuizReviewPageModule), + }, ]; @NgModule({ diff --git a/src/core/features/question/classes/base-question-component.ts b/src/core/features/question/classes/base-question-component.ts index b6f21aef4..b538aa870 100644 --- a/src/core/features/question/classes/base-question-component.ts +++ b/src/core/features/question/classes/base-question-component.ts @@ -423,15 +423,15 @@ export class CoreQuestionBaseComponent { // Check if question is marked as correct. if (input.classList.contains('incorrect')) { question.input.correctClass = 'core-question-incorrect'; - question.input.correctIcon = 'fa-remove'; + question.input.correctIcon = 'fas-times'; question.input.correctIconColor = 'danger'; } else if (input.classList.contains('correct')) { question.input.correctClass = 'core-question-correct'; - question.input.correctIcon = 'fa-check'; + question.input.correctIcon = 'fas-check'; question.input.correctIconColor = 'success'; } else if (input.classList.contains('partiallycorrect')) { question.input.correctClass = 'core-question-partiallycorrect'; - question.input.correctIcon = 'fa-check-square'; + question.input.correctIcon = 'fas-check-square'; question.input.correctIconColor = 'warning'; } else { question.input.correctClass = ''; diff --git a/src/core/features/question/question.scss b/src/core/features/question/question.scss index 74f9d06ad..b6e4f4b80 100644 --- a/src/core/features/question/question.scss +++ b/src/core/features/question/question.scss @@ -2,7 +2,7 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA520, #FFD700, #F0E68C !default; -:host { +:host ::ng-deep { --core-question-correct-color: var(--green-dark); --core-question-correct-color-bg: var(--green-light); --core-question-incorrect-color: var(--red); @@ -22,64 +22,19 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA52 --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: var(--core-question-correct-color); + } - - // .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-answer-incorrect { + color: var(--core-question-incorrect-color); + } .core-question-feedback-container ::ng-deep { --color: var(--core-question-feedback-color); --background: var(--core-question-feedback-background-color); + color: var(--core-question-feedback-color); + background-color: var(--core-question-feedback-background-color); .specificfeedback, .rightanswer, .im-feedback, .feedback, .generalfeedback { margin: 0 0 .5em; @@ -100,7 +55,7 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA52 background-color: var(--red); } &.correct { - background-color: var(--green); + background-color: var(--green); } } } @@ -115,8 +70,11 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA52 padding-bottom: 8px; } - .core-question-correct { - background-color: var(--core-question-state-correct-color); + .core-question-correct, + .core-question-comment { + --background: var(--core-question-correct-color-bg); + background-color: var(--core-question-correct-color-bg); + color: var(--core-question-correct-color); } .core-question-partiallycorrect { background-color: var(--core-question-state-partial-color); @@ -139,4 +97,10 @@ $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA52 .fa.icon.questioncorrectnessicon { font-size: 20px; } + + .item.item-interactive.item-interactive-disabled ::ng-deep { + ion-label, ion-select, ion-checkbox { + opacity: 0.7; + } + } } From 33003da29d1984e18bcec86141e7b0445fdbdd02 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 19 Feb 2021 12:47:16 +0100 Subject: [PATCH 13/16] MOBILE-3651 quiz: Implement missing handlers --- .../addon-block-recentlyaccesseditems.html | 2 +- src/addons/mod/quiz/quiz.module.ts | 15 ++++ .../mod/quiz/services/handlers/grade-link.ts | 35 ++++++++ .../mod/quiz/services/handlers/index-link.ts | 34 ++++++++ .../mod/quiz/services/handlers/list-link.ts | 34 ++++++++ .../mod/quiz/services/handlers/prefetch.ts | 2 +- .../mod/quiz/services/handlers/push-click.ts | 86 +++++++++++++++++++ .../mod/quiz/services/handlers/review-link.ts | 66 ++++++++++++++ .../mod/quiz/services/handlers/sync-cron.ts | 52 +++++++++++ src/addons/mod/quiz/services/quiz-helper.ts | 14 ++- src/addons/mod/quiz/services/quiz.ts | 10 --- .../section-selector/section-selector.html | 2 +- 12 files changed, 331 insertions(+), 21 deletions(-) create mode 100644 src/addons/mod/quiz/services/handlers/grade-link.ts create mode 100644 src/addons/mod/quiz/services/handlers/index-link.ts create mode 100644 src/addons/mod/quiz/services/handlers/list-link.ts create mode 100644 src/addons/mod/quiz/services/handlers/push-click.ts create mode 100644 src/addons/mod/quiz/services/handlers/review-link.ts create mode 100644 src/addons/mod/quiz/services/handlers/sync-cron.ts diff --git a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/addon-block-recentlyaccesseditems.html b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/addon-block-recentlyaccesseditems.html index 2008fbce0..f97b267b1 100644 --- a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/addon-block-recentlyaccesseditems.html +++ b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/addon-block-recentlyaccesseditems.html @@ -6,7 +6,7 @@
+ [title]="item.name" button>

diff --git a/src/addons/mod/quiz/quiz.module.ts b/src/addons/mod/quiz/quiz.module.ts index 3dcf54d19..c39a6deb3 100644 --- a/src/addons/mod/quiz/quiz.module.ts +++ b/src/addons/mod/quiz/quiz.module.ts @@ -14,16 +14,25 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate'; +import { CoreCronDelegate } from '@services/cron'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { AddonModQuizAccessRulesModule } from './accessrules/accessrules.module'; import { AddonModQuizComponentsModule } from './components/components.module'; import { SITE_SCHEMA } from './services/database/quiz'; +import { AddonModQuizGradeLinkHandler } from './services/handlers/grade-link'; +import { AddonModQuizIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModQuizListLinkHandler } from './services/handlers/list-link'; import { AddonModQuizModuleHandler, AddonModQuizModuleHandlerService } from './services/handlers/module'; import { AddonModQuizPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModQuizPushClickHandler } from './services/handlers/push-click'; +import { AddonModQuizReviewLinkHandler } from './services/handlers/review-link'; +import { AddonModQuizSyncCronHandler } from './services/handlers/sync-cron'; const routes: Routes = [ { @@ -51,6 +60,12 @@ const routes: Routes = [ useFactory: () => () => { CoreCourseModuleDelegate.instance.registerHandler(AddonModQuizModuleHandler.instance); CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModQuizPrefetchHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModQuizGradeLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModQuizIndexLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModQuizListLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(AddonModQuizReviewLinkHandler.instance); + CorePushNotificationsDelegate.instance.registerClickHandler(AddonModQuizPushClickHandler.instance); + CoreCronDelegate.instance.register(AddonModQuizSyncCronHandler.instance); }, }, ], diff --git a/src/addons/mod/quiz/services/handlers/grade-link.ts b/src/addons/mod/quiz/services/handlers/grade-link.ts new file mode 100644 index 000000000..06557f770 --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/grade-link.ts @@ -0,0 +1,35 @@ +// (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 { CoreContentLinksModuleGradeHandler } from '@features/contentlinks/classes/module-grade-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to quiz grade. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizGradeLinkHandlerService extends CoreContentLinksModuleGradeHandler { + + name = 'AddonModQuizGradeLinkHandler'; + canReview = false; + + constructor() { + super('AddonModQuiz', 'quiz'); + } + +} + +export class AddonModQuizGradeLinkHandler extends makeSingleton(AddonModQuizGradeLinkHandlerService) {} diff --git a/src/addons/mod/quiz/services/handlers/index-link.ts b/src/addons/mod/quiz/services/handlers/index-link.ts new file mode 100644 index 000000000..a59d0924e --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/index-link.ts @@ -0,0 +1,34 @@ +// (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 { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to quiz index. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModQuizIndexLinkHandler'; + + constructor() { + super('AddonModQuiz', 'quiz', 'q'); + } + +} + +export class AddonModQuizIndexLinkHandler extends makeSingleton(AddonModQuizIndexLinkHandlerService) {} diff --git a/src/addons/mod/quiz/services/handlers/list-link.ts b/src/addons/mod/quiz/services/handlers/list-link.ts new file mode 100644 index 000000000..1158d1beb --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/list-link.ts @@ -0,0 +1,34 @@ +// (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 { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to quiz list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModQuizListLinkHandler'; + + constructor() { + super('AddonModQuiz', 'quiz'); + } + +} + +export class AddonModQuizListLinkHandler extends makeSingleton(AddonModQuizListLinkHandlerService) {} diff --git a/src/addons/mod/quiz/services/handlers/prefetch.ts b/src/addons/mod/quiz/services/handlers/prefetch.ts index e138fe936..251a5e5ed 100644 --- a/src/addons/mod/quiz/services/handlers/prefetch.ts +++ b/src/addons/mod/quiz/services/handlers/prefetch.ts @@ -254,7 +254,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet * @return A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. */ async isEnabled(): Promise { - return AddonModQuiz.instance.isPluginEnabled(); + return true; } /** diff --git a/src/addons/mod/quiz/services/handlers/push-click.ts b/src/addons/mod/quiz/services/handlers/push-click.ts new file mode 100644 index 000000000..b969577ef --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/push-click.ts @@ -0,0 +1,86 @@ +// (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 { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate'; +import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { AddonModQuiz } from '../quiz'; +import { AddonModQuizHelper } from '../quiz-helper'; + +/** + * Handler for quiz push notifications clicks. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizPushClickHandlerService implements CorePushNotificationsClickHandler { + + name = 'AddonModQuizPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModQuiz'; + + protected readonly SUPPORTED_NAMES = ['submission', 'confirmation', 'attempt_overdue']; + + /** + * Check if a notification click is handled by this handler. + * + * @param notification The notification to check. + * @return Whether the notification click is handled by this handler + */ + async handles(notification: AddonModQuizPushNotificationData): Promise { + return CoreUtils.instance.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_quiz' && + this.SUPPORTED_NAMES.indexOf(notification.name!) != -1; + } + + /** + * Handle the notification click. + * + * @param notification The notification to check. + * @return Promise resolved when done. + */ + async handleClick(notification: AddonModQuizPushNotificationData): Promise { + const contextUrlParams = CoreUrlUtils.instance.extractUrlParams(notification.contexturl || ''); + const data = notification.customdata || {}; + const courseId = Number(notification.courseid); + + if (notification.name == 'submission') { + // A student made a submission, go to view the attempt. + return AddonModQuizHelper.instance.handleReviewLink( + Number(contextUrlParams.attempt), + Number(contextUrlParams.page), + courseId, + Number(data.instance), + notification.site, + ); + } + + // Open the activity. + const moduleId = Number(contextUrlParams.id); + + await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.invalidateContent(moduleId, courseId, notification.site)); + + return CoreCourseHelper.instance.navigateToModule(moduleId, notification.site, courseId); + } + +} + +export class AddonModQuizPushClickHandler extends makeSingleton(AddonModQuizPushClickHandlerService) {} + +type AddonModQuizPushNotificationData = CorePushNotificationsNotificationBasicData & { + contexturl?: string; + courseid?: number | string; +}; diff --git a/src/addons/mod/quiz/services/handlers/review-link.ts b/src/addons/mod/quiz/services/handlers/review-link.ts new file mode 100644 index 000000000..7c9f40a63 --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/review-link.ts @@ -0,0 +1,66 @@ +// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { makeSingleton } from '@singletons'; +import { AddonModQuizHelper } from '../quiz-helper'; + +/** + * Handler to treat links to quiz review. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizReviewLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModQuizReviewLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModQuiz'; + pattern = /\/mod\/quiz\/review\.php.*([&?]attempt=\d+)/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @param data Extra data to handle the URL. + * @return List of (or promise resolved with list of) actions. + */ + + getActions( + siteIds: string[], + url: string, + params: Record, + courseId?: number, + data?: Record, + ): CoreContentLinksAction[] | Promise { + + courseId = Number(courseId || params.courseid || params.cid); + data = data || {}; + + return [{ + action: (siteId): void => { + const attemptId = parseInt(params.attempt, 10); + const page = parseInt(params.page, 10); + const quizId = data!.instance ? Number(data!.instance) : undefined; + + AddonModQuizHelper.instance.handleReviewLink(attemptId, page, courseId, quizId, siteId); + }, + }]; + } + +} + +export class AddonModQuizReviewLinkHandler extends makeSingleton(AddonModQuizReviewLinkHandlerService) {} diff --git a/src/addons/mod/quiz/services/handlers/sync-cron.ts b/src/addons/mod/quiz/services/handlers/sync-cron.ts new file mode 100644 index 000000000..9477bbf5e --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/sync-cron.ts @@ -0,0 +1,52 @@ +// (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 { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModQuizSync } from '../quiz-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModQuizSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModQuizSync.instance.syncAllQuizzes(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModQuizSync.instance.syncInterval; + } + +} + +export class AddonModQuizSyncCronHandler extends makeSingleton(AddonModQuizSyncCronHandlerService) {} diff --git a/src/addons/mod/quiz/services/quiz-helper.ts b/src/addons/mod/quiz/services/quiz-helper.ts index 74967b107..6fb0851c3 100644 --- a/src/addons/mod/quiz/services/quiz-helper.ts +++ b/src/addons/mod/quiz/services/quiz-helper.ts @@ -237,14 +237,12 @@ export class AddonModQuizHelperProvider { } // Go to the review page. - const pageParams = { - quizId, - attemptId, - courseId, - page: page == undefined || isNaN(page) ? -1 : page, - }; - - await CoreNavigator.instance.navigateToSitePath('@todo AddonModQuizReviewPage', { params: pageParams, siteId }); + await CoreNavigator.instance.navigateToSitePath(`mod_quiz/review/${courseId}/${quizId}/${attemptId}`, { + params: { + page: page == undefined || isNaN(page) ? -1 : page, + }, + siteId, + }); } catch (error) { CoreDomUtils.instance.showErrorModalDefault(error, 'An error occurred while loading the required data.'); } finally { diff --git a/src/addons/mod/quiz/services/quiz.ts b/src/addons/mod/quiz/services/quiz.ts index 86fd2e27e..7b40b3252 100644 --- a/src/addons/mod/quiz/services/quiz.ts +++ b/src/addons/mod/quiz/services/quiz.ts @@ -1496,16 +1496,6 @@ export class AddonModQuizProvider { return quiz.navmethod == 'sequential'; } - /** - * Return whether or not the plugin is enabled in a certain site. Plugin is enabled if the quiz WS are available. - * - * @return Whether the plugin is enabled. - */ - isPluginEnabled(): boolean { - // Quiz WebServices were introduced in 3.1, it will always be enabled. - return true; - } - /** * Check if a question is blocked. * diff --git a/src/core/features/course/components/section-selector/section-selector.html b/src/core/features/course/components/section-selector/section-selector.html index f0997453a..9eb4b28dd 100644 --- a/src/core/features/course/components/section-selector/section-selector.html +++ b/src/core/features/course/components/section-selector/section-selector.html @@ -14,7 +14,7 @@ + [attr.aria-hidden]="section.uservisible === false" button> From 6ae95070d234a4b608780cdc5d99d90c9485d64a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 24 Feb 2021 10:46:21 +0100 Subject: [PATCH 14/16] MOBILE-3651 quiz: Change routes to use cmId --- src/addons/mod/quiz/components/index/index.ts | 16 ++++++----- .../mod/quiz/pages/attempt/attempt.html | 2 +- src/addons/mod/quiz/pages/attempt/attempt.ts | 25 +++++++++-------- src/addons/mod/quiz/pages/player/player.html | 4 +-- src/addons/mod/quiz/pages/player/player.ts | 27 ++++++++++--------- src/addons/mod/quiz/pages/review/review.html | 8 +++--- src/addons/mod/quiz/pages/review/review.ts | 27 ++++++++++--------- src/addons/mod/quiz/quiz-lazy.module.ts | 8 +++--- .../mod/quiz/services/handlers/prefetch.ts | 2 +- src/addons/mod/quiz/services/quiz-helper.ts | 11 ++++---- src/addons/mod/quiz/services/quiz.ts | 4 +-- 11 files changed, 71 insertions(+), 63 deletions(-) diff --git a/src/addons/mod/quiz/components/index/index.ts b/src/addons/mod/quiz/components/index/index.ts index c08c5e18f..a3fd16fa3 100644 --- a/src/addons/mod/quiz/components/index/index.ts +++ b/src/addons/mod/quiz/components/index/index.ts @@ -413,7 +413,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp try { await AddonModQuiz.instance.getAttemptReview(attemptId, { page: -1, cmId: this.module!.id }); - CoreNavigator.instance.navigate(`../../review/${this.courseId}/${this.quiz!.id}/${attemptId}`); + await CoreNavigator.instance.navigate(`review/${attemptId}`); } catch { // Ignore errors. } @@ -534,7 +534,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp protected openQuiz(): void { this.hasPlayed = true; - CoreNavigator.instance.navigate(`../../player/${this.courseId}/${this.quiz!.id}`, { + CoreNavigator.instance.navigate('player', { params: { moduleUrl: this.module?.url, }, @@ -639,10 +639,12 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp // Get gradebook grade. const data = await AddonModQuiz.instance.getGradeFromGradebook(this.courseId!, this.module!.id); - this.gradebookData = { - grade: data.graderaw, - feedback: data.feedback, - }; + if (data) { + this.gradebookData = { + grade: 'graderaw' in data ? data.graderaw : Number(data.grade), + feedback: data.feedback, + }; + } } catch { // Fallback to quiz best grade if failure or not found. this.gradebookData = { @@ -657,7 +659,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp * @return Promise resolved when done. */ async viewAttempt(attemptId: number): Promise { - CoreNavigator.instance.navigate(`../../attempt/${this.courseId}/${this.quiz!.id}/${attemptId}`); + CoreNavigator.instance.navigate(`attempt/${attemptId}`); } /** diff --git a/src/addons/mod/quiz/pages/attempt/attempt.html b/src/addons/mod/quiz/pages/attempt/attempt.html index 872803363..032b4b9b8 100644 --- a/src/addons/mod/quiz/pages/attempt/attempt.html +++ b/src/addons/mod/quiz/pages/attempt/attempt.html @@ -46,7 +46,7 @@

{{ 'addon.mod_quiz.feedback' | translate }}

+ contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">

diff --git a/src/addons/mod/quiz/pages/attempt/attempt.ts b/src/addons/mod/quiz/pages/attempt/attempt.ts index ccf7ebd30..fe1ee3d27 100644 --- a/src/addons/mod/quiz/pages/attempt/attempt.ts +++ b/src/addons/mod/quiz/pages/attempt/attempt.ts @@ -44,15 +44,15 @@ export class AddonModQuizAttemptPage implements OnInit { loaded = false; // Whether data has been loaded. feedback?: string; // Attempt feedback. showReviewColumn = false; + cmId!: number; // Course module id the attempt belongs to. protected attemptId!: number; // Attempt to view. - protected quizId!: number; // ID of the quiz the attempt belongs to. /** * Component being initialized. */ ngOnInit(): void { - this.quizId = CoreNavigator.instance.getRouteNumberParam('quizId')!; + this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!; this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; this.attemptId = CoreNavigator.instance.getRouteNumberParam('attemptId')!; @@ -79,7 +79,7 @@ export class AddonModQuizAttemptPage implements OnInit { */ protected async fetchQuizData(): Promise { try { - this.quiz = await AddonModQuiz.instance.getQuizById(this.courseId, this.quizId); + this.quiz = await AddonModQuiz.instance.getQuiz(this.courseId, this.cmId); this.componentId = this.quiz.coursemodule; @@ -123,7 +123,7 @@ export class AddonModQuizAttemptPage implements OnInit { */ protected async fetchAttempt(): Promise { // Get all the attempts and search the one we want. - const attempts = await AddonModQuiz.instance.getUserAttempts(this.quizId, { cmId: this.quiz!.coursemodule }); + const attempts = await AddonModQuiz.instance.getUserAttempts(this.quiz!.id, { cmId: this.cmId }); const attempt = attempts.find(attempt => attempt.id == this.attemptId); @@ -143,7 +143,7 @@ export class AddonModQuizAttemptPage implements OnInit { * @return Promise resolved when done. */ protected async fetchAccessInfo(): Promise { - const accessInfo = await AddonModQuiz.instance.getQuizAccessInformation(this.quizId, { cmId: this.quiz!.coursemodule }); + const accessInfo = await AddonModQuiz.instance.getQuizAccessInformation(this.quiz!.id, { cmId: this.cmId }); if (!accessInfo.canreviewmyattempts) { return accessInfo; @@ -171,13 +171,16 @@ export class AddonModQuizAttemptPage implements OnInit { const promises: Promise[] = []; promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId)); - promises.push(AddonModQuiz.instance.invalidateUserAttemptsForUser(this.quizId)); - promises.push(AddonModQuiz.instance.invalidateQuizAccessInformation(this.quizId)); - promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quizId)); promises.push(AddonModQuiz.instance.invalidateAttemptReview(this.attemptId)); - if (this.attempt && typeof this.feedback != 'undefined') { - promises.push(AddonModQuiz.instance.invalidateFeedback(this.quizId)); + if (this.quiz) { + promises.push(AddonModQuiz.instance.invalidateUserAttemptsForUser(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateQuizAccessInformation(this.quiz.id)); + promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quiz.id)); + + if (this.attempt && typeof this.feedback != 'undefined') { + promises.push(AddonModQuiz.instance.invalidateFeedback(this.quiz.id)); + } } await CoreUtils.instance.ignoreErrors(Promise.all(promises)); @@ -191,7 +194,7 @@ export class AddonModQuizAttemptPage implements OnInit { * @return Promise resolved when done. */ async reviewAttempt(): Promise { - CoreNavigator.instance.navigate(`../../../../review/${this.courseId}/${this.quiz!.id}/${this.attempt!.id}`); + CoreNavigator.instance.navigate(`../../review/${this.attempt!.id}`); } } diff --git a/src/addons/mod/quiz/pages/player/player.html b/src/addons/mod/quiz/pages/player/player.html index 8c8f39b12..bf984b8c6 100644 --- a/src/addons/mod/quiz/pages/player/player.html +++ b/src/addons/mod/quiz/pages/player/player.html @@ -78,8 +78,8 @@ diff --git a/src/addons/mod/quiz/pages/player/player.ts b/src/addons/mod/quiz/pages/player/player.ts index 046db21de..3eb0a3d82 100644 --- a/src/addons/mod/quiz/pages/player/player.ts +++ b/src/addons/mod/quiz/pages/player/player.ts @@ -80,8 +80,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { readableTimeLimit?: string; // Time limit in a readable format. dueDateWarning?: string; // Warning about due date. courseId!: number; // The course ID the quiz belongs to. + cmId!: number; // Course module ID. - protected quizId!: number; // Quiz ID to attempt. protected preflightData: Record = {}; // Preflight data to attempt the quiz. protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access information. protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Attempt access info. @@ -104,13 +104,10 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { * Component being initialized. */ ngOnInit(): void { - this.quizId = CoreNavigator.instance.getRouteNumberParam('quizId')!; + this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!; this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; this.moduleUrl = CoreNavigator.instance.getRouteParam('moduleUrl'); - // Block the quiz so it cannot be synced. - CoreSync.instance.blockOperation(AddonModQuizProvider.COMPONENT, this.quizId); - // Create the auto save instance. this.autoSave = new AddonModQuizAutoSave( 'addon-mod_quiz-player-form', @@ -136,8 +133,10 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { this.autoSave.stopCheckChangesProcess(); this.autoSaveErrorSubscription?.unsubscribe(); - // Unblock the quiz so it can be synced. - CoreSync.instance.unblockOperation(AddonModQuizProvider.COMPONENT, this.quizId); + if (this.quiz) { + // Unblock the quiz so it can be synced. + CoreSync.instance.unblockOperation(AddonModQuizProvider.COMPONENT, this.quiz.id); + } } /** @@ -320,11 +319,13 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { */ protected async fetchData(): Promise { try { - // Wait for any ongoing sync to finish. We won't sync a quiz while it's being played. - await AddonModQuizSync.instance.waitForSync(this.quizId); + this.quiz = await AddonModQuiz.instance.getQuiz(this.courseId, this.cmId); - // Sync finished, now get the quiz. - this.quiz = await AddonModQuiz.instance.getQuizById(this.courseId, this.quizId); + // Block the quiz so it cannot be synced. + CoreSync.instance.blockOperation(AddonModQuizProvider.COMPONENT, this.quiz.id); + + // Wait for any ongoing sync to finish. We won't sync a quiz while it's being played. + await AddonModQuizSync.instance.waitForSync(this.quiz.id); this.isSequential = AddonModQuiz.instance.isNavigationSequential(this.quiz); @@ -397,7 +398,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { // Trigger an event to notify the attempt was finished. CoreEvents.trigger(AddonModQuizProvider.ATTEMPT_FINISHED_EVENT, { - quizId: this.quizId, + quizId: this.quiz!.id, attemptId: this.attempt!.id, synced: !this.offline, }, CoreSites.instance.getCurrentSiteId()); @@ -537,7 +538,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { // Log summary as viewed. CoreUtils.instance.ignoreErrors( - AddonModQuiz.instance.logViewAttemptSummary(this.attempt!.id, this.preflightData, this.quizId, this.quiz!.name), + AddonModQuiz.instance.logViewAttemptSummary(this.attempt!.id, this.preflightData, this.quiz!.id, this.quiz!.name), ); } diff --git a/src/addons/mod/quiz/pages/review/review.html b/src/addons/mod/quiz/pages/review/review.html index b740c07c9..7bb142d42 100644 --- a/src/addons/mod/quiz/pages/review/review.html +++ b/src/addons/mod/quiz/pages/review/review.html @@ -73,8 +73,8 @@

{{ data.title }}

- +
@@ -103,9 +103,9 @@ - diff --git a/src/addons/mod/quiz/pages/review/review.ts b/src/addons/mod/quiz/pages/review/review.ts index f02a67fcd..49ec63a6d 100644 --- a/src/addons/mod/quiz/pages/review/review.ts +++ b/src/addons/mod/quiz/pages/review/review.ts @@ -51,7 +51,6 @@ export class AddonModQuizReviewPage implements OnInit { attempt?: AddonModQuizAttemptWSData; // The attempt being reviewed. component = AddonModQuizProvider.COMPONENT; // Component to link the files to. - componentId?: number; // ID to use in conjunction with the component. showAll = false; // Whether to view all questions in the same page. numPages?: number; // Number of pages. showCompleted = false; // Whether to show completed time. @@ -68,8 +67,8 @@ export class AddonModQuizReviewPage implements OnInit { overTime?: string; quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to. courseId!: number; // The course ID the quiz belongs to. + cmId!: number; // Course module id the attempt belongs to. - protected quizId!: number; // Quiz ID the attempt belongs to. protected attemptId!: number; // The attempt being reviewed. protected currentPage!: number; // The current page being reviewed. protected options?: AddonModQuizCombinedReviewOptions; // Review options. @@ -83,7 +82,7 @@ export class AddonModQuizReviewPage implements OnInit { * Component being initialized. */ async ngOnInit(): Promise { - this.quizId = CoreNavigator.instance.getRouteNumberParam('quizId')!; + this.cmId = CoreNavigator.instance.getRouteNumberParam('cmId')!; this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId')!; this.attemptId = CoreNavigator.instance.getRouteNumberParam('attemptId')!; this.currentPage = CoreNavigator.instance.getRouteNumberParam('page') || -1; @@ -93,7 +92,7 @@ export class AddonModQuizReviewPage implements OnInit { await this.fetchData(); CoreUtils.instance.ignoreErrors( - AddonModQuiz.instance.logViewAttemptReview(this.attemptId, this.quizId, this.quiz!.name), + AddonModQuiz.instance.logViewAttemptReview(this.attemptId, this.quiz!.id, this.quiz!.name), ); } finally { this.loaded = true; @@ -144,11 +143,9 @@ export class AddonModQuizReviewPage implements OnInit { */ protected async fetchData(): Promise { try { - this.quiz = await AddonModQuiz.instance.getQuizById(this.courseId, this.quizId); + this.quiz = await AddonModQuiz.instance.getQuiz(this.courseId, this.cmId); - this.componentId = this.quiz.coursemodule; - - this.options = await AddonModQuiz.instance.getCombinedReviewOptions(this.quizId, { cmId: this.quiz.coursemodule }); + this.options = await AddonModQuiz.instance.getCombinedReviewOptions(this.quiz.id, { cmId: this.cmId }); // Load the navigation data. await this.loadNavigation(); @@ -214,11 +211,15 @@ export class AddonModQuizReviewPage implements OnInit { * @param refresher Refresher */ async refreshData(refresher: IonRefresher): Promise { - await CoreUtils.instance.ignoreErrors(Promise.all([ - AddonModQuiz.instance.invalidateQuizData(this.courseId), - AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quizId), - AddonModQuiz.instance.invalidateAttemptReview(this.attemptId), - ])); + const promises: Promise[] = []; + + promises.push(AddonModQuiz.instance.invalidateQuizData(this.courseId)); + promises.push(AddonModQuiz.instance.invalidateAttemptReview(this.attemptId)); + if (this.quiz) { + promises.push(AddonModQuiz.instance.invalidateCombinedReviewOptionsForUser(this.quiz.id)); + } + + await CoreUtils.instance.ignoreErrors(Promise.all(promises)); try { await this.fetchData(); diff --git a/src/addons/mod/quiz/quiz-lazy.module.ts b/src/addons/mod/quiz/quiz-lazy.module.ts index 1cd5e8998..691bfff63 100644 --- a/src/addons/mod/quiz/quiz-lazy.module.ts +++ b/src/addons/mod/quiz/quiz-lazy.module.ts @@ -17,19 +17,19 @@ import { RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { - path: ':courseId/:cmdId', + path: ':courseId/:cmId', loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModQuizIndexPageModule), }, { - path: 'player/:courseId/:quizId', + path: ':courseId/:cmId/player', loadChildren: () => import('./pages/player/player.module').then( m => m.AddonModQuizPlayerPageModule), }, { - path: 'attempt/:courseId/:quizId/:attemptId', + path: ':courseId/:cmId/attempt/:attemptId', loadChildren: () => import('./pages/attempt/attempt.module').then( m => m.AddonModQuizAttemptPageModule), }, { - path: 'review/:courseId/:quizId/:attemptId', + path: ':courseId/:cmId/review/:attemptId', loadChildren: () => import('./pages/review/review.module').then( m => m.AddonModQuizReviewPageModule), }, ]; diff --git a/src/addons/mod/quiz/services/handlers/prefetch.ts b/src/addons/mod/quiz/services/handlers/prefetch.ts index 251a5e5ed..0cddf1b5a 100644 --- a/src/addons/mod/quiz/services/handlers/prefetch.ts +++ b/src/addons/mod/quiz/services/handlers/prefetch.ts @@ -524,7 +524,7 @@ export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefet try { const gradebookData = await AddonModQuiz.instance.getGradeFromGradebook(quiz.course, quiz.coursemodule, true, siteId); - if (typeof gradebookData.graderaw != 'undefined') { + if (gradebookData && 'graderaw' in gradebookData && gradebookData.graderaw !== undefined) { await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, gradebookData.graderaw, modOptions); } } catch { diff --git a/src/addons/mod/quiz/services/quiz-helper.ts b/src/addons/mod/quiz/services/quiz-helper.ts index 6fb0851c3..8df6627a6 100644 --- a/src/addons/mod/quiz/services/quiz-helper.ts +++ b/src/addons/mod/quiz/services/quiz-helper.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreError } from '@classes/errors/error'; -import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreCourse } from '@features/course/services/course'; import { CoreNavigator } from '@services/navigator'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -232,12 +232,13 @@ export class AddonModQuizHelperProvider { if (!quizId) { quizId = await this.getQuizIdByAttemptId(attemptId, { siteId }); } - if (!courseId) { - courseId = await CoreCourseHelper.instance.getModuleCourseIdByInstance(quizId, 'quiz', siteId); - } + + const module = await CoreCourse.instance.getModuleBasicInfoByInstance(quizId, 'quiz', siteId); + + courseId = courseId || module.course; // Go to the review page. - await CoreNavigator.instance.navigateToSitePath(`mod_quiz/review/${courseId}/${quizId}/${attemptId}`, { + await CoreNavigator.instance.navigateToSitePath(`mod_quiz/${courseId}/${module.id}/review/${attemptId}`, { params: { page: page == undefined || isNaN(page) ? -1 : page, }, diff --git a/src/addons/mod/quiz/services/quiz.ts b/src/addons/mod/quiz/services/quiz.ts index 7b40b3252..92b25e9f3 100644 --- a/src/addons/mod/quiz/services/quiz.ts +++ b/src/addons/mod/quiz/services/quiz.ts @@ -19,7 +19,7 @@ import { CoreWSError } from '@classes/errors/wserror'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; -import { CoreGradesFormattedItem, CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { CoreGradesFormattedItem, CoreGradesFormattedRow, CoreGradesHelper } from '@features/grades/services/grades-helper'; import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; import { CoreQuestion, @@ -634,7 +634,7 @@ export class AddonModQuizProvider { ignoreCache?: boolean, siteId?: string, userId?: number, - ): Promise { + ): Promise { const items = await CoreGradesHelper.instance.getGradeModuleItems( courseId, From 7d00aefdd910e8ad95dd716abdb1f8861e4fc31f Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 24 Feb 2021 11:33:55 +0100 Subject: [PATCH 15/16] MOBILE-3651 quiz: Support dark theme --- .../mod/quiz/components/index/index.scss | 33 ++++++++----------- src/addons/qtype/ddwtos/component/ddwtos.scss | 10 ++---- .../qtype/gapselect/component/gapselect.scss | 5 --- src/core/features/question/question.scss | 21 ------------ src/theme/theme.dark.scss | 19 +++++++++++ 5 files changed, 34 insertions(+), 54 deletions(-) diff --git a/src/addons/mod/quiz/components/index/index.scss b/src/addons/mod/quiz/components/index/index.scss index ead659221..c1958ffb6 100644 --- a/src/addons/mod/quiz/components/index/index.scss +++ b/src/addons/mod/quiz/components/index/index.scss @@ -11,10 +11,7 @@ } .item:nth-child(even) { - --background: var(--gray-lighter); - // @include darkmode() { - // background-color: $core-dark-item-divider-bg-color; - // } + --background: var(--light); } .addon-mod_quiz-highlighted, @@ -24,21 +21,17 @@ --background: var(--blue-light); color: var(--blue-dark); } - - // @include darkmode() { - // .addon-mod_quiz-highlighted, - // .item.addon-mod_quiz-highlighted, - // .addon-mod_quiz-highlighted p, - // .item.addon-mod_quiz-highlighted p { - // background-color: $blue-dark; - // color: $blue-light; - // } - - // .item.addon-mod_quiz-highlighted.activated, - // .item.addon-mod_quiz-highlighted.activated p { - // background-color: $blue; - // color: $blue-light; - // } - // } + } +} + +:host-context(body.dark) { + .addon-mod_quiz-table { + .addon-mod_quiz-highlighted, + .item.addon-mod_quiz-highlighted, + .addon-mod_quiz-highlighted p, + .item.addon-mod_quiz-highlighted p { + --background: var(--blue-dark); + color: var(--blue-light); + } } } diff --git a/src/addons/qtype/ddwtos/component/ddwtos.scss b/src/addons/qtype/ddwtos/component/ddwtos.scss index a1643e2e9..4173fe334 100644 --- a/src/addons/qtype/ddwtos/component/ddwtos.scss +++ b/src/addons/qtype/ddwtos/component/ddwtos.scss @@ -76,16 +76,10 @@ } span.incorrect { - background-color: var(--red-light); - // @include darkmode() { - // background-color: $red-dark; - // } + background-color: var(--core-question-incorrect-color-bg); } span.correct { - background-color: var(--green-light); - // @include darkmode() { - // background-color: $green-dark; - // } + background-color: var(--core-question-correct-color-bg); } @for $i from 0 to length($core-dd-question-colors) { diff --git a/src/addons/qtype/gapselect/component/gapselect.scss b/src/addons/qtype/gapselect/component/gapselect.scss index 5060bcf32..7fc7fdbef 100644 --- a/src/addons/qtype/gapselect/component/gapselect.scss +++ b/src/addons/qtype/gapselect/component/gapselect.scss @@ -14,10 +14,5 @@ -moz-border-radius: 4px; border-radius: 4px; margin-bottom: 10px; - background: var(--gray-lighter); - - // @include darkmode() { - // background: $gray-dark; - // } } } diff --git a/src/core/features/question/question.scss b/src/core/features/question/question.scss index b6e4f4b80..1dce88eb1 100644 --- a/src/core/features/question/question.scss +++ b/src/core/features/question/question.scss @@ -1,27 +1,6 @@ @import "~theme/globals"; -$core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA520, #FFD700, #F0E68C !default; - :host ::ng-deep { - --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-question-answer-correct { color: var(--core-question-correct-color); } diff --git a/src/theme/theme.dark.scss b/src/theme/theme.dark.scss index 6bf965ee9..13496a06b 100644 --- a/src/theme/theme.dark.scss +++ b/src/theme/theme.dark.scss @@ -74,4 +74,23 @@ --core-login-background: var(--custom-login-background, #3a3a3a); --core-login-text-color: var(--custom-login-text-color, white); + + --core-question-correct-color: var(--green-light); + --core-question-correct-color-bg: var(--green-dark); + --core-question-incorrect-color: var(--red); + --core-question-incorrect-color-bg: var(--red-dark); + --core-question-feedback-color: var(--yellow-light); + --core-question-feedback-color-bg: var(--yellow-dark); + --core-question-warning-color: var(--red); + --core-question-saved-color-bg: var(--gray-dark); + + --core-question-state-correct-color: var(--green-dark); + --core-question-state-partial-color: var(--yellow-dark); + --core-question-state-partial-text: var(--yellow); + --core-question-state-incorrect-color: var(--red-dark); + + --core-question-feedback-color: var(--yellow-light); + --core-question-feedback-background-color: var(--yellow-dark); + + --core-dd-question-selected-shadow: 2px 2px 4px var(--gray-light); } From 9eac6e43508d2b17a6912209f427d2ff96f6ac90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Tue, 23 Feb 2021 12:21:12 +0100 Subject: [PATCH 16/16] MOBILE-3651 quiz: Fix question styles and icons --- .../component/addon-qtype-calculated.html | 48 ++++++------ .../local-file/core-local-file.html | 6 +- .../rich-text-editor/rich-text-editor.scss | 78 ++++++++----------- .../question/services/question-helper.ts | 36 ++++++--- src/theme/theme.base.scss | 5 ++ 5 files changed, 91 insertions(+), 82 deletions(-) diff --git a/src/addons/qtype/calculated/component/addon-qtype-calculated.html b/src/addons/qtype/calculated/component/addon-qtype-calculated.html index 06421260d..1b2d55d30 100644 --- a/src/addons/qtype/calculated/component/addon-qtype-calculated.html +++ b/src/addons/qtype/calculated/component/addon-qtype-calculated.html @@ -15,24 +15,24 @@ {{ 'addon.mod_quiz.answercolon' | translate }} - - - - +
+ + + + - + + [value]="calcQuestion.input.value" [disabled]="calcQuestion.input.readOnly" autocorrect="off"> - - - - - - + + + +
+
@@ -45,18 +45,16 @@ - - - - - {{option.label}} - - - + + + + {{option.label}} + + diff --git a/src/core/components/local-file/core-local-file.html b/src/core/components/local-file/core-local-file.html index af831e867..43d2ba8e1 100644 --- a/src/core/components/local-file/core-local-file.html +++ b/src/core/components/local-file/core-local-file.html @@ -1,6 +1,8 @@ - - {{fileExtension}} + + + {{fileExtension}} + diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss index 5f641ead3..11299d9de 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss @@ -1,3 +1,20 @@ +@import "~theme/globals"; +:host { + --placeholder-color: var(--gray-light); + --toobar-background: var(--white); + --button-color: var(--ion-text-color); + --button-active-color: var(--gray); +} + +:host-context(body.dark) { + --background: var(--gray-darker); + --color: var(--white); + --button-color: var(--gray-light); + --button-active-color: var(--gray-dark); + --placeholder-color: var(--gray); + --toobar-background: var(--black); +} + :host { height: 40vh; overflow: hidden; @@ -6,9 +23,7 @@ width: 100%; display: flex; flex-direction: column; - // @include darkmode() { - // background-color: $gray-darker; - // } + background: var(--background); .core-rte-editor-container { max-height: calc(100% - 46px); @@ -22,7 +37,7 @@ .core-rte-info-message { padding: 5px; border-top: 1px solid var(--ion-color-secondary); - background: white; + background: var(--background); flex-shrink: 1; font-size: 1.4rem; @@ -37,36 +52,30 @@ margin: 2px; width: 100%; resize: none; - background-color: white; + background-color: var(--background); + color: var(--color); flex-grow: 1; - // @include darkmode() { - // background-color: var(--gray-darker); - // color: var(--white); - // } } .core-rte-editor { flex-grow: 1; flex-shrink: 1; -webkit-user-select: auto !important; + user-select: auto !important; word-wrap: break-word; overflow-x: hidden; overflow-y: auto; cursor: text; img { - // @include padding(null, null, null, 2px); + @include padding(null, null, null, 2px); max-width: 95%; width: auto; } &:empty:before { content: attr(data-placeholder-text); display: block; - color: var(--gray-light); + color: var(--placeholder-color); font-weight: bold; - - // @include darkmode() { - // color: $gray; - // } } // Make empty elements selectable (to move the cursor). @@ -80,19 +89,15 @@ flex-shrink: 1; position: relative; - textarea { + ::ng-deep textarea { margin: 0 !important; padding: 0; - height: 100% !important; - width: 100% !important; resize: none; overflow-x: hidden; overflow-y: auto; position: absolute; top: 0; bottom: 0; - left: 0; - right: 0; } } @@ -102,12 +107,8 @@ z-index: 1; flex-grow: 0; flex-shrink: 0; - background-color: var(--white); - - // @include darkmode() { - // background-color: $black; - // } - // @include padding(5px, null); + background-color: var(--toobar-background); + padding-top: 5px; border-top: 1px solid var(--gray); ion-slides { @@ -126,26 +127,18 @@ padding-left: 6px; margin: 0 auto; font-size: 18px; - background-color: var(--white); + background-color: var(--toobar-background); border-radius: 4px; - // @include core-transition(background-color, 200ms); - color: var(--ion-text-color); + @include core-transition(background-color, 200ms); + color: var(--button-color); cursor: pointer; - // @include darkmode() { - // background-color: $black; - // color: $core-dark-text-color; - // } - &.toolbar-button-enable { width: 100%; } &:active, &[aria-pressed="true"] { - background-color: var(--gray); - // @include darkmode() { - // background-color: $gray-dark; - // } + background-color: var(--button-active-color); } &.toolbar-arrow { @@ -153,13 +146,10 @@ flex-grow: 0; flex-shrink: 0; opacity: 1; - // @include core-transition(opacity, 200ms); + @include core-transition(opacity, 200ms); &:active { - background-color: var(--white); - // @include darkmode() { - // background-color: $black; - // } + background-color: var(--toobar-background); } &.toolbar-arrow-hidden { @@ -178,4 +168,4 @@ :host-context(.keyboard-is-open) { min-height: 200px; -} \ No newline at end of file +} diff --git a/src/core/features/question/services/question-helper.ts b/src/core/features/question/services/question-helper.ts index 0ff86daa6..3b64881e0 100644 --- a/src/core/features/question/services/question-helper.ts +++ b/src/core/features/question/services/question-helper.ts @@ -769,22 +769,36 @@ export class CoreQuestionHelperProvider { * @param element DOM element. */ treatCorrectnessIcons(element: HTMLElement): void { - const icons = Array.from(element.querySelectorAll('img.icon, img.questioncorrectnessicon')); + const icons = Array.from(element.querySelectorAll('img.icon, img.questioncorrectnessicon, i.icon')); icons.forEach((icon) => { - // Replace the icon with the font version. - if (!icon.src) { - return; + let correct = false; + + if ('src' in icon) { + if ((icon as HTMLImageElement).src.indexOf('correct') >= 0) { + correct = true; + } else if ((icon as HTMLImageElement).src.indexOf('incorrect') < 0 ) { + return; + } + } else { + const classList = icon.classList.toString(); + if (classList.indexOf('fa-check') >= 0) { + correct = true; + } else if (classList.indexOf('fa-remove') < 0) { + return; + } } - // @todo: Check the right classes to use. - const newIcon: HTMLElement = document.createElement('i'); + // Replace the icon with the font version. + const newIcon: HTMLElement = document.createElement('ion-icon'); - 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'; + if (correct) { + newIcon.setAttribute('name', 'fas-check'); + newIcon.setAttribute('src', 'assets/fonts/font-awesome/solid/check.svg'); + newIcon.className = 'core-correct-icon ion-color ion-color-success questioncorrectnessicon'; } else { - return; + newIcon.setAttribute('name', 'fas-times'); + newIcon.setAttribute('src', 'assets/fonts/font-awesome/solid/times.svg'); + newIcon.className = 'core-correct-icon ion-color ion-color-danger questioncorrectnessicon'; } newIcon.title = icon.title; diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 9d668a0fc..a8c693a6f 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -28,6 +28,11 @@ text-transform: none; } +.flex-row { + display: flex; + flex-direction: row; +} + // Correctly inherit ion-text-wrap onto labels. ion-item.ion-text-wrap ion-label { white-space: normal !important;