MOBILE-3661 grades: Migrate grades tab

main
Noel De Martin 2021-01-26 18:30:00 +01:00
parent 6d148229f1
commit cd6e93b9d1
19 changed files with 2243 additions and 12 deletions

View File

@ -18,9 +18,9 @@ import { CoreScreen } from '@services/screen';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
enum CoreSplitViewMode { enum CoreSplitViewMode {
Default = '', // Shows both menu and content.
MenuOnly = 'menu-only', // Hides content. MenuOnly = 'menu-only', // Hides content.
ContentOnly = 'content-only', // Hides menu. ContentOnly = 'content-only', // Hides menu.
MenuAndContent = 'menu-and-content', // Shows both menu and content.
} }
@Component({ @Component({
@ -32,8 +32,8 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
@ViewChild(IonRouterOutlet) outlet!: IonRouterOutlet; @ViewChild(IonRouterOutlet) outlet!: IonRouterOutlet;
@HostBinding('class') classes = ''; @HostBinding('class') classes = '';
isNested = false;
private isNestedSplitView = false;
private subscriptions?: Subscription[]; private subscriptions?: Subscription[];
constructor(private element: ElementRef<HTMLElement>) {} constructor(private element: ElementRef<HTMLElement>) {}
@ -42,7 +42,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
* @inheritdoc * @inheritdoc
*/ */
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.isNestedSplitView = !!this.element.nativeElement.parentElement?.closest('core-split-view'); this.isNested = !!this.element.nativeElement.parentElement?.closest('core-split-view');
this.subscriptions = [ this.subscriptions = [
this.outlet.activateEvents.subscribe(() => this.updateClasses()), this.outlet.activateEvents.subscribe(() => this.updateClasses()),
this.outlet.deactivateEvents.subscribe(() => this.updateClasses()), this.outlet.deactivateEvents.subscribe(() => this.updateClasses()),
@ -63,7 +63,13 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
* Update host classes. * Update host classes.
*/ */
private updateClasses(): void { private updateClasses(): void {
this.classes = this.getCurrentMode(); const classes: string[] = [this.getCurrentMode()];
if (this.isNested) {
classes.push('nested');
}
this.classes = classes.join(' ');
} }
/** /**
@ -73,7 +79,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
* @return Split view mode. * @return Split view mode.
*/ */
private getCurrentMode(): CoreSplitViewMode { private getCurrentMode(): CoreSplitViewMode {
if (this.isNestedSplitView) { if (this.isNested) {
return CoreSplitViewMode.MenuOnly; return CoreSplitViewMode.MenuOnly;
} }
@ -83,7 +89,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
: CoreSplitViewMode.MenuOnly; : CoreSplitViewMode.MenuOnly;
} }
return CoreSplitViewMode.Default; return CoreSplitViewMode.MenuAndContent;
} }
} }

View File

@ -140,8 +140,9 @@ export class CoreCourseProvider {
* @param courseId Course ID. * @param courseId Course ID.
* @return Whether the current view is a certain course. * @return Whether the current view is a certain course.
*/ */
currentViewIsCourse(): boolean { // eslint-disable-next-line @typescript-eslint/no-unused-vars
// @ todo add params and logic. currentViewIsCourse(navCtrl: any, courseId: number): boolean {
// @todo implement
return false; return false;
} }

View File

@ -18,6 +18,7 @@ import { CoreCourseModule } from './course/course.module';
import { CoreCoursesModule } from './courses/courses.module'; import { CoreCoursesModule } from './courses/courses.module';
import { CoreEmulatorModule } from './emulator/emulator.module'; import { CoreEmulatorModule } from './emulator/emulator.module';
import { CoreFileUploaderModule } from './fileuploader/fileuploader.module'; import { CoreFileUploaderModule } from './fileuploader/fileuploader.module';
import { CoreGradesModule } from './grades/grades.module';
import { CoreH5PModule } from './h5p/h5p.module'; import { CoreH5PModule } from './h5p/h5p.module';
import { CoreLoginModule } from './login/login.module'; import { CoreLoginModule } from './login/login.module';
import { CoreMainMenuModule } from './mainmenu/mainmenu.module'; import { CoreMainMenuModule } from './mainmenu/mainmenu.module';
@ -35,6 +36,7 @@ import { CoreViewerModule } from './viewer/viewer.module';
CoreCoursesModule, CoreCoursesModule,
CoreEmulatorModule, CoreEmulatorModule,
CoreFileUploaderModule, CoreFileUploaderModule,
CoreGradesModule,
CoreLoginModule, CoreLoginModule,
CoreMainMenuModule, CoreMainMenuModule,
CoreSettingsModule, CoreSettingsModule,

View File

@ -0,0 +1,86 @@
// (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 { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreScreen } from '@services/screen';
import { CoreGradesCoursePage } from './pages/course/course';
import { CoreGradesCoursesPage } from './pages/courses/courses';
import { CoreGradesGradePage } from './pages/grade/grade';
import { conditionalRoutes } from '@/app/app-routing.module';
const mobileRoutes: Routes = [
{
path: '',
component: CoreGradesCoursesPage,
},
{
path: ':courseId',
component: CoreGradesCoursePage,
},
{
path: ':courseId/:gradeId',
component: CoreGradesGradePage,
},
];
const tabletRoutes: Routes = [
{
path: '',
component: CoreGradesCoursesPage,
children: [
{
path: ':courseId',
component: CoreGradesCoursePage,
},
],
},
{
path: ':courseId',
component: CoreGradesCoursePage,
children: [
{
path: ':gradeId',
component: CoreGradesGradePage,
},
],
},
];
const routes: Routes = [
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CommonModule,
IonicModule,
TranslateModule.forChild(),
CoreSharedModule,
],
declarations: [
CoreGradesCoursesPage,
CoreGradesCoursePage,
CoreGradesGradePage,
],
})
export class CoreGradesLazyModule {}

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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { Routes } from '@angular/router';
import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module';
import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate';
import CoreGradesMainMenuHandler, { CoreGradesMainMenuHandlerService } from './services/handlers/mainmenu';
const routes: Routes = [
{
path: CoreGradesMainMenuHandlerService.PAGE_NAME,
loadChildren: () => import('@features/grades/grades-lazy.module').then(m => m.CoreGradesLazyModule),
},
];
@NgModule({
imports: [CoreMainMenuRoutingModule.forChild({ children: routes })],
exports: [CoreMainMenuRoutingModule],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useValue: () => {
CoreMainMenuDelegate.instance.registerHandler(CoreGradesMainMenuHandler.instance);
},
},
],
})
export class CoreGradesModule {}

View File

@ -0,0 +1,16 @@
{
"average": "Average",
"badgrade": "Supplied grade is invalid",
"contributiontocoursetotal": "Contribution to course total",
"feedback": "Feedback",
"grade": "Grade",
"gradeitem": "Grade item",
"grades": "Grades",
"lettergrade": "Letter grade",
"nogradesreturned": "No grades returned",
"nooutcome": "No outcome",
"percentage": "Percentage",
"range": "Range",
"rank": "Rank",
"weight": "Weight"
}

View File

@ -0,0 +1,70 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.grades.grades' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!gradesTableLoaded" (ionRefresh)="refreshGradesTable($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="gradesTableLoaded" class="safe-area-page">
<core-empty-box *ngIf="!gradesTable" icon="stats" [message]="'core.grades.nogradesreturned' | translate">
</core-empty-box>
<div *ngIf="gradesTable" class="core-grades-container">
<table cellspacing="0" cellpadding="0" class="core-grades-table">
<thead>
<tr>
<th
*ngFor="let column of gradesTable.columns"
id="{{column.name}}"
class="ion-text-start"
[class.ion-hide-md-down]="column.hiddenPhone"
[attr.colspan]="column.colspan"
>
{{ 'core.grades.' + column.name | translate }}
</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let row of gradesTable.rows"
(click)="row.itemtype != 'category' && gotoGrade(row.id)"
[class]="row.rowclass"
[ngClass]='{"core-grades-grade-clickable": row.itemtype != "category"}'
>
<ng-container *ngIf="row.itemtype">
<td
*ngIf="row.itemtype == 'category'"
class="core-grades-table-category"
[attr.rowspan]="row.rowspan"
></td>
<th
class="core-grades-table-gradeitem ion-text-start"
[class.column-itemname]="row.itemtype == 'category'"
[class.core-selected-item]="activeGradeId == row.id"
[attr.colspan]="row.colspan"
>
<ion-icon *ngIf="row.icon" name="{{row.icon}}" slot="start"></ion-icon>
<img *ngIf="row.image" [src]="row.image" slot="start" />
<span [innerHTML]="row.gradeitem"></span>
</th>
<ng-container *ngFor="let column of gradesTable.columns">
<td
*ngIf="column.name != 'gradeitem' && row[column.name] != undefined"
[class]="'ion-text-start core-grades-table-' + column.name"
[class.ion-hide-md-down]="column.hiddenPhone"
[innerHTML]="row[column.name]"
></td>
</ng-container>
</ng-container>
</tr>
</tbody>
</table>
</div>
</core-loading>
</core-split-view>
</ion-content>

View File

@ -0,0 +1,133 @@
@import "~theme/breakpoints";
// @todo darkmode
// @todo RTL layout
:host-context(ion-app.md) {
--border-color: var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, .13))));
}
:host-context(ion-app.ios) {
--border-color: var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-250, #c8c7cc)));
}
.core-grades-table {
border-collapse: collapse;
line-height: 20px;
width: 100%;
font-size: 16px;
color: var(--ion-text-color);
// @include darkmode() {
// color: $core-dark-text-color;
// }
tr {
border-bottom: 1px solid var(--border-color);
}
th, td {
padding: 10px 0 10px 10px;
vertical-align: top;
white-space: normal;
text-align: start;
}
thead th {
vertical-align: bottom;
font-weight: bold;
background-color: var(--white);
// @include darkmode() {
// background-color: $black;
// }
}
tbody th {
font-weight: normal;
}
#gradeitem {
padding-left: 5px;
}
.core-grades-table-gradeitem {
padding-left: 5px;
font-weight: bold;
&.column-itemname {
padding-left: 0;
}
img {
width: 16px;
height: 16px;
}
ion-icon {
color: #999999;
}
span {
margin-left: 5px;
}
}
.core-grades-table-feedback {
padding-left: 5px;
.no-overflow {
overflow: auto;
}
}
.dimmed_text,
.hidden {
opacity: .7;
}
.odd {
td, th, th.core-selected-item {
background-color: var(--gray-lighter);
// @include darkmode() {
// background-color: $gray-darker;
// }
}
}
.even {
td, th, th.core-selected-item {
background-color: var(--white);
// @include darkmode() {
// background-color: $black;
// }
}
}
.core-grades-grade-clickable {
cursor: pointer;
}
}
core-split-view.nested .core-grades-table .ion-hide-md-down,
core-split-view.menu-and-content .core-grades-table .ion-hide-md-down {
display: none;
opacity: 0;
}
@media (min-width: $breakpoint-md) {
.core-grades-table td {
font-size: 0.85em;
}
}

View File

@ -0,0 +1,150 @@
// (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 { ActivatedRoute } from '@angular/router';
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreGrades } from '@features/grades/services/grades';
import { CoreGradesFormattedTable, CoreGradesHelper } from '@features/grades/services/grades-helper';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
import { CoreScreen } from '@services/screen';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
import { CoreObject } from '@singletons/object';
/**
* Page that displays a course grades.
*/
@Component({
selector: 'page-core-grades-course',
templateUrl: 'course.html',
styleUrls: ['course.scss'],
})
export class CoreGradesCoursePage implements OnInit, OnDestroy {
courseId: number;
userId: number;
gradesTable?: CoreGradesFormattedTable;
gradesTableLoaded = false;
activeGradeId?: number;
layoutSubscription?: Subscription;
@ViewChild(CoreSplitViewComponent) splitView?: CoreSplitViewComponent;
constructor(private route: ActivatedRoute) {
this.courseId = route.snapshot.params.courseId;
this.userId = route.snapshot.queryParams.userId ?? CoreSites.instance.getCurrentSiteUserId();
}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.layoutSubscription = CoreScreen.instance.layoutObservable.subscribe(() => this.updateActiveGrade());
await this.fetchGradesTable();
// Add log in Moodle.
await CoreUtils.instance.ignoreErrors(CoreGrades.instance.logCourseGradesView(this.courseId, this.userId));
}
/**
* @inheritdoc
*/
ionViewWillEnter(): void {
this.updateActiveGrade();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.layoutSubscription?.unsubscribe();
}
/**
* Fetch all the data required for the view.
*/
async fetchGradesTable(): Promise<void> {
try {
const table = await CoreGrades.instance.getCourseGradesTable(this.courseId, this.userId);
this.gradesTable = CoreGradesHelper.instance.formatGradesTable(table);
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading grades');
this.gradesTable = { rows: [], columns: [] };
} finally {
this.gradesTableLoaded = true;
}
}
/**
* Refresh data.
*
* @param refresher Refresher.
*/
async refreshGradesTable(refresher: IonRefresher): Promise<void> {
await CoreUtils.instance.ignoreErrors(CoreGrades.instance.invalidateCourseGradesData(this.courseId, this.userId));
await CoreUtils.instance.ignoreErrors(this.fetchGradesTable());
refresher.complete();
}
/**
* Navigate to the grade of the selected item.
*
* @param gradeId Grade item ID where to navigate.
*/
async gotoGrade(gradeId: number): Promise<void> {
const path = this.activeGradeId ? `../${gradeId}` : gradeId.toString();
await CoreNavigator.instance.navigate(path, {
params: CoreObject.withoutEmpty({ userId: this.userId }),
});
this.updateActiveGrade(gradeId);
}
/**
* Update active grade.
*
* @param activeGradeId Active grade id.
*/
private updateActiveGrade(activeGradeId?: number): void {
if (CoreScreen.instance.isMobile || this.splitView?.isNested) {
delete this.activeGradeId;
return;
}
this.activeGradeId = activeGradeId ?? this.guessActiveGrade();
}
/**
* Guess active grade looking at the current route.
*
* @return Active grade id.
*/
private guessActiveGrade(): number | undefined {
const gradeId = parseInt(this.route.snapshot?.firstChild?.params.gradeId);
return isNaN(gradeId) ? undefined : gradeId;
}
}

View File

@ -0,0 +1,43 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.grades.grades' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<core-split-view>
<ion-refresher slot="fixed" [disabled]="!gradesLoaded" (ionRefresh)="refreshGrades($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="gradesLoaded">
<core-empty-box
*ngIf="grades && grades.length == 0"
icon="stats"
[message]="'core.grades.nogradesreturned' | translate"
></core-empty-box>
<ion-list *ngIf="grades && grades.length > 0">
<ion-item
*ngFor="let grade of grades"
[title]="grade.courseFullName"
[class.core-selected-item]="grade.courseid === this.activeCourseId"
class="ion-text-wrap"
button
detail
(click)="openCourse(grade.courseid)"
>
<ion-label>
<core-format-text
[text]="grade.courseFullName"
[contextInstanceId]="grade.courseid"
contextLevel="course"
></core-format-text>
</ion-label>
<ion-badge slot="end" color="light">{{grade.grade}}</ion-badge>
</ion-item>
</ion-list>
</core-loading>
</core-split-view>
</ion-content>

View File

@ -0,0 +1,143 @@
// (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, OnDestroy, OnInit } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { Subscription } from 'rxjs';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreGrades } from '@features/grades/services/grades';
import { CoreGradesHelper, CoreGradesGradeOverviewWithCourseData } from '@features/grades/services/grades-helper';
import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen';
import { CoreUtils } from '@services/utils/utils';
import { ActivatedRoute } from '@angular/router';
/**
* Page that displays courses grades (main menu option).
*/
@Component({
selector: 'page-core-grades-courses',
templateUrl: 'courses.html',
})
export class CoreGradesCoursesPage implements OnInit, OnDestroy {
grades?: CoreGradesGradeOverviewWithCourseData[];
gradesLoaded = false;
activeCourseId?: number;
layoutSubscription?: Subscription;
constructor(private route: ActivatedRoute) {}
/**
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
this.layoutSubscription = CoreScreen.instance.layoutObservable.subscribe(() => this.updateActiveCourse());
this.updateActiveCourse();
await this.fetchGrades();
if (!CoreScreen.instance.isMobile && !this.activeCourseId && this.grades && this.grades.length > 0) {
this.openCourse(this.grades[0].courseid);
}
// Add log in Moodle.
await CoreUtils.instance.ignoreErrors(CoreGrades.instance.logCoursesGradesView());
}
/**
* @inheritdoc
*/
ionViewWillEnter(): void {
this.updateActiveCourse();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.layoutSubscription?.unsubscribe();
}
/**
* Fetch all the data required for the view.
*/
async fetchGrades(): Promise<void> {
try {
const grades = await CoreGrades.instance.getCoursesGrades();
const gradesWithCourseData = await CoreGradesHelper.instance.getGradesCourseData(grades);
this.grades = gradesWithCourseData;
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading grades');
this.grades = [];
} finally {
this.gradesLoaded = true;
}
}
/**
* Refresh data.
*
* @param refresher Refresher.
*/
async refreshGrades(refresher: IonRefresher): Promise<void> {
await CoreUtils.instance.ignoreErrors(CoreGrades.instance.invalidateCoursesGradesData());
await CoreUtils.instance.ignoreErrors(this.fetchGrades());
refresher.complete();
}
/**
* Navigate to the grades of the selected course.
*
* @param courseId Course Id where to navigate.
*/
async openCourse(courseId: number): Promise<void> {
const path = this.activeCourseId ? `../${courseId}` : courseId.toString();
await CoreNavigator.instance.navigate(path);
this.updateActiveCourse(courseId);
}
/**
* Update active course.
*
* @param activeCourseId Active course id.
*/
private updateActiveCourse(activeCourseId?: number): void {
if (CoreScreen.instance.isMobile) {
delete this.activeCourseId;
return;
}
this.activeCourseId = activeCourseId ?? this.guessActiveCourse();
}
/**
* Guess active course looking at the current route.
*
* @return Active course id.
*/
private guessActiveCourse(): number | undefined {
const courseId = parseInt(this.route.snapshot?.firstChild?.params.courseId);
return isNaN(courseId) ? undefined : courseId;
}
}

View File

@ -0,0 +1,97 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
</ion-buttons>
<ion-title>{{ 'core.grades.grade' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!gradeLoaded" (ionRefresh)="refreshGrade($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-loading [hideUntil]="gradeLoaded">
<core-empty-box *ngIf="!grade" icon="stats" [message]="'core.grades.nogradesreturned' | translate"></core-empty-box>
<ion-list *ngIf="grade">
<ion-item *ngIf="grade.itemname && grade.link" class="ion-text-wrap" detail="true" [href]="grade.link" core-link capture="true">
<ion-icon *ngIf="grade.icon" name="{{grade.icon}}" slot="start"></ion-icon>
<img *ngIf="grade.image" [src]="grade.image" slot="start" class="core-module-icon" />
<ion-label>
<h2><core-format-text [text]="grade.itemname" contextLevel="course" [contextInstanceId]="courseId"></core-format-text></h2>
</ion-label>
</ion-item>
<ion-item *ngIf="grade.itemname && !grade.link" class="ion-text-wrap" >
<ion-icon *ngIf="grade.icon" name="{{grade.icon}}" slot="start"></ion-icon>
<img *ngIf="grade.image" [src]="grade.image" slot="start" class="core-module-icon" />
<ion-label>
<h2><core-format-text [text]="grade.itemname" contextLevel="course" [contextInstanceId]="courseId"></core-format-text></h2>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.weight">
<ion-label>
<h2>{{ 'core.grades.weight' | translate}}</h2>
<p [innerHTML]="grade.weight"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.grade">
<ion-label>
<h2>{{ 'core.grades.grade' | translate}}</h2>
<p [innerHTML]="grade.grade"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.range">
<ion-label>
<h2>{{ 'core.grades.range' | translate}}</h2>
<p [innerHTML]="grade.range"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.percentage">
<ion-label>
<h2>{{ 'core.grades.percentage' | translate}}</h2>
<p [innerHTML]="grade.percentage"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.lettergrade">
<ion-label>
<h2>{{ 'core.grades.lettergrade' | translate}}</h2>
<p [innerHTML]="grade.lettergrade"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.rank">
<ion-label>
<h2>{{ 'core.grades.rank' | translate}}</h2>
<p [innerHTML]="grade.rank"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.average">
<ion-label>
<h2>{{ 'core.grades.average' | translate}}</h2>
<p [innerHTML]="grade.average"></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.feedback">
<ion-label>
<h2>{{ 'core.grades.feedback' | translate}}</h2>
<p><core-format-text [fullTitle]="'core.grades.feedback' | translate" maxHeight="60" fullOnClick="true" [text]="grade.feedback" contextLevel="course" [contextInstanceId]="courseId"></core-format-text></p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="grade.contributiontocoursetotal">
<ion-label>
<h2>{{ 'core.grades.contributiontocoursetotal' | translate}}</h2>
<p [innerHTML]="grade.contributiontocoursetotal"></p>
</ion-label>
</ion-item>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,77 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ActivatedRoute } from '@angular/router';
import { Component, OnInit } from '@angular/core';
import { IonRefresher } from '@ionic/angular';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreGrades } from '@features/grades/services/grades';
import { CoreGradesFormattedRow, CoreGradesHelper } from '@features/grades/services/grades-helper';
import { CoreSites } from '@services/sites';
import { CoreUtils } from '@services/utils/utils';
/**
* Page that displays activity grade.
*/
@Component({
selector: 'page-core-grades-grade',
templateUrl: 'grade.html',
})
export class CoreGradesGradePage implements OnInit {
courseId: number;
userId: number;
gradeId: number;
grade?: CoreGradesFormattedRow | null;
gradeLoaded = false;
constructor(route: ActivatedRoute) {
this.courseId = route.snapshot.params.courseId ?? route.snapshot.parent?.params.courseId;
this.gradeId = route.snapshot.params.gradeId;
this.userId = route.snapshot.queryParams.userId ?? CoreSites.instance.getCurrentSiteUserId();
}
/**
* @inheritdoc
*/
ngOnInit(): void {
this.fetchGrade();
}
/**
* Fetch all the data required for the view.
*/
async fetchGrade(): Promise<void> {
try {
this.grade = await CoreGradesHelper.instance.getGradeItem(this.courseId, this.gradeId, this.userId);
this.gradeLoaded = true;
} catch (error) {
CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading grade item');
}
}
/**
* Refresh data.
*
* @param refresher Refresher.
*/
async refreshGrade(refresher: IonRefresher): Promise<void> {
await CoreUtils.instance.ignoreErrors(CoreGrades.instance.invalidateCourseGradesData(this.courseId, this.userId));
await CoreUtils.instance.ignoreErrors(this.fetchGrade());
refresher.complete();
}
}

View File

@ -0,0 +1,697 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { NavController } from '@ionic/angular';
import { CoreLogger } from '@singletons/logger';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreCourses, CoreEnrolledCourseData, CoreCourseSearchedData } from '@features/courses/services/courses';
import { CoreCourse } from '@features/course/services/course';
import {
CoreGrades,
CoreGradesGradeItem,
CoreGradesGradeOverview,
CoreGradesTable,
CoreGradesTableRow,
} from '@features/grades/services/grades';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreMenuItem, CoreUtils } from '@services/utils/utils';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreNavigator } from '@services/navigator';
import { makeSingleton } from '@singletons';
/**
* Service that provides some features regarding grades information.
*/
@Injectable({ providedIn: 'root' })
export class CoreGradesHelperProvider {
protected logger: CoreLogger;
constructor() {
this.logger = CoreLogger.getInstance('CoreGradesHelperProvider');
}
/**
* Formats a row from the grades table te be rendered in a page.
*
* @param tableRow JSON object representing row of grades table data.
* @return Formatted row object.
*/
protected formatGradeRow(tableRow: CoreGradesTableRow): CoreGradesFormattedRow {
const row = {};
for (const name in tableRow) {
if (typeof tableRow[name].content != 'undefined' && tableRow[name].content !== null) {
let content = String(tableRow[name].content);
if (name == 'itemname') {
this.setRowIcon(row, content);
row['link'] = this.getModuleLink(content);
row['rowclass'] += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : '';
row['rowclass'] += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : '';
content = content.replace(/<\/span>/gi, '\n');
content = CoreTextUtils.instance.cleanTags(content);
} else {
content = CoreTextUtils.instance.replaceNewLines(content, '<br>');
}
if (content == '&nbsp;') {
content = '';
}
row[name] = content.trim();
}
}
return row;
}
/**
* Formats a row from the grades table to be rendered in one table.
*
* @param tableRow JSON object representing row of grades table data.
* @return Formatted row object.
*/
protected formatGradeRowForTable(tableRow: CoreGradesTableRow): CoreGradesFormattedRowForTable {
const row = {};
for (let name in tableRow) {
if (typeof tableRow[name].content != 'undefined' && tableRow[name].content !== null) {
let content = String(tableRow[name].content);
if (name == 'itemname') {
row['id'] = parseInt(tableRow[name]!.id.split('_')[1], 10);
row['colspan'] = tableRow[name]!.colspan;
row['rowspan'] = (tableRow['leader'] && tableRow['leader'].rowspan) || 1;
this.setRowIcon(row, content);
row['rowclass'] = tableRow[name]!.class.indexOf('leveleven') < 0 ? 'odd' : 'even';
row['rowclass'] += tableRow[name]!.class.indexOf('hidden') >= 0 ? ' hidden' : '';
row['rowclass'] += tableRow[name]!.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : '';
content = content.replace(/<\/span>/gi, '\n');
content = CoreTextUtils.instance.cleanTags(content);
name = 'gradeitem';
} else {
content = CoreTextUtils.instance.replaceNewLines(content, '<br>');
}
if (content == '&nbsp;') {
content = '';
}
row[name] = content.trim();
}
}
return row;
}
/**
* Removes suffix formatted to compatibilize data from table and items.
*
* @param item Grade item to format.
* @return Grade item formatted.
*/
protected formatGradeItem(item: CoreGradesGradeItem): CoreGradesFormattedItem {
for (const name in item) {
const index = name.indexOf('formatted');
if (index > 0) {
item[name.substr(0, index)] = item[name];
}
}
return item;
}
/**
* Formats the response of gradereport_user_get_grades_table to be rendered.
*
* @param table JSON object representing a table with data.
* @return Formatted HTML table.
*/
formatGradesTable(table: CoreGradesTable): CoreGradesFormattedTable {
const maxDepth = table.maxdepth;
const formatted: CoreGradesFormattedTable = {
columns: [],
rows: [],
};
// Columns, in order.
const columns = {
gradeitem: true,
weight: false,
grade: false,
range: false,
percentage: false,
lettergrade: false,
rank: false,
average: false,
feedback: false,
contributiontocoursetotal: false,
};
formatted.rows = table.tabledata.map(row => this.formatGradeRowForTable(row));
// Get a row with some info.
let normalRow = formatted.rows.find(
row =>
row.itemtype != 'leader' &&
(typeof row.grade != 'undefined' || typeof row.percentage != 'undefined'),
);
// Decide if grades or percentage is being shown on phones.
if (normalRow && typeof normalRow.grade != 'undefined') {
columns.grade = true;
} else if (normalRow && typeof normalRow.percentage != 'undefined') {
columns.percentage = true;
} else {
normalRow = formatted.rows.find((e) => e.itemtype != 'leader');
columns.grade = true;
}
for (const colName in columns) {
if (typeof normalRow[colName] != 'undefined') {
formatted.columns.push({
name: colName,
colspan: colName == 'gradeitem' ? maxDepth : 1,
hiddenPhone: !columns[colName],
});
}
}
return formatted;
}
/**
* Get course data for grades since they only have courseid.
*
* @param grades Grades to get the data for.
* @return Promise always resolved. Resolve param is the formatted grades.
*/
async getGradesCourseData(grades: CoreGradesGradeOverview[]): Promise<CoreGradesGradeOverviewWithCourseData[]> {
// Obtain courses from cache to prevent network requests.
let coursesWereMissing;
try {
const courses = await CoreCourses.instance.getUserCourses(undefined, undefined, CoreSitesReadingStrategy.OnlyCache);
const coursesMap = CoreUtils.instance.arrayToObject(courses, 'id');
coursesWereMissing = this.addCourseData(grades, coursesMap);
} catch (error) {
coursesWereMissing = true;
}
// If any course wasn't found, make a network request.
if (coursesWereMissing) {
const coursesPromise = CoreCourses.instance.isGetCoursesByFieldAvailable()
? CoreCourses.instance.getCoursesByField('ids', grades.map((grade) => grade.courseid).join(','))
: CoreCourses.instance.getUserCourses(undefined, undefined, CoreSitesReadingStrategy.PreferNetwork);
const courses = await coursesPromise;
const coursesMap =
CoreUtils.instance.arrayToObject(courses as Record<string, unknown>[], 'id') as
Record<string, CoreEnrolledCourseData> |
Record<string, CoreCourseSearchedData>;
this.addCourseData(grades, coursesMap);
}
return (grades as Record<string, unknown>[])
.filter(grade => 'courseFullName' in grade) as CoreGradesGradeOverviewWithCourseData[];
}
/**
* Adds course data to grades.
*
* @param grades Array of grades to populate.
* @param courses HashMap of courses to read data from.
* @return Boolean indicating if some courses were not found.
*/
protected addCourseData(
grades: CoreGradesGradeOverview[],
courses: Record<string, CoreEnrolledCourseData> | Record<string, CoreCourseSearchedData>,
): boolean {
let someCoursesAreMissing = false;
for (const grade of grades) {
if (!(grade.courseid in courses)) {
someCoursesAreMissing = true;
continue;
}
(grade as CoreGradesGradeOverviewWithCourseData).courseFullName = courses[grade.courseid].fullname;
}
return someCoursesAreMissing;
}
/**
* Get an specific grade item.
*
* @param courseId ID of the course to get the grades from.
* @param gradeId Grade ID.
* @param userId ID of the user to get the grades from. If not defined use site's current user.
* @param siteId Site ID. If not defined, current site.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise to be resolved when the grades are retrieved.
*/
async getGradeItem(
courseId: number,
gradeId: number,
userId?: number,
siteId?: string,
ignoreCache: boolean = false,
): Promise<CoreGradesFormattedRow | null> {
const grades = await CoreGrades.instance.getCourseGradesTable(courseId, userId, siteId, ignoreCache);
if (!grades) {
throw new Error('Couldn\'t get grade item');
}
return this.getGradesTableRow(grades, gradeId);
}
/**
* Returns the label of the selected grade.
*
* @param grades Array with objects with value and label.
* @param selectedGrade Selected grade value.
* @return Selected grade label.
*/
getGradeLabelFromValue(grades: CoreGradesMenuItem[], selectedGrade: number): string {
selectedGrade = Number(selectedGrade);
if (!grades || !selectedGrade || selectedGrade <= 0) {
return '';
}
for (const x in grades) {
if (grades[x].value == selectedGrade) {
return grades[x].label;
}
}
return '';
}
/**
* Get the grade items for a certain module. Keep in mind that may have more than one item to include outcomes and scales.
*
* @param courseId ID of the course to get the grades from.
* @param moduleId Module ID.
* @param userId ID of the user to get the grades from. If not defined use site's current user.
* @param groupId ID of the group to get the grades from. Not used for old gradebook table.
* @param siteId Site ID. If not defined, current site.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise to be resolved when the grades are retrieved.
*/
async getGradeModuleItems(
courseId: number,
moduleId: number,
userId?: number,
groupId?: number,
siteId?: string,
ignoreCache: boolean = false,
): Promise<CoreGradesFormattedItem> {
const grades = await CoreGrades.instance.getGradeItems(courseId, userId, groupId, siteId, ignoreCache);
if (!grades) {
throw new Error('Couldn\'t get grade module items');
}
if ('tabledata' in grades) {
// Table format.
return this.getModuleGradesTableRows(grades, moduleId);
}
return grades.filter((item) => item.cmid == moduleId).map((item) => this.formatGradeItem(item));
}
/**
* Returns the value of the selected grade.
*
* @param grades Array with objects with value and label.
* @param selectedGrade Selected grade label.
* @return Selected grade value.
*/
getGradeValueFromLabel(grades: CoreMenuItem[], selectedGrade: string): number {
if (!grades || !selectedGrade) {
return 0;
}
for (const x in grades) {
if (grades[x].label == selectedGrade) {
return grades[x].value < 0 ? 0 : grades[x].value;
}
}
return 0;
}
/**
* Gets the link to the module for the selected grade.
*
* @param text HTML where the link is present.
* @return URL linking to the module.
*/
protected getModuleLink(text: string): string | false {
const el = CoreDomUtils.instance.toDom(text)[0];
const link = el.attributes['href'] ? el.attributes['href'].value : false;
if (!link || link.indexOf('/mod/') < 0) {
return false;
}
return link;
}
/**
* Get a row from the grades table.
*
* @param table JSON object representing a table with data.
* @param gradeId Grade Object identifier.
* @return Formatted HTML table.
*/
getGradesTableRow(table: CoreGradesTable, gradeId: number): CoreGradesFormattedRow | null {
if (table.tabledata) {
const selectedRow = table.tabledata.find(
(row) =>
row.itemname &&
row.itemname.id &&
row.itemname.id.substr(0, 3) == 'row' &&
parseInt(row.itemname.id.split('_')[1], 10) == gradeId,
);
if (selectedRow) {
return this.formatGradeRow(selectedRow);
}
}
return null;
}
/**
* Get the rows related to a module from the grades table.
*
* @param table JSON object representing a table with data.
* @param moduleId Grade Object identifier.
* @return Formatted HTML table.
*/
getModuleGradesTableRows(table: CoreGradesTable, moduleId: number): CoreGradesFormattedRow[] {
if (!table.tabledata) {
return [];
}
// Find href containing "/mod/xxx/xxx.php".
const regex = /href="([^"]*\/mod\/[^"|^/]*\/[^"|^.]*\.php[^"]*)/;
return table.tabledata.filter((row) => {
if (row.itemname && row.itemname.content) {
const matches = row.itemname.content.match(regex);
if (matches && matches.length) {
const hrefParams = CoreUrlUtils.instance.extractUrlParams(matches[1]);
return hrefParams && parseInt(hrefParams.id) === moduleId;
}
}
return false;
}).map((row) => this.formatGradeRow(row));
}
/**
* Go to view grades.
*
* @param courseId Course ID to view.
* @param userId User to view. If not defined, current user.
* @param moduleId Module to view. If not defined, view all course grades.
* @param navCtrl NavController to use.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async goToGrades(
courseId: number,
userId?: number,
moduleId?: number,
navCtrl?: NavController,
siteId?: string,
): Promise<void> {
const modal = await CoreDomUtils.instance.showModalLoading();
let currentUserId;
try {
const site = await CoreSites.instance.getSite(siteId);
siteId = site.id;
currentUserId = site.getUserId();
if (moduleId) {
// Try to open the module grade directly. Check if it's possible.
const grades = await CoreGrades.instance.isGradeItemsAvalaible(siteId);
if (!grades) {
throw new Error();
}
} else {
throw new Error();
}
try {
// Can get grades. Do it.
const items = await CoreGrades.instance.getGradeItems(courseId, userId, undefined, siteId);
// Find the item of the module.
const item = Array.isArray(items) && items.find((item) => moduleId == item.cmid);
if (!item) {
throw new Error();
}
// Open the item directly.
const gradeId = item.id;
await CoreUtils.instance.ignoreErrors(
CoreNavigator.instance.navigateToSitePath(`/grades/${courseId}/${gradeId}`, {
siteId,
params: { userId },
}),
);
} catch (error) {
// Cannot get grade items or there's no need to.
if (userId && userId != currentUserId) {
// View another user grades. Open the grades page directly.
await CoreUtils.instance.ignoreErrors(
CoreNavigator.instance.navigateToSitePath(`/grades/${courseId}`, {
siteId,
params: { userId },
}),
);
}
// View own grades. Check if we already are in the course index page.
if (CoreCourse.instance.currentViewIsCourse(navCtrl, courseId)) {
// Current view is this course, just select the grades tab.
CoreCourse.instance.selectCourseTab('CoreGrades');
return;
}
// @todo
// Open the course with the grades tab selected.
// await CoreCourseHelper.instance.getCourse(courseId, siteId).then(async (result) => {
// const pageParams = {
// course: result.course,
// selectedTab: 'CoreGrades',
// };
// // CoreContentLinksHelper.instance.goInSite(navCtrl, 'CoreCourseSectionPage', pageParams, siteId)
// return await CoreUtils.instance.ignoreErrors(CoreNavigator.instance.navigateToSitePath('/course', {
// siteId,
// params: pageParams,
// }));
// });
}
} catch (error) {
// Cannot get course for some reason, just open the grades page.
await CoreNavigator.instance.navigateToSitePath(`/grades/${courseId}`, { siteId });
} finally {
modal.dismiss();
}
}
/**
* Invalidate the grade items for a certain module.
*
* @param courseId ID of the course to invalidate the grades.
* @param userId ID of the user to invalidate. If not defined use site's current user.
* @param groupId ID of the group to invalidate. Not used for old gradebook table.
* @param siteId Site ID. If not defined, current site.
* @return Promise to be resolved when the grades are invalidated.
*/
async invalidateGradeModuleItems(courseId: number, userId?: number, groupId?: number, siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
const enabled = await CoreGrades.instance.isGradeItemsAvalaible(siteId);
return enabled
? CoreGrades.instance.invalidateCourseGradesItemsData(courseId, userId, groupId, siteId)
: CoreGrades.instance.invalidateCourseGradesData(courseId, userId, siteId);
}
/**
* Parses the image and sets it to the row.
*
* @param row Formatted grade row object.
* @param text HTML where the image will be rendered.
* @return Row object with the image.
*/
protected setRowIcon(row: CoreGradesFormattedRowForTable, text: string): CoreGradesFormattedRowForTable {
text = text.replace('%2F', '/').replace('%2f', '/');
if (text.indexOf('/agg_mean') > -1) {
row['itemtype'] = 'agg_mean';
row['image'] = 'assets/img/grades/agg_mean.png';
} else if (text.indexOf('/agg_sum') > -1) {
row['itemtype'] = 'agg_sum';
row['image'] = 'assets/img/grades/agg_sum.png';
} else if (text.indexOf('/outcomes') > -1 || text.indexOf('fa-tasks') > -1) {
row['itemtype'] = 'outcome';
row['icon'] = 'fa-tasks';
} else if (text.indexOf('i/folder') > -1 || text.indexOf('fa-folder') > -1) {
row['itemtype'] = 'category';
row['icon'] = 'fa-folder';
} else if (text.indexOf('/manual_item') > -1 || text.indexOf('fa-square-o') > -1) {
row['itemtype'] = 'manual';
row['icon'] = 'fa-square-o';
} else if (text.indexOf('/mod/') > -1) {
const module = text.match(/mod\/([^/]*)\//);
if (typeof module?.[1] != 'undefined') {
row['itemtype'] = 'mod';
row['itemmodule'] = module[1];
row['image'] = CoreCourse.instance.getModuleIconSrc(
module[1],
CoreDomUtils.instance.convertToElement(text).querySelector('img')?.getAttribute('src') ?? undefined,
);
}
} else {
if (row['rowspan'] && row['rowspan'] > 1) {
row['itemtype'] = 'category';
row['icon'] = 'fa-folder';
} else if (text.indexOf('src=') > -1) {
row['itemtype'] = 'unknown';
const src = text.match(/src="([^"]*)"/);
row['image'] = src?.[1];
} else if (text.indexOf('<i ') > -1) {
row['itemtype'] = 'unknown';
const src = text.match(/<i class="(?:[^"]*?\s)?(fa-[a-z0-9-]+)/);
row['icon'] = src ? src[1] : '';
}
}
return row;
}
/**
* Creates an array that represents all the current grades that can be chosen using the given grading type.
* Negative numbers are scales, zero is no grade, and positive numbers are maximum grades.
*
* Taken from make_grades_menu on moodlelib.php
*
* @param gradingType If positive, max grade you can provide. If negative, scale Id.
* @param moduleId Module ID. Used to retrieve the scale items when they are not passed as parameter.
* If the user does not have permision to manage the activity an empty list is returned.
* @param defaultLabel Element that will become default option, if not defined, it won't be added.
* @param defaultValue Element that will become default option value. Default ''.
* @param scale Scale csv list String. If not provided, it will take it from the module grade info.
* @return Array with objects with value and label to create a propper HTML select.
*/
makeGradesMenu(
gradingType: number,
moduleId?: number,
defaultLabel: string = '',
defaultValue: string | number = '',
scale?: string,
): Promise<CoreGradesMenuItem[]> {
if (gradingType < 0) {
if (scale) {
return Promise.resolve(CoreUtils.instance.makeMenuFromList(scale, defaultLabel, undefined, defaultValue));
} else if (moduleId) {
return CoreCourse.instance.getModuleBasicGradeInfo(moduleId).then((gradeInfo) => {
if (gradeInfo && gradeInfo.scale) {
return CoreUtils.instance.makeMenuFromList(gradeInfo.scale, defaultLabel, undefined, defaultValue);
}
return [];
});
} else {
return Promise.resolve([]);
}
}
if (gradingType > 0) {
const grades: CoreGradesMenuItem[] = [];
if (defaultLabel) {
// Key as string to avoid resorting of the object.
grades.push({
label: defaultLabel,
value: defaultValue,
});
}
for (let i = gradingType; i >= 0; i--) {
grades.push({
label: i + ' / ' + gradingType,
value: i,
});
}
return Promise.resolve(grades);
}
return Promise.resolve([]);
}
}
export class CoreGradesHelper extends makeSingleton(CoreGradesHelperProvider) {}
// @todo formatted data types.
export type CoreGradesFormattedRow = any;
export type CoreGradesFormattedRowForTable = any;
export type CoreGradesFormattedItem = any;
export type CoreGradesFormattedTable = {
columns: any[];
rows: any[];
};
/**
* Grade overview with course data added by CoreGradesHelperProvider#addCourseData method.
*/
export type CoreGradesGradeOverviewWithCourseData = CoreGradesGradeOverview & {
courseFullName: string;
};
/**
* Grade menu item created by CoreGradesHelperProvider#makeGradesMenu method.
*/
export type CoreGradesMenuItem = {
label: string;
value: string | number;
};

View File

@ -0,0 +1,582 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreCourses } from '@features/courses/services/courses';
import { CoreSites } from '@services/sites';
import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications';
import { makeSingleton } from '@singletons';
import { CoreLogger } from '@singletons/logger';
import { CoreWSExternalWarning } from '@services/ws';
/**
* Service to provide grade functionalities.
*/
@Injectable({ providedIn: 'root' })
export class CoreGradesProvider {
static readonly TYPE_NONE = 0; // Moodle's GRADE_TYPE_NONE.
static readonly TYPE_VALUE = 1; // Moodle's GRADE_TYPE_VALUE.
static readonly TYPE_SCALE = 2; // Moodle's GRADE_TYPE_SCALE.
static readonly TYPE_TEXT = 3; // Moodle's GRADE_TYPE_TEXT.
protected readonly ROOT_CACHE_KEY = 'mmGrades:';
protected logger: CoreLogger;
constructor() {
this.logger = CoreLogger.getInstance('CoreGradesProvider');
}
/**
* Get cache key for grade table data WS calls.
*
* @param courseId ID of the course to get the grades from.
* @param userId ID of the user to get the grades from.
* @return Cache key.
*/
protected getCourseGradesCacheKey(courseId: number, userId: number): string {
return this.getCourseGradesPrefixCacheKey(courseId) + userId;
}
/**
* Get cache key for grade items data WS calls.
*
* @param courseId ID of the course to get the grades from.
* @param userId ID of the user to get the grades from.
* @param groupId ID of the group to get the grades from. Default: 0.
* @return Cache key.
*/
protected getCourseGradesItemsCacheKey(courseId: number, userId: number, groupId?: number): string {
groupId = groupId ?? 0;
return this.getCourseGradesPrefixCacheKey(courseId) + userId + ':' + groupId;
}
/**
* Get prefix cache key for grade table data WS calls.
*
* @param courseId ID of the course to get the grades from.
* @return Cache key.
*/
protected getCourseGradesPrefixCacheKey(courseId: number): string {
return this.ROOT_CACHE_KEY + 'items:' + courseId + ':';
}
/**
* Get cache key for courses grade WS calls.
*
* @return Cache key.
*/
protected getCoursesGradesCacheKey(): string {
return this.ROOT_CACHE_KEY + 'coursesgrades';
}
/**
* Get the grade items for a certain module. Keep in mind that may have more than one item to include outcomes and scales.
* Fallback function only used if 'gradereport_user_get_grade_items' WS is not avalaible Moodle < 3.2.
*
* @param courseId ID of the course to get the grades from.
* @param userId ID of the user to get the grades from. If not defined use site's current user.
* @param groupId ID of the group to get the grades from. Not used for old gradebook table.
* @param siteId Site ID. If not defined, current site.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise to be resolved when the grades are retrieved.
*/
async getGradeItems(
courseId: number,
userId?: number,
groupId?: number,
siteId?: string,
ignoreCache: boolean = false,
): Promise<CoreGradesGradeItem[] | CoreGradesTable> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
const enabled = await this.isGradeItemsAvalaible(siteId);
if (enabled) {
try {
const items = await this.getCourseGradesItems(courseId, userId, groupId, siteId, ignoreCache);
return items;
} catch (error) {
// Ignore while solving MDL-57255
}
}
return this.getCourseGradesTable(courseId, userId, siteId, ignoreCache);
}
/**
* Get the grade items for a certain course.
*
* @param courseId ID of the course to get the grades from.
* @param userId ID of the user to get the grades from.
* @param groupId ID of the group to get the grades from. Default 0.
* @param siteId Site ID. If not defined, current site.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise to be resolved when the grades table is retrieved.
*/
async getCourseGradesItems(
courseId: number,
userId?: number,
groupId?: number,
siteId?: string,
ignoreCache: boolean = false,
): Promise<CoreGradesGradeItem[]> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
groupId = groupId || 0;
this.logger.debug(`Get grades for course '${courseId}' and user '${userId}'`);
const params: CoreGradesGetUserGradeItemsWSParams = {
courseid: courseId,
userid: userId,
groupid: groupId,
};
const preSets = {
cacheKey: this.getCourseGradesItemsCacheKey(courseId, userId, groupId),
};
if (ignoreCache) {
preSets['getFromCache'] = 0;
preSets['emergencyCache'] = 0;
}
const grades = await site.read<CoreGradesGetUserGradeItemsWSResponse>(
'gradereport_user_get_grade_items',
params,
preSets,
);
if (!grades?.usergrades?.[0]) {
throw new Error('Couldn\'t get course grades items');
}
return grades.usergrades[0].gradeitems;
}
/**
* Get the grades for a certain course.
*
* @param courseId ID of the course to get the grades from.
* @param userId ID of the user to get the grades from.
* @param siteId Site ID. If not defined, current site.
* @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down).
* @return Promise to be resolved when the grades table is retrieved.
*/
async getCourseGradesTable(
courseId: number,
userId?: number,
siteId?: string,
ignoreCache: boolean = false,
): Promise<CoreGradesTable> {
const site = await CoreSites.instance.getSite(siteId);
userId = userId || site.getUserId();
this.logger.debug(`Get grades for course '${courseId}' and user '${userId}'`);
const params: CoreGradesGetUserGradesTableWSParams = {
courseid: courseId,
userid: userId,
};
const preSets = {
cacheKey: this.getCourseGradesCacheKey(courseId, userId),
};
if (ignoreCache) {
preSets['getFromCache'] = 0;
preSets['emergencyCache'] = 0;
}
const table = await site.read<CoreGradesGetUserGradesTableWSResponse>('gradereport_user_get_grades_table', params, preSets);
if (!table?.tables?.[0]) {
throw new Error('Coudln\'t get course grades table');
}
return table.tables[0];
}
/**
* Get the grades for a certain course.
*
* @param siteId Site ID. If not defined, current site.
* @return Promise to be resolved when the grades are retrieved.
*/
async getCoursesGrades(siteId?: string): Promise<CoreGradesGradeOverview[]> {
const site = await CoreSites.instance.getSite(siteId);
this.logger.debug('Get course grades');
const params: CoreGradesGetOverviewCourseGradesWSParams = {};
const preSets = {
cacheKey: this.getCoursesGradesCacheKey(),
};
const data = await site.read<CoreGradesGetOverviewCourseGradesWSResponse>(
'gradereport_overview_get_course_grades',
params,
preSets,
);
if (!data?.grades) {
throw new Error('Couldn\'t get course grades');
}
return data.grades;
}
/**
* Invalidates courses grade table and items WS calls for all users.
*
* @param courseId ID of the course to get the grades from.
* @param siteId Site ID (empty for current site).
* @return Promise resolved when the data is invalidated.
*/
invalidateAllCourseGradesData(courseId: number, siteId?: string): Promise<void> {
return CoreSites.instance.getSite(siteId)
.then((site) => site.invalidateWsCacheForKeyStartingWith(this.getCourseGradesPrefixCacheKey(courseId)));
}
/**
* Invalidates grade table data WS calls.
*
* @param courseId Course ID.
* @param userId User ID.
* @param siteId Site id (empty for current site).
* @return Promise resolved when the data is invalidated.
*/
invalidateCourseGradesData(courseId: number, userId?: number, siteId?: string): Promise<void> {
return CoreSites.instance.getSite(siteId).then((site) => {
userId = userId || site.getUserId();
return site.invalidateWsCacheForKey(this.getCourseGradesCacheKey(courseId, userId));
});
}
/**
* Invalidates courses grade data WS calls.
*
* @param siteId Site id (empty for current site).
* @return Promise resolved when the data is invalidated.
*/
invalidateCoursesGradesData(siteId?: string): Promise<void> {
return CoreSites.instance.getSite(siteId).then((site) => site.invalidateWsCacheForKey(this.getCoursesGradesCacheKey()));
}
/**
* Invalidates courses grade items data WS calls.
*
* @param courseId ID of the course to get the grades from.
* @param userId ID of the user to get the grades from.
* @param groupId ID of the group to get the grades from. Default: 0.
* @param siteId Site id (empty for current site).
* @return Promise resolved when the data is invalidated.
*/
invalidateCourseGradesItemsData(courseId: number, userId: number, groupId?: number, siteId?: string): Promise<void> {
return CoreSites.instance.getSite(siteId)
.then((site) => site.invalidateWsCacheForKey(this.getCourseGradesItemsCacheKey(courseId, userId, groupId)));
}
/**
* Returns whether or not the plugin is enabled for a certain site.
*
* @param siteId Site ID. If not defined, current site.
* @return Resolve with true if plugin is enabled, false otherwise.
* @since Moodle 3.2
*/
isCourseGradesEnabled(siteId?: string): Promise<boolean> {
return CoreSites.instance.getSite(siteId).then((site) => {
if (!site.wsAvailable('gradereport_overview_get_course_grades')) {
return false;
}
// Now check that the configurable mygradesurl is pointing to the gradereport_overview plugin.
const url = site.getStoredConfig('mygradesurl') || '';
return url.indexOf('/grade/report/overview/') !== -1;
});
}
/**
* Returns whether or not the grade addon is enabled for a certain course.
*
* @param courseId Course ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise.
*/
isPluginEnabledForCourse(courseId: number, siteId?: string): Promise<boolean> {
if (!courseId) {
return Promise.reject(null);
}
return CoreCourses.instance.getUserCourse(courseId, true, siteId)
.then((course) => !(course && typeof course.showgrades != 'undefined' && !course.showgrades));
}
/**
* Returns whether or not WS Grade Items is avalaible.
*
* @param siteId Site ID. If not defined, current site.
* @return True if ws is avalaible, false otherwise.
* @since Moodle 3.2
*/
isGradeItemsAvalaible(siteId?: string): Promise<boolean> {
return CoreSites.instance.getSite(siteId).then((site) => site.wsAvailable('gradereport_user_get_grade_items'));
}
/**
* Log Course grades view in Moodle.
*
* @param courseId Course ID.
* @param userId User ID.
* @param name Course name. If not set, it will be calculated.
* @return Promise resolved when done.
*/
async logCourseGradesView(courseId: number, userId: number, name?: string): Promise<void> {
userId = userId || CoreSites.instance.getCurrentSiteUserId();
const wsName = 'gradereport_user_view_grade_report';
if (!name) {
// eslint-disable-next-line promise/catch-or-return
CoreCourses.instance.getUserCourse(courseId, true)
.catch(() => ({}))
.then(course => CorePushNotifications.instance.logViewEvent(
courseId,
'fullname' in course ? course.fullname : '',
'grades',
wsName,
{ userid: userId },
));
} else {
CorePushNotifications.instance.logViewEvent(courseId, name, 'grades', wsName, { userid: userId });
}
const site = await CoreSites.instance.getCurrentSite();
await site?.write(wsName, { courseid: courseId, userid: userId });
}
/**
* Log Courses grades view in Moodle.
*
* @param courseId Course ID. If not defined, site Home ID.
* @return Promise resolved when done.
*/
async logCoursesGradesView(courseId?: number): Promise<void> {
if (!courseId) {
courseId = CoreSites.instance.getCurrentSiteHomeId();
}
const params = {
courseid: courseId,
};
CorePushNotifications.instance.logViewListEvent('grades', 'gradereport_overview_view_grade_report', params);
const site = await CoreSites.instance.getCurrentSite();
await site?.write('gradereport_overview_view_grade_report', params);
}
}
export class CoreGrades extends makeSingleton(CoreGradesProvider) {}
/**
* Params of gradereport_user_get_grade_items WS.
*/
type CoreGradesGetUserGradeItemsWSParams = {
courseid: number; // Course Id.
userid?: number; // Return grades only for this user (optional).
groupid?: number; // Get users from this group only.
};
/**
* Params of gradereport_user_get_grades_table WS.
*/
type CoreGradesGetUserGradesTableWSParams = {
courseid: number; // Course Id.
userid?: number; // Return grades only for this user (optional).
groupid?: number; // Get users from this group only.
};
/**
* Params of gradereport_overview_get_course_grades WS.
*/
type CoreGradesGetOverviewCourseGradesWSParams = {
userid?: number; // Get grades for this user (optional, default current).
};
/**
* Data returned by gradereport_user_get_grade_items WS.
*/
export type CoreGradesGetUserGradeItemsWSResponse = {
usergrades: {
courseid: number; // Course id.
userid: number; // User id.
userfullname: string; // User fullname.
useridnumber: string; // User idnumber.
maxdepth: number; // Table max depth (needed for printing it).
gradeitems: CoreGradesGradeItem[];
}[];
warnings?: CoreWSExternalWarning[];
};
/**
* Data returned by gradereport_user_get_grades_table WS.
*/
export type CoreGradesGetUserGradesTableWSResponse = {
tables: CoreGradesTable[];
warnings?: CoreWSExternalWarning[];
};
/**
* Data returned by gradereport_overview_get_course_grades WS.
*/
export type CoreGradesGetOverviewCourseGradesWSResponse = {
grades: CoreGradesGradeOverview[];
warnings?: CoreWSExternalWarning[];
};
/**
* Grade item data.
*/
export type CoreGradesGradeItem = {
id: number; // Grade item id.
itemname: string; // Grade item name.
itemtype: string; // Grade item type.
itemmodule: string; // Grade item module.
iteminstance: number; // Grade item instance.
itemnumber: number; // Grade item item number.
idnumber: string; // Grade item idnumber.
categoryid: number; // Grade item category id.
outcomeid: number; // Outcome id.
scaleid: number; // Scale id.
locked?: boolean; // Grade item for user locked?.
cmid?: number; // Course module id (if type mod).
weightraw?: number; // Weight raw.
weightformatted?: string; // Weight.
status?: string; // Status.
graderaw?: number; // Grade raw.
gradedatesubmitted?: number; // Grade submit date.
gradedategraded?: number; // Grade graded date.
gradehiddenbydate?: boolean; // Grade hidden by date?.
gradeneedsupdate?: boolean; // Grade needs update?.
gradeishidden?: boolean; // Grade is hidden?.
gradeislocked?: boolean; // Grade is locked?.
gradeisoverridden?: boolean; // Grade overridden?.
gradeformatted?: string; // The grade formatted.
grademin?: number; // Grade min.
grademax?: number; // Grade max.
rangeformatted?: string; // Range formatted.
percentageformatted?: string; // Percentage.
lettergradeformatted?: string; // Letter grade.
rank?: number; // Rank in the course.
numusers?: number; // Num users in course.
averageformatted?: string; // Grade average.
feedback?: string; // Grade feedback.
feedbackformat?: number; // Feedback format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
};
/**
* Grade table data.
*/
export type CoreGradesTable = {
courseid: number; // Course id.
userid: number; // User id.
userfullname: string; // User fullname.
maxdepth: number; // Table max depth (needed for printing it).
tabledata: CoreGradesTableRow[];
};
/**
* Grade table data item.
*/
export type CoreGradesTableRow = {
itemname?: {
class: string; // Class.
colspan: number; // Col span.
content: string; // Cell content.
celltype: string; // Cell type.
id: string; // Id.
}; // The item returned data.
leader?: {
class: string; // Class.
rowspan: number; // Row span.
}; // The item returned data.
weight?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Weight column.
grade?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Grade column.
range?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Range column.
percentage?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Percentage column.
lettergrade?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Lettergrade column.
rank?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Rank column.
average?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Average column.
feedback?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Feedback column.
contributiontocoursetotal?: {
class: string; // Class.
content: string; // Cell content.
headers: string; // Headers.
}; // Contributiontocoursetotal column.
};
/**
* Grade overview data.
*/
export type CoreGradesGradeOverview = {
courseid: number; // Course id.
grade: string; // Grade formatted.
rawgrade: string; // Raw grade, not formatted.
rank?: number; // Your rank in the course.
};

View File

@ -0,0 +1,56 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreGrades } from '@features/grades/services/grades';
import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@features/mainmenu/services/mainmenu-delegate';
import { makeSingleton } from '@singletons';
/**
* Handler to inject an option into main menu.
*/
@Injectable({ providedIn: 'root' })
export class CoreGradesMainMenuHandlerService implements CoreMainMenuHandler {
static readonly PAGE_NAME = 'grades';
name = 'CoreGrades';
priority = 600;
/**
* Check if the handler is enabled on a site level.
*
* @return Whether or not the handler is enabled on a site level.
*/
isEnabled(): Promise<boolean> {
return CoreGrades.instance.isCourseGradesEnabled();
}
/**
* Returns the data needed to render the handler.
*
* @return Data needed to render the handler.
*/
getDisplayData(): CoreMainMenuHandlerData {
return {
icon: 'stats-chart',
title: 'core.grades.grades',
page: CoreGradesMainMenuHandlerService.PAGE_NAME,
class: 'core-grades-coursesgrades-handler',
};
}
}
export default class CoreGradesMainMenuHandler extends makeSingleton(CoreGradesMainMenuHandlerService) {}

View File

@ -846,7 +846,7 @@ export class CoreUtilsProvider {
defaultLabel?: string, defaultLabel?: string,
separator: string = ',', separator: string = ',',
defaultValue?: T, defaultValue?: T,
): { label: string; value: T | number }[] { ): CoreMenuItem<T>[] {
// Split and format the list. // Split and format the list.
const split = list.split(separator).map((label, index) => ({ const split = list.split(separator).map((label, index) => ({
label: label.trim(), label: label.trim(),
@ -1673,3 +1673,11 @@ export type CoreCountry = {
code: string; code: string;
name: string; name: string;
}; };
/**
* Menu item.
*/
export type CoreMenuItem<T = number> = {
label: string;
value: T | number;
};

View File

@ -123,10 +123,13 @@ ion-toolbar {
font-size: 14px; font-size: 14px;
} }
.core-selected-item {
border-inline-start: var(--selected-item-border-width) solid var(--selected-item-color);
}
// Item styles // Item styles
.item.core-selected-item { .item.core-selected-item {
// TODO: Add safe are to border and RTL // TODO: Add safe area to border and RTL
border-inline-start: var(--selected-item-border-width) solid var(--selected-item-color);
--ion-safe-area-left: calc(-1 * var(--selected-item-border-width)); --ion-safe-area-left: calc(-1 * var(--selected-item-border-width));
} }
@ -257,12 +260,18 @@ ion-avatar ion-img, ion-avatar img {
} }
// Activity modules // Activity modules
.core-module-icon {
width: auto;
max-width: 24px;
max-height: 24px;
}
ion-item img.core-module-icon[slot="start"] { ion-item img.core-module-icon[slot="start"] {
margin-top: 12px; margin-top: 12px;
margin-bottom: 12px; margin-bottom: 12px;
margin-right: 32px; margin-right: 32px;
padding: 6px;
} }
[dir=rtl] ion-item img.core-module-icon[slot="start"] { [dir=rtl] ion-item img.core-module-icon[slot="start"] {
margin-right: unset; margin-right: unset;
margin-left: 32px; margin-left: 32px;

View File

@ -0,0 +1,13 @@
/*
* Layout Breakpoints
*
* https://ionicframework.com/docs/layout/grid#default-breakpoints
*/
$breakpoint-xs: 0px;
$breakpoint-sm: 576px;
$breakpoint-md: 768px;
$breakpoint-lg: 992px;
$breakpoint-xl: 1200px;
$breakpoint-tablet: $breakpoint-lg;