Merge pull request #3583 from NoelDeMartin/MOBILE-4188

MOBILE-4188: Implement teacher gradebook
main
Dani Palou 2023-04-17 14:08:04 +02:00 committed by GitHub
commit e87408418b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 543 additions and 74 deletions

View File

@ -22,7 +22,6 @@ const routes: Routes = [
{ {
path: '', path: '',
component: CoreGradesCoursePage, component: CoreGradesCoursePage,
data: { swipeEnabled: false },
}, },
]; ];

View File

@ -0,0 +1,43 @@
// (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 { conditionalRoutes } from '@/app/app-routing.module';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CoreUserParticipantsPageModule } from '@features/user/pages/participants/participants.module';
import { CoreUserParticipantsPage } from '@features/user/pages/participants/participants.page';
import { CoreScreen } from '@services/screen';
const routes: Routes = [
{
path: '',
component: CoreUserParticipantsPage,
children: conditionalRoutes([
{
path: ':userId',
loadChildren: () => import('./grades-course-lazy.module').then(m => m.CoreGradesCourseLazyModule),
data: { swipeManagerSource: 'participants' },
},
], () => CoreScreen.isTablet),
},
];
@NgModule({
imports: [
RouterModule.forChild(routes),
CoreUserParticipantsPageModule,
],
})
export class CoreGradesCourseParticipantsLazyModule {}

View File

@ -22,12 +22,16 @@ import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-ro
import { CoreUserDelegate } from '@features/user/services/user-delegate'; import { CoreUserDelegate } from '@features/user/services/user-delegate';
import { PARTICIPANTS_PAGE_NAME } from '@features/user/user.module'; import { PARTICIPANTS_PAGE_NAME } from '@features/user/user.module';
import { CoreGradesProvider } from './services/grades'; import { CoreGradesProvider } from './services/grades';
import { CoreGradesHelperProvider, GRADES_PAGE_NAME } from './services/grades-helper'; import { CoreGradesHelperProvider, GRADES_PAGE_NAME, GRADES_PARTICIPANTS_PAGE_NAME } from './services/grades-helper';
import { CoreGradesCourseOptionHandler } from './services/handlers/course-option'; import { CoreGradesCourseOptionHandler } from './services/handlers/course-option';
import { CoreGradesOverviewLinkHandler } from './services/handlers/overview-link'; import { CoreGradesOverviewLinkHandler } from './services/handlers/overview-link';
import { CoreGradesUserHandler } from './services/handlers/user'; import { CoreGradesUserHandler } from './services/handlers/user';
import { CoreGradesReportLinkHandler } from './services/handlers/report-link'; import { CoreGradesReportLinkHandler } from './services/handlers/report-link';
import { CoreGradesUserLinkHandler } from './services/handlers/user-link'; import { CoreGradesUserLinkHandler } from './services/handlers/user-link';
import { CoreGradesCourseParticipantsOptionHandler } from '@features/grades/services/handlers/course-participants-option';
import { conditionalRoutes } from '@/app/app-routing.module';
import { COURSE_INDEX_PATH } from '@features/course/course-lazy.module';
import { CoreScreen } from '@services/screen';
export const CORE_GRADES_SERVICES: Type<unknown>[] = [ export const CORE_GRADES_SERVICES: Type<unknown>[] = [
CoreGradesProvider, CoreGradesProvider,
@ -38,11 +42,19 @@ const mainMenuChildrenRoutes: Routes = [
{ {
path: GRADES_PAGE_NAME, path: GRADES_PAGE_NAME,
loadChildren: () => import('./grades-courses-lazy.module').then(m => m.CoreGradesCoursesLazyModule), loadChildren: () => import('./grades-courses-lazy.module').then(m => m.CoreGradesCoursesLazyModule),
data: { swipeManagerSource: 'courses' },
}, },
{ {
path: `${COURSE_PAGE_NAME}/:courseId/${PARTICIPANTS_PAGE_NAME}/:userId/${GRADES_PAGE_NAME}`, path: `${COURSE_PAGE_NAME}/:courseId/${PARTICIPANTS_PAGE_NAME}/:userId/${GRADES_PAGE_NAME}`,
loadChildren: () => import('./grades-course-lazy.module').then(m => m.CoreGradesCourseLazyModule), loadChildren: () => import('./grades-course-lazy.module').then(m => m.CoreGradesCourseLazyModule),
}, },
...conditionalRoutes([
{
path: `${COURSE_PAGE_NAME}/${COURSE_INDEX_PATH}/${GRADES_PARTICIPANTS_PAGE_NAME}/:userId`,
loadChildren: () => import('./grades-course-lazy.module').then(m => m.CoreGradesCourseLazyModule),
data: { swipeManagerSource: 'participants' },
},
], () => CoreScreen.isMobile),
]; ];
const courseIndexRoutes: Routes = [ const courseIndexRoutes: Routes = [
@ -50,6 +62,10 @@ const courseIndexRoutes: Routes = [
path: GRADES_PAGE_NAME, path: GRADES_PAGE_NAME,
loadChildren: () => import('./grades-course-lazy.module').then(m => m.CoreGradesCourseLazyModule), loadChildren: () => import('./grades-course-lazy.module').then(m => m.CoreGradesCourseLazyModule),
}, },
{
path: GRADES_PARTICIPANTS_PAGE_NAME,
loadChildren: () => import('./grades-course-participants-lazy.module').then(m => m.CoreGradesCourseParticipantsLazyModule),
},
]; ];
@NgModule({ @NgModule({
@ -67,6 +83,7 @@ const courseIndexRoutes: Routes = [
CoreContentLinksDelegate.registerHandler(CoreGradesUserLinkHandler.instance); CoreContentLinksDelegate.registerHandler(CoreGradesUserLinkHandler.instance);
CoreContentLinksDelegate.registerHandler(CoreGradesOverviewLinkHandler.instance); CoreContentLinksDelegate.registerHandler(CoreGradesOverviewLinkHandler.instance);
CoreCourseOptionsDelegate.registerHandler(CoreGradesCourseOptionHandler.instance); CoreCourseOptionsDelegate.registerHandler(CoreGradesCourseOptionHandler.instance);
CoreCourseOptionsDelegate.registerHandler(CoreGradesCourseParticipantsOptionHandler.instance);
}, },
}, },
], ],

View File

@ -8,7 +8,7 @@
</ion-title> </ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content [core-swipe-navigation]="courses"> <ion-content [core-swipe-navigation]="swipeManager">
<ion-refresher slot="fixed" [disabled]="!columns || !rows" (ionRefresh)="refreshGrades($event.target)"> <ion-refresher slot="fixed" [disabled]="!columns || !rows" (ionRefresh)="refreshGrades($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher> </ion-refresher>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router';
import { AfterViewInit, Component, ElementRef, OnDestroy } from '@angular/core'; import { AfterViewInit, Component, ElementRef, OnDestroy } from '@angular/core';
import { IonRefresher } from '@ionic/angular'; import { IonRefresher } from '@ionic/angular';
@ -21,6 +21,7 @@ import { CoreGrades } from '@features/grades/services/grades';
import { import {
CoreGradesFormattedTableColumn, CoreGradesFormattedTableColumn,
CoreGradesFormattedTableRow, CoreGradesFormattedTableRow,
CoreGradesGradeOverviewWithCourseData,
CoreGradesHelper, CoreGradesHelper,
} from '@features/grades/services/grades-helper'; } from '@features/grades/services/grades-helper';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
@ -30,6 +31,8 @@ import { CoreScreen } from '@services/screen';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager';
import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker';
import { CoreUserParticipantsSource } from '@features/user/classes/participants-source';
import { CoreUserData, CoreUserParticipant } from '@features/user/services/user';
import { CoreGradesCoursesSource } from '@features/grades/classes/grades-courses-source'; import { CoreGradesCoursesSource } from '@features/grades/classes/grades-courses-source';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
@ -49,7 +52,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
expandLabel!: string; expandLabel!: string;
collapseLabel!: string; collapseLabel!: string;
title?: string; title?: string;
courses?: CoreSwipeNavigationItemsManager; swipeManager?: CoreGradesCourseSwipeManager;
columns: CoreGradesFormattedTableColumn[] = []; columns: CoreGradesFormattedTableColumn[] = [];
rows: CoreGradesFormattedTableRow[] = []; rows: CoreGradesFormattedTableRow[] = [];
rowsOnView = 0; rowsOnView = 0;
@ -72,10 +75,17 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
this.collapseLabel = Translate.instant('core.collapse'); this.collapseLabel = Translate.instant('core.collapse');
this.useLegacyLayout = !CoreSites.getRequiredCurrentSite().isVersionGreaterEqualThan('4.1'); this.useLegacyLayout = !CoreSites.getRequiredCurrentSite().isVersionGreaterEqualThan('4.1');
if (route.snapshot.data.swipeEnabled ?? true) { switch (route.snapshot.data.swipeManagerSource) {
const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreGradesCoursesSource, []); case 'courses':
this.swipeManager = new CoreGradesCourseCoursesSwipeManager(
this.courses = new CoreSwipeNavigationItemsManager(source); CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreGradesCoursesSource, []),
);
break;
case 'participants':
this.swipeManager = new CoreGradesCourseParticipantsSwipeManager(
CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]),
);
break;
} }
} catch (error) { } catch (error) {
CoreDomUtils.showErrorModal(error); CoreDomUtils.showErrorModal(error);
@ -96,7 +106,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
async ngAfterViewInit(): Promise<void> { async ngAfterViewInit(): Promise<void> {
this.withinSplitView = !!this.element.nativeElement.parentElement?.closest('core-split-view'); this.withinSplitView = !!this.element.nativeElement.parentElement?.closest('core-split-view');
await this.courses?.start(); await this.swipeManager?.start();
await this.fetchInitialGrades(); await this.fetchInitialGrades();
} }
@ -104,7 +114,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
* @inheritdoc * @inheritdoc
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.courses?.destroy(); this.swipeManager?.destroy();
} }
/** /**
@ -208,7 +218,9 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
const table = await CoreGrades.getCourseGradesTable(this.courseId, this.userId); const table = await CoreGrades.getCourseGradesTable(this.courseId, this.userId);
const formattedTable = await CoreGradesHelper.formatGradesTable(table); const formattedTable = await CoreGradesHelper.formatGradesTable(table);
this.title = formattedTable.rows[0]?.gradeitem ?? Translate.instant('core.grades.grades'); this.title = this.swipeManager?.getPageTitle()
?? formattedTable.rows[0]?.gradeitem
?? Translate.instant('core.grades.grades');
this.columns = formattedTable.columns; this.columns = formattedTable.columns;
this.rows = formattedTable.rows; this.rows = formattedTable.rows;
this.rowsOnView = this.getRowsOnHeight(); this.rowsOnView = this.getRowsOnHeight();
@ -240,3 +252,64 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
} }
} }
/**
* Swipe manager helper methods.
*/
interface CoreGradesCourseSwipeManager extends CoreSwipeNavigationItemsManager {
/**
* Get title to use in the current page.
*/
getPageTitle(): string | undefined;
}
/**
* Swipe manager for courses grades.
*/
class CoreGradesCourseCoursesSwipeManager extends CoreSwipeNavigationItemsManager<CoreGradesGradeOverviewWithCourseData>
implements CoreGradesCourseSwipeManager {
constructor(source: CoreGradesCoursesSource) {
super(source);
}
/**
* @inheritdoc
*/
getPageTitle(): string | undefined {
const selectedItem = this.getSelectedItem();
return selectedItem?.courseFullName;
}
}
/**
* Swipe manager for participants grades.
*/
class CoreGradesCourseParticipantsSwipeManager extends CoreSwipeNavigationItemsManager<CoreUserParticipant | CoreUserData>
implements CoreGradesCourseSwipeManager {
constructor(source: CoreUserParticipantsSource) {
super(source);
}
/**
* @inheritdoc
*/
getPageTitle(): string | undefined {
const selectedItem = this.getSelectedItem();
return selectedItem?.fullname;
}
/**
* @inheritdoc
*/
protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null {
return route.params.userId;
}
}

View File

@ -16,8 +16,13 @@ import { Injectable } from '@angular/core';
import { CoreLogger } from '@singletons/logger'; import { CoreLogger } from '@singletons/logger';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreCourses, CoreEnrolledCourseData, CoreCourseSearchedData } from '@features/courses/services/courses'; import {
import { CoreCourse } from '@features/course/services/course'; CoreCourses,
CoreEnrolledCourseData,
CoreCourseSearchedData,
CoreCourseUserAdminOrNavOptionIndexed,
} from '@features/courses/services/courses';
import { CoreCourse, CoreCourseProvider } from '@features/course/services/course';
import { import {
CoreGrades, CoreGrades,
CoreGradesGradeItem, CoreGradesGradeItem,
@ -38,8 +43,10 @@ import { CoreError } from '@classes/errors/error';
import { CoreCourseHelper } from '@features/course/services/course-helper'; import { CoreCourseHelper } from '@features/course/services/course-helper';
import { CoreAppProvider } from '@services/app'; import { CoreAppProvider } from '@services/app';
import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
import { CoreCourseAccess } from '@features/course/services/course-options-delegate';
export const GRADES_PAGE_NAME = 'grades'; export const GRADES_PAGE_NAME = 'grades';
export const GRADES_PARTICIPANTS_PAGE_NAME = 'participant-grades';
/** /**
* Service that provides some features regarding grades information. * Service that provides some features regarding grades information.
@ -787,6 +794,30 @@ export class CoreGradesHelperProvider {
return 'outcomeid' in item; return 'outcomeid' in item;
} }
/**
* Check whether to show the gradebook to this user.
*
* @param courseId The course ID.
* @param accessData Access type and data. Default, guest, ...
* @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions.
* @returns Whether to show the gradebook to this user.
*/
async showGradebook(
courseId: number,
accessData: CoreCourseAccess,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): Promise<boolean> {
if (accessData && accessData.type == CoreCourseProvider.ACCESS_GUEST) {
return false; // Not enabled for guests.
}
if (navOptions && navOptions.grades !== undefined) {
return navOptions.grades;
}
return CoreGrades.isPluginEnabledForCourse(courseId);
}
} }
export const CoreGradesHelper = makeSingleton(CoreGradesHelperProvider); export const CoreGradesHelper = makeSingleton(CoreGradesHelperProvider);

View File

@ -77,6 +77,16 @@ export class CoreGradesProvider {
return this.ROOT_CACHE_KEY + 'items:' + courseId + ':'; return this.ROOT_CACHE_KEY + 'items:' + courseId + ':';
} }
/**
* Get prefix cache key for grade permissions WS calls.
*
* @param courseId ID of the course to check permissions.
* @returns Cache key.
*/
protected getCourseGradesPermissionsCacheKey(courseId: number): string {
return this.getCourseGradesPrefixCacheKey(courseId) + ':canviewallgrades';
}
/** /**
* Get cache key for courses grade WS calls. * Get cache key for courses grade WS calls.
* *
@ -290,6 +300,17 @@ export class CoreGradesProvider {
await site.invalidateWsCacheForKey(this.getCourseGradesItemsCacheKey(courseId, userId, groupId)); await site.invalidateWsCacheForKey(this.getCourseGradesItemsCacheKey(courseId, userId, groupId));
} }
/**
* Invalidates course grade permissions WS calls.
*
* @param courseId ID of the course to get the permissions from.
*/
async invalidateCourseGradesPermissionsData(courseId: number): Promise<void> {
const site = CoreSites.getRequiredCurrentSite();
await site.invalidateWsCacheForKey(this.getCourseGradesPermissionsCacheKey(courseId));
}
/** /**
* Returns whether or not the plugin is enabled for a certain site. * Returns whether or not the plugin is enabled for a certain site.
* *
@ -389,6 +410,30 @@ export class CoreGradesProvider {
await site?.write('gradereport_overview_view_grade_report', params); await site?.write('gradereport_overview_view_grade_report', params);
} }
/**
* Check whether the current user can view all the grades in the course.
*
* @param courseId Course id.
* @returns Whether the current user can view all the grades.
*/
async canViewAllGrades(courseId: number): Promise<boolean> {
const site = CoreSites.getRequiredCurrentSite();
if (!site.wsAvailable('gradereport_user_get_access_information')) {
return false;
}
const params: CoreGradesGetUserAccessInformationWSParams = { courseid: courseId };
const preSets: CoreSiteWSPreSets = { cacheKey: this.getCourseGradesPermissionsCacheKey(courseId) };
const access = await site.read<CoreGradesGetUserAccessInformationWSResponse>(
'gradereport_user_get_access_information',
params,
preSets,
);
return access.canviewallgrades;
}
} }
export const CoreGrades = makeSingleton(CoreGradesProvider); export const CoreGrades = makeSingleton(CoreGradesProvider);
@ -426,6 +471,13 @@ type CoreGradesGetOverviewCourseGradesWSParams = {
userid?: number; // Get grades for this user (optional, default current). userid?: number; // Get grades for this user (optional, default current).
}; };
/**
* Params of gradereport_user_get_access_information WS.
*/
type CoreGradesGetUserAccessInformationWSParams = {
courseid: number; // Id of the course.
};
/** /**
* Data returned by gradereport_user_get_grade_items WS. * Data returned by gradereport_user_get_grade_items WS.
*/ */
@ -457,6 +509,15 @@ export type CoreGradesGetOverviewCourseGradesWSResponse = {
warnings?: CoreWSExternalWarning[]; warnings?: CoreWSExternalWarning[];
}; };
/**
* Data returned by gradereport_user_get_access_information WS.
*/
type CoreGradesGetUserAccessInformationWSResponse = {
canviewusergradereport: boolean;
canviewmygrades: boolean;
canviewallgrades: boolean;
};
/** /**
* Grade item data. * Grade item data.
*/ */

View File

@ -13,13 +13,13 @@
// limitations under the License. // limitations under the License.
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreCourseProvider } from '@features/course/services/course';
import { import {
CoreCourseAccess, CoreCourseAccess,
CoreCourseOptionsHandler, CoreCourseOptionsHandler,
CoreCourseOptionsHandlerData, CoreCourseOptionsHandlerData,
} from '@features/course/services/course-options-delegate'; } from '@features/course/services/course-options-delegate';
import { CoreCourseAnyCourseData, CoreCourses, CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses'; import { CoreCourseAnyCourseData, CoreCourses, CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses';
import { CoreGradesHelper, GRADES_PAGE_NAME } from '@features/grades/services/grades-helper';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { CoreGrades } from '../grades'; import { CoreGrades } from '../grades';
@ -35,13 +35,15 @@ export class CoreGradesCourseOptionHandlerService implements CoreCourseOptionsHa
/** /**
* @inheritdoc * @inheritdoc
*/ */
invalidateEnabledForCourse(courseId: number, navOptions?: CoreCourseUserAdminOrNavOptionIndexed): Promise<void> { async invalidateEnabledForCourse(courseId: number, navOptions?: CoreCourseUserAdminOrNavOptionIndexed): Promise<void> {
await CoreGrades.invalidateCourseGradesPermissionsData(courseId);
if (navOptions && navOptions.grades !== undefined) { if (navOptions && navOptions.grades !== undefined) {
// No need to invalidate anything. // No need to invalidate user courses.
return Promise.resolve(); return;
} }
return CoreCourses.invalidateUserCourses(); await CoreCourses.invalidateUserCourses();
} }
/** /**
@ -54,20 +56,20 @@ export class CoreGradesCourseOptionHandlerService implements CoreCourseOptionsHa
/** /**
* @inheritdoc * @inheritdoc
*/ */
isEnabledForCourse( async isEnabledForCourse(
courseId: number, courseId: number,
accessData: CoreCourseAccess, accessData: CoreCourseAccess,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed, navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): boolean | Promise<boolean> { ): Promise<boolean> {
if (accessData && accessData.type == CoreCourseProvider.ACCESS_GUEST) { const showGradebook = await CoreGradesHelper.showGradebook(courseId, accessData, navOptions);
return false; // Not enabled for guests.
if (!showGradebook) {
return false;
} }
if (navOptions && navOptions.grades !== undefined) { const canViewAllGrades = await CoreGrades.canViewAllGrades(courseId);
return navOptions.grades;
}
return CoreGrades.isPluginEnabledForCourse(courseId); return !canViewAllGrades;
} }
/** /**
@ -77,7 +79,7 @@ export class CoreGradesCourseOptionHandlerService implements CoreCourseOptionsHa
return { return {
title: 'core.grades.grades', title: 'core.grades.grades',
class: 'core-grades-course-handler', class: 'core-grades-course-handler',
page: 'grades', page: GRADES_PAGE_NAME,
}; };
} }

View File

@ -0,0 +1,88 @@
// (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 {
CoreCourseAccess,
CoreCourseOptionsHandler,
CoreCourseOptionsHandlerData,
} from '@features/course/services/course-options-delegate';
import { CoreCourses, CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses';
import { CoreGrades } from '@features/grades/services/grades';
import { CoreGradesHelper, GRADES_PARTICIPANTS_PAGE_NAME } from '@features/grades/services/grades-helper';
import { makeSingleton } from '@singletons';
/**
* Course nav handler.
*/
@Injectable({ providedIn: 'root' })
export class CoreGradesCourseParticipantsOptionHandlerService implements CoreCourseOptionsHandler {
name = 'CoreGradesParticipants';
priority = 400;
/**
* @inheritdoc
*/
async invalidateEnabledForCourse(courseId: number, navOptions?: CoreCourseUserAdminOrNavOptionIndexed): Promise<void> {
await CoreGrades.invalidateCourseGradesPermissionsData(courseId);
if (navOptions && navOptions.grades !== undefined) {
// No need to invalidate user courses.
return;
}
await CoreCourses.invalidateUserCourses();
}
/**
* @inheritdoc
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* @inheritdoc
*/
async isEnabledForCourse(
courseId: number,
accessData: CoreCourseAccess,
navOptions?: CoreCourseUserAdminOrNavOptionIndexed,
): Promise<boolean> {
const showGradebook = await CoreGradesHelper.showGradebook(courseId, accessData, navOptions);
if (!showGradebook) {
return false;
}
const canViewAllGrades = await CoreGrades.canViewAllGrades(courseId);
return canViewAllGrades;
}
/**
* @inheritdoc
*/
getDisplayData(): CoreCourseOptionsHandlerData | Promise<CoreCourseOptionsHandlerData> {
return {
title: 'core.grades.grades',
class: 'core-grades-course-participants-handler',
page: GRADES_PARTICIPANTS_PAGE_NAME,
};
}
}
export const CoreGradesCourseParticipantsOptionHandler = makeSingleton(CoreGradesCourseParticipantsOptionHandlerService);

View File

@ -0,0 +1,100 @@
@app @javascript @lms_upto4.1
Feature: Grades navigation
Background:
Given the following "users" exist:
| username | firstname | lastname |
| student1 | Student | first |
| student2 | Student | second |
| teacher1 | Teacher | first |
And the following "courses" exist:
| fullname | shortname |
| Course 2 | C2 |
| Course 1 | C1 |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student1 | C2 | student |
| student2 | C2 | student |
| teacher1 | C2 | editingteacher |
And the following "grade categories" exist:
| fullname | course |
| GC C1 | C1 |
| GC C2.1 | C2 |
| GC C2.2 | C2 |
And the following "grade items" exist:
| gradecategory | itemname | grademin | grademax | course |
| GC C1 | GI C1 | 20 | 40 | C1 |
| GC C2.1 | GI C2.1.1 | 60 | 80 | C2 |
| GC C2.1 | GI C2.1.2 | 10 | 90 | C2 |
| GC C2.2 | GI C2.2.1 | 0 | 100 | C2 |
And the following "grade grades" exist:
| gradeitem | user | grade |
| GI C1 | student1 | 30 |
| GI C2.1.1 | student1 | 70 |
| GI C2.1.2 | student1 | 20 |
| GI C2.2.1 | student1 | 40 |
Scenario: Mobile navigation (teacher)
Given I entered the course "Course 2" as "teacher1" in the app
# Course grades
When I press "Participants" in the app
And I press "Student first" in the app
And I press "Grades" in the app
Then I should find "GC C2.1" in the app
And I should find "70" within "GI C2.1.1" "tr" in the app
And I should find "20" within "GI C2.1.2" "tr" in the app
And I should find "90" within "GC C2.1 total" "tr" in the app
And I should find "GC C2.2" in the app
And I should find "40" within "GI C2.2.1" "tr" in the app
And I should find "40" within "GC C2.2 total" "tr" in the app
And I should find "130" within "Course total" "tr" in the app
But I should not find "GC C1" in the app
And I should not find "GI C1" in the app
# Course grades details
When I press "GI C2.1.1" in the app
Then I should find "Weight" in the app
And I should find "70.00" within "Grade" "ion-item" in the app
And I should find "6080" within "Range" "ion-item" in the app
And I should find "50.00 %" within "Percentage" "ion-item" in the app
And I should find "Contribution to course total" in the app
And I should find "GI C2.1.2" in the app
When I press "GI C2.1.1" in the app
Then I should not find "Weight" in the app
And I should not find "Range" in the app
And I should not find "Percentage" in the app
And I should not find "Contribution to course total" in the app
But I should find "GI C2.1.1" in the app
And I should find "GI C2.1.2" in the app
When I press "Course total" in the app
Then I should find "130" within "Grade" "ion-item" in the app
And I should find "0270" within "Range" "ion-item" in the app
When I press "Course total" in the app
Then I should not find "Weight" in the app
And I should not find "Percentage" in the app
Scenario: Tablet navigation (teacher)
Given I entered the course "Course 2" as "teacher1" in the app
And I change viewport size to "1200x640"
# Course grades
When I press "Participants" in the app
And I press "Student first" in the app
And I press "Grades" in the app
Then I should find "GC C2.1" in the app
And I should find "Weight" in the app
And I should find "Contribution to course total" in the app
And I should find "70.00" within "GI C2.1.1" "tr" in the app
And I should find "6080" within "GI C2.1.1" "tr" in the app
And I should find "50.00 %" within "GI C2.1.1" "tr" in the app
And I should find "20" within "GI C2.1.2" "tr" in the app
And I should find "90" within "GC C2.1 total" "tr" in the app
And I should find "GC C2.2" in the app
And I should find "40" within "GI C2.2.1" "tr" in the app
And I should find "40" within "GC C2.2 total" "tr" in the app
And I should find "130" within "Course total" "tr" in the app

View File

@ -151,13 +151,13 @@ Feature: Grades navigation
Then I should find "Course 1" in the app Then I should find "Course 1" in the app
And I should find "Course 2" in the app And I should find "Course 2" in the app
@lms_from4.2
Scenario: Mobile navigation (teacher) Scenario: Mobile navigation (teacher)
Given I entered the course "Course 2" as "teacher1" in the app Given I entered the course "Course 2" as "teacher1" in the app
# Course grades # Course grades
When I press "Participants" in the app When I press "Grades" in the app
And I press "Student first" in the app And I press "Student first" in the app
And I press "Grades" in the app
Then I should find "GC C2.1" in the app Then I should find "GC C2.1" in the app
And I should find "70" within "GI C2.1.1" "tr" in the app And I should find "70" within "GI C2.1.1" "tr" in the app
And I should find "20" within "GI C2.1.2" "tr" in the app And I should find "20" within "GI C2.1.2" "tr" in the app
@ -194,6 +194,13 @@ Feature: Grades navigation
Then I should not find "Weight" in the app Then I should not find "Weight" in the app
And I should not find "Percentage" in the app And I should not find "Percentage" in the app
# Profile grades
When I press the back button in the app
And I press "Participants" in the app
And I press "Student first" in the app
And I press "Grades" in the app
Then I should find "GC C2.1" in the app
Scenario: Tablet navigation (student) Scenario: Tablet navigation (student)
Given I entered the course "Course 2" as "student1" in the app Given I entered the course "Course 2" as "student1" in the app
And I change viewport size to "1200x640" And I change viewport size to "1200x640"
@ -274,23 +281,53 @@ Feature: Grades navigation
Then I should not find "Weight" inside the split-view content in the app Then I should not find "Weight" inside the split-view content in the app
And I should not find "Percentage" inside the split-view content in the app And I should not find "Percentage" inside the split-view content in the app
@lms_from4.2
Scenario: Tablet navigation (teacher) Scenario: Tablet navigation (teacher)
Given I entered the course "Course 2" as "teacher1" in the app Given I entered the course "Course 2" as "teacher1" in the app
And I change viewport size to "1200x640" And I change viewport size to "1200x640"
# Course grades # User grades
When I press "Grades" in the app
And I press "Student first" in the app
Then "Student first" should be selected in the app
And I should find "GC C2.1" inside the split-view content in the app
And I should find "70" within "GI C2.1.1" "tr" inside the split-view content in the app
And I should find "20" within "GI C2.1.2" "tr" inside the split-view content in the app
And I should find "90" within "GC C2.1 total" "tr" inside the split-view content in the app
And I should find "GC C2.2" inside the split-view content in the app
And I should find "40" within "GI C2.2.1" "tr" inside the split-view content in the app
And I should find "40" within "GC C2.2 total" "tr" inside the split-view content in the app
And I should find "130" within "Course total" "tr" inside the split-view content in the app
But I should not find "GC C1" inside the split-view content in the app
And I should not find "GI C1" inside the split-view content in the app
# User grades details
When I press "GI C2.1.1" in the app
Then I should find "Weight" inside the split-view content in the app
And I should find "70.00" within "Grade" "ion-item" inside the split-view content in the app
And I should find "6080" within "Range" "ion-item" inside the split-view content in the app
And I should find "50.00 %" within "Percentage" "ion-item" inside the split-view content in the app
And I should find "Contribution to course total" inside the split-view content in the app
And I should find "GI C2.1.2" inside the split-view content in the app
When I press "GI C2.1.1" in the app
Then I should not find "Weight" inside the split-view content in the app
And I should not find "Range" inside the split-view content in the app
And I should not find "Percentage" inside the split-view content in the app
And I should not find "Contribution to course total" inside the split-view content in the app
But I should find "GI C2.1.1" inside the split-view content in the app
And I should find "GI C2.1.2" inside the split-view content in the app
When I press "Course total" in the app
Then I should find "130" within "Grade" "ion-item" inside the split-view content in the app
And I should find "0270" within "Range" "ion-item" inside the split-view content in the app
When I press "Course total" in the app
Then I should not find "Weight" inside the split-view content in the app
And I should not find "Percentage" inside the split-view content in the app
# Profile grades
When I press "Participants" in the app When I press "Participants" in the app
And I press "Student first" in the app And I press "Student first" in the app
And I press "Grades" in the app And I press "Grades" in the app
Then I should find "GC C2.1" in the app Then I should find "GC C2.1" in the app
And I should find "Weight" in the app
And I should find "Contribution to course total" in the app
And I should find "70.00" within "GI C2.1.1" "tr" in the app
And I should find "6080" within "GI C2.1.1" "tr" in the app
And I should find "50.00 %" within "GI C2.1.1" "tr" in the app
And I should find "20" within "GI C2.1.2" "tr" in the app
And I should find "90" within "GC C2.1 total" "tr" in the app
And I should find "GC C2.2" in the app
And I should find "40" within "GI C2.2.1" "tr" in the app
And I should find "40" within "GC C2.2 total" "tr" in the app
And I should find "130" within "Course total" "tr" in the app

View File

@ -1,17 +1,11 @@
<core-navbar-buttons slot="end">
<ion-button [hidden]="!searchEnabled" (click)="toggleSearch()" [attr.aria-label]="'core.search' | translate">
<ion-icon name="fas-magnifying-glass" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</core-navbar-buttons>
<ion-content> <ion-content>
<core-split-view> <core-split-view>
<ion-refresher slot="fixed" [disabled]="!participants.loaded || searchInProgress" (ionRefresh)="refreshParticipants($event.target)"> <ion-refresher slot="fixed" [disabled]="!participants.loaded || searchInProgress" (ionRefresh)="refreshParticipants($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher> </ion-refresher>
<core-search-box *ngIf="showSearchBox" [disabled]="searchInProgress" [spellcheck]="false" [autoFocus]="true" [lengthCheck]="1" <core-search-box [disabled]="searchInProgress" [spellcheck]="false" [autoFocus]="true" [lengthCheck]="1" autocorrect="off"
autocorrect="off" searchArea="CoreUserParticipants" (onSubmit)="search($event)" (onClear)="clearSearch()"> searchArea="CoreUserParticipants" (onSubmit)="search($event)" (onClear)="clearSearch()">
</core-search-box> </core-search-box>
<core-loading [hideUntil]="participants.loaded"> <core-loading [hideUntil]="participants.loaded">

View File

@ -0,0 +1,31 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreUserParticipantsPage } from './participants.page';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
@NgModule({
imports: [
CoreSharedModule,
CoreSearchComponentsModule,
],
declarations: [
CoreUserParticipantsPage,
],
})
export class CoreUserParticipantsPageModule {}

View File

@ -31,6 +31,7 @@ import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/
@Component({ @Component({
selector: 'page-core-user-participants', selector: 'page-core-user-participants',
templateUrl: 'participants.html', templateUrl: 'participants.html',
styleUrls: ['participants.scss'],
}) })
export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestroy { export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestroy {
@ -39,7 +40,6 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro
searchQuery: string | null = null; searchQuery: string | null = null;
searchInProgress = false; searchInProgress = false;
searchEnabled = false; searchEnabled = false;
showSearchBox = false;
fetchMoreParticipantsFailed = false; fetchMoreParticipantsFailed = false;
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
@ -84,20 +84,6 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro
this.participants.destroy(); this.participants.destroy();
} }
/**
* Show or hide search box.
*/
toggleSearch(): void {
this.showSearchBox = !this.showSearchBox;
if (this.showSearchBox) {
// Make search bar visible.
this.splitView.menuContent.scrollToTop();
} else {
this.clearSearch();
}
}
/** /**
* Clear search. * Clear search.
*/ */

View File

@ -0,0 +1,13 @@
:host {
core-split-view {
isolation: isolate;
}
core-search-box {
position: sticky;
top: 8px;
z-index: 1;
}
}

View File

@ -15,12 +15,10 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { CoreSharedModule } from '@/core/shared.module'; import { CoreUserParticipantsPage } from './pages/participants/participants.page';
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
import { CoreUserParticipantsPage } from './pages/participants/participants';
import { conditionalRoutes } from '@/app/app-routing.module'; import { conditionalRoutes } from '@/app/app-routing.module';
import { CoreScreen } from '@services/screen'; import { CoreScreen } from '@services/screen';
import { CoreUserParticipantsPageModule } from '@features/user/pages/participants/participants.module';
const routes: Routes = [ const routes: Routes = [
{ {
@ -38,11 +36,7 @@ const routes: Routes = [
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild(routes), RouterModule.forChild(routes),
CoreSharedModule, CoreUserParticipantsPageModule,
CoreSearchComponentsModule,
],
declarations: [
CoreUserParticipantsPage,
], ],
}) })
export class CoreUserCourseLazyModule {} export class CoreUserCourseLazyModule {}