MOBILE-2345 lesson: Implement player page

main
Dani Palou 2018-05-03 13:25:55 +02:00
parent 8a2fdca74b
commit ff06a812d2
10 changed files with 1405 additions and 0 deletions

View File

@ -21,6 +21,7 @@ import { AddonModLessonComponentsModule } from './components/components.module';
import { AddonModLessonProvider } from './providers/lesson';
import { AddonModLessonOfflineProvider } from './providers/lesson-offline';
import { AddonModLessonSyncProvider } from './providers/lesson-sync';
import { AddonModLessonHelperProvider } from './providers/helper';
import { AddonModLessonModuleHandler } from './providers/module-handler';
import { AddonModLessonPrefetchHandler } from './providers/prefetch-handler';
import { AddonModLessonSyncCronHandler } from './providers/sync-cron-handler';
@ -38,6 +39,7 @@ import { AddonModLessonReportLinkHandler } from './providers/report-link-handler
AddonModLessonProvider,
AddonModLessonOfflineProvider,
AddonModLessonSyncProvider,
AddonModLessonHelperProvider,
AddonModLessonModuleHandler,
AddonModLessonPrefetchHandler,
AddonModLessonSyncCronHandler,

View File

@ -0,0 +1,36 @@
<ion-header>
<ion-navbar>
<ion-title>{{ pageInstance.lesson.name }}</ion-title>
<ion-buttons end>
<button ion-button icon-only (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content class="addon-mod_lesson-menu-modal">
<nav>
<ion-list>
<!-- Media file. -->
<ng-container *ngIf="pageInstance.mediaFile">
<ion-item-divider color="light"><h2>{{ 'addon.mod_lesson.linkedmedia' | translate }}</h2></ion-item-divider>
<core-file [file]="pageInstance.mediaFile" [component]="pageInstance.component" [componentId]="pageInstance.lesson.coursemodule"></core-file>
</ng-container>
<!-- Lesson menu. -->
<ng-container *ngIf="pageInstance.displayMenu">
<ion-item-divider color="light"><h2>{{ 'addon.mod_lesson.lessonmenu' | translate }}</h2></ion-item-divider>
<ion-item text-center *ngIf="pageInstance.loadingMenu">
<ion-spinner></ion-spinner>
</ion-item>
<div *ngIf="!pageInstance.loadingMenu">
<ng-container *ngFor="let page of pageInstance.lessonPages">
<a ion-item text-wrap *ngIf="page.display && page.displayinmenublock" (click)="loadPage(page.id)" [ngClass]='{"addon-mod_lesson-selected core-white-push-arrow": !pageInstance.eolData && pageInstance.currentPage == page.id}'>
<p><core-format-text [text]="page.title"></core-format-text></p>
</a>
</ng-container>
</div>
</ng-container>
</ion-list>
</nav>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicPageModule } from 'ionic-angular';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModLessonMenuModalPage } from './menu-modal';
import { TranslateModule } from '@ngx-translate/core';
@NgModule({
declarations: [
AddonModLessonMenuModalPage
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
IonicPageModule.forChild(AddonModLessonMenuModalPage),
TranslateModule.forChild()
]
})
export class AddonModLessonMenuModalPageModule {}

View File

@ -0,0 +1,5 @@
page-addon-mod-lesson-menu-modal {
.addon-mod_lesson-selected, .item.addon-mod_lesson-selected {
background: $blue-light;
}
}

View File

@ -0,0 +1,58 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component } from '@angular/core';
import { IonicPage, ViewController, NavParams } from 'ionic-angular';
/**
* Modal that renders the lesson menu and media file.
*/
@IonicPage({ segment: 'addon-mod-lesson-menu-modal' })
@Component({
selector: 'page-addon-mod-lesson-menu-modal',
templateUrl: 'menu-modal.html',
})
export class AddonModLessonMenuModalPage {
/**
* The instance of the page that opened the modal. We use the instance instead of the needed attributes for these reasons:
* - We want the user to be able to see the media file while the menu is being loaded, so we need to be able to update
* the menu dynamically based on the data retrieved by the page that opened the modal.
* - The onDidDismiss function takes a while to be called, making the app seem slow. This way we can directly call
* the functions we need without having to wait for the modal to be dismissed.
* @type {any}
*/
pageInstance: any;
constructor(params: NavParams, protected viewCtrl: ViewController) {
this.pageInstance = params.get('page');
}
/**
* Close modal.
*/
closeModal(): void {
this.viewCtrl.dismiss();
}
/**
* Load a certain page.
*
* @param {number} pageId The page ID to load.
*/
loadPage(pageId: number): void {
this.pageInstance.changePage && this.pageInstance.changePage(pageId);
this.closeModal();
}
}

View File

@ -0,0 +1,224 @@
<ion-header>
<ion-navbar>
<ion-title><core-format-text [text]="title"></core-format-text></ion-title>
<ion-buttons end>
<button *ngIf="displayMenu || mediaFile" ion-button icon-only [attr.aria-label]="'addon.mod_lesson.lessonmenu' | translate" (click)="menuModal.present()">
<ion-icon name="bookmark"></ion-icon>
</button>
</ion-buttons>
</ion-navbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="loaded">
<!-- Info messages. Only show the first one. -->
<div class="core-info-card" icon-start *ngIf="lesson && messages && messages.length">
<ion-icon name="information"></ion-icon>
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="messages[0].message"></core-format-text>
</div>
<div *ngIf="lesson" [ngClass]='{"addon-mod_lesson-slideshow": lesson.slideshow}' [ngStyle]="{'width': lessonWidth, 'height': lessonHeight}">
<core-timer *ngIf="endTime" [endTime]="endTime" (finished)="timeUp()" [timerText]="'addon.mod_lesson.timeremaining' | translate"></core-timer>
<!-- Retake and ongoing score. -->
<ion-item text-wrap *ngIf="showRetake && !eolData && !processData">
<p>{{ 'addon.mod_lesson.attempt' | translate:{$a: retake} }}</p>
</ion-item>
<ion-item text-wrap *ngIf="pageData && pageData.ongoingscore && !eolData && !processData" class="addon-mod_lesson-ongoingscore">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="pageData.ongoingscore"></core-format-text>
</ion-item>
<!-- Page content. -->
<ion-card *ngIf="!eolData && !processData">
<!-- Content page. -->
<ion-item text-wrap *ngIf="!question">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="pageContent"></core-format-text>
</ion-item>
<!-- Question page. -->
<form *ngIf="question" ion-list [formGroup]="questionForm">
<ion-item-divider text-wrap color="light">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="pageContent"></core-format-text>
</ion-item-divider>
<input *ngFor="let input of question.hiddenInputs" type="hidden" [name]="input.name" [value]="input.value" />
<!-- Render a different input depending on the type of the question. -->
<ng-container [ngSwitch]="question.template">
<!-- Short answer. -->
<ng-container *ngSwitchCase="'shortanswer'">
<ion-input padding-left [type]="question.input.type" placeholder="{{ 'addon.mod_lesson.youranswer' | translate }}" [id]="question.input.id" [formControlName]="question.input.name" autocorrect="off" [maxlength]="question.input.maxlength">
</ion-input>
</ng-container>
<!-- Essay. -->
<ng-container *ngSwitchCase="'essay'">
<ion-item *ngIf="question.textarea">
<core-rich-text-editor item-content placeholder="{{ 'addon.mod_lesson.youranswer' | translate }}" [control]="question.control"></core-rich-text-editor>
<!-- @todo: Attributes that were passed to RTE in Ionic 1 but now they aren't supported yet:
[component]="component" [componentId]="lesson.coursemodule" -->
</ion-item>
<ion-item text-wrap *ngIf="!question.textarea && question.useranswer">
<p class="item-heading">{{ 'addon.mod_lesson.youranswer' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="question.useranswer"></core-format-text></p>
</ion-item>
</ng-container>
<!-- Multichoice. -->
<ng-container *ngSwitchCase="'multichoice'">
<!-- Single choice. -->
<div *ngIf="!question.multi" radio-group [formControlName]="question.controlName">
<ion-item text-wrap *ngFor="let option of question.options">
<ion-label>
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="option.text"></core-format-text>
</ion-label>
<ion-radio [id]="option.id" [value]="option.value" [disabled]="option.disabled"></ion-radio>
</ion-item>
</div>
<!-- Multiple choice. -->
<ng-container *ngIf="question.multi">
<ion-item text-wrap *ngFor="let option of question.options">
<ion-label>
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="option.text"></core-format-text>
</ion-label>
<ion-checkbox [id]="option.id" [formControlName]="option.name" item-end></ion-checkbox>
</ion-item>
</ng-container>
</ng-container>
<!-- Matching. -->
<ng-container *ngSwitchCase="'matching'">
<ion-item text-wrap *ngFor="let row of question.rows">
<ion-grid item-content>
<ion-row>
<ion-col>
<p><core-format-text id="addon-mod_lesson-matching-{{row.id}}" [component]="component" [componentId]="lesson.coursemodule" [text]="row.text"></core-format-text></p>
</ion-col>
<ion-col>
<ion-select [id]="row.id" [formControlName]="row.name" [attr.aria-labelledby]="'addon-mod_lesson-matching-' + row.id" interface="popover">
<ion-option *ngFor="let option of row.options" [value]="option.value">{{option.label}}</ion-option>
</ion-select>
</ion-col>
</ion-row>
</ion-grid>
</ion-item>
</ng-container>
</ng-container>
<ion-item>
<button ion-button block (click)="submitQuestion()">{{ question.submitLabel }}</button>
</ion-item>
</form>
</ion-card>
<!-- Page buttons and progress. -->
<ion-list *ngIf="!eolData && !processData">
<ion-item text-wrap *ngIf="pageButtons && pageButtons.length">
<ion-grid>
<ion-row>
<ion-col *ngFor="let button of pageButtons">
<a ion-button block color="light" text-wrap [id]="button.id" (click)="buttonClicked(button.data)">{{ button.content }}</a>
</ion-col>
</ion-row>
</ion-grid>
</ion-item>
<ion-item text-wrap *ngIf="lesson && lesson.progressbar && !canManage && pageData">
<p>{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: pageData.progress} }}</p>
<core-progress-bar [progress]="pageData.progress"></core-progress-bar>
</ion-item>
<div class="core-info-card" icon-start *ngIf="lesson && lesson.progressbar && canManage">
<ion-icon name="information"></ion-icon>
{{ 'addon.mod_lesson.progressbarteacherwarning2' | translate }}
</div>
</ion-list>
<!-- End of lesson reached. -->
<ion-card *ngIf="eolData && !processData">
<div class="core-warning-card" icon-start *ngIf="eolData.offline && eolData.offline.value">
<ion-icon name="warning"></ion-icon>
{{ 'addon.mod_lesson.finishretakeoffline' | translate }}
</div>
<h3 padding *ngIf="eolData.gradelesson">{{ 'addon.mod_lesson.congratulations' | translate }}</h3>
<ion-item text-wrap *ngIf="eolData.notenoughtimespent">
<core-format-text [text]="eolData.notenoughtimespent.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.numberofpagesviewed">
<core-format-text [text]="eolData.numberofpagesviewed.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.youshouldview">
<core-format-text [text]="eolData.youshouldview.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.numberofcorrectanswers">
<core-format-text [text]="eolData.numberofcorrectanswers.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.displayscorewithessays">
<core-format-text [text]="eolData.displayscorewithessays.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="!eolData.displayscorewithessays && eolData.displayscorewithoutessays">
<core-format-text [text]="eolData.displayscorewithoutessays.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.yourcurrentgradeisoutof">
<core-format-text [text]="eolData.yourcurrentgradeisoutof.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.eolstudentoutoftimenoanswers">
<core-format-text [text]="eolData.eolstudentoutoftimenoanswers.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.welldone">
<core-format-text [text]="eolData.welldone.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="lesson.progressbar && eolData.progresscompleted">
<p>{{ 'addon.mod_lesson.progresscompleted' | translate:{$a: eolData.progresscompleted.value} }}</p>
<core-progress-bar [progress]="eolData.progresscompleted.value"></core-progress-bar>
</ion-item>
<ion-item text-wrap *ngIf="eolData.displayofgrade">
<core-format-text [text]="eolData.displayofgrade.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.reviewlesson">
<a ion-button block (click)="reviewLesson(eolData.reviewlesson.pageid)">
<core-format-text [text]="'addon.mod_lesson.reviewlesson' | translate"></core-format-text>
</a>
</ion-item>
<ion-item text-wrap *ngIf="eolData.modattemptsnoteacher">
<core-format-text [text]="eolData.modattemptsnoteacher.message"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="eolData.activitylink && eolData.activitylink.value">
<ng-container *ngIf="eolData.activitylink.value.formatted">
<!-- Activity link was successfully formatted, render the button. -->
<a ion-button block color="light" [href]="eolData.activitylink.value.href" core-link [capture]="true">
<core-format-text [text]="eolData.activitylink.value.label"></core-format-text>
</a>
</ng-container>
<ng-container *ngIf="!eolData.activitylink.value.formatted">
<!-- Activity link wasn't formatted, render the original link. -->
<core-format-text [text]="eolData.activitylink.value.label"></core-format-text>
</ng-container>
</ion-item>
</ion-card>
<!-- Feedback returned when processing an action. -->
<ion-list *ngIf="processData">
<ion-item text-wrap *ngIf="processData.ongoingscore && !processData.reviewmode" >
<core-format-text class="addon-mod_lesson-ongoingscore" [text]="processData.ongoingscore"></core-format-text>
</ion-item>
<ion-item text-wrap *ngIf="!processData.reviewmode || review">
<p *ngIf="!processData.reviewmode">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="processData.feedback"></core-format-text>
</p>
<div *ngIf="review">
<p>{{ 'addon.mod_lesson.gotoendoflesson' | translate }}</p>
<p>{{ 'addon.mod_lesson.or' | translate }}</p>
<p>{{ 'addon.mod_lesson.continuetonextpage' | translate }}</p>
</div>
</ion-item>
<ion-item text-wrap *ngIf="review || (processData.buttons && processData.buttons.length)">
<a ion-button block color="light" *ngIf="review" (click)="changePage(LESSON_EOL)">{{ 'addon.mod_lesson.finish' | translate }}</a>
<a ion-button block color="light" *ngFor="let button of processData.buttons" (click)="changePage(button.pageId, true)">{{ button.label | translate }}</a>
</ion-item>
</ion-list>
</div>
</core-loading>
</ion-content>

View File

@ -0,0 +1,33 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { IonicPageModule } from 'ionic-angular';
import { TranslateModule } from '@ngx-translate/core';
import { CoreComponentsModule } from '@components/components.module';
import { CoreDirectivesModule } from '@directives/directives.module';
import { AddonModLessonPlayerPage } from './player';
@NgModule({
declarations: [
AddonModLessonPlayerPage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
IonicPageModule.forChild(AddonModLessonPlayerPage),
TranslateModule.forChild()
],
})
export class AddonModLessonPlayerPageModule {}

View File

@ -0,0 +1,11 @@
page-addon-mod-lesson-player {
.addon-mod_lesson-slideshow {
max-width: 100%;
max-height: 100%;
}
ion-input[padding-left] input[padding-left] {
// Applying padding-left to the ion-input applies it twice since it's replicated in the inner input.
padding-left: 0;
}
}

View File

@ -0,0 +1,635 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { IonicPage, NavParams, Content, PopoverController, ModalController, Modal, NavController } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreAppProvider } from '@providers/app';
import { CoreEventsProvider } from '@providers/events';
import { CoreLoggerProvider } from '@providers/logger';
import { CoreSitesProvider } from '@providers/sites';
import { CoreSyncProvider } from '@providers/sync';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUrlUtilsProvider } from '@providers/utils/url';
import { CoreUtilsProvider } from '@providers/utils/utils';
import { AddonModLessonProvider } from '../../providers/lesson';
import { AddonModLessonOfflineProvider } from '../../providers/lesson-offline';
import { AddonModLessonSyncProvider } from '../../providers/lesson-sync';
import { AddonModLessonHelperProvider } from '../../providers/helper';
/**
* Page that allows attempting and reviewing a lesson.
*/
@IonicPage({ segment: 'addon-mod-lesson-player' })
@Component({
selector: 'page-addon-mod-lesson-player',
templateUrl: 'player.html',
})
export class AddonModLessonPlayerPage implements OnInit, OnDestroy {
@ViewChild(Content) content: Content;
component = AddonModLessonProvider.COMPONENT;
LESSON_EOL = AddonModLessonProvider.LESSON_EOL;
questionForm: FormGroup; // The FormGroup for question pages.
title: string; // The page title.
lesson: any; // The lesson object.
currentPage: number; // Current page being viewed.
review: boolean; // Whether the user is reviewing.
messages: any[]; // Messages to display to the user.
menuModal: Modal; // Modal to navigate through the pages.
canManage: boolean; // Whether the user can manage the lesson.
retake: number; // Current retake number.
showRetake: boolean; // Whether the retake number needs to be displayed.
lessonWidth: string; // Width of the lesson (if slideshow mode).
lessonHeight: string; // Height of the lesson (if slideshow mode).
endTime: number; // End time of the lesson if it's timed.
pageData: any; // Current page data.
pageContent: string; // Current page contents.
pageButtons: any[]; // List of buttons of the current page.
question: any; // Question of the current page (if it's a question page).
eolData: any; // Data for EOL page (if current page is EOL).
processData: any; // Data to display after processing a page.
loaded: boolean; // Whether data has been loaded.
displayMenu: boolean; // Whether the lesson menu should be displayed.
originalData: any; // Original question data. It is used to check if data has changed.
protected courseId: number; // The course ID the lesson belongs to.
protected lessonId: number; // Lesson ID.
protected password: string; // Lesson password (if any).
protected forceLeave = false; // If true, don't perform any check when leaving the view.
protected offline: boolean; // Whether we are in offline mode.
protected accessInfo: any; // Lesson access info.
protected jumps: any; // All possible jumps.
protected mediaFile: any; // Media file of the lesson.
protected firstPageLoaded: boolean; // Whether the first page has been loaded.
protected loadingMenu: boolean; // Whether the lesson menu is being loaded.
protected lessonPages: any[]; // Lesson pages (for the lesson menu).
constructor(protected navParams: NavParams, logger: CoreLoggerProvider, protected translate: TranslateService,
protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider,
protected syncProvider: CoreSyncProvider, protected domUtils: CoreDomUtilsProvider, popoverCtrl: PopoverController,
protected timeUtils: CoreTimeUtilsProvider, protected lessonProvider: AddonModLessonProvider,
protected lessonHelper: AddonModLessonHelperProvider, protected lessonSync: AddonModLessonSyncProvider,
protected lessonOfflineProvider: AddonModLessonOfflineProvider, protected cdr: ChangeDetectorRef,
modalCtrl: ModalController, protected navCtrl: NavController, protected appProvider: CoreAppProvider,
protected utils: CoreUtilsProvider, protected urlUtils: CoreUrlUtilsProvider, protected fb: FormBuilder) {
this.lessonId = navParams.get('lessonId');
this.courseId = navParams.get('courseId');
this.password = navParams.get('password');
this.review = !!navParams.get('review');
this.currentPage = navParams.get('pageId');
// Block the lesson so it cannot be synced.
this.syncProvider.blockOperation(this.component, this.lessonId);
// Create the navigation modal.
this.menuModal = modalCtrl.create('AddonModLessonMenuModalPage', {
page: this
});
}
/**
* Component being initialized.
*/
ngOnInit(): void {
// Fetch the Lesson data.
this.fetchLessonData().then((success) => {
if (success) {
// Review data loaded or new retake started, remove any retake being finished in sync.
this.lessonSync.deleteRetakeFinishedInSync(this.lessonId);
}
}).finally(() => {
this.loaded = true;
});
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
// Unblock the lesson so it can be synced.
this.syncProvider.unblockOperation(this.component, this.lessonId);
}
/**
* Check if we can leave the page or not.
*
* @return {boolean|Promise<void>} Resolved if we can leave it, rejected if not.
*/
ionViewCanLeave(): boolean | Promise<void> {
if (this.forceLeave) {
return true;
}
if (this.question && !this.eolData && !this.processData && this.originalData) {
// Question shown. Check if there is any change.
if (!this.utils.basicLeftCompare(this.questionForm.getRawValue(), this.originalData, 3)) {
return this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit'));
}
}
return Promise.resolve();
}
/**
* A button was clicked.
*
* @param {any} data Button data.
*/
buttonClicked(data: any): void {
this.processPage(data);
}
/**
* Call a function and go offline if allowed and the call fails.
*
* @param {Function} func Function to call.
* @param {any[]} args Arguments to pass to the function.
* @param {number} offlineParamPos Position of the offline parameter in the args.
* @param {number} [jumpsParamPos] Position of the jumps parameter in the args.
* @return {Promise<any>} Promise resolved in success, rejected otherwise.
*/
protected callFunction(func: Function, args: any[], offlineParamPos: number, jumpsParamPos?: number): Promise<any> {
return func.apply(func, args).catch((error) => {
if (!this.offline && !this.review && this.lessonProvider.isLessonOffline(this.lesson) &&
!this.utils.isWebServiceError(error)) {
// If it fails, go offline.
this.offline = true;
// Get the possible jumps now.
return this.lessonProvider.getPagesPossibleJumps(this.lesson.id, true).then((jumpList) => {
this.jumps = jumpList;
// Call the function again with offline set to true and the new jumps.
args[offlineParamPos] = true;
if (typeof jumpsParamPos != 'undefined') {
args[jumpsParamPos] = this.jumps;
}
return func.apply(func, args);
});
}
return Promise.reject(error);
});
}
/**
* Change the page from menu or when continuing from a feedback page.
*
* @param {number} pageId Page to load.
* @param {boolean} [ignoreCurrent] If true, allow loading current page.
*/
changePage(pageId: number, ignoreCurrent?: boolean): void {
if (!ignoreCurrent && !this.eolData && this.currentPage == pageId) {
// Page already loaded, stop.
return;
}
this.loaded = true;
this.messages = [];
this.loadPage(pageId).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error loading page');
}).finally(() => {
this.loaded = true;
});
}
/**
* Get the lesson data and load the page.
*
* @return {Promise<boolean>} Promise resolved with true if success, resolved with false otherwise.
*/
protected fetchLessonData(): Promise<boolean> {
// Wait for any ongoing sync to finish. We won't sync a lesson while it's being played.
return this.lessonSync.waitForSync(this.lessonId).then(() => {
return this.lessonProvider.getLessonById(this.courseId, this.lessonId);
}).then((lessonData) => {
this.lesson = lessonData;
this.title = this.lesson.name; // Temporary title.
// If lesson has offline data already, use offline mode.
return this.lessonOfflineProvider.hasOfflineData(this.lessonId);
}).then((offlineMode) => {
this.offline = offlineMode;
if (!offlineMode && !this.appProvider.isOnline() && this.lessonProvider.isLessonOffline(this.lesson) && !this.review) {
// Lesson doesn't have offline data, but it allows offline and the device is offline. Use offline mode.
this.offline = true;
}
return this.callFunction(this.lessonProvider.getAccessInformation.bind(this.lessonProvider),
[this.lesson.id, this.offline, true], 1);
}).then((info) => {
const promises = [];
this.accessInfo = info;
this.canManage = info.canmanage;
this.retake = info.attemptscount;
this.showRetake = !this.currentPage && this.retake > 0; // Only show it in first page if it isn't the first retake.
if (info.preventaccessreasons && info.preventaccessreasons.length) {
// If it's a password protected lesson and we have the password, allow playing it.
if (!this.password || info.preventaccessreasons.length > 1 || !this.lessonProvider.isPasswordProtected(info)) {
// Lesson cannot be played, show message and go back.
return Promise.reject(info.preventaccessreasons[0].message);
}
}
if (this.review && this.navParams.get('retake') != info.attemptscount - 1) {
// Reviewing a retake that isn't the last one. Error.
return Promise.reject(this.translate.instant('addon.mod_lesson.errorreviewretakenotlast'));
}
if (this.password) {
// Lesson uses password, get the whole lesson object.
promises.push(this.callFunction(this.lessonProvider.getLessonWithPassword.bind(this.lessonProvider),
[this.lesson.id, this.password, true, this.offline, true], 3).then((lesson) => {
this.lesson = lesson;
}));
}
if (this.offline) {
// Offline mode, get the list of possible jumps to allow navigation.
promises.push(this.lessonProvider.getPagesPossibleJumps(this.lesson.id, true).then((jumpList) => {
this.jumps = jumpList;
}));
}
return Promise.all(promises);
}).then(() => {
this.mediaFile = this.lesson.mediafiles && this.lesson.mediafiles[0];
this.lessonWidth = this.lesson.slideshow ? this.domUtils.formatPixelsSize(this.lesson.mediawidth) : '';
this.lessonHeight = this.lesson.slideshow ? this.domUtils.formatPixelsSize(this.lesson.mediaheight) : '';
return this.launchRetake(this.currentPage);
}).then(() => {
return true;
}).catch((error) => {
// An error occurred.
let promise;
if (this.review && this.navParams.get('retake') && this.utils.isWebServiceError(error)) {
// The user cannot review the retake. Unmark the retake as being finished in sync.
promise = this.lessonSync.deleteRetakeFinishedInSync(this.lessonId);
} else {
promise = Promise.resolve();
}
return promise.then(() => {
this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true);
this.forceLeave = true;
this.navCtrl.pop();
return false;
});
});
}
/**
* Finish the retake.
*
* @param {boolean} [outOfTime] Whether the retake is finished because the user ran out of time.
* @return {Promise<any>} Promise resolved when done.
*/
protected finishRetake(outOfTime?: boolean): Promise<any> {
let promise;
this.messages = [];
if (this.offline && this.appProvider.isOnline()) {
// Offline mode but the app is online. Try to sync the data.
promise = this.lessonSync.syncLesson(this.lesson.id, true, true).then((result) => {
if (result.warnings && result.warnings.length) {
const error = result.warnings[0];
// Some data was deleted. Check if the retake has changed.
return this.lessonProvider.getAccessInformation(this.lesson.id).then((info) => {
if (info.attemptscount != this.accessInfo.attemptscount) {
// The retake has changed. Leave the view and show the error.
this.forceLeave = true;
this.navCtrl.pop();
return Promise.reject(error);
}
// Retake hasn't changed, show the warning and finish the retake in offline.
this.offline = false;
this.domUtils.showErrorModal(error);
});
}
this.offline = false;
}, () => {
// Ignore errors.
});
} else {
promise = Promise.resolve();
}
return promise.then(() => {
// Now finish the retake.
const args = [this.lesson, this.courseId, this.password, outOfTime, this.review, this.offline, this.accessInfo];
return this.callFunction(this.lessonProvider.finishRetake.bind(this.lessonProvider), args, 5);
}).then((data) => {
this.title = this.lesson.name;
this.eolData = data.data;
this.messages = this.messages.concat(data.messages);
this.processData = undefined;
// Format activity link if present.
if (this.eolData && this.eolData.activitylink) {
this.eolData.activitylink.value = this.lessonHelper.formatActivityLink(this.eolData.activitylink.value);
}
// Format review lesson if present.
if (this.eolData && this.eolData.reviewlesson) {
const params = this.urlUtils.extractUrlParams(this.eolData.reviewlesson.value);
if (!params || !params.pageid) {
// No pageid in the URL, the user cannot review (probably didn't answer any question).
delete this.eolData.reviewlesson;
} else {
this.eolData.reviewlesson.pageid = params.pageid;
}
}
});
}
/**
* Jump to a certain page after performing an action.
*
* @param {number} pageId The page to load.
* @return {Promise<any>} Promise resolved when done.
*/
protected jumpToPage(pageId: number): Promise<any> {
if (pageId === 0) {
// Not a valid page, return to entry view.
// This happens, for example, when the user clicks to go to previous page and there is no previous page.
this.forceLeave = true;
this.navCtrl.pop();
return Promise.resolve();
} else if (pageId == AddonModLessonProvider.LESSON_EOL) {
// End of lesson reached.
return this.finishRetake();
}
// Load new page.
this.messages = [];
return this.loadPage(pageId);
}
/**
* Start or continue a retake.
*
* @param {number} pageId The page to load.
* @return {Promise<any>} Promise resolved when done.
*/
protected launchRetake(pageId: number): Promise<any> {
let promise;
if (this.review) {
// Review mode, no need to launch the retake.
promise = Promise.resolve({});
} else if (!this.offline) {
// Not in offline mode, launch the retake.
promise = this.lessonProvider.launchRetake(this.lesson.id, this.password, pageId);
} else {
// Check if there is a finished offline retake.
promise = this.lessonOfflineProvider.hasFinishedRetake(this.lesson.id).then((finished) => {
if (finished) {
// Always show EOL page.
pageId = AddonModLessonProvider.LESSON_EOL;
}
return {};
});
}
return promise.then((data) => {
this.currentPage = pageId || this.accessInfo.firstpageid;
this.messages = data.messages || [];
if (this.lesson.timelimit && !this.accessInfo.canmanage) {
// Get the last lesson timer.
return this.lessonProvider.getTimers(this.lesson.id, false, true).then((timers) => {
this.endTime = timers[timers.length - 1].starttime + this.lesson.timelimit;
});
}
}).then(() => {
return this.loadPage(this.currentPage);
});
}
/**
* Load the lesson menu.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected loadMenu(): Promise<any> {
if (this.loadingMenu) {
// Already loading.
return;
}
this.loadingMenu = true;
const args = [this.lessonId, this.password, this.offline, true];
return this.callFunction(this.lessonProvider.getPages.bind(this.lessonProvider), args, 2).then((pages) => {
this.lessonPages = pages.map((entry) => {
return entry.page;
});
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error loading menu.');
}).finally(() => {
this.loadingMenu = false;
});
}
/**
* Load a certain page.
*
* @param {number} pageId The page to load.
* @return {Promise<any>} Promise resolved when done.
*/
protected loadPage(pageId: number): Promise<any> {
if (pageId == AddonModLessonProvider.LESSON_EOL) {
// End of lesson reached.
return this.finishRetake();
}
const args = [this.lesson, pageId, this.password, this.review, true, this.offline, true, this.accessInfo, this.jumps];
return this.callFunction(this.lessonProvider.getPageData.bind(this.lessonProvider), args, 5, 8).then((data) => {
if (data.newpageid == AddonModLessonProvider.LESSON_EOL) {
// End of lesson reached.
return this.finishRetake();
}
this.pageData = data;
this.title = data.page.title;
this.pageContent = this.lessonHelper.getPageContentsFromPageData(data);
this.loaded = true;
this.currentPage = pageId;
this.messages = this.messages.concat(data.messages);
// Page loaded, hide EOL and feedback data if shown.
this.eolData = this.processData = undefined;
if (this.lessonProvider.isQuestionPage(data.page.type)) {
// Create an empty FormGroup without controls, they will be added in getQuestionFromPageData.
this.questionForm = this.fb.group({});
this.pageButtons = [];
this.question = this.lessonHelper.getQuestionFromPageData(this.questionForm, data);
this.originalData = this.questionForm.getRawValue(); // Use getRawValue to include disabled values.
} else {
this.pageButtons = this.lessonHelper.getPageButtonsFromHtml(data.pagecontent);
this.question = undefined;
this.originalData = undefined;
}
if (data.displaymenu && !this.displayMenu) {
// Load the menu.
this.loadMenu();
}
this.displayMenu = !!data.displaymenu;
if (!this.firstPageLoaded) {
this.firstPageLoaded = true;
} else {
this.showRetake = false;
}
});
}
/**
* Process a page, sending some data.
*
* @param {any} data The data to send.
* @return {Promise<any>} Promise resolved when done.
*/
protected processPage(data: any): Promise<any> {
this.loaded = false;
const args = [this.lesson, this.courseId, this.pageData, data, this.password, this.review, this.offline, this.accessInfo,
this.jumps];
return this.callFunction(this.lessonProvider.processPage.bind(this.lessonProvider), args, 6, 8).then((result) => {
if (!this.offline && !this.review && this.lessonProvider.isLessonOffline(this.lesson)) {
// Lesson allows offline and the user changed some data in server. Update cached data.
const retake = this.accessInfo.attemptscount;
if (this.lessonProvider.isQuestionPage(this.pageData.page.type)) {
this.lessonProvider.getQuestionsAttemptsOnline(this.lessonId, retake, false, undefined, false, true);
} else {
this.lessonProvider.getContentPagesViewedOnline(this.lessonId, retake, false, true);
}
}
if (result.nodefaultresponse || result.inmediatejump) {
// Don't display feedback or force a redirect to a new page. Load the new page.
return this.jumpToPage(result.newpageid);
} else {
// Not inmediate jump, show the feedback.
result.feedback = this.lessonHelper.removeQuestionFromFeedback(result.feedback);
this.messages = result.messages;
this.processData = result;
this.processData.buttons = [];
if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion &&
!result.maxattemptsreached && !result.reviewmode) {
// User can try again, show button to do so.
this.processData.buttons.push({
label: 'addon.mod_lesson.reviewquestionback',
pageId: this.currentPage
});
}
// Button to continue.
if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion &&
!result.maxattemptsreached) {
this.processData.buttons.push({
label: 'addon.mod_lesson.reviewquestioncontinue',
pageId: result.newpageid
});
} else {
this.processData.buttons.push({
label: 'addon.mod_lesson.continue',
pageId: result.newpageid
});
}
}
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error processing page');
}).finally(() => {
this.loaded = true;
});
}
/**
* Review the lesson.
*
* @param {number} pageId Page to load.
*/
reviewLesson(pageId: number): void {
this.loaded = false;
this.review = true;
this.offline = false; // Don't allow offline mode in review.
this.loadPage(pageId).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error loading page');
}).finally(() => {
this.loaded = true;
});
}
/**
* Submit a question.
*/
submitQuestion(): void {
this.loaded = false;
// Use getRawValue to include disabled values.
this.lessonHelper.prepareQuestionData(this.question, this.questionForm.getRawValue()).then((data) => {
return this.processPage(data);
}).finally(() => {
this.loaded = true;
});
}
/**
* Time up.
*/
timeUp(): void {
// Time up called, hide the timer.
this.endTime = undefined;
this.loaded = false;
this.finishRetake(true).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error finishing attempt');
}).finally(() => {
this.loaded = true;
});
}
}

View File

@ -0,0 +1,368 @@
// (C) Copyright 2015 Martin Dougiamas
//
// 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 { FormBuilder, FormGroup } from '@angular/forms';
import { TranslateService } from '@ngx-translate/core';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { AddonModLessonProvider } from './lesson';
/**
* Helper service that provides some features for quiz.
*/
@Injectable()
export class AddonModLessonHelperProvider {
protected div = document.createElement('div'); // A div element to search in HTML code.
constructor(private domUtils: CoreDomUtilsProvider, private fb: FormBuilder, private translate: TranslateService,
private textUtils: CoreTextUtilsProvider) { }
/**
* Given the HTML of next activity link, format it to extract the href and the text.
*
* @param {string} activityLink HTML of the activity link.
* @return {{formatted: boolean, label: string, href: string}} Formatted data.
*/
formatActivityLink(activityLink: string): {formatted: boolean, label: string, href: string} {
this.div.innerHTML = activityLink;
const anchor = this.div.querySelector('a');
if (!anchor) {
// Anchor not found, return the original HTML.
return {
formatted: false,
label: activityLink,
href: ''
};
}
return {
formatted: true,
label: anchor.innerHTML,
href: anchor.href
};
}
/**
* Get the buttons to change pages.
*
* @param {string} html Page's HTML.
* @return {any[]} List of buttons.
*/
getPageButtonsFromHtml(html: string): any[] {
const buttons = [];
// Get the container of the buttons if it exists.
this.div.innerHTML = html;
let buttonsContainer = this.div.querySelector('.branchbuttoncontainer');
if (!buttonsContainer) {
// Button container not found, might be a legacy lesson (from 1.9).
if (!this.div.querySelector('form input[type="submit"]')) {
// No buttons found.
return buttons;
}
buttonsContainer = this.div;
}
const forms = Array.from(buttonsContainer.querySelectorAll('form'));
forms.forEach((form) => {
const buttonSelector = 'input[type="submit"], button[type="submit"]',
buttonEl = <HTMLInputElement | HTMLButtonElement> form.querySelector(buttonSelector),
inputs = Array.from(form.querySelectorAll('input'));
if (!buttonEl || !inputs || !inputs.length) {
// Button not found or no inputs, ignore it.
return;
}
const button = {
id: buttonEl.id,
title: buttonEl.title || buttonEl.value,
content: buttonEl.tagName == 'INPUT' ? buttonEl.value : buttonEl.innerHTML.trim(),
data: {}
};
inputs.forEach((input) => {
if (input.type != 'submit') {
button.data[input.name] = input.value;
}
});
buttons.push(button);
});
return buttons;
}
/**
* Given a page data (result of getPageData), get the page contents.
*
* @param {any} data Page data.
* @return {string} Page contents.
*/
getPageContentsFromPageData(data: any): string {
// Search the page contents inside the whole page HTML. Use data.pagecontent because it's filtered.
this.div.innerHTML = data.pagecontent;
const contents = this.div.querySelector('.contents');
if (contents) {
return contents.innerHTML.trim();
}
// Cannot find contents element, return the page.contents (some elements like videos might not work).
return data.page.contents;
}
/**
* Get a question and all the data required to render it from the page data (result of AddonModLessonProvider.getPageData).
*
* @param {FormGroup} questionForm The form group where to add the controls.
* @param {any} pageData Page data (result of $mmaModLesson#getPageData).
* @return {any} Question data.
*/
getQuestionFromPageData(questionForm: FormGroup, pageData: any): any {
const question: any = {};
// Get the container of the question answers if it exists.
this.div.innerHTML = pageData.pagecontent;
const fieldContainer = this.div.querySelector('.fcontainer');
// Get hidden inputs and add their data to the form group.
const hiddenInputs = <HTMLInputElement[]> Array.from(this.div.querySelectorAll('input[type="hidden"]'));
hiddenInputs.forEach((input) => {
questionForm.addControl(input.name, this.fb.control(input.value));
});
// Get the submit button and extract its value.
const submitButton = <HTMLInputElement> this.div.querySelector('input[type="submit"]');
question.submitLabel = submitButton ? submitButton.value : this.translate.instant('addon.mod_lesson.submit');
if (!fieldContainer) {
// Element not found, return.
return question;
}
let type;
switch (pageData.page.qtype) {
case AddonModLessonProvider.LESSON_PAGE_TRUEFALSE:
case AddonModLessonProvider.LESSON_PAGE_MULTICHOICE:
question.template = 'multichoice';
question.options = [];
// Get all the inputs. Search radio first.
let inputs = <HTMLInputElement[]> Array.from(fieldContainer.querySelectorAll('input[type="radio"]'));
if (!inputs || !inputs.length) {
// Radio buttons not found, it might be a multi answer. Search for checkbox.
question.multi = true;
inputs = <HTMLInputElement[]> Array.from(fieldContainer.querySelectorAll('input[type="checkbox"]'));
if (!inputs || !inputs.length) {
// No checkbox found either. Stop.
return question;
}
}
let controlAdded = false;
inputs.forEach((input) => {
const option: any = {
id: input.id,
name: input.name,
value: input.value,
checked: !!input.checked,
disabled: !!input.disabled
},
parent = input.parentElement;
if (option.checked || question.multi) {
// Add the control.
const value = question.multi ? {value: option.checked, disabled: option.disabled} : option.value;
questionForm.addControl(option.name, this.fb.control(value));
controlAdded = true;
}
// Remove the input and use the rest of the parent contents as the label.
input.remove();
option.text = parent.innerHTML.trim();
question.options.push(option);
});
if (!question.multi) {
question.controlName = inputs[0].name; // All option have the same name in single choice.
if (!controlAdded) {
// No checked option for single choice, add the control with an empty value.
questionForm.addControl(question.controlName, this.fb.control(''));
}
}
break;
case AddonModLessonProvider.LESSON_PAGE_NUMERICAL:
type = 'number';
case AddonModLessonProvider.LESSON_PAGE_SHORTANSWER:
question.template = 'shortanswer';
// Get the input.
const input = <HTMLInputElement> fieldContainer.querySelector('input[type="text"], input[type="number"]');
if (!input) {
return question;
}
question.input = {
id: input.id,
name: input.name,
maxlength: input.maxLength,
type: type || 'text'
};
// Init the control.
questionForm.addControl(input.name, this.fb.control({value: input.value, disabled: input.readOnly}));
break;
case AddonModLessonProvider.LESSON_PAGE_ESSAY:
question.template = 'essay';
// Get the textarea.
const textarea = fieldContainer.querySelector('textarea');
if (!textarea) {
// Textarea not found, probably review mode.
const answerEl = fieldContainer.querySelector('.reviewessay');
if (!answerEl) {
// Answer not found, stop.
return question;
}
question.useranswer = answerEl.innerHTML;
} else {
question.textarea = {
id: textarea.id,
name: textarea.name || 'answer[text]'
};
// Init the control.
question.control = this.fb.control('');
questionForm.addControl(question.textarea.name, question.control);
}
break;
case AddonModLessonProvider.LESSON_PAGE_MATCHING:
question.template = 'matching';
const rows = Array.from(fieldContainer.querySelectorAll('.answeroption'));
question.rows = [];
rows.forEach((row) => {
const label = row.querySelector('label'),
select = row.querySelector('select'),
options = Array.from(row.querySelectorAll('option')),
rowData: any = {};
if (!label || !select || !options || !options.length) {
return;
}
// Get the row's text (label).
rowData.text = label.innerHTML.trim();
rowData.id = select.id;
rowData.name = select.name;
rowData.options = [];
// Treat each option.
let controlAdded = false;
options.forEach((option) => {
if (typeof option.value == 'undefined') {
// Option not valid, ignore it.
return;
}
const opt = {
value: option.value,
label: option.innerHTML.trim(),
selected: option.selected
};
if (opt.selected) {
controlAdded = true;
questionForm.addControl(rowData.name, this.fb.control({value: opt.value, disabled: !!select.disabled}));
}
rowData.options.push(opt);
});
if (!controlAdded) {
// No selected option, add the control with an empty value.
questionForm.addControl(rowData.name, this.fb.control({value: '', disabled: !!select.disabled}));
}
question.rows.push(rowData);
});
break;
default:
// Nothing to do.
}
return question;
}
/**
* Prepare the question data to be sent to server.
*
* @param {any} question Question to prepare.
* @param {any} data Data to prepare.
* @return {Promise<any>} Promise resolved with the data to send when done.
*/
prepareQuestionData(question: any, data: any): Promise<any> {
if (question.template == 'essay' && question.textarea) {
// The answer might need formatting. Check if rich text editor is enabled or not.
return this.domUtils.isRichTextEditorEnabled().then((enabled) => {
if (!enabled) {
// Rich text editor not enabled, add some HTML to the answer if needed.
data[question.textarea.property] = this.textUtils.formatHtmlLines(data[question.textarea.property]);
}
return data;
});
} else if (question.template == 'multichoice' && question.multi) {
// Only send the options with value set to true.
for (const name in data) {
if (name.match(/answer\[\d+\]/) && data[name] == false) {
delete data[name];
}
}
}
return Promise.resolve(data);
}
/**
* Given the feedback of a process page in HTML, remove the question text.
*
* @param {string} html Feedback's HTML.
* @return {string} Feedback without the question text.
*/
removeQuestionFromFeedback(html: string): string {
this.div.innerHTML = html;
// Remove the question text.
this.domUtils.removeElement(this.div, '.generalbox:not(.feedback):not(.correctanswer)');
return this.div.innerHTML.trim();
}
}