diff --git a/src/app/app.component.html b/src/app/app.component.html index 94c506c21..9b06aaff5 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,5 +1,3 @@ - - diff --git a/src/core/classes/page-items-list-manager.ts b/src/core/classes/page-items-list-manager.ts index 0984b31e8..1c226eaa6 100644 --- a/src/core/classes/page-items-list-manager.ts +++ b/src/core/classes/page-items-list-manager.ts @@ -27,6 +27,7 @@ export abstract class CorePageItemsListManager { protected itemsList: Item[] | null = null; protected itemsMap: Record | null = null; + protected hasMoreItems = true; protected selectedItem: Item | null = null; protected pageComponent: unknown; protected splitView?: CoreSplitViewComponent; @@ -44,6 +45,10 @@ export abstract class CorePageItemsListManager { return this.itemsMap !== null; } + get completed(): boolean { + return !this.hasMoreItems; + } + get empty(): boolean { return this.itemsList === null || this.itemsList.length === 0; } @@ -90,6 +95,16 @@ export abstract class CorePageItemsListManager { this.updateSelectedItem(splitView.outletRoute); } + /** + * Reset items data. + */ + resetItems(): void { + this.itemsList = null; + this.itemsMap = null; + this.hasMoreItems = true; + this.selectedItem = null; + } + // @todo Implement watchResize. /** @@ -133,8 +148,10 @@ export abstract class CorePageItemsListManager { * Set the list of items. * * @param items Items. + * @param hasMoreItems Whether the list has more items that haven't been loaded. */ - setItems(items: Item[]): void { + setItems(items: Item[], hasMoreItems: boolean = false): void { + this.hasMoreItems = hasMoreItems; this.itemsList = items.slice(0); this.itemsMap = items.reduce((map, item) => { map[this.getItemPath(item)] = item; diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts index 041f192de..f48fae07b 100644 --- a/src/core/classes/tabs.ts +++ b/src/core/classes/tabs.ts @@ -520,12 +520,12 @@ export class CoreTabsBaseComponent implements OnInit, Aft * @return Promise resolved when done. */ async selectByIndex(index: number, e?: Event): Promise { + e?.preventDefault(); + e?.stopPropagation(); + if (index < 0 || index >= this.tabs.length) { if (this.selected) { // Invalid index do not change tab. - e?.preventDefault(); - e?.stopPropagation(); - return; } @@ -536,9 +536,6 @@ export class CoreTabsBaseComponent implements OnInit, Aft const tabToSelect = this.tabs[index]; if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) { // Already selected or not enabled. - e?.preventDefault(); - e?.stopPropagation(); - return; } diff --git a/src/core/components/infinite-loading/infinite-loading.ts b/src/core/components/infinite-loading/infinite-loading.ts index 4be22c507..8bec2ddb2 100644 --- a/src/core/components/infinite-loading/infinite-loading.ts +++ b/src/core/components/infinite-loading/infinite-loading.ts @@ -12,9 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional, ViewChild, ElementRef } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnChanges, SimpleChange, ViewChild, ElementRef } from '@angular/core'; import { IonContent, IonInfiniteScroll } from '@ionic/angular'; import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; + +const THRESHOLD = .15; // % of the scroll element height that must be close to the edge to consider loading more items necessary. /** * Component to show a infinite loading trigger and spinner while more data is being loaded. @@ -41,12 +44,7 @@ export class CoreInfiniteLoadingComponent implements OnChanges { loadingMore = false; // Hide button and avoid loading more. - protected threshold = parseFloat('15%') / 100; - - constructor( - protected element: ElementRef, - @Optional() protected content: IonContent, - ) { + constructor(protected element: ElementRef) { this.action = new EventEmitter(); } @@ -70,26 +68,31 @@ export class CoreInfiniteLoadingComponent implements OnChanges { * like the Ionic component does. */ protected async checkScrollDistance(): Promise { - if (this.enabled) { - const scrollElement = await this.content.getScrollElement(); + if (!this.enabled) { + return; + } - const infiniteHeight = this.element.nativeElement.getBoundingClientRect().height; + // Wait until next tick to allow items to render and scroll content to grow. + await CoreUtils.instance.nextTick(); - const scrollTop = scrollElement.scrollTop; - const height = scrollElement.offsetHeight; - const threshold = height * this.threshold; + // Calculate distance from edge. + const content = this.element.nativeElement.closest('ion-content') as IonContent; + const scrollElement = await content.getScrollElement(); - const distanceFromInfinite = (this.position === 'bottom') - ? scrollElement.scrollHeight - infiniteHeight - scrollTop - threshold - height - : scrollTop - infiniteHeight - threshold; + const infiniteHeight = this.element.nativeElement.getBoundingClientRect().height; + const scrollTop = scrollElement.scrollTop; + const height = scrollElement.offsetHeight; + const threshold = height * THRESHOLD; + const distanceFromInfinite = (this.position === 'bottom') + ? scrollElement.scrollHeight - infiniteHeight - scrollTop - threshold - height + : scrollTop - infiniteHeight - threshold; - if (distanceFromInfinite < 0 && !this.loadingMore && this.enabled) { - this.loadMore(); - } + // If it's close enough the edge, trigger the action to load more items. + if (distanceFromInfinite < 0 && !this.loadingMore && this.enabled) { + this.loadMore(); } } - /** * Load More items calling the action provided. */ diff --git a/src/core/components/split-view/split-view.ts b/src/core/components/split-view/split-view.ts index 101719d7d..870db7056 100644 --- a/src/core/components/split-view/split-view.ts +++ b/src/core/components/split-view/split-view.ts @@ -14,11 +14,11 @@ import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; -import { IonRouterOutlet } from '@ionic/angular'; +import { IonContent, IonRouterOutlet } from '@ionic/angular'; import { CoreScreen } from '@services/screen'; import { BehaviorSubject, Observable, Subscription } from 'rxjs'; -enum CoreSplitViewMode { +export enum CoreSplitViewMode { MenuOnly = 'menu-only', // Hides content. ContentOnly = 'content-only', // Hides menu. MenuAndContent = 'menu-and-content', // Shows both menu and content. @@ -31,9 +31,11 @@ enum CoreSplitViewMode { }) export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { - @ViewChild(IonRouterOutlet) outlet!: IonRouterOutlet; + @ViewChild(IonContent) menuContent!: IonContent; + @ViewChild(IonRouterOutlet) contentOutlet!: IonRouterOutlet; @HostBinding('class') classes = ''; @Input() placeholderText = 'core.emptysplit'; + @Input() mode?: CoreSplitViewMode; isNested = false; private outletRouteSubject: BehaviorSubject = new BehaviorSubject(null); @@ -55,11 +57,11 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { ngAfterViewInit(): void { this.isNested = !!this.element.nativeElement.parentElement?.closest('core-split-view'); this.subscriptions = [ - this.outlet.activateEvents.subscribe(() => { + this.contentOutlet.activateEvents.subscribe(() => { this.updateClasses(); - this.outletRouteSubject.next(this.outlet.activatedRoute.snapshot); + this.outletRouteSubject.next(this.contentOutlet.activatedRoute.snapshot); }), - this.outlet.deactivateEvents.subscribe(() => { + this.contentOutlet.deactivateEvents.subscribe(() => { this.updateClasses(); this.outletRouteSubject.next(null); }), @@ -82,7 +84,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { private updateClasses(): void { const classes: string[] = [this.getCurrentMode()]; - if (this.outlet.isActivated) { + if (this.contentOutlet.isActivated) { classes.push('outlet-activated'); } @@ -100,12 +102,16 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { * @return Split view mode. */ private getCurrentMode(): CoreSplitViewMode { + if (this.mode) { + return this.mode; + } + if (this.isNested) { return CoreSplitViewMode.MenuOnly; } if (CoreScreen.instance.isMobile) { - return this.outlet.isActivated + return this.contentOutlet.isActivated ? CoreSplitViewMode.ContentOnly : CoreSplitViewMode.MenuOnly; } @@ -119,7 +125,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { * @return If split view is enabled. */ isOn(): boolean { - return this.outlet.isActivated; + return this.contentOutlet.isActivated; } } diff --git a/src/core/components/tabs-outlet/tabs-outlet.ts b/src/core/components/tabs-outlet/tabs-outlet.ts index d4df2c488..7567f4619 100644 --- a/src/core/components/tabs-outlet/tabs-outlet.ts +++ b/src/core/components/tabs-outlet/tabs-outlet.ts @@ -43,8 +43,8 @@ import { CoreTabBase, CoreTabsBaseComponent } from '@classes/tabs'; * * Tab contents will only be shown if that tab is selected. * - * @todo: Test behaviour when tabs are added late. * @todo: Test RTL and tab history. + * @todo: This should behave like the split-view in relation to routing (maybe we could reuse some code from CoreItemsListManager). */ @Component({ selector: 'core-tabs-outlet', diff --git a/src/core/features/course/pages/index/index.ts b/src/core/features/course/pages/index/index.ts index 9c5b834e4..3723a5360 100644 --- a/src/core/features/course/pages/index/index.ts +++ b/src/core/features/course/pages/index/index.ts @@ -115,7 +115,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { // Load the course handlers. const handlers = await CoreCourseOptionsDelegate.instance.getHandlersToDisplay(this.course!, false, false); - this.tabs.concat(handlers.map(handler => handler.data)); + this.tabs = [...this.tabs, ...handlers.map(handler => handler.data)]; let tabToLoad: number | undefined; diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index a3e29dea8..d3692a017 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -29,6 +29,7 @@ import { CoreUserModule } from './user/user.module'; import { CorePushNotificationsModule } from './pushnotifications/pushnotifications.module'; import { CoreXAPIModule } from './xapi/xapi.module'; import { CoreViewerModule } from './viewer/viewer.module'; +import { CoreSearchModule } from './search/search.module'; @NgModule({ imports: [ @@ -44,6 +45,7 @@ import { CoreViewerModule } from './viewer/viewer.module'; CoreTagModule, CoreUserModule, CorePushNotificationsModule, + CoreSearchModule, CoreXAPIModule, CoreH5PModule, CoreViewerModule, diff --git a/src/core/features/grades/grades-course-lazy.module.ts b/src/core/features/grades/grades-course-lazy.module.ts new file mode 100644 index 000000000..e6164e920 --- /dev/null +++ b/src/core/features/grades/grades-course-lazy.module.ts @@ -0,0 +1,38 @@ +// (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 { RouterModule, Routes } from '@angular/router'; + +import { CoreGradesCoursePage } from './pages/course/course.page'; +import { CoreGradesCoursePageModule } from './pages/course/course.module'; + +const routes: Routes = [ + { + path: '', + component: CoreGradesCoursePage, + data: { + useSplitView: false, + outsideGradesTab: true, + }, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreGradesCoursePageModule, + ], +}) +export class CoreGradesCourseLazyModule {} diff --git a/src/core/features/grades/grades-lazy.module.ts b/src/core/features/grades/grades-lazy.module.ts index 5b22bbb13..5a4ac60e5 100644 --- a/src/core/features/grades/grades-lazy.module.ts +++ b/src/core/features/grades/grades-lazy.module.ts @@ -18,13 +18,14 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { CoreSharedModule } from '@/core/shared.module'; +import { conditionalRoutes } from '@/app/app-routing.module'; import { CoreScreen } from '@services/screen'; +import { CoreSharedModule } from '@/core/shared.module'; -import { CoreGradesCoursePage } from './pages/course/course'; +import { CoreGradesCoursePage } from './pages/course/course.page'; +import { CoreGradesCoursePageModule } from './pages/course/course.module'; import { CoreGradesCoursesPage } from './pages/courses/courses'; import { CoreGradesGradePage } from './pages/grade/grade'; -import { conditionalRoutes } from '@/app/app-routing.module'; const mobileRoutes: Routes = [ { @@ -76,10 +77,10 @@ const routes: Routes = [ IonicModule, TranslateModule.forChild(), CoreSharedModule, + CoreGradesCoursePageModule, ], declarations: [ CoreGradesCoursesPage, - CoreGradesCoursePage, CoreGradesGradePage, ], }) diff --git a/src/core/features/grades/grades.module.ts b/src/core/features/grades/grades.module.ts index 94ea4f04d..eea8feb79 100644 --- a/src/core/features/grades/grades.module.ts +++ b/src/core/features/grades/grades.module.ts @@ -15,6 +15,7 @@ import { APP_INITIALIZER, NgModule } from '@angular/core'; import { Routes } from '@angular/router'; import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseIndexRoutingModule } from '@features/course/pages/index/index-routing.module'; 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'; @@ -33,10 +34,18 @@ const routes: Routes = [ }, ]; +const courseIndexRoutes: Routes = [ + { + path: 'grades', + loadChildren: () => import('@features/grades/grades-course-lazy.module').then(m => m.CoreGradesCourseLazyModule), + }, +]; + @NgModule({ imports: [ CoreMainMenuTabRoutingModule.forChild(routes), CoreMainMenuRoutingModule.forChild({ children: routes }), + CoreCourseIndexRoutingModule.forChild({ children: courseIndexRoutes }), ], providers: [ { diff --git a/src/core/features/grades/pages/course/course.html b/src/core/features/grades/pages/course/course.html index f732a8470..579fffc6d 100644 --- a/src/core/features/grades/pages/course/course.html +++ b/src/core/features/grades/pages/course/course.html @@ -7,7 +7,7 @@ - + diff --git a/src/core/features/grades/pages/course/course.module.ts b/src/core/features/grades/pages/course/course.module.ts new file mode 100644 index 000000000..d098cd9c8 --- /dev/null +++ b/src/core/features/grades/pages/course/course.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { NgModule } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; + +import { CoreGradesCoursePage } from './course.page'; + +@NgModule({ + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + declarations: [ + CoreGradesCoursePage, + ], +}) +export class CoreGradesCoursePageModule {} diff --git a/src/core/features/grades/pages/course/course.ts b/src/core/features/grades/pages/course/course.page.ts similarity index 84% rename from src/core/features/grades/pages/course/course.ts rename to src/core/features/grades/pages/course/course.page.ts index ae9367d28..b0cb64d33 100644 --- a/src/core/features/grades/pages/course/course.ts +++ b/src/core/features/grades/pages/course/course.page.ts @@ -27,9 +27,10 @@ import { } from '@features/grades/services/grades-helper'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; -import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreSplitViewComponent, CoreSplitViewMode } from '@components/split-view/split-view'; import { CoreObject } from '@singletons/object'; import { CorePageItemsListManager } from '@classes/page-items-list-manager'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays a course grades. @@ -42,14 +43,18 @@ import { CorePageItemsListManager } from '@classes/page-items-list-manager'; export class CoreGradesCoursePage implements AfterViewInit, OnDestroy { grades: CoreGradesCourseManager; + splitViewMode?: CoreSplitViewMode; @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; constructor(route: ActivatedRoute) { - const courseId = parseInt(route.snapshot.params.courseId); + const courseId = parseInt(route.snapshot.params.courseId ?? route.snapshot.queryParams.courseId); const userId = parseInt(route.snapshot.queryParams.userId ?? CoreSites.instance.getCurrentSiteUserId()); + const useSplitView = route.snapshot.data.useSplitView ?? true; + const outsideGradesTab = route.snapshot.data.outsideGradesTab ?? false; - this.grades = new CoreGradesCourseManager(CoreGradesCoursePage, courseId, userId); + this.splitViewMode = useSplitView ? undefined : CoreSplitViewMode.MenuOnly; + this.grades = new CoreGradesCourseManager(CoreGradesCoursePage, courseId, userId, outsideGradesTab); } /** @@ -118,11 +123,14 @@ class CoreGradesCourseManager extends CorePageItemsListManager { + if (this.outsideGradesTab) { + await CoreNavigator.instance.navigateToSitePath(`/grades/${this.courseId}/${row.id}`); + + return; + } + + return super.select(row); + } + /** * @inheritdoc */ diff --git a/src/core/features/grades/services/handlers/course-option.ts b/src/core/features/grades/services/handlers/course-option.ts index 9dcb7cb61..dae804c8a 100644 --- a/src/core/features/grades/services/handlers/course-option.ts +++ b/src/core/features/grades/services/handlers/course-option.ts @@ -83,19 +83,14 @@ export class CoreGradesCourseOptionHandlerService implements CoreCourseOptionsHa } /** - * Returns the data needed to render the handler. - * - * @return Data or promise resolved with the data. + * @inheritdoc */ getDisplayData(): CoreCourseOptionsHandlerData | Promise { - throw new Error('CoreGradesCourseOptionHandler.getDisplayData is not implemented'); - - // @todo - // return { - // title: 'core.grades.grades', - // class: 'core-grades-course-handler', - // component: CoreGradesCourseComponent, - // }; + return { + title: 'core.grades.grades', + class: 'core-grades-course-handler', + page: 'grades', + }; } /** diff --git a/src/core/features/user/pages/participants/participants.html b/src/core/features/user/pages/participants/participants.html new file mode 100644 index 000000000..8d1fb6c47 --- /dev/null +++ b/src/core/features/user/pages/participants/participants.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ participant.fullname }} + {{ 'core.lastaccess' | translate }}: {{ participant.lastcourseaccess | coreTimeAgo }} + {{ 'core.lastaccess' | translate }}: {{ participant.lastaccess | coreTimeAgo }} + + + + + + + + + + + + + + + + diff --git a/src/core/features/user/pages/participants/participants.ts b/src/core/features/user/pages/participants/participants.ts new file mode 100644 index 000000000..7ec5932b1 --- /dev/null +++ b/src/core/features/user/pages/participants/participants.ts @@ -0,0 +1,242 @@ +// (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, ActivatedRouteSnapshot } from '@angular/router'; +import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; + +import { CoreApp } from '@services/app'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreNavigator } from '@services/navigator'; +import { CorePageItemsListManager } from '@classes/page-items-list-manager'; +import { CoreScreen } from '@services/screen'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreUser, CoreUserProvider, CoreUserParticipant, CoreUserData } from '@features/user/services/user'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Page that displays the list of course participants. + */ +@Component({ + selector: 'page-core-user-participants', + templateUrl: 'participants.html', +}) +export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestroy { + + participants: CoreUserParticipantsManager; + searchQuery: string | null = null; + searchInProgress = false; + searchEnabled = false; + showSearchBox = false; + fetchMoreParticipantsFailed = false; + + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + + constructor(route: ActivatedRoute) { + const courseId = parseInt(route.snapshot.queryParams.courseId); + + this.participants = new CoreUserParticipantsManager(CoreUserParticipantsPage, courseId); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.searchEnabled = await CoreUser.instance.canSearchParticipantsInSite(); + } + + /** + * @inheritdoc + */ + async ngAfterViewInit(): Promise { + await this.fetchInitialParticipants(); + + this.participants.watchSplitViewOutlet(this.splitView); + this.participants.start(); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + 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. + */ + async clearSearch(): Promise { + if (this.searchQuery === null) { + // Nothing to clear. + return; + } + + this.searchQuery = null; + this.searchInProgress = false; + this.participants.resetItems(); + + await this.fetchInitialParticipants(); + } + + /** + * Start a new search. + * + * @param query Text to search for. + */ + async search(query: string): Promise { + CoreApp.instance.closeKeyboard(); + + this.searchInProgress = true; + this.searchQuery = query; + this.participants.resetItems(); + + await this.fetchInitialParticipants(); + + this.searchInProgress = false; + } + + /** + * Refresh participants. + * + * @param refresher Refresher. + */ + async refreshParticipants(refresher: IonRefresher): Promise { + await CoreUtils.instance.ignoreErrors(CoreUser.instance.invalidateParticipantsList(this.participants.courseId)); + await CoreUtils.instance.ignoreErrors(this.fetchParticipants()); + + refresher?.complete(); + } + + /** + * Load a new batch of participants. + * + * @param complete Completion callback. + */ + async fetchMoreParticipants(complete: () => void): Promise { + try { + await this.fetchParticipants(this.participants.items); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading more participants'); + + this.fetchMoreParticipantsFailed = true; + } + + complete(); + } + + /** + * Obtain the initial batch of participants. + */ + private async fetchInitialParticipants(): Promise { + try { + await this.fetchParticipants(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading participants'); + + this.participants.setItems([]); + } + } + + /** + * Update the list of participants. + * + * @param loadedParticipants Participants list to continue loading from. + */ + private async fetchParticipants(loadedParticipants: CoreUserParticipant[] | CoreUserData[] = []): Promise { + if (this.searchQuery) { + const { participants, canLoadMore } = await CoreUser.instance.searchParticipants( + this.participants.courseId, + this.searchQuery, + true, + Math.ceil(loadedParticipants.length / CoreUserProvider.PARTICIPANTS_LIST_LIMIT), + CoreUserProvider.PARTICIPANTS_LIST_LIMIT, + ); + + this.participants.setItems((loadedParticipants as CoreUserData[]).concat(participants), canLoadMore); + } else { + const { participants, canLoadMore } = await CoreUser.instance.getParticipants( + this.participants.courseId, + loadedParticipants.length, + ); + + this.participants.setItems((loadedParticipants as CoreUserParticipant[]).concat(participants), canLoadMore); + } + + this.fetchMoreParticipantsFailed = false; + } + +} + +/** + * Helper to manage the list of participants. + */ +class CoreUserParticipantsManager extends CorePageItemsListManager { + + courseId: number; + + constructor(pageComponent: unknown, courseId: number) { + super(pageComponent); + + this.courseId = courseId; + } + + /** + * @inheritdoc + */ + async select(participant: CoreUserParticipant | CoreUserData): Promise { + if (CoreScreen.instance.isMobile) { + await CoreNavigator.instance.navigateToSitePath('/user/profile', { params: { userId: participant.id } }); + + return; + } + + return super.select(participant); + } + + /** + * @inheritdoc + */ + protected getItemPath(participant: CoreUserParticipant | CoreUserData): string { + return participant.id.toString(); + } + + /** + * @inheritdoc + */ + protected getSelectedItemPath(route: ActivatedRouteSnapshot): string | null { + return route.params.userId ?? null; + } + + /** + * @inheritdoc + */ + protected async logActivity(): Promise { + await CoreUser.instance.logParticipantsView(this.courseId); + } + +} diff --git a/src/core/features/user/services/handlers/course-option.ts b/src/core/features/user/services/handlers/course-option.ts new file mode 100644 index 000000000..0ec75f4ca --- /dev/null +++ b/src/core/features/user/services/handlers/course-option.ts @@ -0,0 +1,116 @@ +// (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 { + CoreCourseAccess, + CoreCourseOptionsHandler, + CoreCourseOptionsHandlerData, +} from '@features/course/services/course-options-delegate'; +import { CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses'; +import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper'; +import { makeSingleton } from '@singletons'; +import { CoreUser } from '../user'; + +/** + * Course nav handler. + */ +@Injectable({ providedIn: 'root' }) +export class CoreUserCourseOptionHandlerService implements CoreCourseOptionsHandler { + + name = 'CoreUserParticipants'; + priority = 600; + + /** + * 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.participants != 'undefined') { + // No need to invalidate anything. + return Promise.resolve(); + } + + return CoreUser.instance.invalidateParticipantsList(courseId); + } + + /** + * 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: CoreCourseAccess, + navOptions?: CoreCourseUserAdminOrNavOptionIndexed, + ): boolean | Promise { + if (accessData && accessData.type == CoreCourseProvider.ACCESS_GUEST) { + return false; // Not enabled for guests. + } + + if (navOptions && typeof navOptions.participants != 'undefined') { + return navOptions.participants; + } + + return CoreUser.instance.isPluginEnabledForCourse(courseId); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreCourseOptionsHandlerData | Promise { + return { + title: 'core.user.participants', + class: 'core-user-participants-handler', + page: 'participants', + }; + } + + /** + * 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 { + let offset = 0; + let canLoadMore = true; + + do { + const result = await CoreUser.instance.getParticipants(course.id, offset, undefined, undefined, true); + + offset += result.participants.length; + canLoadMore = result.canLoadMore; + } while (canLoadMore); + } + +} + +export class CoreUserCourseOptionHandler extends makeSingleton(CoreUserCourseOptionHandlerService) {} diff --git a/src/core/features/user/user-course-lazy.module.ts b/src/core/features/user/user-course-lazy.module.ts new file mode 100644 index 000000000..03ebf37f0 --- /dev/null +++ b/src/core/features/user/user-course-lazy.module.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; + +import { CoreUserParticipantsPage } from './pages/participants/participants'; + +const routes: Routes = [ + { + path: '', + component: CoreUserParticipantsPage, + children: [ + { + path: ':userId', + loadChildren: () => import('@features/user/pages/profile/profile.module').then(m => m.CoreUserProfilePageModule), + }, + ], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + CoreSearchComponentsModule, + ], + declarations: [ + CoreUserParticipantsPage, + ], +}) +export class CoreUserCourseLazyModule {} diff --git a/src/core/features/user/user.module.ts b/src/core/features/user/user.module.ts index e0bdc99cb..0169c35ef 100644 --- a/src/core/features/user/user.module.ts +++ b/src/core/features/user/user.module.ts @@ -27,6 +27,9 @@ import { CoreCronDelegate } from '@services/cron'; import { CoreUserSyncCronHandler } from './services/handlers/sync-cron'; import { CoreUserTagAreaHandler } from './services/handlers/tag-area'; import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; +import { CoreCourseIndexRoutingModule } from '@features/course/pages/index/index-routing.module'; +import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; +import { CoreUserCourseOptionHandler } from './services/handlers/course-option'; const routes: Routes = [ { @@ -35,9 +38,17 @@ const routes: Routes = [ }, ]; +const courseIndexRoutes: Routes = [ + { + path: 'participants', + loadChildren: () => import('@features/user/user-course-lazy.module').then(m => m.CoreUserCourseLazyModule), + }, +]; + @NgModule({ imports: [ CoreMainMenuTabRoutingModule.forChild(routes), + CoreCourseIndexRoutingModule.forChild({ children: courseIndexRoutes }), CoreUserComponentsModule, ], providers: [ @@ -58,6 +69,7 @@ const routes: Routes = [ CoreContentLinksDelegate.instance.registerHandler(CoreUserProfileLinkHandler.instance); CoreCronDelegate.instance.register(CoreUserSyncCronHandler.instance); CoreTagAreaDelegate.instance.registerHandler(CoreUserTagAreaHandler.instance); + CoreCourseOptionsDelegate.instance.registerHandler(CoreUserCourseOptionHandler.instance); }, }, ], diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 9c29ee25d..d4ccd5df4 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -1623,6 +1623,13 @@ export class CoreUtilsProvider { return new Promise(resolve => setTimeout(resolve, milliseconds)); } + /** + * Wait until the next tick. + */ + nextTick(): Promise { + return this.wait(0); + } + } export class CoreUtils extends makeSingleton(CoreUtilsProvider) {}
{{ 'core.lastaccess' | translate }}: {{ participant.lastcourseaccess | coreTimeAgo }}
{{ 'core.lastaccess' | translate }}: {{ participant.lastaccess | coreTimeAgo }}