diff --git a/src/core/classes/page-items-list-manager.ts b/src/core/classes/page-items-list-manager.ts index 0984b31e8..4148f52cc 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; } @@ -133,8 +138,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/features/user/pages/participants/participants.html b/src/core/features/user/pages/participants/participants.html new file mode 100644 index 000000000..a0fc5140e --- /dev/null +++ b/src/core/features/user/pages/participants/participants.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + +

+ +

+
+
+
+ + +
+
+
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..9ebca2847 --- /dev/null +++ b/src/core/features/user/pages/participants/participants.ts @@ -0,0 +1,170 @@ +// (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, ViewChild } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; + +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, CoreUserParticipant } 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 AfterViewInit, OnDestroy { + + participants: CoreUserParticipantsManager; + fetchMoreParticipantsFailed = false; + + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + + constructor(route: ActivatedRoute) { + const courseId = parseInt(route.snapshot.queryParams.courseId); + + this.participants = new CoreUserParticipantsManager(CoreUserParticipantsPage, courseId); + } + + /** + * @inheritdoc + */ + async ngAfterViewInit(): Promise { + await this.fetchInitialParticipants(); + + this.participants.watchSplitViewOutlet(this.splitView); + this.participants.start(); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.participants.destroy(); + } + + /** + * 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[] = []): Promise { + const { participants, canLoadMore } = await CoreUser.instance.getParticipants( + this.participants.courseId, + loadedParticipants.length, + ); + + this.participants.setItems(loadedParticipants.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): 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): 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..2b7c61ae7 --- /dev/null +++ b/src/core/features/user/user-course-lazy.module.ts @@ -0,0 +1,50 @@ +// (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 { 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, + ], + 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) {}