MOBILE-3651 question: Implement services and classes
parent
fc39c3e30e
commit
dd060d8168
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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 {}
|
|
@ -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>
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
||||||
|
};
|
|
@ -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';
|
||||||
|
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
||||||
|
}
|
|
@ -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) {}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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>;
|
Loading…
Reference in New Issue