MOBILE-3651 question: Implement services and classes

main
Dani Palou 2021-02-05 14:28:15 +01:00
parent fc39c3e30e
commit dd060d8168
14 changed files with 2953 additions and 0 deletions

View File

@ -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<CoreQuestionState> {
// 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<unknown>[] | Promise<Type<unknown>[]> {
// 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<boolean> {
return true;
}
}

View File

@ -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<boolean> {
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<unknown> | Promise<Type<unknown>> {
// 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<void> {
// 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);
}
}

View File

@ -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 {}

View File

@ -0,0 +1,44 @@
<!-- Question contents. -->
<core-dynamic-component *ngIf="loaded" [component]="componentClass" [data]="data" class="core-question-{{question?.slot}}">
<!-- This content will only be shown if there's no component to render the question. -->
<p class="ion-padding">{{ 'core.question.errorquestionnotsupported' | translate:{$a: question?.type} }}</p>
</core-dynamic-component>
<!-- Sequence check input. -->
<input *ngIf="seqCheck" type="hidden" name="{{seqCheck.name}}" value="{{seqCheck.value}}" >
<!-- Question behaviour components. -->
<core-dynamic-component *ngFor="let componentClass of behaviourComponents" [component]="componentClass" [data]="data">
</core-dynamic-component>
<!-- Question validation error. -->
<ion-item class="ion-text-wrap core-danger-item" *ngIf="validationError">
<ion-label>
<p>{{ validationError }}</p>
</ion-label>
</ion-item>
<!-- Question behaviour buttons. -->
<ion-button *ngFor="let button of question?.behaviourButtons" class="ion-margin ion-text-wrap" expand="block"
(click)="buttonClicked.emit(button)" [disabled]="button.disabled">
{{ button.value }}
</ion-button>
<!-- Question feedback. -->
<ion-item class="ion-text-wrap core-question-feedback-container" *ngIf="question && question.feedbackHtml">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="question.feedbackHtml"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<!-- Question comment. -->
<ion-item class="ion-text-wrap core-question-comment" *ngIf="question && question.commentHtml">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="question.commentHtml"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>

View File

@ -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<CoreQuestionBehaviourButton>(); // Will emit when a behaviour button is clicked.
@Output() onAbort= new EventEmitter<void>(); // Will emit an event if the question should be aborted.
componentClass?: Type<unknown>; // The class of the component to render.
data: Record<string, unknown> = {}; // Data to pass to the component.
seqCheck?: { name: string; value: string }; // Sequenche check name and value (if any).
behaviourComponents?: Type<unknown>[] = []; // 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<void> {
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<number, { name: string; value: string }>): void {
if (!this.question || !sequenceChecks[this.question.slot]) {
return;
}
this.seqCheck = sequenceChecks[this.question.slot];
this.changeDetector.detectChanges();
}
}

View File

@ -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"
}

View File

@ -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;
}
}

View File

@ -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<CoreQuestionState>;
/**
* 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<unknown>[] | Promise<Type<unknown>[]>;
}
/**
* Delegate to register question behaviour handlers.
*/
@Injectable({ providedIn: 'root' })
export class CoreQuestionBehaviourDelegateService extends CoreDelegate<CoreQuestionBehaviourHandler> {
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<CoreQuestionState | undefined> {
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<Type<unknown>[] | 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;
};

View File

@ -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;
};

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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<unknown> | Promise<Type<unknown>>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<void>;
}
/**
* Delegate to register question handlers.
*/
@Injectable({ providedIn: 'root' })
export class CoreQuestionDelegateService extends CoreDelegate<CoreQuestionHandler> {
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<string>(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<Type<unknown> | 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<string>(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<number>(
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<number>(
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<void> {
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<void> {
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<void> {
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<void> {
const type = this.getTypeName(question);
await this.executeFunctionOnEnabled(type, 'prepareSyncData', [question, answers, component, componentId, siteId]);
}
}
export class CoreQuestionDelegate extends makeSingleton(CoreQuestionDelegateService) {}

View File

@ -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<void> {
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<void> {
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 = <HTMLInputElement[]> 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 = <HTMLInputElement> 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 = <HTMLInputElement> 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 = <HTMLElement[]> 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(/<script[^>]*>[\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<string, boolean> {
const element = CoreDomUtils.instance.convertToElement('<form>' + html + '</form>');
const form = <HTMLFormElement> element.children[0];
const answers: Record<string, boolean> = {};
// 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 = <HTMLInputElement> 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<void> {
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('<form>' + question.html + '</form>');
const form = <HTMLFormElement> 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<void> {
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<string, boolean> = {};
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<CoreQuestionsAnswers> {
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 = <HTMLInputElement> 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<void>, 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 = <HTMLImageElement[]> 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 = <HTMLElement> 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 = <HTMLElement[]> 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 = <HTMLElement | undefined> 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<string, string>;
commentHtml?: string;
feedbackHtml?: string;
infoHtml?: string;
behaviourButtons?: CoreQuestionBehaviourButton[];
behaviourCertaintyOptions?: CoreQuestionBehaviourCertaintyOption[];
behaviourCertaintySelected?: string;
behaviourSeenInput?: { name: string; value: string };
scriptsCode?: string;
initObjects?: Record<string, unknown> | 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;
};

View File

@ -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<string, CoreQuestionState> = {
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<string, unknown>, newAnswers: Record<string, unknown>): 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<string, string> {
const result: Record<string, string> = {};
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<CoreQuestionAnswerDBRecord> {
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<CoreQuestionAnswerDBRecord[]> {
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<CoreQuestionDBRecord[]> {
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<T = string>(answers: Record<string, T>): Record<string, T> {
const result: Record<string, T> = {};
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<CoreQuestionDBRecord> {
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<CoreQuestionAnswerDBRecord[]> {
const db = await CoreSites.instance.getSiteDb(siteId);
const answers = await db.getRecords<CoreQuestionAnswerDBRecord>(
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
timemodified = timemodified || CoreTimeUtils.instance.timestamp();
const db = await CoreSites.instance.getSiteDb(siteId);
const promises: Promise<unknown>[] = [];
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<void> {
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<string, unknown> | null;
};
/**
* List of answers to a set of questions.
*/
export type CoreQuestionsAnswers = Record<string, string | boolean>;