MOBILE-4550 quiz: Change how list of attempts is displayed

main
Dani Palou 2024-04-19 16:02:06 +02:00
parent ec195696e0
commit a765f3cb8d
22 changed files with 494 additions and 662 deletions

View File

@ -872,6 +872,7 @@
"addon.mod_page.errorwhileloadingthepage": "local_moodlemobileapp",
"addon.mod_page.modulenameplural": "page",
"addon.mod_quiz.answercolon": "qtype_numerical",
"addon.mod_quiz.attempt": "quiz",
"addon.mod_quiz.attemptduration": "quiz",
"addon.mod_quiz.attemptfirst": "quiz",
"addon.mod_quiz.attemptlast": "quiz",
@ -902,7 +903,7 @@
"addon.mod_quiz.errorsaveattempt": "local_moodlemobileapp",
"addon.mod_quiz.feedback": "quiz",
"addon.mod_quiz.finishattemptdots": "quiz",
"addon.mod_quiz.finishnotsynced": "local_moodlemobileapp",
"addon.mod_quiz.finishedofflinenotice": "local_moodlemobileapp",
"addon.mod_quiz.grade": "quiz",
"addon.mod_quiz.gradeaverage": "quiz",
"addon.mod_quiz.gradehighest": "quiz",
@ -1864,6 +1865,7 @@
"core.grades.grade": "grades",
"core.grades.gradebook": "grades",
"core.grades.gradeitem": "grades",
"core.grades.gradelong": "grades",
"core.grades.gradepass": "grades",
"core.grades.grades": "grades",
"core.grades.lettergrade": "grades",
@ -2560,6 +2562,7 @@
"core.strftimetime12": "langconfig",
"core.strftimetime24": "langconfig",
"core.submit": "moodle",
"core.submittedoffline": "local_moodlemobileapp",
"core.success": "moodle",
"core.summary": "moodle",
"core.swipenavigationtourdescription": "local_moodlemobileapp",

View File

@ -0,0 +1,78 @@
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-status">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<addon-mod-quiz-attempt-state [state]="attempt.state" [finishedOffline]="attempt.finishedOffline" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-startedon">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.startedon' | translate }}</p>
<p>{{ attempt.timestart! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@if (isFinished) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-completedon">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.completedon' | translate }}</p>
<p>{{ attempt.timefinish! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
@if (timeTaken) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-duration">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptduration' | translate }}</p>
<p>{{ timeTaken }}</p>
</ion-label>
</ion-item>
}
@if (overTime) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-overdue">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.overdue' | translate }}</p>
<p>{{ overTime }}</p>
</ion-label>
</ion-item>
}
@for (gradeItemMark of gradeItemMarks; track $index) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-gradeitemmark">
<ion-label>
<p class="item-heading">{{ gradeItemMark.name }}</p>
<p [innerHTML]="gradeItemMark.grade"></p>
</ion-label>
</ion-item>
}
@if (quiz.showAttemptsMarks && readableMark && attempt?.sumgrades !== null) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-mark">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }}</p>
<p>{{ readableMark }}</p>
</ion-label>
</ion-item>
}
@if (readableGrade) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-grade">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }}</p>
<p [innerHTML]="readableGrade"></p>
</ion-label>
</ion-item>
}
@for (data of additionalData; track data.id) {
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-{{data.id}}">
<ion-label>
<p class="item-heading">{{ data.title }}</p>
<core-format-text [component]="component" [componentId]="quiz.coursemodule" [text]="data.content" contextLevel="module"
[contextInstanceId]="quiz.coursemodule" [courseId]="quiz.course" />
</ion-label>
</ion-item>
}
}

View File

@ -0,0 +1,123 @@
// (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, OnChanges, SimpleChanges } from '@angular/core';
import { AddonModQuizAttempt, AddonModQuizQuizData } from '../../services/quiz-helper';
import { AddonModQuiz, AddonModQuizWSAdditionalData } from '../../services/quiz';
import { ADDON_MOD_QUIZ_COMPONENT, AddonModQuizAttemptStates } from '../../constants';
import { CoreTime } from '@singletons/time';
import { Translate } from '@singletons';
import { CoreDomUtils } from '@services/utils/dom';
import { isSafeNumber } from '@/core/utils/types';
/**
* Component that displays an attempt info.
*/
@Component({
selector: 'addon-mod-quiz-attempt-info',
templateUrl: 'attempt-info.html',
})
export class AddonModQuizAttemptInfoComponent implements OnChanges {
@Input() quiz!: AddonModQuizQuizData;
@Input() attempt!: AddonModQuizAttempt;
@Input() additionalData?: AddonModQuizWSAdditionalData[]; // Additional data to display for the attempt.
isFinished = false;
readableMark = '';
readableGrade = '';
timeTaken?: string;
overTime?: string;
gradeItemMarks: { name: string; grade: string }[] = [];
component = ADDON_MOD_QUIZ_COMPONENT;
/**
* @inheritdoc
*/
async ngOnChanges(changes: SimpleChanges): Promise<void> {
if (changes.additionalData) {
this.additionalData?.forEach((data) => {
// Remove help links from additional data.
data.content = CoreDomUtils.removeElementFromHtml(data.content, '.helptooltip');
});
}
if (!changes.attempt) {
return;
}
this.isFinished = this.attempt.state === AddonModQuizAttemptStates.FINISHED;
if (!this.isFinished) {
return;
}
const timeTaken = (this.attempt.timefinish || 0) - (this.attempt.timestart || 0);
if (timeTaken > 0) {
// Format time taken.
this.timeTaken = CoreTime.formatTime(timeTaken);
// Calculate overdue time.
if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) {
this.overTime = CoreTime.formatTime(timeTaken - this.quiz.timelimit);
}
} else {
this.timeTaken = undefined;
}
// Treat grade item marks.
if (this.attempt.sumgrades === null || !this.attempt.gradeitemmarks) {
this.gradeItemMarks = [];
} else {
this.gradeItemMarks = this.attempt.gradeitemmarks.map((gradeItemMark) => ({
name: gradeItemMark.name,
grade: Translate.instant('addon.mod_quiz.outof', { $a: {
grade: '<strong>' + AddonModQuiz.formatGrade(gradeItemMark.grade, this.quiz?.decimalpoints) + '</strong>',
maxgrade: AddonModQuiz.formatGrade(gradeItemMark.maxgrade, this.quiz?.decimalpoints),
} }),
}));
}
if (!this.quiz.showAttemptsGrades) {
return;
}
// Treat grade and mark.
if (!isSafeNumber(this.attempt.rescaledGrade)) {
this.readableGrade = Translate.instant('addon.mod_quiz.notyetgraded');
return;
}
if (this.quiz.showAttemptsMarks) {
this.readableMark = Translate.instant('addon.mod_quiz.outofshort', { $a: {
grade: AddonModQuiz.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints),
maxgrade: AddonModQuiz.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints),
} });
}
const gradeObject: Record<string, unknown> = {
grade: '<strong>' + AddonModQuiz.formatGrade(this.attempt.rescaledGrade, this.quiz.decimalpoints) + '</strong>',
maxgrade: AddonModQuiz.formatGrade(this.quiz.grade, this.quiz.decimalpoints),
};
if (this.quiz.grade != 100) {
const percentage = (this.attempt.sumgrades ?? 0) * 100 / (this.quiz.sumgrades ?? 1);
gradeObject.percent = '<strong>' + AddonModQuiz.formatGrade(percentage, this.quiz.decimalpoints) + '</strong>';
this.readableGrade = Translate.instant('addon.mod_quiz.outofpercent', { $a: gradeObject });
} else {
this.readableGrade = Translate.instant('addon.mod_quiz.outof', { $a: gradeObject });
}
}
}

View File

@ -0,0 +1,6 @@
<ion-badge [color]="color">
@if (finishedOffline) {
<ion-icon name="fas-clock" aria-hidden="true" />
}
{{ readableState }}
</ion-badge>

View File

@ -0,0 +1,14 @@
@use "theme/globals" as *;
:host {
ion-badge {
display: inline-flex;
align-items: center;
ion-icon {
@include margin-horizontal(0px, var(--mdl-spacing-1));
}
}
}

View File

@ -0,0 +1,42 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, Input, OnChanges } from '@angular/core';
import { AddonModQuiz } from '../../services/quiz';
/**
* Component that displays an attempt state.
*/
@Component({
selector: 'addon-mod-quiz-attempt-state',
templateUrl: 'attempt-state.html',
styleUrls: ['attempt-state.scss'],
})
export class AddonModQuizAttemptStateComponent implements OnChanges {
@Input() state = '';
@Input() finishedOffline = false;
readableState = '';
color = '';
/**
* @inheritdoc
*/
async ngOnChanges(): Promise<void> {
this.readableState = AddonModQuiz.getAttemptReadableStateName(this.state, this.finishedOffline);
this.color = AddonModQuiz.getAttemptStateColor(this.state, this.finishedOffline);
}
}

View File

@ -20,9 +20,13 @@ import { AddonModQuizConnectionErrorComponent } from './connection-error/connect
import { AddonModQuizIndexComponent } from './index/index';
import { AddonModQuizNavigationModalComponent } from './navigation-modal/navigation-modal';
import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight-modal';
import { AddonModQuizAttemptInfoComponent } from './attempt-info/attempt-info';
import { AddonModQuizAttemptStateComponent } from './attempt-state/attempt-state';
@NgModule({
declarations: [
AddonModQuizAttemptInfoComponent,
AddonModQuizAttemptStateComponent,
AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent,
AddonModQuizNavigationModalComponent,
@ -35,6 +39,8 @@ import { AddonModQuizPreflightModalComponent } from './preflight-modal/preflight
providers: [
],
exports: [
AddonModQuizAttemptInfoComponent,
AddonModQuizAttemptStateComponent,
AddonModQuizIndexComponent,
AddonModQuizConnectionErrorComponent,
AddonModQuizNavigationModalComponent,

View File

@ -45,64 +45,74 @@
</ion-card>
<!-- List of user attempts. -->
<ion-card class="addon-mod_quiz-table" *ngIf="quiz && attempts.length">
<ion-card class="addon-mod_quiz-attempts-summary" *ngIf="quiz && attempts.length">
<ion-card-header class="ion-text-wrap">
<ion-card-title>{{ 'addon.mod_quiz.summaryofattempts' | translate }}</ion-card-title>
</ion-card-header>
<ion-card-content role="table">
<!-- "Header" of the table -->
<ion-item class="ion-text-wrap addon-mod_quiz-table-header hide-detail" [detail]="true">
<ion-label role="rowgroup">
<ion-row class="ion-align-items-center" role="row">
<ion-col class="ion-text-center" *ngIf="quiz.showAttemptColumn" role="columnheader">
<strong class="ion-hide-md-up" aria-hidden="true">#</strong>
<span class="sr-only ion-hide-md-up">{{ 'addon.mod_quiz.attemptnumber' | translate }}</span>
<strong class="ion-hide-md-down">{{ 'addon.mod_quiz.attemptnumber' | translate }}</strong>
</ion-col>
<ion-col size="7" role="columnheader">
<strong>{{ 'addon.mod_quiz.attemptstate' | translate }}</strong>
</ion-col>
<ion-col class="ion-text-center ion-hide-md-down" *ngIf="quiz.showMarkColumn" role="columnheader">
<strong>{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz.sumGradesFormatted }}</strong>
</ion-col>
<ion-col class="ion-text-center" *ngIf="quiz.showGradeColumn" role="columnheader">
<strong>{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz.gradeFormatted }}</strong>
</ion-col>
</ion-row>
</ion-label>
</ion-item>
<div role="rowgroup">
<!-- List of attempts. -->
<ion-item button [detail]="true" *ngFor="let attempt of attempts" class="ion-text-wrap"
[ngClass]='{"addon-mod_quiz-highlighted": attempt.highlightGrade}' [attr.aria-label]="'core.seemoredetail' | translate"
(click)="viewAttempt(attempt.id)">
<ion-accordion-group>
@for (attempt of attempts; track attempt.id) {
<ion-accordion [value]="attempt.id" toggleIconSlot="start">
<ion-item slot="header" class="ion-text-wrap addon-mod_quiz-attempt-title" lines="none">
<ion-label>
<ion-row class="ion-align-items-center" role="row">
<ion-col class="ion-text-center" *ngIf="quiz.showAttemptColumn && attempt.preview" role="cell">
{{ 'addon.mod_quiz.preview' | translate }}
</ion-col>
<ion-col class="ion-text-center" *ngIf="quiz.showAttemptColumn && !attempt.preview" role="cell">
{{ attempt.attempt }}
</ion-col>
<ion-col size="7" role="cell">
<p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
</ion-col>
<ion-col class="ion-text-center ion-hide-md-down" *ngIf="quiz.showMarkColumn" role="cell">
<p>{{ attempt.readableMark }}</p>
</ion-col>
<ion-col class="ion-text-center" *ngIf="quiz.showGradeColumn" role="cell">
<p>{{ attempt.readableGrade }}</p>
</ion-col>
</ion-row>
<h3>{{ 'addon.mod_quiz.attempt' | translate:{ $a: attempt.attempt } }}</h3>
</ion-label>
<div slot="end" class="addon-mod_quiz-attempt-title-info">
<addon-mod-quiz-attempt-state [state]="attempt.state" [finishedOffline]="attempt.finishedOffline" />
@if (attempt.finished && quiz.showAttemptsGrades) {
@if (attempt.rescaledGrade !== undefined && attempt.rescaledGrade >= 0) {
<p>
{{ 'core.grades.gradelong' | translate: { $a: {
grade: attempt.formattedGrade,
max: quiz.gradeFormatted,
} } }}
</p>
} @else {
<p>{{ 'addon.mod_quiz.notyetgraded' | translate }}</p>
}
}
</div>
</ion-item>
</div>
</ion-card-content>
<div class="addon-mod_quiz-attempt-details" slot="content">
<addon-mod-quiz-attempt-info [quiz]="quiz" [attempt]="attempt" [additionalData]="attempt.additionalData" />
@if (attempt.canReview) {
<hr>
<ion-button class="ion-text-wrap ion-margin" expand="block" (click)="reviewAttempt(attempt.id)" fill="outline">
<ion-icon name="fas-magnifying-glass" slot="start" aria-hidden="true" />
{{ 'addon.mod_quiz.review' | translate }}
</ion-button>
} @else if (attempt.completed) {
<hr>
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-noreview">
<ion-label>
<p>
<ion-icon name="fas-circle-info" color="info" slot="start" aria-hidden="true" />
{{ 'addon.mod_quiz.noreviewattempt' | translate }}
<!-- TODO: Check if we can calculate the time when the attempt can be reviewed. -->
</p>
</ion-label>
</ion-item>
} @else if (attempt.finishedOffline) {
<hr>
<ion-item class="ion-text-wrap addon-mod_quiz-attempt-finishedoffline">
<ion-label>
<p>
<ion-icon name="fas-clock" slot="start" aria-hidden="true" />
{{ 'addon.mod_quiz.finishedofflinenotice' | translate }}
</p>
</ion-label>
</ion-item>
}
</div>
</ion-accordion>
}
</ion-accordion-group>
</ion-card>
<!-- Result info. -->
<ion-card *ngIf="quiz && showResults &&
(gradeResult || gradeOverridden || gradebookFeedback || (quiz.showFeedbackColumn && overallFeedback))">
(gradeResult || gradeOverridden || gradebookFeedback || (quiz.showFeedback && overallFeedback))">
<ion-list>
<ion-item class="ion-text-wrap" *ngIf="gradeResult">
<ion-label>{{ gradeResult }}</ion-label>
@ -119,7 +129,7 @@
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz.showFeedbackColumn && overallFeedback">
<ion-item class="ion-text-wrap" *ngIf="quiz.showFeedback && overallFeedback">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.overallfeedback' | translate }}</p>
<p>

View File

@ -1,33 +1,43 @@
@use "theme/globals" as *;
:host {
.addon-mod_quiz-table {
ion-card-content {
padding-left: 0;
padding-right: 0;
}
.addon-mod_quiz-attempt-title-info {
text-align: end;
padding-top: 8px;
padding-bottom: 8px;
.item:nth-child(even) {
--background: var(--light);
}
.addon-mod_quiz-highlighted,
.item.addon-mod_quiz-highlighted,
.addon-mod_quiz-highlighted p,
.item.addon-mod_quiz-highlighted p {
--background: var(--primary-tint);
color: var(--primary-shade);
p {
margin: 0px;
margin-top: 4px;
}
}
}
:host-context(html.dark) {
.addon-mod_quiz-table {
.addon-mod_quiz-highlighted,
.item.addon-mod_quiz-highlighted,
.addon-mod_quiz-highlighted p,
.item.addon-mod_quiz-highlighted p {
--background: var(--primary-shade);
color: var(--primary-tint);
.accordion-expanded .addon-mod_quiz-attempt-title-info,
.accordion-expanding .addon-mod_quiz-attempt-title-info {
visibility: hidden;
}
hr {
background-color: var(--stroke);
height: 1px;
margin: 0px 16px;
}
ion-accordion:nth-child(odd) {
background-color: var(--core-table-odd-cell-background);
::ng-deep ion-item {
--background: var(--core-table-odd-cell-background);
}
}
ion-accordion:nth-child(even) {
background-color: var(--core-table-even-cell-background);
::ng-deep ion-item {
--background: var(--core-table-even-cell-background);
}
}
}

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { DownloadStatus } from '@/core/constants';
import { safeNumber, SafeNumber } from '@/core/utils/types';
import { isSafeNumber, safeNumber, SafeNumber } from '@/core/utils/types';
import { Component, OnDestroy, OnInit, Optional } from '@angular/core';
import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component';
@ -36,6 +36,7 @@ import {
AddonModQuizGetAttemptAccessInformationWSResponse,
AddonModQuizGetQuizAccessInformationWSResponse,
AddonModQuizGetUserBestGradeWSResponse,
AddonModQuizWSAdditionalData,
} from '../../services/quiz';
import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper';
import {
@ -44,7 +45,7 @@ import {
AddonModQuizSyncProvider,
AddonModQuizSyncResult,
} from '../../services/quiz-sync';
import { ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT, ADDON_MOD_QUIZ_COMPONENT, AddonModQuizGradeMethods } from '../../constants';
import { ADDON_MOD_QUIZ_ATTEMPT_FINISHED_EVENT, ADDON_MOD_QUIZ_COMPONENT, AddonModQuizAttemptStates } from '../../constants';
import { QuestionDisplayOptionsMarks } from '@features/question/constants';
/**
@ -78,13 +79,12 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
showStatusSpinner = true; // Whether to show a spinner due to quiz status.
gradeMethodReadable?: string; // Grade method in a readable format.
showReviewColumn = false; // Whether to show the review column.
attempts: AddonModQuizAttempt[] = []; // List of attempts the user has made.
attempts: QuizAttempt[] = []; // List of attempts the user has made.
bestGrade?: AddonModQuizGetUserBestGradeWSResponse; // Best grade data.
protected fetchContentDefaultError = 'addon.mod_quiz.errorgetquiz'; // Default error to show when loading contents.
protected syncEventName = AddonModQuizSyncProvider.AUTO_SYNCED;
// protected quizData: any; // Quiz instance. This variable will store the quiz instance until it's ready to be shown
protected autoReview?: AddonModQuizAttemptFinishedData; // Data to auto-review an attempt after finishing.
protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access info.
protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Last attempt access info.
@ -263,7 +263,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
// Check if user can create/continue attempts.
if (this.attempts.length) {
const last = this.attempts[this.attempts.length - 1];
const last = this.attempts[0];
this.moreAttempts = !AddonModQuiz.isAttemptCompleted(last.state) || !this.attemptAccessInfo.isfinished;
} else {
this.moreAttempts = !this.attemptAccessInfo.isfinished;
@ -283,7 +283,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
this.buttonText = '';
if (quiz.hasquestions !== 0) {
if (this.attempts.length && !AddonModQuiz.isAttemptCompleted(this.attempts[this.attempts.length - 1].state)) {
if (this.attempts.length && !AddonModQuiz.isAttemptCompleted(this.attempts[0].state)) {
// Last attempt is unfinished.
if (this.quizAccessInfo?.canattempt) {
this.buttonText = 'addon.mod_quiz.continueattemptquiz';
@ -331,7 +331,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
* @returns Promise resolved when done.
*/
protected async getResultInfo(quiz: AddonModQuizQuizData): Promise<void> {
if (!this.attempts.length || !quiz.showGradeColumn || !this.bestGrade?.hasgrade ||
if (!this.attempts.length || !quiz.showAttemptsGrades || !this.bestGrade?.hasgrade ||
this.gradebookData?.grade === undefined) {
this.showResults = false;
@ -372,7 +372,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
}
}
if (quiz.showFeedbackColumn) {
if (quiz.showFeedback) {
// Get the quiz overall feedback.
const response = await AddonModQuiz.getFeedbackForGrade(quiz.id, this.gradebookData.grade, {
cmId: this.module.id,
@ -583,7 +583,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
quiz: AddonModQuizQuizData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempts: AddonModQuizAttemptWSData[],
): Promise<AddonModQuizAttempt[]> {
): Promise<QuizAttempt[]> {
if (!attempts || !attempts.length) {
// There are no attempts to treat.
quiz.gradeFormatted = AddonModQuiz.formatGrade(quiz.grade, quiz.decimalpoints);
@ -609,25 +609,43 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
]);
this.options = options;
const grade = this.gradebookData?.grade !== undefined ? this.gradebookData.grade : this.bestGrade?.grade;
const quizGrade = AddonModQuiz.formatGrade(grade, quiz.decimalpoints);
// Calculate data to construct the header of the attempts table.
AddonModQuizHelper.setQuizCalculatedData(quiz, this.options);
this.overallStats = !!lastCompleted && this.options.alloptions.marks >= QuestionDisplayOptionsMarks.MARK_AND_MAX;
// Calculate data to show for each attempt.
const formattedAttempts = await Promise.all(attempts.map((attempt, index) => {
// Highlight the highest grade if appropriate.
const shouldHighlight = this.overallStats && quiz.grademethod === AddonModQuizGradeMethods.HIGHEST_GRADE &&
attempts.length > 1;
const isLast = index == attempts.length - 1;
const formattedAttempts = await Promise.all(attempts.map(async (attempt) => {
const [formattedAttempt, canReview] = await Promise.all([
AddonModQuizHelper.setAttemptCalculatedData(quiz, attempt) as Promise<QuizAttempt>,
AddonModQuizHelper.canReviewAttempt(quiz, accessInfo, attempt),
]);
return AddonModQuizHelper.setAttemptCalculatedData(quiz, accessInfo, attempt, shouldHighlight, quizGrade, isLast);
formattedAttempt.canReview = canReview;
if (quiz.showFeedback && attempt.state === AddonModQuizAttemptStates.FINISHED &&
options.someoptions.overallfeedback && isSafeNumber(formattedAttempt.rescaledGrade)) {
// Feedback should be displayed, get the feedback for the grade.
const response = await AddonModQuiz.getFeedbackForGrade(quiz.id, formattedAttempt.rescaledGrade, {
cmId: quiz.coursemodule,
});
if (response.feedbacktext) {
formattedAttempt.additionalData = [
{
id: 'feedback',
title: Translate.instant('addon.mod_quiz.feedback'),
content: response.feedbacktext,
},
];
}
}
return formattedAttempt;
}));
return formattedAttempts;
return formattedAttempts.reverse();
}
/**
@ -657,13 +675,13 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
}
/**
* Go to page to view the attempt details.
* Go to page to review the attempt.
*
* @returns Promise resolved when done.
*/
async viewAttempt(attemptId: number): Promise<void> {
async reviewAttempt(attemptId: number): Promise<void> {
await CoreNavigator.navigateToSitePath(
`${AddonModQuizModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/attempt/${attemptId}`,
`${AddonModQuizModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/review/${attemptId}`,
);
}
@ -677,3 +695,8 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp
}
}
type QuizAttempt = AddonModQuizAttempt & {
canReview?: boolean;
additionalData?: AddonModQuizWSAdditionalData[]; // Additional data to display for the attempt.
};

View File

@ -1,5 +1,6 @@
{
"answercolon": "Answer:",
"attempt": "Attempt {{$a}}",
"attemptduration": "Duration",
"attemptfirst": "First attempt",
"attemptlast": "Last attempt",
@ -30,7 +31,7 @@
"errorsaveattempt": "An error occurred while saving the attempt data.",
"feedback": "Feedback",
"finishattemptdots": "Finish attempt...",
"finishnotsynced": "Finished but not synchronised",
"finishedofflinenotice": "Your attempt has been submitted and saved. It will be sent to the site when you're online again.",
"grade": "Grade",
"gradeaverage": "Average grade",
"gradehighest": "Highest grade",

View File

@ -1,79 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>
<core-format-text *ngIf="quiz" [text]="quiz.name" contextLevel="module" [contextInstanceId]="quiz.coursemodule"
[courseId]="courseId" />
</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="loaded">
<ion-list *ngIf="attempt">
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptnumber' | translate }}</p>
<p *ngIf="attempt.preview">{{ 'addon.mod_quiz.preview' | translate }}</p>
<p *ngIf="!attempt.preview">{{ attempt.attempt }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
</ion-label>
</ion-item>
<ng-container *ngIf="attempt.completed && attempt.sumgrades !== null">
<ion-item *ngFor="let gradeItemMark of attempt.gradeitemmarks ?? []" class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ gradeItemMark.name }} / {{ gradeItemMark.maxgrade }}</p>
<p>{{ gradeItemMark.grade }}</p>
</ion-label>
</ion-item>
</ng-container>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showMarkColumn && attempt.readableMark !== '' && attempt.sumgrades !== null">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}</p>
<p>{{ attempt.readableMark }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showGradeColumn && attempt.readableGrade !== ''">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}</p>
<p>{{ attempt.readableGrade }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showFeedbackColumn && feedback">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.feedback' | translate }}</p>
<p>
<core-format-text [component]="component" [componentId]="componentId" [text]="feedback" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId" />
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap core-danger-item" *ngIf="!showReviewColumn">
<ion-label>
<p>{{ 'addon.mod_quiz.noreviewattempt' | translate }}</p>
</ion-label>
</ion-item>
</ion-list>
<div collapsible-footer appearOnBottom *ngIf="loaded && attempt && showReviewColumn && attempt.completed" slot="fixed">
<div class="list-item-limited-width">
<ion-button class="ion-margin ion-text-wrap" expand="block" (click)="reviewAttempt()">
<ion-icon name="fas-magnifying-glass" slot="start" aria-hidden="true" />
{{ 'addon.mod_quiz.review' | translate }}
</ion-button>
</div>
</div>
</core-loading>
</ion-content>

View File

@ -1,221 +0,0 @@
// (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 { isSafeNumber } from '@/core/utils/types';
import { Component, OnInit } from '@angular/core';
import { CoreError } from '@classes/errors/error';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import {
AddonModQuiz,
AddonModQuizAttemptWSData,
AddonModQuizGetQuizAccessInformationWSResponse,
} from '../../services/quiz';
import { AddonModQuizAttempt, AddonModQuizHelper, AddonModQuizQuizData } from '../../services/quiz-helper';
import { ADDON_MOD_QUIZ_COMPONENT } from '../../constants';
/**
* Page that displays some summary data about an attempt.
*/
@Component({
selector: 'page-addon-mod-quiz-attempt',
templateUrl: 'attempt.html',
})
export class AddonModQuizAttemptPage implements OnInit {
courseId!: number; // The course ID the quiz belongs to.
quiz?: AddonModQuizQuizData; // The quiz the attempt belongs to.
attempt?: AddonModQuizAttempt; // The attempt to view.
component = ADDON_MOD_QUIZ_COMPONENT; // Component to link the files to.
componentId?: number; // Component ID to use in conjunction with the component.
loaded = false; // Whether data has been loaded.
feedback?: string; // Attempt feedback.
showReviewColumn = false;
cmId!: number; // Course module id the attempt belongs to.
protected attemptId!: number; // Attempt to view.
/**
* @inheritdoc
*/
ngOnInit(): void {
try {
this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId');
this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId');
this.attemptId = CoreNavigator.getRequiredRouteNumberParam('attemptId');
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
return;
}
this.fetchQuizData().finally(() => {
this.loaded = true;
});
}
/**
* Refresh the data.
*
* @param refresher Refresher.
*/
doRefresh(refresher: HTMLIonRefresherElement): void {
this.refreshData().finally(() => {
refresher.complete();
});
}
/**
* Get quiz data and attempt data.
*
* @returns Promise resolved when done.
*/
protected async fetchQuizData(): Promise<void> {
try {
this.quiz = await AddonModQuiz.getQuiz(this.courseId, this.cmId);
this.componentId = this.quiz.coursemodule;
// Load attempt data.
const [options, accessInfo, attempt] = await Promise.all([
AddonModQuiz.getCombinedReviewOptions(this.quiz.id, { cmId: this.quiz.coursemodule }),
this.fetchAccessInfo(this.quiz),
this.fetchAttempt(this.quiz.id),
]);
// Set calculated data.
this.showReviewColumn = accessInfo.canreviewmyattempts;
AddonModQuizHelper.setQuizCalculatedData(this.quiz, options);
this.attempt = await AddonModQuizHelper.setAttemptCalculatedData(
this.quiz,
accessInfo,
attempt,
false,
undefined,
true,
);
// Check if the feedback should be displayed.
const grade = Number(this.attempt.rescaledGrade);
if (this.quiz.showFeedbackColumn && AddonModQuiz.isAttemptCompleted(this.attempt.state) &&
options.someoptions.overallfeedback && isSafeNumber(grade)) {
// Feedback should be displayed, get the feedback for the grade.
const response = await AddonModQuiz.getFeedbackForGrade(this.quiz.id, grade, {
cmId: this.quiz.coursemodule,
});
this.feedback = response.feedbacktext;
} else {
delete this.feedback;
}
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'addon.mod_quiz.errorgetattempt', true);
}
}
/**
* Get the attempt.
*
* @param quizId Quiz ID.
* @returns Promise resolved when done.
*/
protected async fetchAttempt(quizId: number): Promise<AddonModQuizAttemptWSData> {
// Get all the attempts and search the one we want.
const attempts = await AddonModQuiz.getUserAttempts(quizId, { cmId: this.cmId });
const attempt = attempts.find(attempt => attempt.id == this.attemptId);
if (!attempt) {
// Attempt not found, error.
this.attempt = undefined;
throw new CoreError(Translate.instant('addon.mod_quiz.errorgetattempt'));
}
return attempt;
}
/**
* Get the access info.
*
* @param quiz Quiz instance.
* @returns Promise resolved when done.
*/
protected async fetchAccessInfo(quiz: AddonModQuizQuizData): Promise<AddonModQuizGetQuizAccessInformationWSResponse> {
const accessInfo = await AddonModQuiz.getQuizAccessInformation(quiz.id, { cmId: this.cmId });
if (!accessInfo.canreviewmyattempts) {
return accessInfo;
}
// Check if the user can review the attempt.
await CoreUtils.ignoreErrors(AddonModQuiz.invalidateAttemptReviewForPage(this.attemptId, -1));
try {
await AddonModQuiz.getAttemptReview(this.attemptId, { page: -1, cmId: quiz.coursemodule });
} catch {
// Error getting the review, assume the user cannot review the attempt.
accessInfo.canreviewmyattempts = false;
}
return accessInfo;
}
/**
* Refresh the data.
*
* @returns Promise resolved when done.
*/
protected async refreshData(): Promise<void> {
const promises: Promise<void>[] = [];
promises.push(AddonModQuiz.invalidateQuizData(this.courseId));
promises.push(AddonModQuiz.invalidateAttemptReview(this.attemptId));
if (this.quiz) {
promises.push(AddonModQuiz.invalidateUserAttemptsForUser(this.quiz.id));
promises.push(AddonModQuiz.invalidateQuizAccessInformation(this.quiz.id));
promises.push(AddonModQuiz.invalidateCombinedReviewOptionsForUser(this.quiz.id));
if (this.attempt && this.feedback !== undefined) {
promises.push(AddonModQuiz.invalidateFeedback(this.quiz.id));
}
}
await CoreUtils.ignoreErrors(Promise.all(promises));
await this.fetchQuizData();
}
/**
* Go to the page to review the attempt.
*
* @returns Promise resolved when done.
*/
async reviewAttempt(): Promise<void> {
if (!this.attempt) {
return;
}
CoreNavigator.navigate(`../../review/${this.attempt.id}`);
}
}

View File

@ -10,6 +10,7 @@ $quiz-timer-iterations: 15 !default;
font-size: var(--mdl-typography-fontSize-md);
margin-top: 2px;
margin-bottom: 2px;
text-align: end;
}
core-timer {

View File

@ -40,7 +40,7 @@ import {
AddonModQuizGetQuizAccessInformationWSResponse,
AddonModQuizQuizWSData,
} from '../../services/quiz';
import { AddonModQuizAttempt, AddonModQuizHelper } from '../../services/quiz-helper';
import { AddonModQuizHelper } from '../../services/quiz-helper';
import { AddonModQuizSync } from '../../services/quiz-sync';
import { CanLeave } from '@guards/can-leave';
import { CoreForms } from '@singletons/form';
@ -66,7 +66,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
@ViewChild('quizForm') formElement?: ElementRef;
quiz?: AddonModQuizQuizWSData; // The quiz the attempt belongs to.
attempt?: AddonModQuizAttempt; // The attempt being attempted.
attempt?: QuizAttempt; // The attempt being attempted.
moduleUrl?: string; // URL to the module in the site.
component = ADDON_MOD_QUIZ_COMPONENT; // Component to link the files to.
loaded = false; // Whether data has been loaded.
@ -91,7 +91,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
protected preflightData: Record<string, string> = {}; // Preflight data to attempt the quiz.
protected quizAccessInfo?: AddonModQuizGetQuizAccessInformationWSResponse; // Quiz access information.
protected attemptAccessInfo?: AddonModQuizGetAttemptAccessInformationWSResponse; // Attempt access info.
protected lastAttempt?: AddonModQuizAttemptWSData; // Last user attempt before a new one is created (if needed).
protected lastAttempt?: QuizAttempt; // Last user attempt before a new one is created (if needed).
protected newAttempt = false; // Whether the user is starting a new attempt.
protected quizDataLoaded = false; // Whether the quiz data has been loaded.
protected timeUpCalled = false; // Whether the time up function has been called.
@ -381,14 +381,9 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
}
// Get the last attempt. If it's finished, start a new one.
this.lastAttempt = await AddonModQuizHelper.setAttemptCalculatedData(
this.quiz,
this.quizAccessInfo,
attempts[attempts.length - 1],
false,
undefined,
true,
);
this.lastAttempt = attempts[attempts.length - 1];
this.lastAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(this.lastAttempt.id);
this.newAttempt = AddonModQuiz.isAttemptCompleted(this.lastAttempt.state);
}
@ -945,3 +940,10 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
type QuizQuestion = CoreQuestionQuestionParsed & {
readableMark?: string;
};
/**
* Attempt with some calculated data for the view.
*/
type QuizAttempt = AddonModQuizAttemptWSData & {
finishedOffline?: boolean;
};

View File

@ -24,61 +24,7 @@
<!-- Review summary -->
<ion-card *ngIf="attempt">
<ion-list>
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.startedon' | translate }}</p>
<p>{{ attempt.timestart! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p>{{ readableState }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="showCompleted">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.completedon' | translate }}</p>
<p>{{ attempt.timefinish! * 1000 | coreFormatDate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="timeTaken">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.attemptduration' | translate }}</p>
<p>{{ timeTaken }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="overTime">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.overdue' | translate }}</p>
<p>{{ overTime }}</p>
</ion-label>
</ion-item>
<ion-item *ngFor="let gradeItemMark of gradeItemMarks" class="ion-text-wrap">
<ion-label>
<p class="item-heading">{{ gradeItemMark.name }}</p>
<p>{{ gradeItemMark.grade }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="readableMark && attempt?.sumgrades !== null">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }}</p>
<p>{{ readableMark }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="readableGrade">
<ion-label>
<p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }}</p>
<p>{{ readableGrade }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngFor="let data of additionalData">
<ion-label>
<p class="item-heading">{{ data.title }}</p>
<core-format-text [component]="component" [componentId]="cmId" [text]="data.content" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId" />
</ion-label>
</ion-item>
<addon-mod-quiz-attempt-info [quiz]="quiz" [attempt]="attempt" [additionalData]="additionalData" />
</ion-list>
</ion-card>

View File

@ -19,7 +19,6 @@ import { IonContent } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { Translate } from '@singletons';
import { CoreDom } from '@singletons/dom';
import { CoreTime } from '@singletons/time';
import {
@ -31,14 +30,12 @@ import {
AddonModQuiz,
AddonModQuizAttemptWSData,
AddonModQuizCombinedReviewOptions,
AddonModQuizGetAttemptReviewResponse,
AddonModQuizQuizWSData,
AddonModQuizWSAdditionalData,
} from '../../services/quiz';
import { AddonModQuizHelper } from '../../services/quiz-helper';
import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics';
import { AddonModQuizAttemptStates, ADDON_MOD_QUIZ_COMPONENT } from '../../constants';
import { QuestionDisplayOptionsMarks } from '@features/question/constants';
import { ADDON_MOD_QUIZ_COMPONENT } from '../../constants';
/**
* Page that allows reviewing a quiz attempt.
@ -63,7 +60,6 @@ export class AddonModQuizReviewPage implements OnInit {
questions: QuizQuestion[] = []; // Questions of the current page.
nextPage = -2; // Next page.
previousPage = -2; // Previous page.
readableState?: string;
readableGrade?: string;
readableMark?: string;
timeTaken?: string;
@ -159,6 +155,8 @@ export class AddonModQuizReviewPage implements OnInit {
this.options = await AddonModQuiz.getCombinedReviewOptions(this.quiz.id, { cmId: this.cmId });
AddonModQuizHelper.setQuizCalculatedData(this.quiz, this.options);
// Load the navigation data.
await this.loadNavigation();
@ -178,15 +176,17 @@ export class AddonModQuizReviewPage implements OnInit {
* @returns Promise resolved when done.
*/
protected async loadPage(page: number): Promise<void> {
const data = await AddonModQuiz.getAttemptReview(this.attemptId, { page, cmId: this.quiz?.coursemodule });
if (!this.quiz) {
return;
}
this.attempt = data.attempt;
const data = await AddonModQuiz.getAttemptReview(this.attemptId, { page, cmId: this.quiz.coursemodule });
this.attempt = await AddonModQuizHelper.setAttemptCalculatedData(this.quiz, data.attempt);
this.attempt.currentpage = page;
this.additionalData = data.additionaldata;
this.currentPage = page;
// Set the summary data.
this.setSummaryCalculatedData(data);
this.questions = data.questions;
this.nextPage = page + 1;
this.previousPage = page - 1;
@ -254,91 +254,6 @@ export class AddonModQuizReviewPage implements OnInit {
);
}
/**
* Calculate review summary data.
*
* @param data Result of getAttemptReview.
*/
protected setSummaryCalculatedData(data: AddonModQuizGetAttemptReviewResponse): void {
if (!this.attempt || !this.quiz) {
return;
}
this.readableState = AddonModQuiz.getAttemptReadableStateName(this.attempt.state ?? '');
if (this.attempt.state !== AddonModQuizAttemptStates.FINISHED) {
return;
}
this.showCompleted = true;
this.additionalData = data.additionaldata;
const timeTaken = (this.attempt.timefinish || 0) - (this.attempt.timestart || 0);
if (timeTaken > 0) {
// Format time taken.
this.timeTaken = CoreTime.formatTime(timeTaken);
// Calculate overdue time.
if (this.quiz.timelimit && timeTaken > this.quiz.timelimit + 60) {
this.overTime = CoreTime.formatTime(timeTaken - this.quiz.timelimit);
}
} else {
this.timeTaken = undefined;
}
// Treat grade item marks.
if (this.attempt.sumgrades === null || !this.attempt.gradeitemmarks) {
this.gradeItemMarks = [];
} else {
this.gradeItemMarks = this.attempt.gradeitemmarks.map((gradeItemMark) => ({
name: gradeItemMark.name,
grade: Translate.instant('addon.mod_quiz.outof', { $a: {
grade: AddonModQuiz.formatGrade(gradeItemMark.grade, this.quiz?.decimalpoints),
maxgrade: AddonModQuiz.formatGrade(gradeItemMark.maxgrade, this.quiz?.decimalpoints),
} }),
}));
}
// Treat grade.
if (this.options && this.options.someoptions.marks >= QuestionDisplayOptionsMarks.MARK_AND_MAX &&
AddonModQuiz.quizHasGrades(this.quiz)) {
if (data.grade === null || data.grade === undefined) {
this.readableGrade = AddonModQuiz.formatGrade(data.grade, this.quiz.decimalpoints);
} else {
// Show raw marks only if they are different from the grade (like on the entry page).
if (this.quiz.grade != this.quiz.sumgrades) {
this.readableMark = Translate.instant('addon.mod_quiz.outofshort', { $a: {
grade: AddonModQuiz.formatGrade(this.attempt.sumgrades, this.quiz.decimalpoints),
maxgrade: AddonModQuiz.formatGrade(this.quiz.sumgrades, this.quiz.decimalpoints),
} });
}
// Now the scaled grade.
const gradeObject: Record<string, unknown> = {
grade: AddonModQuiz.formatGrade(Number(data.grade), this.quiz.decimalpoints),
maxgrade: AddonModQuiz.formatGrade(this.quiz.grade, this.quiz.decimalpoints),
};
if (this.quiz.grade != 100) {
gradeObject.percent = AddonModQuiz.formatGrade(
(this.attempt.sumgrades ?? 0) * 100 / (this.quiz.sumgrades ?? 1),
this.quiz.decimalpoints,
);
this.readableGrade = Translate.instant('addon.mod_quiz.outofpercent', { $a: gradeObject });
} else {
this.readableGrade = Translate.instant('addon.mod_quiz.outof', { $a: gradeObject });
}
}
}
// Treat additional data.
this.additionalData.forEach((data) => {
// Remove help links from additional data.
data.content = CoreDomUtils.removeElementFromHtml(data.content, '.helptooltip');
});
}
/**
* Switch mode: all questions in same page OR one page at a time.
*/

View File

@ -19,7 +19,6 @@ import { CoreSharedModule } from '@/core/shared.module';
import { AddonModQuizComponentsModule } from './components/components.module';
import { AddonModQuizIndexPage } from './pages/index';
import { AddonModQuizAttemptPage } from '@addons/mod/quiz/pages/attempt/attempt';
import { CoreQuestionComponentsModule } from '@features/question/components/components.module';
import { AddonModQuizPlayerPage } from '@addons/mod/quiz/pages/player/player';
import { canLeaveGuard } from '@guards/can-leave';
@ -35,10 +34,6 @@ const routes: Routes = [
component: AddonModQuizPlayerPage,
canDeactivate: [canLeaveGuard],
},
{
path: ':courseId/:cmId/attempt/:attemptId',
component: AddonModQuizAttemptPage,
},
{
path: ':courseId/:cmId/review/:attemptId',
component: AddonModQuizReviewPage,
@ -54,7 +49,6 @@ const routes: Routes = [
],
declarations: [
AddonModQuizIndexPage,
AddonModQuizAttemptPage,
AddonModQuizPlayerPage,
AddonModQuizReviewPage,
],

View File

@ -359,53 +359,28 @@ export class AddonModQuizHelperProvider {
* Add some calculated data to the attempt.
*
* @param quiz Quiz.
* @param accessInfo Quiz access info.
* @param attempt Attempt.
* @param highlight Whether we should check if attempt should be highlighted.
* @param bestGrade Quiz's best grade (formatted). Required if highlight=true.
* @param isLastAttempt Whether the attempt is the last one.
* @param siteId Site ID.
* @returns Quiz attemptw with calculated data.
* @returns Quiz attempt with calculated data.
*/
async setAttemptCalculatedData(
quiz: AddonModQuizQuizData,
accessInfo: AddonModQuizGetQuizAccessInformationWSResponse,
attempt: AddonModQuizAttemptWSData,
highlight?: boolean,
bestGrade?: string,
isLastAttempt?: boolean,
siteId?: string,
): Promise<AddonModQuizAttempt> {
const formattedAttempt = <AddonModQuizAttempt> attempt;
formattedAttempt.rescaledGrade = AddonModQuiz.rescaleGrade(attempt.sumgrades, quiz, false);
formattedAttempt.finished = attempt.state === AddonModQuizAttemptStates.FINISHED;
formattedAttempt.completed = AddonModQuiz.isAttemptCompleted(attempt.state);
formattedAttempt.readableState = AddonModQuiz.getAttemptReadableState(quiz, attempt);
formattedAttempt.rescaledGrade = Number(AddonModQuiz.rescaleGrade(attempt.sumgrades, quiz, false));
if (quiz.showMarkColumn && formattedAttempt.completed) {
formattedAttempt.readableMark = AddonModQuiz.formatGrade(attempt.sumgrades, quiz.decimalpoints);
if (quiz.showAttemptsGrades && formattedAttempt.finished) {
formattedAttempt.formattedGrade = AddonModQuiz.formatGrade(formattedAttempt.rescaledGrade, quiz.decimalpoints);
} else {
formattedAttempt.readableMark = '';
formattedAttempt.formattedGrade = '';
}
if (quiz.showGradeColumn && formattedAttempt.completed) {
formattedAttempt.readableGrade = AddonModQuiz.formatGrade(
Number(formattedAttempt.rescaledGrade),
quiz.decimalpoints,
);
// Highlight the highest grade if appropriate.
formattedAttempt.highlightGrade = !!(highlight && !attempt.preview &&
attempt.state === AddonModQuizAttemptStates.FINISHED && formattedAttempt.readableGrade == bestGrade);
} else {
formattedAttempt.readableGrade = '';
}
if (isLastAttempt || isLastAttempt === undefined) {
formattedAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(attempt.id, siteId);
}
formattedAttempt.canReview = await this.canReviewAttempt(quiz, accessInfo, attempt);
formattedAttempt.finishedOffline = await AddonModQuiz.isAttemptFinishedOffline(attempt.id, siteId);
return formattedAttempt;
}
@ -423,11 +398,10 @@ export class AddonModQuizHelperProvider {
formattedQuiz.sumGradesFormatted = AddonModQuiz.formatGrade(quiz.sumgrades, quiz.decimalpoints);
formattedQuiz.gradeFormatted = AddonModQuiz.formatGrade(quiz.grade, quiz.decimalpoints);
formattedQuiz.showAttemptColumn = quiz.attempts != 1;
formattedQuiz.showGradeColumn = options.someoptions.marks >= QuestionDisplayOptionsMarks.MARK_AND_MAX &&
formattedQuiz.showAttemptsGrades = options.someoptions.marks >= QuestionDisplayOptionsMarks.MARK_AND_MAX &&
AddonModQuiz.quizHasGrades(quiz);
formattedQuiz.showMarkColumn = formattedQuiz.showGradeColumn && quiz.grade != quiz.sumgrades;
formattedQuiz.showFeedbackColumn = !!quiz.hasfeedback && !!options.alloptions.overallfeedback;
formattedQuiz.showAttemptsMarks = formattedQuiz.showAttemptsGrades && quiz.grade !== quiz.sumgrades;
formattedQuiz.showFeedback = !!quiz.hasfeedback && !!options.alloptions.overallfeedback;
return formattedQuiz;
}
@ -523,10 +497,9 @@ export const AddonModQuizHelper = makeSingleton(AddonModQuizHelperProvider);
export type AddonModQuizQuizData = AddonModQuizQuizWSData & {
sumGradesFormatted?: string;
gradeFormatted?: string;
showAttemptColumn?: boolean;
showGradeColumn?: boolean;
showMarkColumn?: boolean;
showFeedbackColumn?: boolean;
showAttemptsGrades?: boolean;
showAttemptsMarks?: boolean;
showFeedback?: boolean;
};
/**
@ -534,11 +507,8 @@ export type AddonModQuizQuizData = AddonModQuizQuizWSData & {
*/
export type AddonModQuizAttempt = AddonModQuizAttemptWSData & {
finishedOffline?: boolean;
rescaledGrade?: string;
rescaledGrade?: number;
finished?: boolean;
completed?: boolean;
readableState?: string[];
readableMark?: string;
readableGrade?: string;
highlightGrade?: boolean;
canReview?: boolean;
formattedGrade?: string;
};

View File

@ -37,7 +37,6 @@ import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWar
import { makeSingleton, Translate } from '@singletons';
import { CoreLogger } from '@singletons/logger';
import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate';
import { AddonModQuizAttempt } from './quiz-helper';
import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline';
import { AddonModQuizAutoSyncData, AddonModQuizSyncProvider } from './quiz-sync';
import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site';
@ -55,6 +54,7 @@ import {
AddonModQuizDisplayOptionsAttemptStates,
ADDON_MOD_QUIZ_IMMEDIATELY_AFTER_PERIOD,
} from '../constants';
import { CoreIonicColorNames } from '@singletons/colors';
declare module '@singletons/events' {
@ -412,61 +412,17 @@ export class AddonModQuizProvider {
}
/**
* Turn attempt's state into a readable state, including some extra data depending on the state.
*
* @param quiz Quiz.
* @param attempt Attempt.
* @returns List of state sentences.
*/
getAttemptReadableState(quiz: AddonModQuizQuizWSData, attempt: AddonModQuizAttempt): string[] {
if (attempt.finishedOffline) {
return [Translate.instant('addon.mod_quiz.finishnotsynced')];
}
switch (attempt.state) {
case AddonModQuizAttemptStates.IN_PROGRESS:
return [Translate.instant('addon.mod_quiz.stateinprogress')];
case AddonModQuizAttemptStates.OVERDUE: {
const sentences: string[] = [];
const dueDate = this.getAttemptDueDate(quiz, attempt);
sentences.push(Translate.instant('addon.mod_quiz.stateoverdue'));
if (dueDate) {
sentences.push(Translate.instant(
'addon.mod_quiz.stateoverduedetails',
{ $a: CoreTimeUtils.userDate(dueDate) },
));
}
return sentences;
}
case AddonModQuizAttemptStates.FINISHED:
return [
Translate.instant('addon.mod_quiz.statefinished'),
Translate.instant(
'addon.mod_quiz.statefinisheddetails',
{ $a: CoreTimeUtils.userDate((attempt.timefinish ?? 0) * 1000) },
),
];
case AddonModQuizAttemptStates.ABANDONED:
return [Translate.instant('addon.mod_quiz.stateabandoned')];
default:
return [];
}
}
/**
* Turn attempt's state into a readable state name, without any more data.
* Turn attempt's state into a readable state name.
*
* @param state State.
* @param finishedOffline Whether the attempt was finished offline.
* @returns Readable state name.
*/
getAttemptReadableStateName(state: string): string {
getAttemptReadableStateName(state: string, finishedOffline = false): string {
if (finishedOffline) {
return Translate.instant('core.submittedoffline');
}
switch (state) {
case AddonModQuizAttemptStates.IN_PROGRESS:
return Translate.instant('addon.mod_quiz.stateinprogress');
@ -485,6 +441,36 @@ export class AddonModQuizProvider {
}
}
/**
* Get the color to apply to the attempt state.
*
* @param state State.
* @param finishedOffline Whether the attempt was finished offline.
* @returns State color.
*/
getAttemptStateColor(state: string, finishedOffline = false): string {
if (finishedOffline) {
return CoreIonicColorNames.MEDIUM;
}
switch (state) {
case AddonModQuizAttemptStates.IN_PROGRESS:
return CoreIonicColorNames.WARNING;
case AddonModQuizAttemptStates.OVERDUE:
return CoreIonicColorNames.INFO;
case AddonModQuizAttemptStates.FINISHED:
return CoreIonicColorNames.SUCCESS;
case AddonModQuizAttemptStates.ABANDONED:
return CoreIonicColorNames.DANGER;
default:
return '';
}
}
/**
* Get cache key for get attempt review WS calls.
*

View File

@ -11,6 +11,7 @@
"grade": "Grade",
"gradebook": "Gradebook",
"gradeitem": "Grade item",
"gradelong": "{{$a.grade}} / {{$a.max}}",
"gradepass": "Grade to pass",
"grades": "Grades",
"lettergrade": "Letter grade",

View File

@ -327,6 +327,7 @@
"strftimetime12": "%I:%M %p",
"strftimetime24": "%H:%M",
"submit": "Submit",
"submittedoffline": "Submitted (Offline)",
"success": "Success",
"summary": "Summary",
"swipenavigationtourdescription": "Swipe left and right to navigate around.",