MOBILE-2345 lesson: Implement user retake page

main
Dani Palou 2018-05-04 10:45:46 +02:00
parent ff06a812d2
commit b1469987e2
6 changed files with 558 additions and 2 deletions

View File

@ -0,0 +1,159 @@
<ion-header>
<ion-navbar>
<ion-title>{{ 'addon.mod_lesson.detailedstats' | translate }}</ion-title>
</ion-navbar>
</ion-header>
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-list *ngIf="student">
<!-- Student data. -->
<a ion-item text-wrap core-user-link [userId]="student.id" [courseId]="courseId" [title]="student.fullname">
<ion-avatar *ngIf="student.profileimageurl" item-start>
<img [src]="student.profileimageurl" [alt]="'core.pictureof' | translate:{$a: student.fullname}" core-external-content role="presentation">
</ion-avatar>
<h2>{{student.fullname}}</h2>
<core-progress-bar [progress]="student.bestgrade"></core-progress-bar>
</a>
<!-- Retake selector if there is more than one retake. -->
<ion-item text-wrap *ngIf="student.attempts && student.attempts.length > 1">
<ion-label id="addon-mod_lesson-retakeslabel">{{ 'addon.mod_lesson.attemptheader' | translate }}</ion-label>
<ion-select [(ngModel)]="selectedRetake" (ionChange)="changeRetake(selectedRetake)" aria-labelledby="addon-mod_lesson-retakeslabel" interface="popover">
<ion-option *ngFor="let retake of student.attempts" [value]="retake.try">{{retake.label}}</ion-option>
</ion-select>
</ion-item>
<!-- Retake stats. -->
<div *ngIf="retake && retake.userstats && retake.userstats.gradeinfo" class="addon-mod_lesson-userstats">
<ion-item text-wrap>
<ion-row>
<ion-col>
<p class="item-heading">{{ 'addon.mod_lesson.grade' | translate }}</p>
<p>{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}</p>
</ion-col>
<ion-col>
<p class="item-heading">{{ 'addon.mod_lesson.rawgrade' | translate }}</p>
<p>{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}</p>
</ion-col>
</ion-row>
</ion-item>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_lesson.timetaken' | translate }}</p>
<p>{{ retake.userstats.timetakenReadable }}</p>
</ion-item>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_lesson.completed' | translate }}</p>
<p>{{ retake.userstats.completed * 1000 | coreFormatDate:"dfmediumdate" }}</p>
</ion-item>
</div>
<!-- Not completed, no stats. -->
<ion-item text-wrap *ngIf="retake && (!retake.userstats || !retake.userstats.gradeinfo)">
{{ 'addon.mod_lesson.notcompleted' | translate }}
</ion-item>
<!-- Pages. -->
<ng-container *ngIf="retake">
<!-- The "text-dimmed" class does nothing, but the same goes for the "dimmed" class in Moodle. -->
<ion-card *ngFor="let page of retake.answerpages" class="addon-mod_lesson-answerpage" [ngClass]="{'text-dimmed': page.grayout}">
<ion-card-header text-wrap>
<h2>{{page.qtype}}: {{page.title}}</h2>
</ion-card-header>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_lesson.question' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [maxHeight]="50" [text]="page.contents"></core-format-text></p>
</ion-item>
<ion-item text-wrap>
<p class="item-heading">{{ 'addon.mod_lesson.answer' | translate }}</p>
</ion-item>
<ion-item text-wrap *ngIf="!page.answerdata || !page.answerdata.answers || !page.answerdata.answers.length">
<p>{{ 'addon.mod_lesson.didnotanswerquestion' | translate }}</p>
</ion-item>
<div *ngIf="page.answerdata && page.answerdata.answers && page.answerdata.answers.length" class="addon-mod_lesson-answer">
<div *ngFor="let answer of page.answerdata.answers">
<ion-item text-wrap *ngIf="page.isContent">
<!-- Content page, display a button and the content. -->
<ion-row>
<ion-col>
<button ion-button block color="light" [disabled]="true">{{ answer[0].buttonText }}</button>
</ion-col>
<ion-col>
<p [innerHTML]="answer[0].content"></p>
</ion-col>
</ion-row>
</ion-item>
<div *ngIf="page.isQuestion">
<!-- Question page, show the right input for the answer. -->
<!-- Truefalse or matching. -->
<ion-item text-wrap *ngIf="answer[0].isCheckbox" [ngClass]="{'addon-mod_lesson-highlight': answer[0].highlight}">
<ion-label>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[0].content"></core-format-text></p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-label>
<ion-checkbox [attr.name]="answer[0].name" [ngModel]="answer[0].checked" [disabled]="true" item-end>
</ion-checkbox>
</ion-item>
<!-- Short answer or numeric. -->
<ion-item text-wrap *ngIf="answer[0].isText">
<p>{{ answer[0].value }}</p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-item>
<!-- Matching. -->
<ion-item text-wrap *ngIf="answer[0].isSelect">
<ion-row>
<ion-col>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]=" answer[0].content"></core-format-text></p>
</ion-col>
<ion-col>
<p>{{answer[0].value}}</p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-col>
</ion-row>
</ion-item>
<!-- Essay or couldn't determine. -->
<ion-item text-wrap *ngIf="!answer[0].isCheckbox && !answer[0].isText && !answer[0].isSelect">
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[0]"></core-format-text></p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-item>
</div>
<ion-item text-wrap *ngIf="!page.isContent && !page.isQuestion">
<!-- Another page (end of branch, ...). -->
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[0]"></core-format-text></p>
<ion-badge *ngIf="answer[1]" color="dark">
<core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="answer[1]"></core-format-text>
</ion-badge>
</ion-item>
</div>
<ion-item text-wrap *ngIf="page.answerdata.response">
<p class="item-heading">{{ 'addon.mod_lesson.response' | translate }}</p>
<p><core-format-text [component]="component" [componentId]="lesson.coursemodule" [text]="page.answerdata.response"></core-format-text></p>
</ion-item>
<ion-item text-wrap *ngIf="page.answerdata.score">
<p>{{page.answerdata.score}}</p>
</ion-item>
</div>
</ion-card>
</ng-container>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,35 @@
// (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 { CorePipesModule } from '@pipes/pipes.module';
import { AddonModLessonUserRetakePage } from './user-retake';
@NgModule({
declarations: [
AddonModLessonUserRetakePage,
],
imports: [
CoreComponentsModule,
CoreDirectivesModule,
CorePipesModule,
IonicPageModule.forChild(AddonModLessonUserRetakePage),
TranslateModule.forChild()
],
})
export class AddonModLessonUserRetakePageModule {}

View File

@ -0,0 +1,8 @@
page-addon-mod-lesson-user-retake {
.addon-mod_lesson-highlight {
background: $blue-light;
.label, .label p {
color: $blue-dark;
}
}
}

View File

@ -0,0 +1,228 @@
// (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 } from '@angular/core';
import { IonicPage, NavParams } from 'ionic-angular';
import { TranslateService } from '@ngx-translate/core';
import { CoreSitesProvider } from '@providers/sites';
import { CoreDomUtilsProvider } from '@providers/utils/dom';
import { CoreTextUtilsProvider } from '@providers/utils/text';
import { CoreTimeUtilsProvider } from '@providers/utils/time';
import { CoreUserProvider } from '@core/user/providers/user';
import { AddonModLessonProvider } from '../../providers/lesson';
import { AddonModLessonHelperProvider } from '../../providers/helper';
/**
* Page that displays a retake made by a certain user.
*/
@IonicPage({ segment: 'addon-mod-lesson-user-retake' })
@Component({
selector: 'page-addon-mod-lesson-user-retake',
templateUrl: 'user-retake.html',
})
export class AddonModLessonUserRetakePage implements OnInit {
component = AddonModLessonProvider.COMPONENT;
lesson: any; // The lesson the retake belongs to.
courseId: number; // Course ID the lesson belongs to.
selectedRetake: number; // The retake to see.
student: any; // Data about the student and his retakes.
retake: any; // Data about the retake.
loaded: boolean; // Whether the data has been loaded.
protected lessonId: number; // The lesson ID the retake belongs to.
protected userId: number; // User ID to see the retakes.
protected retakeNumber: number; // Number of the initial retake to see.
constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, protected textUtils: CoreTextUtilsProvider,
protected translate: TranslateService, protected domUtils: CoreDomUtilsProvider,
protected userProvider: CoreUserProvider, protected timeUtils: CoreTimeUtilsProvider,
protected lessonProvider: AddonModLessonProvider, protected lessonHelper: AddonModLessonHelperProvider) {
this.lessonId = navParams.get('lessonId');
this.courseId = navParams.get('courseId');
this.userId = navParams.get('userId') || sitesProvider.getCurrentSiteUserId();
this.retakeNumber = navParams.get('retake');
}
/**
* Component being initialized.
*/
ngOnInit(): void {
// Fetch the data.
this.fetchData().finally(() => {
this.loaded = true;
});
}
/**
* Change the retake displayed.
*
* @param {number} retakeNumber The new retake number.
*/
changeRetake(retakeNumber: number): void {
this.loaded = false;
this.setRetake(retakeNumber).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting attempt.');
}).finally(() => {
this.loaded = true;
});
}
/**
* Pull to refresh.
*
* @param {any} refresher Refresher.
*/
doRefresh(refresher: any): void {
this.refreshData().finally(() => {
refresher.complete();
});
}
/**
* Get lesson and retake data.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected fetchData(): Promise<any> {
return this.lessonProvider.getLessonById(this.courseId, this.lessonId).then((lessonData) => {
this.lesson = lessonData;
// Get the retakes overview for all participants.
return this.lessonProvider.getRetakesOverview(this.lesson.id);
}).then((data) => {
// Search the student.
let student;
if (data && data.students) {
for (let i = 0; i < data.students.length; i++) {
if (data.students[i].id == this.userId) {
student = data.students[i];
break;
}
}
}
if (!student) {
// Student not found.
return Promise.reject(this.translate.instant('addon.mod_lesson.cannotfinduser'));
}
if (!student.attempts || !student.attempts.length) {
// No retakes.
return Promise.reject(this.translate.instant('addon.mod_lesson.cannotfindattempt'));
}
student.bestgrade = this.textUtils.roundToDecimals(student.bestgrade, 2);
student.attempts.forEach((retake) => {
if (this.retakeNumber == retake.try) {
// The retake specified as parameter exists. Use it.
this.selectedRetake = this.retakeNumber;
}
retake.label = this.lessonHelper.getRetakeLabel(retake);
});
if (!this.selectedRetake) {
// Retake number not specified or not valid, use the last retake.
this.selectedRetake = student.attempts[student.attempts.length - 1].try;
}
// Get the profile image of the user.
return this.userProvider.getProfile(student.id, this.courseId, true).then((user) => {
student.profileimageurl = user.profileimageurl;
return student;
}).catch(() => {
// Error getting profile, resolve promise without adding any extra data.
return student;
});
}).then((student) => {
this.student = student;
return this.setRetake(this.selectedRetake);
}).catch((error) => {
this.domUtils.showErrorModalDefault(error, 'Error getting data.', true);
});
}
/**
* Refreshes data.
*
* @return {Promise<any>} Promise resolved when done.
*/
protected refreshData(): Promise<any> {
const promises = [];
promises.push(this.lessonProvider.invalidateLessonData(this.courseId));
if (this.lesson) {
promises.push(this.lessonProvider.invalidateRetakesOverview(this.lesson.id));
promises.push(this.lessonProvider.invalidateUserRetakesForUser(this.lesson.id, this.userId));
}
return Promise.all(promises).catch(() => {
// Ignore errors.
}).then(() => {
return this.fetchData();
});
}
/**
* Set the retake to view and load its data.
*
* @param {number}retakeNumber Retake number to set.
* @return {Promise<any>} Promise resolved when done.
*/
protected setRetake(retakeNumber: number): Promise<any> {
this.selectedRetake = retakeNumber;
return this.lessonProvider.getUserRetake(this.lessonId, retakeNumber, this.userId).then((data) => {
if (data && data.completed != -1) {
// Completed.
data.userstats.grade = this.textUtils.roundToDecimals(data.userstats.grade, 2);
data.userstats.timetakenReadable = this.timeUtils.formatTime(data.userstats.timetotake);
}
if (data && data.answerpages) {
// Format pages data.
data.answerpages.forEach((page) => {
if (this.lessonProvider.answerPageIsContent(page)) {
page.isContent = true;
if (page.answerdata && page.answerdata.answers) {
page.answerdata.answers.forEach((answer) => {
// Content pages only have 1 valid field in the answer array.
answer[0] = this.lessonHelper.getContentPageAnswerDataFromHtml(answer[0]);
});
}
} else if (this.lessonProvider.answerPageIsQuestion(page)) {
page.isQuestion = true;
if (page.answerdata && page.answerdata.answers) {
page.answerdata.answers.forEach((answer) => {
// Only the first field of the answer array requires to be parsed.
answer[0] = this.lessonHelper.getQuestionPageAnswerDataFromHtml(answer[0]);
});
}
}
});
}
this.retake = data;
});
}
}

View File

@ -17,7 +17,9 @@ 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 { CoreTimeUtilsProvider } from '@providers/utils/time';
import { AddonModLessonProvider } from './lesson';
import * as moment from 'moment';
/**
* Helper service that provides some features for quiz.
@ -28,7 +30,7 @@ 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) { }
private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { }
/**
* Given the HTML of next activity link, format it to extract the href and the text.
@ -55,6 +57,33 @@ export class AddonModLessonHelperProvider {
};
}
/**
* Given the HTML of an answer from a content page, extract the data to render the answer.
*
* @param {String} html Answer's HTML.
* @return {{buttonText: string, content: string}} Data to render the answer.
*/
getContentPageAnswerDataFromHtml(html: string): {buttonText: string, content: string} {
const data = {
buttonText: '',
content: ''
};
// Search the input button.
this.div.innerHTML = html;
const button = <HTMLInputElement> this.div.querySelector('input[type="button"]');
if (button) {
// Extract the button content and remove it from the HTML.
data.buttonText = button.value;
button.remove();
}
data.content = this.div.innerHTML.trim();
return data;
}
/**
* Get the buttons to change pages.
*
@ -321,6 +350,103 @@ export class AddonModLessonHelperProvider {
return question;
}
/**
* Given the HTML of an answer from a question page, extract the data to render the answer.
*
* @param {string} html Answer's HTML.
* @return {any} Object with the data to render the answer. If the answer doesn't require any parsing, return a string with
* the HTML.
*/
getQuestionPageAnswerDataFromHtml(html: string): any {
const data: any = {};
this.div.innerHTML = html;
// Check if it has a checkbox.
let input = <HTMLInputElement> this.div.querySelector('input[type="checkbox"][name*="answer"]');
if (input) {
// Truefalse or multichoice.
data.isCheckbox = true;
data.checked = !!input.checked;
data.name = input.name;
data.highlight = !!this.div.querySelector('.highlight');
input.remove();
data.content = this.div.innerHTML.trim();
return data;
}
// Check if it has an input text or number.
input = <HTMLInputElement> this.div.querySelector('input[type="number"],input[type="text"]');
if (input) {
// Short answer or numeric.
data.isText = true;
data.value = input.value;
return data;
}
// Check if it has a select.
const select = <HTMLSelectElement> this.div.querySelector('select');
if (select && select.options) {
// Matching.
const selectedOption = select.options[select.selectedIndex];
data.isSelect = true;
data.id = select.id;
if (selectedOption) {
data.value = selectedOption.value;
} else {
data.value = '';
}
select.remove();
data.content = this.div.innerHTML.trim();
return data;
}
// The answer doesn't need any parsing, return the HTML as it is.
return html;
}
/**
* Get a label to identify a retake (lesson attempt).
*
* @param {any} retake Retake object.
* @param {boolean} [includeDuration] Whether to include the duration of the retake.
* @return {string} Retake label.
*/
getRetakeLabel(retake: any, includeDuration?: boolean): string {
const data = {
retake: retake.try + 1,
grade: '',
timestart: '',
duration: ''
},
hasGrade = retake.grade != null;
if (hasGrade || retake.end) {
// Retake finished with or without grade (if the lesson only has content pages, it has no grade).
if (hasGrade) {
data.grade = this.translate.instant('core.percentagenumber', {$a: retake.grade});
}
data.timestart = moment(retake.timestart * 1000).format('LLL');
if (includeDuration) {
data.duration = this.timeUtils.formatTime(retake.timeend - retake.timestart);
}
} else {
// The user has not completed the retake.
data.grade = this.translate.instant('addon.mod_lesson.notcompleted');
if (retake.timestart) {
data.timestart = moment(retake.timestart * 1000).format('LLL');
}
}
return this.translate.instant('addon.mod_lesson.retakelabel' + (includeDuration ? 'full' : 'short'), data);
}
/**
* Prepare the question data to be sent to server.
*

View File

@ -12,7 +12,7 @@
<core-format-text [component]="component" [componentId]="componentId" [text]="option.text"></core-format-text>
<p *ngIf="option.feedback" class="core-question-feedback-container"><core-format-text [component]="component" [componentId]="componentId" [text]="option.feedback"></core-format-text></p>
</ion-label>
<ion-checkbox [name]="option.name" [(ngModel)]="option.checked" [disabled]="option.disabled" item-end></ion-checkbox>
<ion-checkbox [attr.name]="option.name" [(ngModel)]="option.checked" [disabled]="option.disabled" item-end></ion-checkbox>
<!-- ion-checkbox doesn't use an input. Create a hidden input to hold the value. -->
<input item-content type="hidden" [ngModel]="option.checked" [attr.name]="option.name">