diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 957f6e580..937bc5166 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -40,10 +40,11 @@ function buildAppRoutes(injector: Injector): Routes { /** * Create a url matcher that will only match when a given condition is met. * + * @param pathOrMatcher Original path or matcher configured in the route. * @param condition Condition. * @return Conditional url matcher. */ -function buildConditionalUrlMatcher(condition: () => boolean): UrlMatcher { +function buildConditionalUrlMatcher(pathOrMatcher: string | UrlMatcher, condition: () => boolean): UrlMatcher { // Create a matcher based on Angular's default matcher. // see https://github.com/angular/angular/blob/10.0.x/packages/router/src/shared.ts#L127 return (segments: UrlSegment[], segmentGroup: UrlSegmentGroup, route: Route): UrlMatchResult | null => { @@ -52,10 +53,20 @@ function buildConditionalUrlMatcher(condition: () => boolean): UrlMatcher { return null; } - const { path, pathMatch } = route as { path: string; pathMatch?: 'full' }; - const posParams: Record = {}; - const isFullMatch = pathMatch === 'full'; + // Use existing matcher if any. + if (typeof pathOrMatcher === 'function') { + return pathOrMatcher(segments, segmentGroup, route); + } + + const path = pathOrMatcher; const parts = path.split('/'); + const isFullMatch = route.pathMatch === 'full'; + const posParams: Record = {}; + + // The path matches anything. + if (path === '') { + return (!isFullMatch || segments.length === 0) ? { consumed: [] } : null; + } // The actual URL is shorter than the config, no match. if (parts.length > segments.length) { @@ -97,12 +108,15 @@ export type ModuleRoutesConfig = Routes | Partial; * @return Conditional routes. */ export function conditionalRoutes(routes: Routes, condition: () => boolean): Routes { - const conditionalMatcher = buildConditionalUrlMatcher(condition); + return routes.map(route => { + // We need to remove the path from the route because Angular doesn't call the matcher for empty paths. + const { path, matcher, ...newRoute } = route; - return routes.map(route => ({ - ...route, - matcher: conditionalMatcher, - })); + return { + ...newRoute, + matcher: buildConditionalUrlMatcher(matcher || path!, condition), + }; + }); } /** diff --git a/src/core/components/split-view/split-view.html b/src/core/components/split-view/split-view.html index ee5f74817..a585f4b0a 100644 --- a/src/core/components/split-view/split-view.html +++ b/src/core/components/split-view/split-view.html @@ -1,5 +1,5 @@ - + - + diff --git a/src/core/components/split-view/split-view.scss b/src/core/components/split-view/split-view.scss index 13cf43e23..d94a6efcc 100644 --- a/src/core/components/split-view/split-view.scss +++ b/src/core/components/split-view/split-view.scss @@ -1,11 +1,11 @@ -@import "~theme/breakpoints"; - // @todo RTL layout :host { - --side-width: 100%; - --side-min-width: 270px; - --side-max-width: 28%; + --menu-min-width: 270px; + --menu-max-width: 28%; + --menu-display: flex; + --content-display: block; + --border-width: 1; top: 0; right: 0; @@ -19,16 +19,28 @@ contain: strict; } +:host(.menu-only) { + --menu-min-width: 0; + --menu-max-width: 100%; + --content-display: none; + --border-width: 0; +} + +:host(.content-only) { + --menu-display: none; + --border-width: 0; +} + :host-context(ion-app.md) { - --border: 1px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, .13)))); + --border: calc(var(--border-width) * 1px) solid 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: .55px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-250, #c8c7cc))); + --border: calc(var(--border-width) * .55px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-250, #c8c7cc))); } -ion-content, -ion-router-outlet { +.menu, +.content { top: 0; right: 0; bottom: 0; @@ -38,49 +50,25 @@ ion-router-outlet { z-index: 0; } -ion-content { - display: flex; +.menu { + display: var(--menu-display); flex-shrink: 0; order: -1; border-left: unset; border-right: unset; border-inline-start: 0; - border-inline-end: 0; + border-inline-end: var(--border); + min-width: var(--menu-min-width); + max-width: var(--menu-max-width); width: 100%; } -ion-router-outlet { +.content { + display: var(--content-display); flex: 1; - display: none; ::ng-deep ion-header { display: none; } } - -:host(.outlet-activated) { - - ion-router-outlet { - display: block; - } - - ion-content { - display: none; - } - -} - -@media (min-width: $breakpoint-tablet) { - - ion-content { - border-inline-end: var(--border); - min-width: var(--side-min-width); - max-width: var(--side-max-width); - } - - :host(.outlet-activated) ion-content { - display: flex; - } - -} diff --git a/src/core/components/split-view/split-view.ts b/src/core/components/split-view/split-view.ts index 6f2488445..22d8b1e98 100644 --- a/src/core/components/split-view/split-view.ts +++ b/src/core/components/split-view/split-view.ts @@ -12,10 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AfterViewInit, Component, HostBinding, OnDestroy, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, HostBinding, OnDestroy, ViewChild } from '@angular/core'; import { IonRouterOutlet } from '@ionic/angular'; +import { CoreScreen } from '@services/screen'; import { Subscription } from 'rxjs'; +enum CoreSplitViewMode { + MenuOnly = 'menu-only', // Hides content. + ContentOnly = 'content-only', // Hides menu. + MenuAndContent = 'menu-and-content', // Shows both menu and content. +} + @Component({ selector: 'core-split-view', templateUrl: 'split-view.html', @@ -24,19 +31,25 @@ import { Subscription } from 'rxjs'; export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { @ViewChild(IonRouterOutlet) outlet!: IonRouterOutlet; - @HostBinding('class.outlet-activated') outletActivated = false; + @HostBinding('class') classes = ''; + isNested = false; private subscriptions?: Subscription[]; + constructor(private element: ElementRef) {} + /** * @inheritdoc */ ngAfterViewInit(): void { - this.outletActivated = this.outlet.isActivated; + this.isNested = !!this.element.nativeElement.parentElement?.closest('core-split-view'); this.subscriptions = [ - this.outlet.activateEvents.subscribe(() => this.outletActivated = true), - this.outlet.deactivateEvents.subscribe(() => this.outletActivated = false), + this.outlet.activateEvents.subscribe(() => this.updateClasses()), + this.outlet.deactivateEvents.subscribe(() => this.updateClasses()), + CoreScreen.instance.layoutObservable.subscribe(() => this.updateClasses()), ]; + + this.updateClasses(); } /** @@ -46,4 +59,37 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { this.subscriptions?.forEach(subscription => subscription.unsubscribe()); } + /** + * Update host classes. + */ + private updateClasses(): void { + const classes: string[] = [this.getCurrentMode()]; + + if (this.isNested) { + classes.push('nested'); + } + + this.classes = classes.join(' '); + } + + /** + * Get the current mode. Depending on the layout, outlet status, and whether this split view + * is nested or not, this method will indicate which parts of the split view should be visible. + * + * @return Split view mode. + */ + private getCurrentMode(): CoreSplitViewMode { + if (this.isNested) { + return CoreSplitViewMode.MenuOnly; + } + + if (CoreScreen.instance.isMobile) { + return this.outlet.isActivated + ? CoreSplitViewMode.ContentOnly + : CoreSplitViewMode.MenuOnly; + } + + return CoreSplitViewMode.MenuAndContent; + } + } diff --git a/src/core/directives/link.ts b/src/core/directives/link.ts index 40efb60b9..9d31dfdda 100644 --- a/src/core/directives/link.ts +++ b/src/core/directives/link.ts @@ -58,7 +58,7 @@ export class CoreLinkDirective implements OnInit { // @todo: Handle split view? - this.element.addEventListener('click', (event) => { + this.element.addEventListener('click', async (event) => { if (event.defaultPrevented) { return; // Link already treated, stop. } @@ -77,7 +77,8 @@ export class CoreLinkDirective implements OnInit { if (CoreUtils.instance.isTrueOrOne(this.capture)) { href = CoreTextUtils.instance.decodeURI(href); - const treated = CoreContentLinksHelper.instance.handleLink(href, undefined, true, true); + const treated = await CoreContentLinksHelper.instance.handleLink(href, undefined, true, true); + if (!treated) { this.navigate(href, openIn); } diff --git a/src/core/features/contentlinks/classes/base-handler.ts b/src/core/features/contentlinks/classes/base-handler.ts index e216559d8..0e0e43d3a 100644 --- a/src/core/features/contentlinks/classes/base-handler.ts +++ b/src/core/features/contentlinks/classes/base-handler.ts @@ -58,6 +58,7 @@ export class CoreContentLinksHandlerBase implements CoreContentLinksHandler { * @param url The URL to treat. * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @param courseId Course ID related to the URL. Optional but recommended. + * @param data Extra data to handle the URL. * @return List of (or promise resolved with list of) actions. */ getActions( @@ -69,6 +70,8 @@ export class CoreContentLinksHandlerBase implements CoreContentLinksHandler { params: Params, // eslint-disable-next-line @typescript-eslint/no-unused-vars courseId?: number, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + data?: unknown, ): CoreContentLinksAction[] | Promise { return []; } diff --git a/src/core/features/course/services/course-options-delegate.ts b/src/core/features/course/services/course-options-delegate.ts index 9c28d0071..2b13c9efe 100644 --- a/src/core/features/course/services/course-options-delegate.ts +++ b/src/core/features/course/services/course-options-delegate.ts @@ -48,7 +48,7 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler { * @return True or promise resolved with true if enabled. */ isEnabledForCourse(courseId: number, - accessData: any, // @todo: define type. + accessData: CoreCourseAccessData, navOptions?: CoreCourseUserAdminOrNavOptionIndexed, admOptions?: CoreCourseUserAdminOrNavOptionIndexed, ): boolean | Promise; @@ -672,3 +672,6 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate 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 {} diff --git a/src/core/features/grades/grades.module.ts b/src/core/features/grades/grades.module.ts new file mode 100644 index 000000000..94ea4f04d --- /dev/null +++ b/src/core/features/grades/grades.module.ts @@ -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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; +import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate'; +import { CoreUserDelegate } from '@features/user/services/user-delegate'; +import { CoreGradesCourseOptionHandler } from './services/handlers/course-option'; +import CoreGradesMainMenuHandler, { CoreGradesMainMenuHandlerService } from './services/handlers/mainmenu'; +import { CoreGradesOverviewLinkHandler } from './services/handlers/overview-link'; +import { CoreGradesUserHandler } from './services/handlers/user'; +import { CoreGradesUserLinkHandler } from './services/handlers/user-link'; + +const routes: Routes = [ + { + path: CoreGradesMainMenuHandlerService.PAGE_NAME, + loadChildren: () => import('@features/grades/grades-lazy.module').then(m => m.CoreGradesLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + CoreMainMenuRoutingModule.forChild({ children: routes }), + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useValue: () => { + CoreMainMenuDelegate.instance.registerHandler(CoreGradesMainMenuHandler.instance); + CoreUserDelegate.instance.registerHandler(CoreGradesUserHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(CoreGradesUserLinkHandler.instance); + CoreContentLinksDelegate.instance.registerHandler(CoreGradesOverviewLinkHandler.instance); + CoreCourseOptionsDelegate.instance.registerHandler(CoreGradesCourseOptionHandler.instance); + }, + }, + ], +}) +export class CoreGradesModule {} diff --git a/src/core/features/grades/lang.json b/src/core/features/grades/lang.json new file mode 100644 index 000000000..79e5dbda0 --- /dev/null +++ b/src/core/features/grades/lang.json @@ -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" +} diff --git a/src/core/features/grades/pages/course/course.html b/src/core/features/grades/pages/course/course.html new file mode 100644 index 000000000..a7ba31495 --- /dev/null +++ b/src/core/features/grades/pages/course/course.html @@ -0,0 +1,70 @@ + + + + + + {{ 'core.grades.grades' | translate }} + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ {{ 'core.grades.' + column.name | translate }} +
+ + + +
+
+
+
+
diff --git a/src/core/features/grades/pages/course/course.scss b/src/core/features/grades/pages/course/course.scss new file mode 100644 index 000000000..fdad1dc2b --- /dev/null +++ b/src/core/features/grades/pages/course/course.scss @@ -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; + } + +} diff --git a/src/core/features/grades/pages/course/course.ts b/src/core/features/grades/pages/course/course.ts new file mode 100644 index 000000000..eb3385778 --- /dev/null +++ b/src/core/features/grades/pages/course/course.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } + +} diff --git a/src/core/features/grades/pages/courses/courses.html b/src/core/features/grades/pages/courses/courses.html new file mode 100644 index 000000000..64b6a39a4 --- /dev/null +++ b/src/core/features/grades/pages/courses/courses.html @@ -0,0 +1,43 @@ + + + + + + {{ 'core.grades.grades' | translate }} + + + + + + + + + + + + + + + + {{grade.grade}} + + + + + diff --git a/src/core/features/grades/pages/courses/courses.ts b/src/core/features/grades/pages/courses/courses.ts new file mode 100644 index 000000000..e25404996 --- /dev/null +++ b/src/core/features/grades/pages/courses/courses.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } + +} diff --git a/src/core/features/grades/pages/grade/grade.html b/src/core/features/grades/pages/grade/grade.html new file mode 100644 index 000000000..0e81eec52 --- /dev/null +++ b/src/core/features/grades/pages/grade/grade.html @@ -0,0 +1,97 @@ + + + + + + {{ 'core.grades.grade' | translate }} + + + + + + + + + + + + + + +

+
+
+ + + + + +

+
+
+ + + +

{{ 'core.grades.weight' | translate}}

+

+
+
+ + + +

{{ 'core.grades.grade' | translate}}

+

+
+
+ + + +

{{ 'core.grades.range' | translate}}

+

+
+
+ + + +

{{ 'core.grades.percentage' | translate}}

+

+
+
+ + + +

{{ 'core.grades.lettergrade' | translate}}

+

+
+
+ + + +

{{ 'core.grades.rank' | translate}}

+

+
+
+ + + +

{{ 'core.grades.average' | translate}}

+

+
+
+ + + +

{{ 'core.grades.feedback' | translate}}

+

+
+
+ + + +

{{ 'core.grades.contributiontocoursetotal' | translate}}

+

+
+
+
+
+
diff --git a/src/core/features/grades/pages/grade/grade.ts b/src/core/features/grades/pages/grade/grade.ts new file mode 100644 index 000000000..3d41e0b7f --- /dev/null +++ b/src/core/features/grades/pages/grade/grade.ts @@ -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 { + 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 { + await CoreUtils.instance.ignoreErrors(CoreGrades.instance.invalidateCourseGradesData(this.courseId, this.userId)); + await CoreUtils.instance.ignoreErrors(this.fetchGrade()); + + refresher.complete(); + } + +} diff --git a/src/core/features/grades/services/grades-helper.ts b/src/core/features/grades/services/grades-helper.ts new file mode 100644 index 000000000..0bb36e7ee --- /dev/null +++ b/src/core/features/grades/services/grades-helper.ts @@ -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, '
'); + } + + if (content == ' ') { + 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, '
'); + } + + if (content == ' ') { + 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 { + // 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[], 'id') as + Record | + Record; + + this.addCourseData(grades, coursesMap); + } + + return (grades as Record[]) + .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 | Record, + ): 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 { + 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 { + 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 { + 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 { + 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(' -1) { + row['itemtype'] = 'unknown'; + const src = text.match(/ { + 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; +}; diff --git a/src/core/features/grades/services/grades.ts b/src/core/features/grades/services/grades.ts new file mode 100644 index 000000000..22c3a1103 --- /dev/null +++ b/src/core/features/grades/services/grades.ts @@ -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 { + 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 { + 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( + '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 { + 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('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 { + 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( + '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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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. +}; diff --git a/src/core/features/grades/services/handlers/course-option.ts b/src/core/features/grades/services/handlers/course-option.ts new file mode 100644 index 000000000..5f5f050c7 --- /dev/null +++ b/src/core/features/grades/services/handlers/course-option.ts @@ -0,0 +1,113 @@ +// (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 { CoreCourseProvider } from '@features/course/services/course'; +import { + CoreCourseAccessData, + CoreCourseOptionsHandler, + CoreCourseOptionsHandlerData, +} from '@features/course/services/course-options-delegate'; +import { CoreCourses, CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses'; +import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper'; +import { makeSingleton } from '@singletons'; +import { CoreGrades } from '../grades'; + +/** + * Course nav handler. + */ +@Injectable({ providedIn: 'root' }) +export class CoreGradesCourseOptionHandlerService implements CoreCourseOptionsHandler { + + name = 'CoreGrades'; + priority = 400; + + /** + * Should invalidate the data to determine if the handler is enabled for a certain course. + * + * @param courseId The course ID. + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @return Promise resolved when done. + */ + invalidateEnabledForCourse(courseId: number, navOptions?: CoreCourseUserAdminOrNavOptionIndexed): Promise { + if (navOptions && typeof navOptions.grades != 'undefined') { + // No need to invalidate anything. + return Promise.resolve(); + } + + return CoreCourses.instance.invalidateUserCourses(); + } + + /** + * Check if the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): Promise { + return Promise.resolve(true); + } + + /** + * Whether or not the handler is enabled for a certain course. + * + * @param courseId The course ID. + * @param accessData Access type and data. Default, guest, ... + * @param navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @return True or promise resolved with true if enabled. + */ + isEnabledForCourse( + courseId: number, + accessData: CoreCourseAccessData, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): boolean | Promise { + if (accessData && accessData.type == CoreCourseProvider.ACCESS_GUEST) { + return false; // Not enabled for guests. + } + + if (navOptions && typeof navOptions.grades != 'undefined') { + return navOptions.grades; + } + + return CoreGrades.instance.isPluginEnabledForCourse(courseId); + } + + /** + * Returns the data needed to render the handler. + * + * @return Data or promise resolved with the data. + */ + getDisplayData(): CoreCourseOptionsHandlerData | Promise { + throw new Error('CoreGradesCourseOptionHandler.getDisplayData is not implemented'); + + // @todo + // return { + // title: 'core.grades.grades', + // class: 'core-grades-course-handler', + // component: CoreGradesCourseComponent, + // }; + } + + /** + * Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline. + * + * @param course The course. + * @return Promise resolved when done. + */ + async prefetch(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise { + await CoreGrades.instance.getCourseGradesTable(course.id, undefined, undefined, true); + } + +} + +export class CoreGradesCourseOptionHandler extends makeSingleton(CoreGradesCourseOptionHandlerService) {} diff --git a/src/core/features/grades/services/handlers/mainmenu.ts b/src/core/features/grades/services/handlers/mainmenu.ts new file mode 100644 index 000000000..b39b77f04 --- /dev/null +++ b/src/core/features/grades/services/handlers/mainmenu.ts @@ -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 { + 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) {} diff --git a/src/core/features/grades/services/handlers/overview-link.ts b/src/core/features/grades/services/handlers/overview-link.ts new file mode 100644 index 000000000..325dff4a9 --- /dev/null +++ b/src/core/features/grades/services/handlers/overview-link.ts @@ -0,0 +1,57 @@ +// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { CoreGrades } from '../grades'; + +/** + * Handler to treat links to overview courses grades. + */ +@Injectable({ providedIn: 'root' }) +export class CoreGradesOverviewLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'CoreGradesOverviewLinkHandler'; + pattern = /\/grade\/report\/overview\/index.php/; + + /** + * Get the list of actions for a link (url). + * + * @return List of (or promise resolved with list of) actions. + */ + getActions(): CoreContentLinksAction[] | Promise { + return [{ + action: siteId => { + CoreNavigator.instance.navigateToSitePath('/grades', { siteId }); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @return Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string): boolean | Promise { + return CoreGrades.instance.isCourseGradesEnabled(siteId); + } + +} + +export class CoreGradesOverviewLinkHandler extends makeSingleton(CoreGradesOverviewLinkHandlerService) {} diff --git a/src/core/features/grades/services/handlers/user-link.ts b/src/core/features/grades/services/handlers/user-link.ts new file mode 100644 index 000000000..e6a3116d5 --- /dev/null +++ b/src/core/features/grades/services/handlers/user-link.ts @@ -0,0 +1,82 @@ +// (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 { Params } from '@angular/router'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreGrades } from '@features/grades/services/grades'; +import { CoreGradesHelper } from '@features/grades/services/grades-helper'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to user grades. + */ +@Injectable({ providedIn: 'root' }) +export class CoreGradesUserLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'CoreGradesUserLinkHandler'; + pattern = /\/grade\/report(\/user)?\/index.php/; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @param data Extra data to handle the URL. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], + url: string, + params: Params, + courseId?: number, + data?: { cmid?: string }, + ): CoreContentLinksAction[] | Promise { + courseId = courseId || params.id; + data = data || {}; + + return [{ + action: (siteId, navCtrl?): void => { + const userId = params.userid && parseInt(params.userid, 10); + const moduleId = data?.cmid && parseInt(data.cmid, 10) || undefined; + + CoreGradesHelper.instance.goToGrades(courseId!, userId, moduleId, navCtrl, siteId); + }, + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise { + if (!courseId && !params.id) { + return false; + } + + return CoreGrades.instance.isPluginEnabledForCourse(courseId || params.id, siteId); + } + +} + +export class CoreGradesUserLinkHandler extends makeSingleton(CoreGradesUserLinkHandlerService) {} diff --git a/src/core/features/grades/services/handlers/user.ts b/src/core/features/grades/services/handlers/user.ts new file mode 100644 index 000000000..2895a2fe4 --- /dev/null +++ b/src/core/features/grades/services/handlers/user.ts @@ -0,0 +1,118 @@ +// (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 { CoreUserProfile } from '@features/user/services/user'; +import { + CoreUserDelegateService , + CoreUserProfileHandler, + CoreUserProfileHandlerData, +} from '@features/user/services/user-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; + +/** + * Profile grades handler. + */ +@Injectable({ providedIn: 'root' }) +export class CoreGradesUserHandlerService implements CoreUserProfileHandler { + + name = 'CoreGrades:viewGrades'; + priority = 400; + type = CoreUserDelegateService.TYPE_NEW_PAGE; + viewGradesEnabledCache = {}; + + /** + * Clear view grades cache. + * If a courseId and userId are specified, it will only delete the entry for that user and course. + * + * @param courseId Course ID. + * @param userId User ID. + */ + clearViewGradesCache(courseId?: number, userId?: number): void { + if (courseId && userId) { + delete this.viewGradesEnabledCache[this.getCacheKey(courseId, userId)]; + } else { + this.viewGradesEnabledCache = {}; + } + } + + /** + * Get a cache key to identify a course and a user. + * + * @param courseId Course ID. + * @param userId User ID. + * @return Cache key. + */ + protected getCacheKey(courseId: number, userId: number): string { + return courseId + '#' + userId; + } + + /** + * Check if handler is enabled. + * + * @return Always enabled. + */ + isEnabled(): Promise { + return Promise.resolve(true); + } + + /** + * Check if handler is enabled for this user in this context. + * + * @param user User to check. + * @param courseId Course ID. + * @return Promise resolved with true if enabled, resolved with false otherwise. + */ + async isEnabledForUser(user: CoreUserProfile, courseId: number): Promise { + const cacheKey = this.getCacheKey(courseId, user.id); + const cache = this.viewGradesEnabledCache[cacheKey]; + + if (typeof cache != 'undefined') { + return cache; + } + + const enabled = await CoreUtils.instance.ignoreErrors(CoreGrades.instance.isPluginEnabledForCourse(courseId), false); + + this.viewGradesEnabledCache[cacheKey] = enabled; + + return enabled; + } + + /** + * Returns the data needed to render the handler. + * + * @return Data needed to render the handler. + */ + getDisplayData(): CoreUserProfileHandlerData { + return { + icon: 'stats-chart', + title: 'core.grades.grades', + class: 'core-grades-user-handler', + action: (event, user, courseId): void => { + event.preventDefault(); + event.stopPropagation(); + CoreNavigator.instance.navigateToSitePath(`/grades/${courseId}`, { + params: { userId: user.id }, + }); + }, + }; + } + +} + +export class CoreGradesUserHandler extends makeSingleton(CoreGradesUserHandlerService) {} diff --git a/src/core/features/settings/pages/about/about.module.ts b/src/core/features/settings/pages/about/about.module.ts index 768b416cb..cd58152b3 100644 --- a/src/core/features/settings/pages/about/about.module.ts +++ b/src/core/features/settings/pages/about/about.module.ts @@ -26,18 +26,6 @@ const routes: Routes = [ path: '', component: CoreSettingsAboutPage, }, - { - path: 'deviceinfo', - loadChildren: () => - import('@features/settings/pages/deviceinfo/deviceinfo.module') - .then(m => m.CoreSettingsDeviceInfoPageModule), - }, - { - path: 'licenses', - loadChildren: () => - import('@features/settings/pages/licenses/licenses.module') - .then(m => m.CoreSettingsLicensesPageModule), - }, ]; @NgModule({ diff --git a/src/core/features/settings/settings-lazy.module.ts b/src/core/features/settings/settings-lazy.module.ts index 990d4751c..b29c962dc 100644 --- a/src/core/features/settings/settings-lazy.module.ts +++ b/src/core/features/settings/settings-lazy.module.ts @@ -46,24 +46,40 @@ const sectionRoutes: Routes = [ }, ]; -const routes: Routes = [ +const mobileRoutes: Routes = [ { - matcher: segments => { - const matches = CoreScreen.instance.isMobile ? segments.length === 0 : true; - - return matches ? { consumed: [] } : null; - }, + path: '', component: CoreSettingsIndexPage, - children: conditionalRoutes([ + }, + ...sectionRoutes, +]; + +const tabletRoutes: Routes = [ + { + path: '', + component: CoreSettingsIndexPage, + children: [ { path: '', pathMatch: 'full', redirectTo: 'general', }, ...sectionRoutes, - ], () => !CoreScreen.instance.isMobile), + ], + }, +]; + +const routes: Routes = [ + ...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile), + ...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet), + { + path: 'about/deviceinfo', + loadChildren: () => import('./pages/deviceinfo/deviceinfo.module').then(m => m.CoreSettingsDeviceInfoPageModule), + }, + { + path: 'about/licenses', + loadChildren: () => import('./pages/licenses/licenses.module').then(m => m.CoreSettingsLicensesPageModule), }, - ...conditionalRoutes(sectionRoutes, () => CoreScreen.instance.isMobile), ]; @NgModule({ diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index da0f1dc72..261b5b92c 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -846,7 +846,7 @@ export class CoreUtilsProvider { defaultLabel?: string, separator: string = ',', defaultValue?: T, - ): { label: string; value: T | number }[] { + ): CoreMenuItem[] { // Split and format the list. const split = list.split(separator).map((label, index) => ({ label: label.trim(), @@ -1673,3 +1673,11 @@ export type CoreCountry = { code: string; name: string; }; + +/** + * Menu item. + */ +export type CoreMenuItem = { + label: string; + value: T | number; +}; diff --git a/src/theme/app.scss b/src/theme/app.scss index 8ef64fe3e..705825efe 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -123,10 +123,13 @@ ion-toolbar { font-size: 14px; } +.core-selected-item { + border-inline-start: var(--selected-item-border-width) solid var(--selected-item-color); +} + // Item styles .item.core-selected-item { - // TODO: Add safe are to border and RTL - border-inline-start: var(--selected-item-border-width) solid var(--selected-item-color); + // TODO: Add safe area to border and RTL --ion-safe-area-left: calc(-1 * var(--selected-item-border-width)); } @@ -257,12 +260,18 @@ ion-avatar ion-img, ion-avatar img { } // Activity modules +.core-module-icon { + width: auto; + max-width: 24px; + max-height: 24px; +} + ion-item img.core-module-icon[slot="start"] { margin-top: 12px; margin-bottom: 12px; margin-right: 32px; - padding: 6px; } + [dir=rtl] ion-item img.core-module-icon[slot="start"] { margin-right: unset; margin-left: 32px; diff --git a/src/theme/breakpoints.scss b/src/theme/breakpoints.scss index 8cc9f2207..48953000f 100644 --- a/src/theme/breakpoints.scss +++ b/src/theme/breakpoints.scss @@ -4,10 +4,10 @@ * https://ionicframework.com/docs/layout/grid#default-breakpoints */ -$breakpoint-xs: 0px; -$breakpoint-sm: 576px; -$breakpoint-md: 768px; -$breakpoint-lg: 992px; -$breakpoint-xl: 1200px; + $breakpoint-xs: 0px; + $breakpoint-sm: 576px; + $breakpoint-md: 768px; + $breakpoint-lg: 992px; + $breakpoint-xl: 1200px; -$breakpoint-tablet: $breakpoint-lg; + $breakpoint-tablet: $breakpoint-lg;