forked from EVOgeek/Vmeda.Online
		
	Merge pull request #3012 from NoelDeMartin/MOBILE-3926
MOBILE-3926: Add swipe navigation to pages with split-view
This commit is contained in:
		
						commit
						ad6c7367ff
					
				
							
								
								
									
										60
									
								
								src/addons/badges/classes/user-badges-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/addons/badges/classes/user-badges-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,60 @@ | ||||
| // (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 { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { AddonBadges, AddonBadgesUserBadge } from '../services/badges'; | ||||
| 
 | ||||
| /** | ||||
|  * Provides a collection of user badges. | ||||
|  */ | ||||
| export class AddonBadgesUserBadgesSource extends CoreItemsManagerSource<AddonBadgesUserBadge> { | ||||
| 
 | ||||
|     readonly COURSE_ID: number; | ||||
|     readonly USER_ID: number; | ||||
| 
 | ||||
|     constructor(courseId: number, userId: number) { | ||||
|         super(); | ||||
| 
 | ||||
|         this.COURSE_ID = courseId; | ||||
|         this.USER_ID = userId; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemPath(badge: AddonBadgesUserBadge): string { | ||||
|         return badge.uniquehash; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemQueryParams(): Params { | ||||
|         return { | ||||
|             courseId: this.COURSE_ID, | ||||
|             userId: this.USER_ID, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async loadPageItems(): Promise<{ items: AddonBadgesUserBadge[] }> { | ||||
|         const badges = await AddonBadges.getUserBadges(this.COURSE_ID, this.USER_ID); | ||||
| 
 | ||||
|         return { items: badges }; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -10,6 +10,7 @@ | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
|     <core-swipe-navigation [manager]="badges"> | ||||
|         <ion-refresher slot="fixed" [disabled]="!badgeLoaded" (ionRefresh)="refreshBadges($event.target)"> | ||||
|             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|         </ion-refresher> | ||||
| @ -245,4 +246,5 @@ | ||||
|                 </ion-item-group> | ||||
|             </ng-container> | ||||
|         </core-loading> | ||||
|     </core-swipe-navigation> | ||||
| </ion-content> | ||||
|  | ||||
| @ -23,6 +23,9 @@ import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays the list of calendar events. | ||||
| @ -40,12 +43,11 @@ export class AddonBadgesIssuedBadgePage implements OnInit { | ||||
|     user?: CoreUserProfile; | ||||
|     course?: CoreEnrolledCourseData; | ||||
|     badge?: AddonBadgesUserBadge; | ||||
|     badges?: CoreSwipeItemsManager; | ||||
|     badgeLoaded = false; | ||||
|     currentTime = 0; | ||||
| 
 | ||||
|     constructor( | ||||
|         protected route: ActivatedRoute, | ||||
|     ) { } | ||||
|     constructor(protected route: ActivatedRoute) { } | ||||
| 
 | ||||
|     /** | ||||
|      * View loaded. | ||||
| @ -58,6 +60,11 @@ export class AddonBadgesIssuedBadgePage implements OnInit { | ||||
|         this.fetchIssuedBadge().finally(() => { | ||||
|             this.badgeLoaded = true; | ||||
|         }); | ||||
| 
 | ||||
|         const source = CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [this.courseId, this.userId]); | ||||
|         this.badges = new CoreSwipeItemsManager(source); | ||||
| 
 | ||||
|         this.badges.start(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -19,10 +19,11 @@ import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CorePageItemsListManager } from '@classes/page-items-list-manager'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||
| import { AddonBadgesUserBadgesSource } from '@addons/badges/classes/user-badges-source'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays the list of calendar events. | ||||
| @ -34,7 +35,7 @@ import { CoreNavigator } from '@services/navigator'; | ||||
| export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | ||||
| 
 | ||||
|     currentTime = 0; | ||||
|     badges: AddonBadgesUserBadgesManager; | ||||
|     badges: CoreListItemsManager<AddonBadgesUserBadge, AddonBadgesUserBadgesSource>; | ||||
| 
 | ||||
|     @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; | ||||
| 
 | ||||
| @ -47,7 +48,10 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | ||||
|             courseId = 0; | ||||
|         } | ||||
| 
 | ||||
|         this.badges = new AddonBadgesUserBadgesManager(AddonBadgesUserBadgesPage, courseId, userId); | ||||
|         this.badges = new CoreListItemsManager( | ||||
|             CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [courseId, userId]), | ||||
|             AddonBadgesUserBadgesPage, | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -72,8 +76,13 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | ||||
|      * @param refresher Refresher. | ||||
|      */ | ||||
|     async refreshBadges(refresher?: IonRefresher): Promise<void> { | ||||
|         await CoreUtils.ignoreErrors(AddonBadges.invalidateUserBadges(this.badges.courseId, this.badges.userId)); | ||||
|         await CoreUtils.ignoreErrors(this.fetchBadges()); | ||||
|         await CoreUtils.ignoreErrors( | ||||
|             AddonBadges.invalidateUserBadges( | ||||
|                 this.badges.getSource().COURSE_ID, | ||||
|                 this.badges.getSource().USER_ID, | ||||
|             ), | ||||
|         ); | ||||
|         await CoreUtils.ignoreErrors(this.badges.reload()); | ||||
| 
 | ||||
|         refresher?.complete(); | ||||
|     } | ||||
| @ -85,55 +94,12 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { | ||||
|         this.currentTime = CoreTimeUtils.timestamp(); | ||||
| 
 | ||||
|         try { | ||||
|             await this.fetchBadges(); | ||||
|             await this.badges.reload(); | ||||
|         } catch (message) { | ||||
|             CoreDomUtils.showErrorModalDefault(message, 'Error loading badges'); | ||||
| 
 | ||||
|             this.badges.setItems([]); | ||||
|             this.badges.reset(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update the list of badges. | ||||
|      */ | ||||
|     private async fetchBadges(): Promise<void> { | ||||
|         const badges = await AddonBadges.getUserBadges(this.badges.courseId, this.badges.userId); | ||||
| 
 | ||||
|         this.badges.setItems(badges); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Helper class to manage badges. | ||||
|  */ | ||||
| class AddonBadgesUserBadgesManager extends CorePageItemsListManager<AddonBadgesUserBadge> { | ||||
| 
 | ||||
|     courseId: number; | ||||
|     userId: number; | ||||
| 
 | ||||
|     constructor(pageComponent: unknown, courseId: number, userId: number) { | ||||
|         super(pageComponent); | ||||
| 
 | ||||
|         this.courseId = courseId; | ||||
|         this.userId = userId; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemPath(badge: AddonBadgesUserBadge): string { | ||||
|         return badge.uniquehash; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemQueryParams(): Params { | ||||
|         return { | ||||
|             courseId: this.courseId, | ||||
|             userId: this.userId, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
							
								
								
									
										257
									
								
								src/addons/mod/assign/classes/submissions-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								src/addons/mod/assign/classes/submissions-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,257 @@ | ||||
| // (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 { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { CoreGroupInfo, CoreGroups } from '@services/groups'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Translate } from '@singletons'; | ||||
| import { CoreEvents } from '@singletons/events'; | ||||
| import { | ||||
|     AddonModAssign, | ||||
|     AddonModAssignAssign, | ||||
|     AddonModAssignGrade, | ||||
|     AddonModAssignProvider, | ||||
|     AddonModAssignSubmission, | ||||
| } from '../services/assign'; | ||||
| import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../services/assign-helper'; | ||||
| import { AddonModAssignOffline } from '../services/assign-offline'; | ||||
| import { AddonModAssignSync, AddonModAssignSyncProvider } from '../services/assign-sync'; | ||||
| 
 | ||||
| /** | ||||
|  * Provides a collection of assignment submissions. | ||||
|  */ | ||||
| export class AddonModAssignSubmissionsSource extends CoreItemsManagerSource<AddonModAssignSubmissionForList> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     static getSourceId(courseId: number, moduleId: number, selectedStatus?: string): string { | ||||
|         selectedStatus = selectedStatus ?? '__empty__'; | ||||
| 
 | ||||
|         return `submissions-${courseId}-${moduleId}-${selectedStatus}`; | ||||
|     } | ||||
| 
 | ||||
|     readonly COURSE_ID: number; | ||||
|     readonly MODULE_ID: number; | ||||
|     readonly SELECTED_STATUS: string | undefined; | ||||
| 
 | ||||
|     assign?: AddonModAssignAssign; | ||||
|     groupId = 0; | ||||
|     groupInfo: CoreGroupInfo = { | ||||
|         groups: [], | ||||
|         separateGroups: false, | ||||
|         visibleGroups: false, | ||||
|         defaultGroupId: 0, | ||||
|     }; | ||||
| 
 | ||||
|     protected submissionsData: { canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] } = { | ||||
|         canviewsubmissions: false, | ||||
|     }; | ||||
| 
 | ||||
|     constructor(courseId: number, moduleId: number, selectedStatus?: string) { | ||||
|         super(); | ||||
| 
 | ||||
|         this.COURSE_ID = courseId; | ||||
|         this.MODULE_ID = moduleId; | ||||
|         this.SELECTED_STATUS = selectedStatus; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemPath(submission: AddonModAssignSubmissionForList): string { | ||||
|         return String(submission.submitid); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemQueryParams(submission: AddonModAssignSubmissionForList): Params { | ||||
|         return { | ||||
|             blindId: submission.blindid, | ||||
|             groupId: this.groupId, | ||||
|             selectedStatus: this.SELECTED_STATUS, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Invalidate assignment cache. | ||||
|      */ | ||||
|     async invalidateCache(): Promise<void> { | ||||
|         await Promise.all([ | ||||
|             AddonModAssign.invalidateAssignmentData(this.COURSE_ID), | ||||
|             this.assign && AddonModAssign.invalidateAllSubmissionData(this.assign.id), | ||||
|             this.assign && AddonModAssign.invalidateAssignmentUserMappingsData(this.assign.id), | ||||
|             this.assign && AddonModAssign.invalidateAssignmentGradesData(this.assign.id), | ||||
|             this.assign && AddonModAssign.invalidateListParticipantsData(this.assign.id), | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load assignment. | ||||
|      */ | ||||
|     async loadAssignment(sync: boolean = false): Promise<void> { | ||||
|         // Get assignment data.
 | ||||
|         this.assign = await AddonModAssign.getAssignment(this.COURSE_ID, this.MODULE_ID); | ||||
| 
 | ||||
|         if (sync) { | ||||
|             try { | ||||
|                 // Try to synchronize data.
 | ||||
|                 const result = await AddonModAssignSync.syncAssign(this.assign.id); | ||||
| 
 | ||||
|                 if (result && result.updated) { | ||||
|                     CoreEvents.trigger( | ||||
|                         AddonModAssignSyncProvider.MANUAL_SYNCED, | ||||
|                         { | ||||
|                             assignId: this.assign.id, | ||||
|                             warnings: result.warnings, | ||||
|                             gradesBlocked: result.gradesBlocked, | ||||
|                             context: 'submission-list', | ||||
|                         }, | ||||
|                         CoreSites.getCurrentSiteId(), | ||||
|                     ); | ||||
|                 } | ||||
|             } catch (error) { | ||||
|                 // Ignore errors, probably user is offline or sync is blocked.
 | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Get assignment submissions.
 | ||||
|         this.submissionsData = await AddonModAssign.getSubmissions(this.assign.id, { cmId: this.assign.cmid }); | ||||
| 
 | ||||
|         if (!this.submissionsData.canviewsubmissions) { | ||||
|             // User shouldn't be able to reach here.
 | ||||
|             throw new Error('Cannot view submissions.'); | ||||
|         } | ||||
| 
 | ||||
|         // Check if groupmode is enabled to avoid showing wrong numbers.
 | ||||
|         this.groupInfo = await CoreGroups.getActivityGroupInfo(this.assign.cmid, false); | ||||
| 
 | ||||
|         this.groupId = CoreGroups.validateGroupId(this.groupId, this.groupInfo); | ||||
| 
 | ||||
|         await this.reload(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async loadPageItems(): Promise<{ items: AddonModAssignSubmissionForList[] }> { | ||||
|         const assign = this.assign; | ||||
| 
 | ||||
|         if (!assign) { | ||||
|             throw new Error('Can\'t load submissions without assignment'); | ||||
|         } | ||||
| 
 | ||||
|         // Fetch submissions and grades.
 | ||||
|         const submissions = | ||||
|             await AddonModAssignHelper.getSubmissionsUserData( | ||||
|                 assign, | ||||
|                 this.submissionsData.submissions, | ||||
|                 this.groupId, | ||||
|             ); | ||||
|         // Get assignment grades only if workflow is not enabled to check grading date.
 | ||||
|         const grades = !assign.markingworkflow | ||||
|             ? await AddonModAssign.getAssignmentGrades(assign.id, { cmId: assign.cmid }) | ||||
|             : []; | ||||
| 
 | ||||
|         // Filter the submissions to get only the ones with the right status and add some extra data.
 | ||||
|         const getNeedGrading = this.SELECTED_STATUS == AddonModAssignProvider.NEED_GRADING; | ||||
|         const searchStatus = getNeedGrading ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : this.SELECTED_STATUS; | ||||
| 
 | ||||
|         const promises: Promise<void>[] = []; | ||||
|         const showSubmissions: AddonModAssignSubmissionForList[] = []; | ||||
| 
 | ||||
|         submissions.forEach((submission: AddonModAssignSubmissionForList) => { | ||||
|             if (!searchStatus || searchStatus == submission.status) { | ||||
|                 promises.push( | ||||
|                     CoreUtils.ignoreErrors( | ||||
|                         AddonModAssignOffline.getSubmissionGrade(assign.id, submission.userid), | ||||
|                     ).then(async (data) => { | ||||
|                         if (getNeedGrading) { | ||||
|                             // Only show the submissions that need to be graded.
 | ||||
|                             const add = await AddonModAssign.needsSubmissionToBeGraded(submission, assign.id); | ||||
| 
 | ||||
|                             if (!add) { | ||||
|                                 return; | ||||
|                             } | ||||
|                         } | ||||
| 
 | ||||
|                         // Load offline grades.
 | ||||
|                         const notSynced = !!data && submission.timemodified < data.timemodified; | ||||
| 
 | ||||
|                         if (submission.gradingstatus == 'graded' && !assign.markingworkflow) { | ||||
|                             // Get the last grade of the submission.
 | ||||
|                             const grade = grades | ||||
|                                 .filter((grade) => grade.userid == submission.userid) | ||||
|                                 .reduce( | ||||
|                                     (a, b) => (a && a.timemodified > b.timemodified ? a : b), | ||||
|                                     <AddonModAssignGrade | undefined> undefined, | ||||
|                                 ); | ||||
| 
 | ||||
|                             if (grade && grade.timemodified < submission.timemodified) { | ||||
|                                 submission.gradingstatus = AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT; | ||||
|                             } | ||||
|                         } | ||||
|                         submission.statusColor = AddonModAssign.getSubmissionStatusColor(submission.status); | ||||
|                         submission.gradingColor = AddonModAssign.getSubmissionGradingStatusColor( | ||||
|                             submission.gradingstatus, | ||||
|                         ); | ||||
| 
 | ||||
|                         // Show submission status if not submitted for grading.
 | ||||
|                         if (submission.statusColor != 'success' || !submission.gradingstatus) { | ||||
|                             submission.statusTranslated = Translate.instant( | ||||
|                                 'addon.mod_assign.submissionstatus_' + submission.status, | ||||
|                             ); | ||||
|                         } else { | ||||
|                             submission.statusTranslated = ''; | ||||
|                         } | ||||
| 
 | ||||
|                         if (notSynced) { | ||||
|                             submission.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; | ||||
|                             submission.gradingColor = ''; | ||||
|                         } else if (submission.statusColor != 'danger' || submission.gradingColor != 'danger') { | ||||
|                             // Show grading status if one of the statuses is not done.
 | ||||
|                             submission.gradingStatusTranslationId = AddonModAssign.getSubmissionGradingStatusTranslationId( | ||||
|                                 submission.gradingstatus, | ||||
|                             ); | ||||
|                         } else { | ||||
|                             submission.gradingStatusTranslationId = ''; | ||||
|                         } | ||||
| 
 | ||||
|                         showSubmissions.push(submission); | ||||
| 
 | ||||
|                         return; | ||||
|                     }), | ||||
|                 ); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
| 
 | ||||
|         return { items: showSubmissions }; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Calculated data for an assign submission. | ||||
|  */ | ||||
| export type AddonModAssignSubmissionForList = AddonModAssignSubmissionFormatted & { | ||||
|     statusColor?: string; // Calculated in the app. Color of the submission status.
 | ||||
|     gradingColor?: string; // Calculated in the app. Color of the submission grading status.
 | ||||
|     statusTranslated?: string; // Calculated in the app. Translated text of the submission status.
 | ||||
|     gradingStatusTranslationId?: string; // Calculated in the app. Key of the text of the submission grading status.
 | ||||
| }; | ||||
| @ -16,10 +16,10 @@ | ||||
| 
 | ||||
| <ion-content> | ||||
|     <core-split-view> | ||||
|         <ion-refresher slot="fixed" [disabled]="!loaded || !submissions.loaded" (ionRefresh)="refreshList($event.target)"> | ||||
|         <ion-refresher slot="fixed" [disabled]="!submissions.loaded" (ionRefresh)="refreshList($event.target)"> | ||||
|             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|         </ion-refresher> | ||||
|         <core-loading [hideUntil]="loaded && submissions.loaded"> | ||||
|         <core-loading [hideUntil]="submissions.loaded"> | ||||
|             <core-empty-box *ngIf="!submissions || submissions.empty" icon="fas-file-signature" | ||||
|                 [message]="'addon.mod_assign.submissionstatus_' | translate"> | ||||
|             </core-empty-box> | ||||
| @ -32,7 +32,7 @@ | ||||
|                     <ion-label id="addon-assign-groupslabel" *ngIf="groupInfo.visibleGroups"> | ||||
|                         {{ 'core.groupsvisible' | translate }} | ||||
|                     </ion-label> | ||||
|                     <ion-select [(ngModel)]="groupId" (ionChange)="setGroup(groupId)" aria-labelledby="addon-assign-groupslabel" | ||||
|                     <ion-select [(ngModel)]="groupId" (ionChange)="reloadSubmissions()" aria-labelledby="addon-assign-groupslabel" | ||||
|                         interface="action-sheet" slot="end" [interfaceOptions]="{header: 'core.group' | translate}"> | ||||
|                         <ion-select-option *ngFor="let groupOpt of groupInfo.groups" [value]="groupOpt.id"> | ||||
|                             {{groupOpt.name}} | ||||
|  | ||||
| @ -13,29 +13,20 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { CorePageItemsListManager } from '@classes/page-items-list-manager'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| import { CoreGroupInfo, CoreGroups } from '@services/groups'; | ||||
| import { CoreGroupInfo } from '@services/groups'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { Translate } from '@singletons'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { | ||||
|     AddonModAssignAssign, | ||||
|     AddonModAssignSubmission, | ||||
|     AddonModAssignProvider, | ||||
|     AddonModAssign, | ||||
|     AddonModAssignGrade, | ||||
| } from '../../services/assign'; | ||||
| import { AddonModAssignHelper, AddonModAssignSubmissionFormatted } from '../../services/assign-helper'; | ||||
| import { AddonModAssignOffline } from '../../services/assign-offline'; | ||||
| import { AddonModAssignSubmissionForList, AddonModAssignSubmissionsSource } from '../../classes/submissions-source'; | ||||
| import { AddonModAssignAssign, AddonModAssignProvider } from '../../services/assign'; | ||||
| import { | ||||
|     AddonModAssignSyncProvider, | ||||
|     AddonModAssignSync, | ||||
|     AddonModAssignManualSyncData, | ||||
|     AddonModAssignAutoSyncData, | ||||
| } from '../../services/assign-sync'; | ||||
| @ -51,47 +42,26 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | ||||
| 
 | ||||
|     @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; | ||||
| 
 | ||||
|     title = ''; // Title to display.
 | ||||
|     assign?: AddonModAssignAssign; // Assignment.
 | ||||
|     submissions: AddonModAssignSubmissionListManager; // List of submissions
 | ||||
|     loaded = false; // Whether data has been loaded.
 | ||||
|     groupId = 0; // Group ID to show.
 | ||||
|     courseId!: number; // Course ID the assignment belongs to.
 | ||||
|     moduleId!: number; // Module ID the submission belongs to.
 | ||||
|     title = ''; | ||||
|     submissions!: CoreListItemsManager<AddonModAssignSubmissionForList, AddonModAssignSubmissionsSource>; // List of submissions
 | ||||
| 
 | ||||
|     groupInfo: CoreGroupInfo = { | ||||
|         groups: [], | ||||
|         separateGroups: false, | ||||
|         visibleGroups: false, | ||||
|         defaultGroupId: 0, | ||||
|     }; | ||||
| 
 | ||||
|     protected selectedStatus?: string; // The status to see.
 | ||||
|     protected gradedObserver: CoreEventObserver; // Observer to refresh data when a grade changes.
 | ||||
|     protected syncObserver: CoreEventObserver; // Observer to refresh data when the async is synchronized.
 | ||||
|     protected submissionsData: { canviewsubmissions: boolean; submissions?: AddonModAssignSubmission[] } = { | ||||
|         canviewsubmissions: false, | ||||
|     }; | ||||
|     protected sourceUnsubscribe?: () => void; | ||||
| 
 | ||||
|     constructor() { | ||||
|         this.submissions = new AddonModAssignSubmissionListManager(AddonModAssignSubmissionListPage); | ||||
| 
 | ||||
|         // Update data if some grade changes.
 | ||||
|         this.gradedObserver = CoreEvents.on( | ||||
|             AddonModAssignProvider.GRADED_EVENT, | ||||
|             (data) => { | ||||
|                 if ( | ||||
|                     this.loaded && | ||||
|                     this.assign && | ||||
|                     data.assignmentId == this.assign.id && | ||||
|                     this.submissions.loaded && | ||||
|                     this.submissions.getSource().assign && | ||||
|                     data.assignmentId == this.submissions.getSource().assign?.id && | ||||
|                     data.userId == CoreSites.getCurrentSiteUserId() | ||||
|                 ) { | ||||
|                     // Grade changed, refresh the data.
 | ||||
|                     this.loaded = false; | ||||
| 
 | ||||
|                     this.refreshAllData(true).finally(() => { | ||||
|                         this.loaded = true; | ||||
|                     }); | ||||
|                     this.refreshAllData(true); | ||||
|                 } | ||||
|             }, | ||||
|             CoreSites.getCurrentSiteId(), | ||||
| @ -102,29 +72,36 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | ||||
|         this.syncObserver = CoreEvents.onMultiple<AddonModAssignAutoSyncData | AddonModAssignManualSyncData>( | ||||
|             events, | ||||
|             (data) => { | ||||
|                 if (!this.loaded || ('context' in data && data.context == 'submission-list')) { | ||||
|                 if (!this.submissions.loaded || ('context' in data && data.context == 'submission-list')) { | ||||
|                     return; | ||||
|                 } | ||||
| 
 | ||||
|                 this.loaded = false; | ||||
| 
 | ||||
|                 this.refreshAllData(false).finally(() => { | ||||
|                     this.loaded = true; | ||||
|                 }); | ||||
|                 this.refreshAllData(false); | ||||
|             }, | ||||
|             CoreSites.getCurrentSiteId(), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngAfterViewInit(): void { | ||||
|         try { | ||||
|             this.moduleId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|             this.groupId = CoreNavigator.getRouteNumberParam('groupId') || 0; | ||||
|             this.selectedStatus = CoreNavigator.getRouteParam('status'); | ||||
|             const moduleId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||
|             const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|             const groupId = CoreNavigator.getRouteNumberParam('groupId') || 0; | ||||
|             const selectedStatus = CoreNavigator.getRouteParam('status'); | ||||
|             const submissionsSource = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                 AddonModAssignSubmissionsSource, | ||||
|                 [courseId, moduleId, selectedStatus], | ||||
|             ); | ||||
| 
 | ||||
|             submissionsSource.groupId = groupId; | ||||
|             this.sourceUnsubscribe = submissionsSource.addListener({ | ||||
|                 onItemsUpdated: () => { | ||||
|                     this.title = this.submissions.getSource().assign?.name || this.title; | ||||
|                 }, | ||||
|             }); | ||||
| 
 | ||||
|             this.submissions = new CoreListItemsManager( | ||||
|                 submissionsSource, | ||||
|                 AddonModAssignSubmissionListPage, | ||||
|             ); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModal(error); | ||||
| 
 | ||||
| @ -132,18 +109,48 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|         if (this.selectedStatus) { | ||||
|             if (this.selectedStatus == AddonModAssignProvider.NEED_GRADING) { | ||||
|                 this.title = Translate.instant('addon.mod_assign.numberofsubmissionsneedgrading'); | ||||
|             } else { | ||||
|                 this.title = Translate.instant('addon.mod_assign.submissionstatus_' + this.selectedStatus); | ||||
|     get assign(): AddonModAssignAssign | undefined { | ||||
|         return this.submissions.getSource().assign; | ||||
|     } | ||||
|         } else { | ||||
|             this.title = Translate.instant('addon.mod_assign.numberofparticipants'); | ||||
| 
 | ||||
|     get groupInfo(): CoreGroupInfo { | ||||
|         return this.submissions.getSource().groupInfo; | ||||
|     } | ||||
| 
 | ||||
|     get moduleId(): number { | ||||
|         return this.submissions.getSource().MODULE_ID; | ||||
|     } | ||||
| 
 | ||||
|     get courseId(): number { | ||||
|         return this.submissions.getSource().COURSE_ID; | ||||
|     } | ||||
| 
 | ||||
|     get groupId(): number { | ||||
|         return this.submissions.getSource().groupId; | ||||
|     } | ||||
| 
 | ||||
|     set groupId(value: number) { | ||||
|         this.submissions.getSource().groupId = value; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngAfterViewInit(): void { | ||||
|         const selectedStatus = this.submissions.getSource().SELECTED_STATUS; | ||||
|         this.title = Translate.instant( | ||||
|             selectedStatus | ||||
|                 ? ( | ||||
|                     selectedStatus === AddonModAssignProvider.NEED_GRADING | ||||
|                         ? 'addon.mod_assign.numberofsubmissionsneedgrading' | ||||
|                         : `addon.mod_assign.submissionstatus_${selectedStatus}` | ||||
|                 ) | ||||
|                 : 'addon.mod_assign.numberofparticipants', | ||||
|         ); | ||||
| 
 | ||||
|         this.fetchAssignment(true).finally(() => { | ||||
|             this.loaded = true; | ||||
|             this.submissions.start(this.splitView); | ||||
|         }); | ||||
|     } | ||||
| @ -156,148 +163,12 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | ||||
|      */ | ||||
|     protected async fetchAssignment(sync = false): Promise<void> { | ||||
|         try { | ||||
|             // Get assignment data.
 | ||||
|             this.assign = await AddonModAssign.getAssignment(this.courseId, this.moduleId); | ||||
| 
 | ||||
|             this.title = this.assign.name || this.title; | ||||
| 
 | ||||
|             if (sync) { | ||||
|                 try { | ||||
|                     // Try to synchronize data.
 | ||||
|                     const result = await AddonModAssignSync.syncAssign(this.assign.id); | ||||
| 
 | ||||
|                     if (result && result.updated) { | ||||
|                         CoreEvents.trigger( | ||||
|                             AddonModAssignSyncProvider.MANUAL_SYNCED, | ||||
|                             { | ||||
|                                 assignId: this.assign.id, | ||||
|                                 warnings: result.warnings, | ||||
|                                 gradesBlocked: result.gradesBlocked, | ||||
|                                 context: 'submission-list', | ||||
|                             }, | ||||
|                             CoreSites.getCurrentSiteId(), | ||||
|                         ); | ||||
|                     } | ||||
|                 } catch (error) { | ||||
|                     // Ignore errors, probably user is offline or sync is blocked.
 | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             // Get assignment submissions.
 | ||||
|             this.submissionsData = await AddonModAssign.getSubmissions(this.assign.id, { cmId: this.assign.cmid }); | ||||
| 
 | ||||
|             if (!this.submissionsData.canviewsubmissions) { | ||||
|                 // User shouldn't be able to reach here.
 | ||||
|                 throw new Error('Cannot view submissions.'); | ||||
|             } | ||||
| 
 | ||||
|             // Check if groupmode is enabled to avoid showing wrong numbers.
 | ||||
|             this.groupInfo = await CoreGroups.getActivityGroupInfo(this.assign.cmid, false); | ||||
| 
 | ||||
|             await this.setGroup(CoreGroups.validateGroupId(this.groupId, this.groupInfo)); | ||||
|             await this.submissions.getSource().loadAssignment(sync); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'Error getting assigment data.'); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set group to see the summary. | ||||
|      * | ||||
|      * @param groupId Group ID. | ||||
|      * @return Resolved when done. | ||||
|      */ | ||||
|     async setGroup(groupId: number): Promise<void> { | ||||
|         this.groupId = groupId; | ||||
| 
 | ||||
|         // Fetch submissions and grades.
 | ||||
|         const submissions = | ||||
|             await AddonModAssignHelper.getSubmissionsUserData( | ||||
|                 this.assign!, | ||||
|                 this.submissionsData.submissions, | ||||
|                 this.groupId, | ||||
|             ); | ||||
|         // Get assignment grades only if workflow is not enabled to check grading date.
 | ||||
|         const grades = !this.assign!.markingworkflow | ||||
|             ? await AddonModAssign.getAssignmentGrades(this.assign!.id, { cmId: this.assign!.cmid }) | ||||
|             : []; | ||||
| 
 | ||||
|         // Filter the submissions to get only the ones with the right status and add some extra data.
 | ||||
|         const getNeedGrading = this.selectedStatus == AddonModAssignProvider.NEED_GRADING; | ||||
|         const searchStatus = getNeedGrading ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : this.selectedStatus; | ||||
| 
 | ||||
|         const promises: Promise<void>[] = []; | ||||
|         const showSubmissions: AddonModAssignSubmissionForList[] = []; | ||||
| 
 | ||||
|         submissions.forEach((submission: AddonModAssignSubmissionForList) => { | ||||
|             if (!searchStatus || searchStatus == submission.status) { | ||||
|                 promises.push( | ||||
|                     CoreUtils.ignoreErrors( | ||||
|                         AddonModAssignOffline.getSubmissionGrade(this.assign!.id, submission.userid), | ||||
|                     ).then(async (data) => { | ||||
|                         if (getNeedGrading) { | ||||
|                             // Only show the submissions that need to be graded.
 | ||||
|                             const add = await AddonModAssign.needsSubmissionToBeGraded(submission, this.assign!.id); | ||||
| 
 | ||||
|                             if (!add) { | ||||
|                                 return; | ||||
|                             } | ||||
|                         } | ||||
| 
 | ||||
|                         // Load offline grades.
 | ||||
|                         const notSynced = !!data && submission.timemodified < data.timemodified; | ||||
| 
 | ||||
|                         if (submission.gradingstatus == 'graded' && !this.assign!.markingworkflow) { | ||||
|                             // Get the last grade of the submission.
 | ||||
|                             const grade = grades | ||||
|                                 .filter((grade) => grade.userid == submission.userid) | ||||
|                                 .reduce( | ||||
|                                     (a, b) => (a && a.timemodified > b.timemodified ? a : b), | ||||
|                                     <AddonModAssignGrade | undefined> undefined, | ||||
|                                 ); | ||||
| 
 | ||||
|                             if (grade && grade.timemodified < submission.timemodified) { | ||||
|                                 submission.gradingstatus = AddonModAssignProvider.GRADED_FOLLOWUP_SUBMIT; | ||||
|                             } | ||||
|                         } | ||||
|                         submission.statusColor = AddonModAssign.getSubmissionStatusColor(submission.status); | ||||
|                         submission.gradingColor = AddonModAssign.getSubmissionGradingStatusColor( | ||||
|                             submission.gradingstatus, | ||||
|                         ); | ||||
| 
 | ||||
|                         // Show submission status if not submitted for grading.
 | ||||
|                         if (submission.statusColor != 'success' || !submission.gradingstatus) { | ||||
|                             submission.statusTranslated = Translate.instant( | ||||
|                                 'addon.mod_assign.submissionstatus_' + submission.status, | ||||
|                             ); | ||||
|                         } else { | ||||
|                             submission.statusTranslated = ''; | ||||
|                         } | ||||
| 
 | ||||
|                         if (notSynced) { | ||||
|                             submission.gradingStatusTranslationId = 'addon.mod_assign.gradenotsynced'; | ||||
|                             submission.gradingColor = ''; | ||||
|                         } else if (submission.statusColor != 'danger' || submission.gradingColor != 'danger') { | ||||
|                             // Show grading status if one of the statuses is not done.
 | ||||
|                             submission.gradingStatusTranslationId = AddonModAssign.getSubmissionGradingStatusTranslationId( | ||||
|                                 submission.gradingstatus, | ||||
|                             ); | ||||
|                         } else { | ||||
|                             submission.gradingStatusTranslationId = ''; | ||||
|                         } | ||||
| 
 | ||||
|                         showSubmissions.push(submission); | ||||
| 
 | ||||
|                         return; | ||||
|                     }), | ||||
|                 ); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
| 
 | ||||
|         this.submissions.setItems(showSubmissions); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Refresh all the data. | ||||
|      * | ||||
| @ -305,18 +176,8 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async refreshAllData(sync?: boolean): Promise<void> { | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         promises.push(AddonModAssign.invalidateAssignmentData(this.courseId)); | ||||
|         if (this.assign) { | ||||
|             promises.push(AddonModAssign.invalidateAllSubmissionData(this.assign.id)); | ||||
|             promises.push(AddonModAssign.invalidateAssignmentUserMappingsData(this.assign.id)); | ||||
|             promises.push(AddonModAssign.invalidateAssignmentGradesData(this.assign.id)); | ||||
|             promises.push(AddonModAssign.invalidateListParticipantsData(this.assign.id)); | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             await Promise.all(promises); | ||||
|             await this.submissions.getSource().invalidateCache(); | ||||
|         } finally { | ||||
|             this.fetchAssignment(sync); | ||||
|         } | ||||
| @ -333,6 +194,13 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reload submissions list. | ||||
|      */ | ||||
|     async reloadSubmissions(): Promise<void> { | ||||
|         await this.submissions.reload(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being destroyed. | ||||
|      */ | ||||
| @ -340,43 +208,7 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro | ||||
|         this.gradedObserver?.off(); | ||||
|         this.syncObserver?.off(); | ||||
|         this.submissions.destroy(); | ||||
|         this.sourceUnsubscribe && this.sourceUnsubscribe(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Helper class to manage submissions. | ||||
|  */ | ||||
| class AddonModAssignSubmissionListManager extends CorePageItemsListManager<AddonModAssignSubmissionForList> { | ||||
| 
 | ||||
|     constructor(pageComponent: unknown) { | ||||
|         super(pageComponent); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemPath(submission: AddonModAssignSubmissionForList): string { | ||||
|         return String(submission.submitid); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemQueryParams(submission: AddonModAssignSubmissionForList): Params { | ||||
|         return { | ||||
|             blindId: submission.blindid, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Calculated data for an assign submission. | ||||
|  */ | ||||
| type AddonModAssignSubmissionForList = AddonModAssignSubmissionFormatted & { | ||||
|     statusColor?: string; // Calculated in the app. Color of the submission status.
 | ||||
|     gradingColor?: string; // Calculated in the app. Color of the submission grading status.
 | ||||
|     statusTranslated?: string; // Calculated in the app. Translated text of the submission status.
 | ||||
|     gradingStatusTranslationId?: string; // Calculated in the app. Key of the text of the submission grading status.
 | ||||
| }; | ||||
|  | ||||
| @ -20,12 +20,14 @@ | ||||
|     </core-navbar-buttons> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
| 
 | ||||
|     <core-swipe-navigation [manager]="submissions"> | ||||
|         <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshSubmission($event.target)"> | ||||
|             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|         </ion-refresher> | ||||
|         <core-loading [hideUntil]="loaded"> | ||||
|         <addon-mod-assign-submission [courseId]="courseId" [moduleId]="moduleId" [submitId]="submitId" [blindId]="blindId"> | ||||
|             <addon-mod-assign-submission *ngIf="loaded" | ||||
|                 [courseId]="courseId" [moduleId]="moduleId" [submitId]="submitId" [blindId]="blindId"> | ||||
|             </addon-mod-assign-submission> | ||||
|         </core-loading> | ||||
|     </core-swipe-navigation> | ||||
| </ion-content> | ||||
|  | ||||
| @ -12,14 +12,17 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit, ViewChild } from '@angular/core'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreCourse } from '@features/course/services/course'; | ||||
| import { CanLeave } from '@guards/can-leave'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreScreen } from '@services/screen'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { AddonModAssignSubmissionsSource } from '../../classes/submissions-source'; | ||||
| import { AddonModAssignSubmissionComponent } from '../../components/submission/submission'; | ||||
| import { AddonModAssign, AddonModAssignAssign } from '../../services/assign'; | ||||
| 
 | ||||
| @ -30,11 +33,12 @@ import { AddonModAssign, AddonModAssignAssign } from '../../services/assign'; | ||||
|     selector: 'page-addon-mod-assign-submission-review', | ||||
|     templateUrl: 'submission-review.html', | ||||
| }) | ||||
| export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | ||||
| export class AddonModAssignSubmissionReviewPage implements OnInit, OnDestroy, CanLeave { | ||||
| 
 | ||||
|     @ViewChild(AddonModAssignSubmissionComponent) submissionComponent?: AddonModAssignSubmissionComponent; | ||||
| 
 | ||||
|     title = ''; // Title to display.
 | ||||
|     submissions?: AddonModAssignSubmissionSwipeItemsManager; | ||||
|     moduleId!: number; // Module ID the submission belongs to.
 | ||||
|     courseId!: number; // Course ID the assignment belongs to.
 | ||||
|     submitId!: number; // User that did the submission.
 | ||||
| @ -46,9 +50,7 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | ||||
|     protected blindMarking = false; // Whether it uses blind marking.
 | ||||
|     protected forceLeave = false; // To allow leaving the page without checking for changes.
 | ||||
| 
 | ||||
|     constructor( | ||||
|         protected route: ActivatedRoute, | ||||
|     ) { } | ||||
|     constructor(protected route: ActivatedRoute) { } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
| @ -60,6 +62,19 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | ||||
|                 this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|                 this.submitId = CoreNavigator.getRouteNumberParam('submitId') || 0; | ||||
|                 this.blindId = CoreNavigator.getRouteNumberParam('blindId', { params }); | ||||
|                 const groupId = CoreNavigator.getRequiredRouteNumberParam('groupId'); | ||||
|                 const selectedStatus = CoreNavigator.getRouteParam('selectedStatus'); | ||||
|                 const submissionsSource = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     AddonModAssignSubmissionsSource, | ||||
|                     [this.courseId, this.moduleId, selectedStatus], | ||||
|                 ); | ||||
| 
 | ||||
|                 this.submissions?.destroy(); | ||||
| 
 | ||||
|                 submissionsSource.groupId = groupId; | ||||
|                 this.submissions = new AddonModAssignSubmissionSwipeItemsManager(submissionsSource); | ||||
| 
 | ||||
|                 this.submissions.start(); | ||||
|             } catch (error) { | ||||
|                 CoreDomUtils.showErrorModal(error); | ||||
| 
 | ||||
| @ -74,6 +89,13 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.submissions?.destroy(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if we can leave the page or not. | ||||
|      * | ||||
| @ -190,3 +212,17 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, CanLeave { | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of submissions. | ||||
|  */ | ||||
| class AddonModAssignSubmissionSwipeItemsManager extends CoreSwipeItemsManager { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||
|         return route.params.submitId; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
							
								
								
									
										265
									
								
								src/addons/mod/forum/classes/forum-discussions-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										265
									
								
								src/addons/mod/forum/classes/forum-discussions-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,265 @@ | ||||
| // (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 { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { CoreUser } from '@features/user/services/user'; | ||||
| import { | ||||
|     AddonModForum, | ||||
|     AddonModForumData, | ||||
|     AddonModForumDiscussion, | ||||
|     AddonModForumProvider, | ||||
|     AddonModForumSortOrder, | ||||
| } from '../services/forum'; | ||||
| import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '../services/forum-offline'; | ||||
| 
 | ||||
| export class AddonModForumDiscussionsSource extends CoreItemsManagerSource<AddonModForumDiscussionItem> { | ||||
| 
 | ||||
|     static readonly NEW_DISCUSSION: AddonModForumNewDiscussionForm = { newDiscussion: true }; | ||||
| 
 | ||||
|     readonly DISCUSSIONS_PATH_PREFIX: string; | ||||
|     readonly COURSE_ID: number; | ||||
|     readonly CM_ID: number; | ||||
| 
 | ||||
|     forum?: AddonModForumData; | ||||
|     trackPosts = false; | ||||
|     usesGroups = false; | ||||
|     selectedSortOrder: AddonModForumSortOrder | null = null; | ||||
| 
 | ||||
|     constructor(courseId: number, cmId: number, discussionsPathPrefix: string) { | ||||
|         super(); | ||||
| 
 | ||||
|         this.DISCUSSIONS_PATH_PREFIX = discussionsPathPrefix; | ||||
|         this.COURSE_ID = courseId; | ||||
|         this.CM_ID = cmId; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer NewDiscussionForm objects. | ||||
|      * | ||||
|      * @param discussion Item to check. | ||||
|      * @return Whether the item is a new discussion form. | ||||
|      */ | ||||
|     isNewDiscussionForm(discussion: AddonModForumDiscussionItem): discussion is AddonModForumNewDiscussionForm { | ||||
|         return 'newDiscussion' in discussion; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer AddonModForumDiscussion objects. | ||||
|      * | ||||
|      * @param discussion Item to check. | ||||
|      * @return Whether the item is an online discussion. | ||||
|      */ | ||||
|     isOfflineDiscussion(discussion: AddonModForumDiscussionItem): discussion is AddonModForumOfflineDiscussion { | ||||
|         return !this.isNewDiscussionForm(discussion) && !this.isOnlineDiscussion(discussion); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer AddonModForumDiscussion objects. | ||||
|      * | ||||
|      * @param discussion Item to check. | ||||
|      * @return Whether the item is an online discussion. | ||||
|      */ | ||||
|     isOnlineDiscussion(discussion: AddonModForumDiscussionItem): discussion is AddonModForumDiscussion { | ||||
|         return 'id' in discussion; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemPath(discussion: AddonModForumDiscussionItem): string { | ||||
|         if (this.isOnlineDiscussion(discussion)) { | ||||
|             return this.DISCUSSIONS_PATH_PREFIX + discussion.discussion; | ||||
|         } | ||||
| 
 | ||||
|         if (this.isOfflineDiscussion(discussion)) { | ||||
|             return `${this.DISCUSSIONS_PATH_PREFIX}new/${discussion.timecreated}`; | ||||
|         } | ||||
| 
 | ||||
|         return `${this.DISCUSSIONS_PATH_PREFIX}new/0`; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemQueryParams(discussion: AddonModForumDiscussionItem): Params { | ||||
|         return { | ||||
|             courseId: this.COURSE_ID, | ||||
|             cmId: this.CM_ID, | ||||
|             forumId: this.forum?.id, | ||||
|             ...(this.isOnlineDiscussion(discussion) ? { discussion, trackPosts: this.trackPosts } : {}), | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getPagesLoaded(): number { | ||||
|         if (this.items === null) { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         const onlineEntries = this.items.filter(item => this.isOnlineDiscussion(item)); | ||||
| 
 | ||||
|         return Math.ceil(onlineEntries.length / this.getPageLength()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getPageLength(): number { | ||||
|         return AddonModForumProvider.DISCUSSIONS_PER_PAGE; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load forum. | ||||
|      */ | ||||
|     async loadForum(): Promise<void> { | ||||
|         this.forum = await AddonModForum.getForum(this.COURSE_ID, this.CM_ID); | ||||
| 
 | ||||
|         if (typeof this.forum.istracked != 'undefined') { | ||||
|             this.trackPosts = this.forum.istracked; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async loadPageItems(page: number): Promise<{ items: AddonModForumDiscussionItem[]; hasMoreItems: boolean }> { | ||||
|         const discussions: AddonModForumDiscussionItem[] = []; | ||||
| 
 | ||||
|         if (page === 0) { | ||||
|             const offlineDiscussions = await this.loadOfflineDiscussions(); | ||||
| 
 | ||||
|             discussions.push(AddonModForumDiscussionsSource.NEW_DISCUSSION); | ||||
|             discussions.push(...offlineDiscussions); | ||||
|         } | ||||
| 
 | ||||
|         const { discussions: onlineDiscussions, canLoadMore } = await this.loadOnlineDiscussions(page); | ||||
| 
 | ||||
|         discussions.push(...onlineDiscussions); | ||||
| 
 | ||||
|         return { | ||||
|             items: discussions, | ||||
|             hasMoreItems: canLoadMore, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load online discussions for the given page. | ||||
|      * | ||||
|      * @param page Page. | ||||
|      * @returns Online discussions info. | ||||
|      */ | ||||
|     private async loadOnlineDiscussions(page: number): Promise<{ | ||||
|         discussions: AddonModForumDiscussionItem[]; | ||||
|         canLoadMore: boolean; | ||||
|     }> { | ||||
|         if (!this.forum || !this.selectedSortOrder) { | ||||
|             throw new Error('Can\'t load discussions without a forum or selected sort order'); | ||||
|         } | ||||
| 
 | ||||
|         const response = await AddonModForum.getDiscussions(this.forum.id, { | ||||
|             cmId: this.forum.cmid, | ||||
|             sortOrder: this.selectedSortOrder.value, | ||||
|             page, | ||||
|         }); | ||||
|         let discussions = response.discussions; | ||||
| 
 | ||||
|         if (this.usesGroups) { | ||||
|             discussions = await AddonModForum.formatDiscussionsGroups(this.forum.cmid, discussions); | ||||
|         } | ||||
| 
 | ||||
|         // Hide author for first post and type single.
 | ||||
|         if (this.forum.type === 'single') { | ||||
|             for (const discussion of discussions) { | ||||
|                 if (discussion.userfullname && discussion.parent === 0) { | ||||
|                     discussion.userfullname = false; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // If any discussion has unread posts, the whole forum is being tracked.
 | ||||
|         if (typeof this.forum.istracked === 'undefined' && !this.trackPosts) { | ||||
|             for (const discussion of discussions) { | ||||
|                 if (discussion.numunread > 0) { | ||||
|                     this.trackPosts = true; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return { discussions, canLoadMore: response.canLoadMore }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load offline discussions. | ||||
|      * | ||||
|      * @returns Offline discussions. | ||||
|      */ | ||||
|     private async loadOfflineDiscussions(): Promise<AddonModForumOfflineDiscussion[]> { | ||||
|         if (!this.forum) { | ||||
|             throw new Error('Can\'t load discussions without a forum'); | ||||
|         } | ||||
| 
 | ||||
|         const forum = this.forum; | ||||
|         let offlineDiscussions = await AddonModForumOffline.getNewDiscussions(forum.id); | ||||
| 
 | ||||
|         if (offlineDiscussions.length === 0) { | ||||
|             return []; | ||||
|         } | ||||
| 
 | ||||
|         if (this.usesGroups) { | ||||
|             offlineDiscussions = await AddonModForum.formatDiscussionsGroups(forum.cmid, offlineDiscussions); | ||||
|         } | ||||
| 
 | ||||
|         // Fill user data for Offline discussions (should be already cached).
 | ||||
|         const promises = offlineDiscussions.map(async (offlineDiscussion) => { | ||||
|             const discussion = offlineDiscussion as unknown as AddonModForumDiscussion; | ||||
| 
 | ||||
|             if (discussion.parent === 0 || forum.type === 'single') { | ||||
|                 // Do not show author for first post and type single.
 | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|                 const user = await CoreUser.getProfile(discussion.userid, this.COURSE_ID, true); | ||||
| 
 | ||||
|                 discussion.userfullname = user.fullname; | ||||
|                 discussion.userpictureurl = user.profileimageurl; | ||||
|             } catch (error) { | ||||
|                 // Ignore errors.
 | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
| 
 | ||||
|         // Sort discussion by time (newer first).
 | ||||
|         offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated); | ||||
| 
 | ||||
|         return offlineDiscussions; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Type to select the new discussion form. | ||||
|  */ | ||||
| export type AddonModForumNewDiscussionForm = { newDiscussion: true }; | ||||
| 
 | ||||
| /** | ||||
|  * Type of items that can be held by the discussions manager. | ||||
|  */ | ||||
| export type AddonModForumDiscussionItem = AddonModForumDiscussion | AddonModForumOfflineDiscussion | AddonModForumNewDiscussionForm; | ||||
| @ -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 { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from './forum-discussions-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of discussions. | ||||
|  */ | ||||
| export class AddonModForumDiscussionsSwipeManager | ||||
|     extends CoreSwipeItemsManager<AddonModForumDiscussionItem, AddonModForumDiscussionsSource> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async navigateToNextItem(): Promise<void> { | ||||
|         let delta = -1; | ||||
|         const item = await this.getItemBy(-1); | ||||
| 
 | ||||
|         if (item && this.getSource().isNewDiscussionForm(item)) { | ||||
|             delta--; | ||||
|         } | ||||
| 
 | ||||
|         await this.navigateToItemBy(delta, 'back'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async navigateToPreviousItem(): Promise<void> { | ||||
|         let delta = 1; | ||||
|         const item = await this.getItemBy(1); | ||||
| 
 | ||||
|         if (item && this.getSource().isNewDiscussionForm(item)) { | ||||
|             delta++; | ||||
|         } | ||||
| 
 | ||||
|         await this.navigateToItemBy(delta, 'forward'); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -10,11 +10,11 @@ | ||||
|         <core-context-menu-item *ngIf="blog" [priority]="750" content="{{'addon.blog.blog' | translate}}" iconAction="far-newspaper" | ||||
|             (action)="gotoBlog()"> | ||||
|         </core-context-menu-item> | ||||
|         <core-context-menu-item *ngIf="discussions.loaded && !(hasOffline || hasOfflineRatings) && isOnline" [priority]="700" | ||||
|         <core-context-menu-item *ngIf="discussions && discussions.loaded && !(hasOffline || hasOfflineRatings) && isOnline" [priority]="700" | ||||
|             [content]="'addon.mod_forum.refreshdiscussions' | translate" [iconAction]="refreshIcon" [closeOnClick]="false" | ||||
|             (action)="doRefresh(null, $event)"> | ||||
|         </core-context-menu-item> | ||||
|         <core-context-menu-item *ngIf="discussions.loaded && (hasOffline || hasOfflineRatings) && isOnline" [priority]="600" | ||||
|         <core-context-menu-item *ngIf="discussions && discussions.loaded && (hasOffline || hasOfflineRatings) && isOnline" [priority]="600" | ||||
|             [content]="'core.settings.synchronizenow' | translate" [iconAction]="syncIcon" [closeOnClick]="false" | ||||
|             (action)="doRefresh(null, $event, true)"> | ||||
|         </core-context-menu-item> | ||||
| @ -32,11 +32,11 @@ | ||||
| 
 | ||||
| <!-- Content. --> | ||||
| <core-split-view> | ||||
|     <ion-refresher slot="fixed" [disabled]="!discussions.loaded" (ionRefresh)="doRefresh($event.target)"> | ||||
|     <ion-refresher slot="fixed" [disabled]="discussions && !discussions.loaded" (ionRefresh)="doRefresh($event.target)"> | ||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|     </ion-refresher> | ||||
| 
 | ||||
|     <core-loading [hideUntil]="discussions.loaded"> | ||||
|     <core-loading [hideUntil]="discussions && discussions.loaded"> | ||||
|         <!-- Activity info. --> | ||||
|         <core-course-module-info [module]="module" (completionChanged)="onCompletionChange()" | ||||
|             [description]="forum && forum.type != 'single' && description" [component]="component" [componentId]="componentId" | ||||
| @ -57,17 +57,18 @@ | ||||
|         </ion-card> | ||||
| 
 | ||||
|         <ng-container *ngIf="forum"> | ||||
|             <core-empty-box *ngIf="discussions.empty" icon="far-comments" [message]="'addon.mod_forum.forumnodiscussionsyet' | translate"> | ||||
|             <core-empty-box *ngIf="!discussions || discussions.empty" icon="far-comments" | ||||
|                 [message]="'addon.mod_forum.forumnodiscussionsyet' | translate"> | ||||
|             </core-empty-box> | ||||
| 
 | ||||
|             <div *ngIf="!discussions.empty && sortingAvailable && selectedSortOrder" class="ion-text-wrap"> | ||||
|             <div *ngIf="discussions && !discussions.empty && sortingAvailable && selectedSortOrder" class="ion-text-wrap"> | ||||
|                 <core-combobox [modalOptions]="sortOrderSelectorModalOptions" listboxId="addon-mod-forum-sort-selector" | ||||
|                     [label]="('core.sort' | translate)" (onChange)="setSortOrder($event)" [selection]="selectedSortOrder.label | translate" | ||||
|                     interface="modal"> | ||||
|                 </core-combobox> | ||||
|             </div> | ||||
| 
 | ||||
|             <ion-item *ngFor="let discussion of discussions.items" class="addon-mod-forum-discussion" detail="true" | ||||
|             <ion-item *ngFor="let discussion of discussionsItems" class="addon-mod-forum-discussion" detail="true" | ||||
|                 [lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions.getItemAriaCurrent(discussion)" | ||||
|                 (click)="discussions.select(discussion)" button> | ||||
|                 <ion-label> | ||||
| @ -96,17 +97,16 @@ | ||||
|                                 <ion-icon name="fas-users" [attr.aria-label]="'addon.mod_forum.group' | translate"> | ||||
|                                 </ion-icon> {{ discussion.groupname }} | ||||
|                             </p> | ||||
|                             <p *ngIf="discussions.isOnlineDiscussion(discussion)"> | ||||
|                             <p *ngIf="isOnlineDiscussion(discussion)"> | ||||
|                                 {{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}} | ||||
|                             </p> | ||||
|                             <p *ngIf="discussions.isOfflineDiscussion(discussion)"> | ||||
|                             <p *ngIf="isOfflineDiscussion(discussion)"> | ||||
|                                 <ion-icon name="fas-clock" aria-hidden="true"></ion-icon> | ||||
|                                 {{ 'core.notsent' | translate }} | ||||
|                             </p> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <ion-row *ngIf="discussions.isOnlineDiscussion(discussion)" | ||||
|                         class="ion-text-center addon-mod-forum-discussion-more-info"> | ||||
|                     <ion-row *ngIf="isOnlineDiscussion(discussion)" class="ion-text-center addon-mod-forum-discussion-more-info"> | ||||
|                         <ion-col class="ion-text-start"> | ||||
|                             <ion-note> | ||||
|                                 <ion-icon name="fas-clock" aria-hidden="true"></ion-icon> {{ 'addon.mod_forum.lastpost' | translate }} | ||||
| @ -134,7 +134,7 @@ | ||||
|                 </ion-label> | ||||
|             </ion-item> | ||||
| 
 | ||||
|             <core-infinite-loading [enabled]="discussions.onlineLoaded && !discussions.completed" [error]="discussions.fetchFailed" | ||||
|             <core-infinite-loading [enabled]="discussions && discussions.loaded && !discussions.completed" [error]="fetchFailed" | ||||
|                 (action)="fetchMoreDiscussions($event)"> | ||||
|             </core-infinite-loading> | ||||
|         </ng-container> | ||||
|  | ||||
| @ -13,7 +13,7 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, Optional, OnInit, OnDestroy, ViewChild, AfterViewInit } from '@angular/core'; | ||||
| import { ActivatedRoute, Params } from '@angular/router'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { IonContent } from '@ionic/angular'; | ||||
| import { ModalOptions } from '@ionic/core'; | ||||
| 
 | ||||
| @ -27,7 +27,7 @@ import { | ||||
|     AddonModForumNewDiscussionData, | ||||
|     AddonModForumReplyDiscussionData, | ||||
| } from '@addons/mod/forum/services/forum'; | ||||
| import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '@addons/mod/forum/services/forum-offline'; | ||||
| import { AddonModForumOffline } from '@addons/mod/forum/services/forum-offline'; | ||||
| import { Translate } from '@singletons'; | ||||
| import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; | ||||
| import { AddonModForumHelper } from '@addons/mod/forum/services/forum-helper'; | ||||
| @ -44,7 +44,6 @@ import { CoreUser } from '@features/user/services/user'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreCourse } from '@features/course/services/course'; | ||||
| import { CorePageItemsListManager } from '@classes/page-items-list-manager'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { AddonModForumDiscussionOptionsMenuComponent } from '../discussion-options-menu/discussion-options-menu'; | ||||
| import { AddonModForumSortOrderSelectorComponent } from '../sort-order-selector/sort-order-selector'; | ||||
| @ -56,6 +55,9 @@ import { CoreRatingProvider } from '@features/rating/services/rating'; | ||||
| import { CoreRatingSyncProvider } from '@features/rating/services/rating-sync'; | ||||
| import { CoreRatingOffline } from '@features/rating/services/rating-offline'; | ||||
| import { ContextLevel } from '@/core/constants'; | ||||
| import { AddonModForumDiscussionItem, AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; | ||||
| import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that displays a forum entry page. | ||||
| @ -72,24 +74,21 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|     component = AddonModForumProvider.COMPONENT; | ||||
|     moduleName = 'forum'; | ||||
|     descriptionNote?: string; | ||||
|     forum?: AddonModForumData; | ||||
|     discussions: AddonModForumDiscussionsManager; | ||||
|     discussions!: AddonModForumDiscussionsManager; | ||||
|     discussionsItems: AddonModForumDiscussionItem[] = []; | ||||
|     fetchFailed = false; | ||||
|     canAddDiscussion = false; | ||||
|     addDiscussionText!: string; | ||||
|     availabilityMessage: string | null = null; | ||||
|     sortingAvailable!: boolean; | ||||
|     sortOrders: AddonModForumSortOrder[] = []; | ||||
|     selectedSortOrder: AddonModForumSortOrder | null = null; | ||||
|     canPin = false; | ||||
|     trackPosts = false; | ||||
|     hasOfflineRatings = false; | ||||
|     sortOrderSelectorModalOptions: ModalOptions = { | ||||
|         component: AddonModForumSortOrderSelectorComponent, | ||||
|     }; | ||||
| 
 | ||||
|     protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; | ||||
|     protected page = 0; | ||||
|     protected usesGroups = false; | ||||
|     protected syncManualObserver?: CoreEventObserver; // It will observe the sync manual event.
 | ||||
|     protected replyObserver?: CoreEventObserver; | ||||
|     protected newDiscObserver?: CoreEventObserver; | ||||
| @ -97,19 +96,42 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|     protected changeDiscObserver?: CoreEventObserver; | ||||
|     protected ratingOfflineObserver?: CoreEventObserver; | ||||
|     protected ratingSyncObserver?: CoreEventObserver; | ||||
|     protected sourceUnsubscribe?: () => void; | ||||
| 
 | ||||
|     constructor( | ||||
|         route: ActivatedRoute, | ||||
|         public route: ActivatedRoute, | ||||
|         @Optional() protected content?: IonContent, | ||||
|         @Optional() courseContentsPage?: CoreCourseContentsPage, | ||||
|     ) { | ||||
|         super('AddonModForumIndexComponent', content, courseContentsPage); | ||||
|     } | ||||
| 
 | ||||
|         this.discussions = new AddonModForumDiscussionsManager( | ||||
|             route.component, | ||||
|             this, | ||||
|             courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : '', | ||||
|         ); | ||||
|     get forum(): AddonModForumData | undefined { | ||||
|         return this.discussions?.getSource().forum; | ||||
|     } | ||||
| 
 | ||||
|     get selectedSortOrder(): AddonModForumSortOrder | undefined { | ||||
|         return this.discussions?.getSource().selectedSortOrder ?? undefined; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether a discussion is online. | ||||
|      * | ||||
|      * @param discussion Discussion | ||||
|      * @return Whether the discussion is online. | ||||
|      */ | ||||
|     isOnlineDiscussion(discussion: AddonModForumDiscussionItem): boolean { | ||||
|         return this.discussions && this.discussions.getSource().isOnlineDiscussion(discussion); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether a discussion is offline. | ||||
|      * | ||||
|      * @param discussion Discussion | ||||
|      * @return Whether the discussion is offline. | ||||
|      */ | ||||
|     isOfflineDiscussion(discussion: AddonModForumDiscussionItem): boolean { | ||||
|         return this.discussions && this.discussions.getSource().isOfflineDiscussion(discussion); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -126,6 +148,48 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
| 
 | ||||
|         await super.ngOnInit(); | ||||
| 
 | ||||
|         // Initialize discussions manager.
 | ||||
|         const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|             AddonModForumDiscussionsSource, | ||||
|             [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : ''], | ||||
|         ); | ||||
| 
 | ||||
|         this.sourceUnsubscribe = source.addListener({ | ||||
|             onItemsUpdated: async discussions => { | ||||
|                 this.discussionsItems = discussions.filter(discussion => !source.isNewDiscussionForm(discussion)); | ||||
| 
 | ||||
|                 if (!this.forum) { | ||||
|                     return; | ||||
|                 } | ||||
| 
 | ||||
|                 // Check if there are replies for discussions stored in offline.
 | ||||
|                 const hasOffline = await AddonModForumOffline.hasForumReplies(this.forum.id); | ||||
| 
 | ||||
|                 this.hasOffline = this.hasOffline || hasOffline; | ||||
| 
 | ||||
|                 if (hasOffline) { | ||||
|                     // Only update new fetched discussions.
 | ||||
|                     const promises = discussions.map(async (discussion) => { | ||||
|                         if (!this.discussions.getSource().isOnlineDiscussion(discussion)) { | ||||
|                             return; | ||||
|                         } | ||||
| 
 | ||||
|                         // Get offline discussions.
 | ||||
|                         const replies = await AddonModForumOffline.getDiscussionReplies(discussion.discussion); | ||||
| 
 | ||||
|                         discussion.numreplies = Number(discussion.numreplies) + replies.length; | ||||
|                     }); | ||||
| 
 | ||||
|                     await Promise.all(promises); | ||||
|                 } | ||||
|             }, | ||||
|             onReset: () => { | ||||
|                 this.discussionsItems = []; | ||||
|             }, | ||||
|         }); | ||||
| 
 | ||||
|         this.discussions = new AddonModForumDiscussionsManager(source, this); | ||||
| 
 | ||||
|         // Refresh data if this forum discussion is synchronized from discussions list.
 | ||||
|         this.syncManualObserver = CoreEvents.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => { | ||||
|             this.autoSyncEventReceived(data); | ||||
| @ -141,12 +205,16 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|             this.eventReceived.bind(this, false), | ||||
|         ); | ||||
|         this.changeDiscObserver = CoreEvents.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data => { | ||||
|             if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module.id) { | ||||
|                 AddonModForum.invalidateDiscussionsList(this.forum!.id).finally(() => { | ||||
|             if (!this.forum) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             if (this.forum.id === data.forumId || data.cmId === this.module.id) { | ||||
|                 AddonModForum.invalidateDiscussionsList(this.forum.id).finally(() => { | ||||
|                     if (data.discussionId) { | ||||
|                         // Discussion changed, search it in the list of discussions.
 | ||||
|                         const discussion = this.discussions.items.find( | ||||
|                             (disc) => this.discussions.isOnlineDiscussion(disc) && data.discussionId == disc.discussion, | ||||
|                             (disc) => this.discussions.getSource().isOnlineDiscussion(disc) && data.discussionId == disc.discussion, | ||||
|                         ) as AddonModForumDiscussion; | ||||
| 
 | ||||
|                         if (discussion) { | ||||
| @ -196,20 +264,6 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|     async ngAfterViewInit(): Promise<void> { | ||||
|         await this.loadContent(false, true); | ||||
| 
 | ||||
|         if (!this.forum) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         CoreUtils.ignoreErrors( | ||||
|             AddonModForum.instance | ||||
|                 .logView(this.forum.id, this.forum.name) | ||||
|                 .then(async () => { | ||||
|                     CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); | ||||
| 
 | ||||
|                     return; | ||||
|                 }), | ||||
|         ); | ||||
| 
 | ||||
|         this.discussions.start(this.splitView); | ||||
|     } | ||||
| 
 | ||||
| @ -226,6 +280,8 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|         this.changeDiscObserver && this.changeDiscObserver.off(); | ||||
|         this.ratingOfflineObserver && this.ratingOfflineObserver.off(); | ||||
|         this.ratingSyncObserver && this.ratingSyncObserver.off(); | ||||
|         this.sourceUnsubscribe && this.sourceUnsubscribe(); | ||||
|         this.discussions.destroy(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -236,19 +292,21 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|      * @param showErrors Wether to show errors to the user or hide them. | ||||
|      */ | ||||
|     protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> { | ||||
|         this.discussions.fetchFailed = false; | ||||
| 
 | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         promises.push(this.fetchForum(sync, showErrors)); | ||||
|         promises.push(this.fetchSortOrderPreference()); | ||||
|         this.fetchFailed = false; | ||||
| 
 | ||||
|         try { | ||||
|             await Promise.all(promises); | ||||
|             await Promise.all([ | ||||
|                 this.fetchOfflineDiscussions(), | ||||
|                 this.fetchDiscussions(refresh), | ||||
|                 CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum!.cmid).then((hasRatings) => { | ||||
|                 this.fetchForum(sync, showErrors), | ||||
|                 this.fetchSortOrderPreference(), | ||||
|             ]); | ||||
| 
 | ||||
|             if (!this.forum) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             await Promise.all([ | ||||
|                 refresh ? this.discussions.reload() : this.discussions.load(), | ||||
|                 CoreRatingOffline.hasRatings('mod_forum', 'post', ContextLevel.MODULE, this.forum.cmid).then((hasRatings) => { | ||||
|                     this.hasOfflineRatings = hasRatings; | ||||
| 
 | ||||
|                     return; | ||||
| @ -258,7 +316,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|             if (refresh) { | ||||
|                 CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); | ||||
| 
 | ||||
|                 this.discussions.fetchFailed = true; // Set to prevent infinite calls with infinite-loading.
 | ||||
|                 this.fetchFailed = true; // Set to prevent infinite calls with infinite-loading.
 | ||||
|             } else { | ||||
|                 // Get forum failed, retry without using cache since it might be a new activity.
 | ||||
|                 await this.refreshContent(sync); | ||||
| @ -273,19 +331,19 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const forum = await AddonModForum.getForum(this.courseId, this.module.id); | ||||
|         await this.discussions.getSource().loadForum(); | ||||
| 
 | ||||
|         this.forum = forum; | ||||
|         if (!this.forum) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const forum = this.forum; | ||||
|         this.description = forum.intro || this.description; | ||||
|         this.availabilityMessage = AddonModForumHelper.getAvailabilityMessage(forum); | ||||
|         this.descriptionNote = Translate.instant('addon.mod_forum.numdiscussions', { | ||||
|             numdiscussions: forum.numdiscussions, | ||||
|         }); | ||||
| 
 | ||||
|         if (typeof forum.istracked != 'undefined') { | ||||
|             this.trackPosts = forum.istracked; | ||||
|         } | ||||
| 
 | ||||
|         this.dataRetrieved.emit(forum); | ||||
| 
 | ||||
|         switch (forum.type) { | ||||
| @ -319,10 +377,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|         // Check if the activity uses groups.
 | ||||
|         promises.push( | ||||
|             CoreGroups.instance | ||||
|                 .getActivityGroupMode(this.forum.cmid) | ||||
|                 .getActivityGroupMode(forum.cmid) | ||||
|                 .then(async mode => { | ||||
|                     this.usesGroups = mode === CoreGroupsProvider.SEPARATEGROUPS | ||||
|                                     || mode === CoreGroupsProvider.VISIBLEGROUPS; | ||||
|                     this.discussions.getSource().usesGroups = | ||||
|                         mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS; | ||||
| 
 | ||||
|                     return; | ||||
|                 }), | ||||
| @ -330,14 +388,14 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
| 
 | ||||
|         promises.push( | ||||
|             AddonModForum.instance | ||||
|                 .getAccessInformation(this.forum.id, { cmId: this.module.id }) | ||||
|                 .getAccessInformation(forum.id, { cmId: this.module.id }) | ||||
|                 .then(async accessInfo => { | ||||
|                     // Disallow adding discussions if cut-off date is reached and the user has not the
 | ||||
|                     // capability to override it.
 | ||||
|                     // Just in case the forum was fetched from WS when the cut-off date was not reached but it is now.
 | ||||
|                     const cutoffDateReached = AddonModForumHelper.isCutoffDateReached(this.forum!) | ||||
|                     const cutoffDateReached = AddonModForumHelper.isCutoffDateReached(forum) | ||||
|                                     && !accessInfo.cancanoverridecutoff; | ||||
|                     this.canAddDiscussion = !!this.forum?.cancreatediscussions && !cutoffDateReached; | ||||
|                     this.canAddDiscussion = !!forum.cancreatediscussions && !cutoffDateReached; | ||||
| 
 | ||||
|                     return; | ||||
|                 }), | ||||
| @ -347,7 +405,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|             // Use the canAddDiscussion WS to check if the user can pin discussions.
 | ||||
|             promises.push( | ||||
|                 AddonModForum.instance | ||||
|                     .canAddDiscussionToAll(this.forum.id, { cmId: this.module.id }) | ||||
|                     .canAddDiscussionToAll(forum.id, { cmId: this.module.id }) | ||||
|                     .then(async response => { | ||||
|                         this.canPin = !!response.canpindiscussions; | ||||
| 
 | ||||
| @ -366,124 +424,6 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|         await Promise.all(promises); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Convenience function to fetch offline discussions. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async fetchOfflineDiscussions(): Promise<void> { | ||||
|         const forum = this.forum!; | ||||
|         let offlineDiscussions = await AddonModForumOffline.getNewDiscussions(forum.id); | ||||
|         this.hasOffline = !!offlineDiscussions.length; | ||||
| 
 | ||||
|         if (!this.hasOffline) { | ||||
|             this.discussions.setOfflineDiscussions([]); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (this.usesGroups) { | ||||
|             offlineDiscussions = await AddonModForum.formatDiscussionsGroups(forum.cmid, offlineDiscussions); | ||||
|         } | ||||
| 
 | ||||
|         // Fill user data for Offline discussions (should be already cached).
 | ||||
|         const promises = offlineDiscussions.map(async (offlineDiscussion) => { | ||||
|             const discussion = offlineDiscussion as unknown as AddonModForumDiscussion; | ||||
| 
 | ||||
|             if (discussion.parent === 0 || forum.type === 'single') { | ||||
|                 // Do not show author for first post and type single.
 | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|                 const user = await CoreUser.getProfile(discussion.userid, this.courseId, true); | ||||
| 
 | ||||
|                 discussion.userfullname = user.fullname; | ||||
|                 discussion.userpictureurl = user.profileimageurl; | ||||
|             } catch (error) { | ||||
|                 // Ignore errors.
 | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
| 
 | ||||
|         // Sort discussion by time (newer first).
 | ||||
|         offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated); | ||||
| 
 | ||||
|         this.discussions.setOfflineDiscussions(offlineDiscussions); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Convenience function to get forum discussions. | ||||
|      * | ||||
|      * @param refresh Whether we're refreshing data. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async fetchDiscussions(refresh: boolean): Promise<void> { | ||||
|         const forum = this.forum!; | ||||
|         this.discussions.fetchFailed = false; | ||||
| 
 | ||||
|         if (refresh) { | ||||
|             this.page = 0; | ||||
|         } | ||||
| 
 | ||||
|         const response = await AddonModForum.getDiscussions(forum.id, { | ||||
|             cmId: forum.cmid, | ||||
|             sortOrder: this.selectedSortOrder!.value, | ||||
|             page: this.page, | ||||
|         }); | ||||
|         let discussions = response.discussions; | ||||
| 
 | ||||
|         if (this.usesGroups) { | ||||
|             discussions = await AddonModForum.formatDiscussionsGroups(forum.cmid, discussions); | ||||
|         } | ||||
| 
 | ||||
|         // Hide author for first post and type single.
 | ||||
|         if (forum.type === 'single') { | ||||
|             for (const discussion of discussions) { | ||||
|                 if (discussion.userfullname && discussion.parent === 0) { | ||||
|                     discussion.userfullname = false; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // If any discussion has unread posts, the whole forum is being tracked.
 | ||||
|         if (typeof forum.istracked === 'undefined' && !this.trackPosts) { | ||||
|             for (const discussion of discussions) { | ||||
|                 if (discussion.numunread > 0) { | ||||
|                     this.trackPosts = true; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (this.page === 0) { | ||||
|             this.discussions.setOnlineDiscussions(discussions, response.canLoadMore); | ||||
|         } else { | ||||
|             this.discussions.setItems(this.discussions.items.concat(discussions), response.canLoadMore); | ||||
|         } | ||||
| 
 | ||||
|         this.page++; | ||||
| 
 | ||||
|         // Check if there are replies for discussions stored in offline.
 | ||||
|         const hasOffline = await AddonModForumOffline.hasForumReplies(forum.id); | ||||
| 
 | ||||
|         this.hasOffline = this.hasOffline || hasOffline; | ||||
| 
 | ||||
|         if (hasOffline) { | ||||
|             // Only update new fetched discussions.
 | ||||
|             const promises = discussions.map(async (discussion) => { | ||||
|                 // Get offline discussions.
 | ||||
|                 const replies = await AddonModForumOffline.getDiscussionReplies(discussion.discussion); | ||||
| 
 | ||||
|                 discussion.numreplies = Number(discussion.numreplies) + replies.length; | ||||
|             }); | ||||
| 
 | ||||
|             await Promise.all(promises); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Convenience function to load more forum discussions. | ||||
|      * | ||||
| @ -492,11 +432,13 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|      */ | ||||
|     async fetchMoreDiscussions(complete: () => void): Promise<void> { | ||||
|         try { | ||||
|             await this.fetchDiscussions(false); | ||||
|             this.fetchFailed = false; | ||||
| 
 | ||||
|             await this.discussions.load(); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.mod_forum.errorgetforum', true); | ||||
| 
 | ||||
|             this.discussions.fetchFailed = true; | ||||
|             this.fetchFailed = true; | ||||
|         } finally { | ||||
|             complete(); | ||||
|         } | ||||
| @ -521,9 +463,13 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|         }; | ||||
| 
 | ||||
|         const value = await getSortOrder(); | ||||
|         const selectedOrder = this.sortOrders.find(sortOrder => sortOrder.value === value) || this.sortOrders[0]; | ||||
| 
 | ||||
|         this.selectedSortOrder = this.sortOrders.find(sortOrder => sortOrder.value === value) || this.sortOrders[0]; | ||||
|         this.sortOrderSelectorModalOptions.componentProps!.selected = this.selectedSortOrder.value; | ||||
|         this.discussions.getSource().selectedSortOrder = selectedOrder; | ||||
| 
 | ||||
|         if (this.sortOrderSelectorModalOptions.componentProps) { | ||||
|             this.sortOrderSelectorModalOptions.componentProps.selected = selectedOrder.value; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -597,11 +543,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|                 if (isNewDiscussion && CoreScreen.isTablet) { | ||||
|                     const newDiscussionData = data as AddonModForumNewDiscussionData; | ||||
|                     const discussion = this.discussions.items.find(disc => { | ||||
|                         if (this.discussions.isOfflineDiscussion(disc)) { | ||||
|                         if (this.discussions.getSource().isOfflineDiscussion(disc)) { | ||||
|                             return disc.timecreated === newDiscussionData.discTimecreated; | ||||
|                         } | ||||
| 
 | ||||
|                         if (this.discussions.isOnlineDiscussion(disc)) { | ||||
|                         if (this.discussions.getSource().isOnlineDiscussion(disc)) { | ||||
|                             return CoreArray.contains(newDiscussionData.discussionIds ?? [], disc.discussion); | ||||
|                         } | ||||
| 
 | ||||
| @ -625,7 +571,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|      * @param timeCreated Creation time of the offline discussion. | ||||
|      */ | ||||
|     openNewDiscussion(): void { | ||||
|         this.discussions.select({ newDiscussion: true }); | ||||
|         this.discussions.select(AddonModForumDiscussionsSource.NEW_DISCUSSION); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -634,10 +580,13 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|      * @param sortOrder Sort order new data. | ||||
|      */ | ||||
|     async setSortOrder(sortOrder: AddonModForumSortOrder): Promise<void> { | ||||
|         if (sortOrder.value != this.selectedSortOrder?.value) { | ||||
|             this.selectedSortOrder = sortOrder; | ||||
|             this.sortOrderSelectorModalOptions.componentProps!.selected = this.selectedSortOrder.value; | ||||
|             this.page = 0; | ||||
|         if (sortOrder.value != this.discussions.getSource().selectedSortOrder?.value) { | ||||
|             this.discussions.getSource().selectedSortOrder = sortOrder; | ||||
|             this.discussions.getSource().setDirty(true); | ||||
| 
 | ||||
|             if (this.sortOrderSelectorModalOptions.componentProps) { | ||||
|                 this.sortOrderSelectorModalOptions.componentProps.selected = sortOrder.value; | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|                 await CoreUser.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, sortOrder.value.toFixed(0)); | ||||
| @ -666,6 +615,10 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|      * @param discussion Discussion. | ||||
|      */ | ||||
|     async showOptionsMenu(event: Event, discussion: AddonModForumDiscussion): Promise<void> { | ||||
|         if (!this.forum) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
| 
 | ||||
| @ -673,7 +626,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
|             component: AddonModForumDiscussionOptionsMenuComponent, | ||||
|             componentProps: { | ||||
|                 discussion, | ||||
|                 forumId: this.forum!.id, | ||||
|                 forumId: this.forum.id, | ||||
|                 cmId: this.module.id, | ||||
|             }, | ||||
|             event, | ||||
| @ -698,125 +651,47 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Type to select the new discussion form. | ||||
|  */ | ||||
| type NewDiscussionForm = { newDiscussion: true }; | ||||
| 
 | ||||
| /** | ||||
|  * Type of items that can be held by the discussions manager. | ||||
|  */ | ||||
| type DiscussionItem = AddonModForumDiscussion | AddonModForumOfflineDiscussion | NewDiscussionForm; | ||||
| 
 | ||||
| /** | ||||
|  * Discussions manager. | ||||
|  */ | ||||
| class AddonModForumDiscussionsManager extends CorePageItemsListManager<DiscussionItem> { | ||||
| class AddonModForumDiscussionsManager extends CoreListItemsManager<AddonModForumDiscussionItem, AddonModForumDiscussionsSource> { | ||||
| 
 | ||||
|     onlineLoaded = false; | ||||
|     fetchFailed = false; | ||||
|     page: AddonModForumIndexComponent; | ||||
| 
 | ||||
|     private discussionsPathPrefix: string; | ||||
|     private component: AddonModForumIndexComponent; | ||||
|     constructor(source: AddonModForumDiscussionsSource, page: AddonModForumIndexComponent) { | ||||
|         super(source, page.route.component); | ||||
| 
 | ||||
|     constructor(pageComponent: unknown, component: AddonModForumIndexComponent, discussionsPathPrefix: string) { | ||||
|         super(pageComponent); | ||||
| 
 | ||||
|         this.component = component; | ||||
|         this.discussionsPathPrefix = discussionsPathPrefix; | ||||
|     } | ||||
| 
 | ||||
|     get loaded(): boolean { | ||||
|         return super.loaded && (this.onlineLoaded || this.fetchFailed); | ||||
|     } | ||||
| 
 | ||||
|     get onlineDiscussions(): AddonModForumDiscussion[] { | ||||
|         return this.items.filter(discussion => this.isOnlineDiscussion(discussion)) as AddonModForumDiscussion[]; | ||||
|         this.page = page; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemQueryParams(discussion: DiscussionItem): Params { | ||||
|         return { | ||||
|             courseId: this.component.courseId, | ||||
|             cmId: this.component.module.id, | ||||
|             forumId: this.component.forum!.id, | ||||
|             ...(this.isOnlineDiscussion(discussion) ? { discussion, trackPosts: this.component.trackPosts } : {}), | ||||
|         }; | ||||
|     } | ||||
|     protected getDefaultItem(): AddonModForumDiscussionItem | null { | ||||
|         const source = this.getSource(); | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer NewDiscussionForm objects. | ||||
|      * | ||||
|      * @param discussion Item to check. | ||||
|      * @return Whether the item is a new discussion form. | ||||
|      */ | ||||
|     isNewDiscussionForm(discussion: DiscussionItem): discussion is NewDiscussionForm { | ||||
|         return 'newDiscussion' in discussion; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer AddonModForumDiscussion objects. | ||||
|      * | ||||
|      * @param discussion Item to check. | ||||
|      * @return Whether the item is an online discussion. | ||||
|      */ | ||||
|     isOfflineDiscussion(discussion: DiscussionItem): discussion is AddonModForumOfflineDiscussion { | ||||
|         return !this.isNewDiscussionForm(discussion) | ||||
|             && !this.isOnlineDiscussion(discussion); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer AddonModForumDiscussion objects. | ||||
|      * | ||||
|      * @param discussion Item to check. | ||||
|      * @return Whether the item is an online discussion. | ||||
|      */ | ||||
|     isOnlineDiscussion(discussion: DiscussionItem): discussion is AddonModForumDiscussion { | ||||
|         return 'id' in discussion; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update online discussion items. | ||||
|      * | ||||
|      * @param onlineDiscussions Online discussions | ||||
|      */ | ||||
|     setOnlineDiscussions(onlineDiscussions: AddonModForumDiscussion[], hasMoreItems: boolean = false): void { | ||||
|         const otherDiscussions = this.items.filter(discussion => !this.isOnlineDiscussion(discussion)); | ||||
| 
 | ||||
|         this.setItems(otherDiscussions.concat(onlineDiscussions), hasMoreItems); | ||||
|         this.onlineLoaded = true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update offline discussion items. | ||||
|      * | ||||
|      * @param offlineDiscussions Offline discussions | ||||
|      */ | ||||
|     setOfflineDiscussions(offlineDiscussions: AddonModForumOfflineDiscussion[]): void { | ||||
|         const otherDiscussions = this.items.filter(discussion => !this.isOfflineDiscussion(discussion)); | ||||
| 
 | ||||
|         this.setItems((offlineDiscussions as DiscussionItem[]).concat(otherDiscussions), this.hasMoreItems); | ||||
|         return this.items.find(discussion => !source.isNewDiscussionForm(discussion)) || null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemPath(discussion: DiscussionItem): string { | ||||
|         const getRelativePath = () => { | ||||
|             if (this.isOnlineDiscussion(discussion)) { | ||||
|                 return discussion.discussion; | ||||
|     protected async logActivity(): Promise<void> { | ||||
|         const forum = this.getSource().forum; | ||||
| 
 | ||||
|         if (!forum) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|             if (this.isOfflineDiscussion(discussion)) { | ||||
|                 return `new/${discussion.timecreated}`; | ||||
|             } | ||||
|         CoreUtils.ignoreErrors( | ||||
|             AddonModForum.instance | ||||
|                 .logView(forum.id, forum.name) | ||||
|                 .then(async () => { | ||||
|                     CoreCourse.checkModuleCompletion(this.page.courseId, this.page.module.completiondata); | ||||
| 
 | ||||
|             return 'new/0'; | ||||
|         }; | ||||
| 
 | ||||
|         return this.discussionsPathPrefix + getRelativePath(); | ||||
|                     return; | ||||
|                 }), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -55,6 +55,7 @@ const mainMenuRoutes: Routes = [ | ||||
|     { | ||||
|         path: `${AddonModForumModuleHandlerService.PAGE_NAME}/discussion/:discussionId`, | ||||
|         loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), | ||||
|         data: { swipeEnabled: false }, | ||||
|     }, | ||||
|     { | ||||
|         path: AddonModForumModuleHandlerService.PAGE_NAME, | ||||
| @ -66,10 +67,12 @@ const mainMenuRoutes: Routes = [ | ||||
|                 path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, | ||||
|                 loadChildren: () => import('./pages/new-discussion/new-discussion.module') | ||||
|                     .then(m => m.AddonForumNewDiscussionPageModule), | ||||
|                 data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, | ||||
|             }, | ||||
|             { | ||||
|                 path: `${COURSE_CONTENTS_PATH}/${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, | ||||
|                 loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), | ||||
|                 data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, | ||||
|             }, | ||||
|         ], | ||||
|         () => CoreScreen.isMobile, | ||||
| @ -82,10 +85,12 @@ const courseContentsRoutes: Routes = conditionalRoutes( | ||||
|             path: `${AddonModForumModuleHandlerService.PAGE_NAME}/new/:timeCreated`, | ||||
|             loadChildren: () => import('./pages/new-discussion/new-discussion.module') | ||||
|                 .then(m => m.AddonForumNewDiscussionPageModule), | ||||
|             data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, | ||||
|         }, | ||||
|         { | ||||
|             path: `${AddonModForumModuleHandlerService.PAGE_NAME}/:discussionId`, | ||||
|             loadChildren: () => import('./pages/discussion/discussion.module').then(m => m.AddonForumDiscussionPageModule), | ||||
|             data: { discussionsPathPrefix: `${AddonModForumModuleHandlerService.PAGE_NAME}/` }, | ||||
|         }, | ||||
|     ], | ||||
|     () => CoreScreen.isTablet, | ||||
|  | ||||
| @ -56,6 +56,7 @@ | ||||
|     </core-context-menu> | ||||
| </core-navbar-buttons> | ||||
| <ion-content> | ||||
|     <core-swipe-navigation [manager]="discussions"> | ||||
|         <ion-refresher slot="fixed" [disabled]="!discussionLoaded" (ionRefresh)="doRefresh($event.target)"> | ||||
|             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|         </ion-refresher> | ||||
| @ -87,8 +88,8 @@ | ||||
|             <div *ngIf="startingPost" class="ion-margin-bottom"> | ||||
|                 <addon-mod-forum-post [post]="startingPost" [discussion]="discussion" [courseId]="courseId" [highlight]="true" | ||||
|                     [discussionId]="discussionId" [component]="component" [componentId]="cmId" [formData]="formData" | ||||
|                 [originalData]="originalData" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" | ||||
|                 [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||
|                     [originalData]="originalData" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" | ||||
|                     [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||
|                 </addon-mod-forum-post> | ||||
|             </div> | ||||
| 
 | ||||
| @ -96,9 +97,9 @@ | ||||
|                 <ng-container *ngFor="let post of posts; first as first"> | ||||
|                     <core-spacer *ngIf="!first"></core-spacer> | ||||
|                     <addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" | ||||
|                     [componentId]="cmId" [formData]="formData" [originalData]="originalData" [parentSubject]="postSubjects[post.parentid]" | ||||
|                     [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" | ||||
|                     [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||
|                         [componentId]="cmId" [formData]="formData" [originalData]="originalData" | ||||
|                         [parentSubject]="postSubjects[post.parentid]" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" | ||||
|                         [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||
|                     </addon-mod-forum-post> | ||||
|                 </ng-container> | ||||
|             </ion-card> | ||||
| @ -112,9 +113,9 @@ | ||||
|             <ng-template #nestedPosts let-post="post"> | ||||
|                 <ion-card> | ||||
|                     <addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" | ||||
|                     [componentId]="cmId" [formData]="formData" [originalData]="originalData" [parentSubject]="postSubjects[post.parentid]" | ||||
|                     [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" [ratingInfo]="ratingInfo" | ||||
|                     [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||
|                         [componentId]="cmId" [formData]="formData" [originalData]="originalData" | ||||
|                         [parentSubject]="postSubjects[post.parentid]" [forum]="forum" [accessInfo]="accessInfo" [trackPosts]="trackPosts" | ||||
|                         [ratingInfo]="ratingInfo" [leavingPage]="leavingPage" (onPostChange)="postListChanged()"> | ||||
|                     </addon-mod-forum-post> | ||||
|                 </ion-card> | ||||
|                 <div class="ion-padding-start" *ngIf="post.children && post.children.length && post.children[0].subject"> | ||||
| @ -124,4 +125,5 @@ | ||||
|                 </div> | ||||
|             </ng-template> | ||||
|         </core-loading> | ||||
|     </core-swipe-navigation> | ||||
| </ion-content> | ||||
|  | ||||
| @ -14,6 +14,8 @@ | ||||
| 
 | ||||
| import { ContextLevel, CoreConstants } from '@/core/constants'; | ||||
| import { Component, OnDestroy, ViewChild, OnInit, AfterViewInit, ElementRef, Optional } from '@angular/core'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; | ||||
| import { CoreRatingInfo, CoreRatingProvider } from '@features/rating/services/rating'; | ||||
| @ -32,6 +34,8 @@ import { Network, NgZone, Translate } from '@singletons'; | ||||
| import { CoreArray } from '@singletons/array'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; | ||||
| import { AddonModForumDiscussionsSwipeManager } from '../../classes/forum-discussions-swipe-manager'; | ||||
| import { | ||||
|     AddonModForum, | ||||
|     AddonModForumAccessInformation, | ||||
| @ -68,6 +72,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | ||||
|     forum: Partial<AddonModForumData> = {}; | ||||
|     accessInfo: AddonModForumAccessInformation = {}; | ||||
|     discussion?: AddonModForumDiscussion; | ||||
|     discussions?: AddonModForumDiscussionDiscussionsSwipeManager; | ||||
|     startingPost?: Post; | ||||
|     posts!: Post[]; | ||||
|     discussionLoaded = false; | ||||
| @ -117,14 +122,16 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | ||||
|     constructor( | ||||
|         @Optional() protected splitView: CoreSplitViewComponent, | ||||
|         protected elementRef: ElementRef, | ||||
|         protected route: ActivatedRoute, | ||||
|     ) {} | ||||
| 
 | ||||
|     get isMobile(): boolean { | ||||
|         return CoreScreen.isMobile; | ||||
|     } | ||||
| 
 | ||||
|     ngOnInit(): void { | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         try { | ||||
|             const routeData = this.route.snapshot.data; | ||||
|             this.courseId = CoreNavigator.getRouteNumberParam('courseId'); | ||||
|             this.cmId = CoreNavigator.getRouteNumberParam('cmId'); | ||||
|             this.forumId = CoreNavigator.getRouteNumberParam('forumId'); | ||||
| @ -136,6 +143,16 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | ||||
|             this.postId = CoreNavigator.getRouteNumberParam('postId'); | ||||
|             this.parent = CoreNavigator.getRouteNumberParam('parent'); | ||||
| 
 | ||||
|             if (this.courseId && this.cmId && (routeData.swipeEnabled ?? true)) { | ||||
|                 this.discussions = new AddonModForumDiscussionDiscussionsSwipeManager( | ||||
|                     CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                         AddonModForumDiscussionsSource, | ||||
|                         [this.courseId, this.cmId, routeData.discussionsPathPrefix ?? ''], | ||||
|                     ), | ||||
|                 ); | ||||
| 
 | ||||
|                 await this.discussions.start(); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModal(error); | ||||
| 
 | ||||
| @ -311,6 +328,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.onlineObserver && this.onlineObserver.unsubscribe(); | ||||
|         this.discussions && this.discussions.destroy(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -839,3 +857,17 @@ export type AddonModForumSharedPostFormData = Omit<AddonModForumPostFormData, 'i | ||||
|     id?: number; // ID when editing an online reply.
 | ||||
|     syncId?: string; // Sync ID if some post has blocked synchronization.
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of discussions. | ||||
|  */ | ||||
| class AddonModForumDiscussionDiscussionsSwipeManager extends AddonModForumDiscussionsSwipeManager { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||
|         return this.getSource().DISCUSSIONS_PATH_PREFIX + route.params.discussionId; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -12,10 +12,10 @@ | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
|     <core-swipe-navigation [manager]="discussions"> | ||||
|         <ion-refresher slot="fixed" [disabled]="!groupsLoaded" (ionRefresh)="refreshGroups($event.target)"> | ||||
|             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|         </ion-refresher> | ||||
| 
 | ||||
|         <core-loading [hideUntil]="groupsLoaded"> | ||||
|             <form *ngIf="showForm" #newDiscFormEl> | ||||
|                 <ion-item> | ||||
| @ -63,8 +63,8 @@ | ||||
|                         <ion-toggle [(ngModel)]="newDiscussion.pin" name="pin"></ion-toggle> | ||||
|                     </ion-item> | ||||
|                     <core-attachments *ngIf="canCreateAttachments && forum && forum.maxattachments > 0" [files]="newDiscussion.files" | ||||
|                     [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" [componentId]="forum.cmid" | ||||
|                     [allowOffline]="true" [courseId]="courseId"> | ||||
|                         [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" | ||||
|                         [componentId]="forum.cmid" [allowOffline]="true" [courseId]="courseId"> | ||||
|                     </core-attachments> | ||||
|                 </div> | ||||
|                 <ion-item> | ||||
| @ -84,4 +84,5 @@ | ||||
|                 </ion-item> | ||||
|             </form> | ||||
|         </core-loading> | ||||
|     </core-swipe-navigation> | ||||
| </ion-content> | ||||
|  | ||||
| @ -40,6 +40,10 @@ import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { CanLeave } from '@guards/can-leave'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreForms } from '@singletons/form'; | ||||
| import { AddonModForumDiscussionsSwipeManager } from '../../classes/forum-discussions-swipe-manager'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { AddonModForumDiscussionsSource } from '../../classes/forum-discussions-source'; | ||||
| 
 | ||||
| type NewDiscussionData = { | ||||
|     subject: string; | ||||
| @ -88,6 +92,8 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea | ||||
|     accessInfo: AddonModForumAccessInformation = {}; | ||||
|     courseId!: number; | ||||
| 
 | ||||
|     discussions?: AddonModForumNewDiscussionDiscussionsSwipeManager; | ||||
| 
 | ||||
|     protected cmId!: number; | ||||
|     protected forumId!: number; | ||||
|     protected timeCreated!: number; | ||||
| @ -97,17 +103,29 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea | ||||
|     protected originalData?: Partial<NewDiscussionData>; | ||||
|     protected forceLeave = false; | ||||
| 
 | ||||
|     constructor(@Optional() protected splitView: CoreSplitViewComponent) {} | ||||
|     constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {} | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         try { | ||||
|             const routeData = this.route.snapshot.data; | ||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|             this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||
|             this.forumId = CoreNavigator.getRequiredRouteNumberParam('forumId'); | ||||
|             this.timeCreated = CoreNavigator.getRequiredRouteNumberParam('timeCreated'); | ||||
| 
 | ||||
|             if (this.timeCreated !== 0 && (routeData.swipeEnabled ?? true)) { | ||||
|                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     AddonModForumDiscussionsSource, | ||||
|                     [this.courseId, this.cmId, routeData.discussionsPathPrefix ?? ''], | ||||
|                 ); | ||||
| 
 | ||||
|                 this.discussions = new AddonModForumNewDiscussionDiscussionsSwipeManager(source); | ||||
| 
 | ||||
|                 await this.discussions.start(); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModal(error); | ||||
| 
 | ||||
| @ -625,3 +643,17 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of discussions. | ||||
|  */ | ||||
| class AddonModForumNewDiscussionDiscussionsSwipeManager extends AddonModForumDiscussionsSwipeManager { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||
|         return `${this.getSource().DISCUSSIONS_PATH_PREFIX}new/${route.params.timeCreated}`; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
							
								
								
									
										381
									
								
								src/addons/mod/glossary/classes/glossary-entries-source.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										381
									
								
								src/addons/mod/glossary/classes/glossary-entries-source.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,381 @@ | ||||
| // (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 { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| import { | ||||
|     AddonModGlossary, | ||||
|     AddonModGlossaryEntry, | ||||
|     AddonModGlossaryGetEntriesOptions, | ||||
|     AddonModGlossaryGetEntriesWSResponse, | ||||
|     AddonModGlossaryGlossary, | ||||
|     AddonModGlossaryProvider, | ||||
| } from '../services/glossary'; | ||||
| import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../services/glossary-offline'; | ||||
| 
 | ||||
| /** | ||||
|  * Provides a collection of glossary entries. | ||||
|  */ | ||||
| export class AddonModGlossaryEntriesSource extends CoreItemsManagerSource<AddonModGlossaryEntryItem> { | ||||
| 
 | ||||
|     static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true }; | ||||
| 
 | ||||
|     readonly COURSE_ID: number; | ||||
|     readonly CM_ID: number; | ||||
|     readonly GLOSSARY_PATH_PREFIX: string; | ||||
| 
 | ||||
|     isSearch = false; | ||||
|     hasSearched = false; | ||||
|     fetchMode?: AddonModGlossaryFetchMode; | ||||
|     viewMode?: string; | ||||
|     glossary?: AddonModGlossaryGlossary; | ||||
|     onlineEntries: AddonModGlossaryEntry[] = []; | ||||
|     offlineEntries: AddonModGlossaryOfflineEntry[] = []; | ||||
| 
 | ||||
|     protected fetchFunction?: (options?: AddonModGlossaryGetEntriesOptions) => AddonModGlossaryGetEntriesWSResponse; | ||||
|     protected fetchInvalidate?: () => Promise<void>; | ||||
| 
 | ||||
|     constructor(courseId: number, cmId: number, glossaryPathPrefix: string) { | ||||
|         super(); | ||||
| 
 | ||||
|         this.COURSE_ID = courseId; | ||||
|         this.CM_ID = cmId; | ||||
|         this.GLOSSARY_PATH_PREFIX = glossaryPathPrefix; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer NewEntryForm objects. | ||||
|      * | ||||
|      * @param entry Item to check. | ||||
|      * @return Whether the item is a new entry form. | ||||
|      */ | ||||
|     isNewEntryForm(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryNewEntryForm { | ||||
|         return 'newEntry' in entry; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer entry objects. | ||||
|      * | ||||
|      * @param entry Item to check. | ||||
|      * @return Whether the item is an offline entry. | ||||
|      */ | ||||
|     isOnlineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryEntry { | ||||
|         return 'id' in entry; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer entry objects. | ||||
|      * | ||||
|      * @param entry Item to check. | ||||
|      * @return Whether the item is an offline entry. | ||||
|      */ | ||||
|     isOfflineEntry(entry: AddonModGlossaryEntryItem): entry is AddonModGlossaryOfflineEntry { | ||||
|         return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemPath(entry: AddonModGlossaryEntryItem): string { | ||||
|         if (this.isOnlineEntry(entry)) { | ||||
|             return `${this.GLOSSARY_PATH_PREFIX}entry/${entry.id}`; | ||||
|         } | ||||
| 
 | ||||
|         if (this.isOfflineEntry(entry)) { | ||||
|             return `${this.GLOSSARY_PATH_PREFIX}edit/${entry.timecreated}`; | ||||
|         } | ||||
| 
 | ||||
|         return `${this.GLOSSARY_PATH_PREFIX}edit/0`; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemQueryParams(entry: AddonModGlossaryEntryItem): Params { | ||||
|         const params: Params = { | ||||
|             cmId: this.CM_ID, | ||||
|             courseId: this.COURSE_ID, | ||||
|         }; | ||||
| 
 | ||||
|         if (this.isOfflineEntry(entry)) { | ||||
|             params.concept = entry.concept; | ||||
|         } | ||||
| 
 | ||||
|         return params; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getPagesLoaded(): number { | ||||
|         if (this.items === null) { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         return Math.ceil(this.onlineEntries.length / this.getPageLength()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Start searching. | ||||
|      */ | ||||
|     startSearch(): void { | ||||
|         this.isSearch = true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Stop searching and restore unfiltered collection. | ||||
|      * | ||||
|      * @param cachedOnlineEntries Cached online entries. | ||||
|      * @param hasMoreOnlineEntries Whether there were more online entries. | ||||
|      */ | ||||
|     stopSearch(cachedOnlineEntries: AddonModGlossaryEntry[], hasMoreOnlineEntries: boolean): void { | ||||
|         if (!this.fetchMode) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.isSearch = false; | ||||
|         this.hasSearched = false; | ||||
|         this.onlineEntries = cachedOnlineEntries; | ||||
|         this.hasMoreItems = hasMoreOnlineEntries; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set search query. | ||||
|      * | ||||
|      * @param query Search query. | ||||
|      */ | ||||
|     search(query: string): void { | ||||
|         if (!this.glossary) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.fetchFunction = AddonModGlossary.getEntriesBySearch.bind( | ||||
|             AddonModGlossary.instance, | ||||
|             this.glossary.id, | ||||
|             query, | ||||
|             true, | ||||
|             'CONCEPT', | ||||
|             'ASC', | ||||
|         ); | ||||
|         this.fetchInvalidate = AddonModGlossary.invalidateEntriesBySearch.bind( | ||||
|             AddonModGlossary.instance, | ||||
|             this.glossary.id, | ||||
|             query, | ||||
|             true, | ||||
|             'CONCEPT', | ||||
|             'ASC', | ||||
|         ); | ||||
|         this.hasSearched = true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load glossary. | ||||
|      */ | ||||
|     async loadGlossary(): Promise<void> { | ||||
|         this.glossary = await AddonModGlossary.getGlossary(this.COURSE_ID, this.CM_ID); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Invalidate glossary cache. | ||||
|      */ | ||||
|     async invalidateCache(): Promise<void> { | ||||
|         await Promise.all([ | ||||
|             AddonModGlossary.invalidateCourseGlossaries(this.COURSE_ID), | ||||
|             this.fetchInvalidate && this.fetchInvalidate(), | ||||
|             this.glossary && AddonModGlossary.invalidateCategories(this.glossary.id), | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Change fetch mode. | ||||
|      * | ||||
|      * @param mode New mode. | ||||
|      */ | ||||
|     switchMode(mode: AddonModGlossaryFetchMode): void { | ||||
|         if (!this.glossary) { | ||||
|             throw new Error('Can\'t switch entries mode without a glossary!'); | ||||
|         } | ||||
| 
 | ||||
|         this.fetchMode = mode; | ||||
|         this.isSearch = false; | ||||
| 
 | ||||
|         switch (mode) { | ||||
|             case 'author_all': | ||||
|                 // Browse by author.
 | ||||
|                 this.viewMode = 'author'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByAuthor.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     'ALL', | ||||
|                     'LASTNAME', | ||||
|                     'ASC', | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByAuthor.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     'ALL', | ||||
|                     'LASTNAME', | ||||
|                     'ASC', | ||||
|                 ); | ||||
|                 break; | ||||
| 
 | ||||
|             case 'cat_all': | ||||
|                 // Browse by category.
 | ||||
|                 this.viewMode = 'cat'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByCategory.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByCategory.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, | ||||
|                 ); | ||||
|                 break; | ||||
| 
 | ||||
|             case 'newest_first': | ||||
|                 // Newest first.
 | ||||
|                 this.viewMode = 'date'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     'CREATION', | ||||
|                     'DESC', | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     'CREATION', | ||||
|                     'DESC', | ||||
|                 ); | ||||
|                 break; | ||||
| 
 | ||||
|             case 'recently_updated': | ||||
|                 // Recently updated.
 | ||||
|                 this.viewMode = 'date'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     'UPDATE', | ||||
|                     'DESC', | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     'UPDATE', | ||||
|                     'DESC', | ||||
|                 ); | ||||
|                 break; | ||||
| 
 | ||||
|             case 'letter_all': | ||||
|             default: | ||||
|                 // Consider it is 'letter_all'.
 | ||||
|                 this.viewMode = 'letter'; | ||||
|                 this.fetchMode = 'letter_all'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByLetter.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     'ALL', | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByLetter.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary.id, | ||||
|                     'ALL', | ||||
|                 ); | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async loadPageItems(page: number): Promise<{ items: AddonModGlossaryEntryItem[]; hasMoreItems: boolean }> { | ||||
|         const glossary = this.glossary; | ||||
|         const fetchFunction = this.fetchFunction; | ||||
| 
 | ||||
|         if (!glossary || !fetchFunction) { | ||||
|             throw new Error('Can\'t load entries without glossary or fetch function'); | ||||
|         } | ||||
| 
 | ||||
|         const entries: AddonModGlossaryEntryItem[] = []; | ||||
| 
 | ||||
|         if (page === 0) { | ||||
|             const offlineEntries = await AddonModGlossaryOffline.getGlossaryNewEntries(glossary.id); | ||||
| 
 | ||||
|             offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept)); | ||||
| 
 | ||||
|             entries.push(AddonModGlossaryEntriesSource.NEW_ENTRY); | ||||
|             entries.push(...offlineEntries); | ||||
|         } | ||||
| 
 | ||||
|         const from = page * this.getPageLength(); | ||||
|         const pageEntries = await fetchFunction({ from, cmId: this.CM_ID }); | ||||
| 
 | ||||
|         entries.push(...pageEntries.entries); | ||||
| 
 | ||||
|         return { | ||||
|             items: entries, | ||||
|             hasMoreItems: from + pageEntries.entries.length < pageEntries.count, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getPageLength(): number { | ||||
|         return AddonModGlossaryProvider.LIMIT_ENTRIES; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected setItems(entries: AddonModGlossaryEntryItem[], hasMoreItems: boolean): void { | ||||
|         this.onlineEntries = []; | ||||
|         this.offlineEntries = []; | ||||
| 
 | ||||
|         entries.forEach(entry => { | ||||
|             this.isOnlineEntry(entry) && this.onlineEntries.push(entry); | ||||
|             this.isOfflineEntry(entry) && this.offlineEntries.push(entry); | ||||
|         }); | ||||
| 
 | ||||
|         super.setItems(entries, hasMoreItems); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     reset(): void { | ||||
|         this.onlineEntries = []; | ||||
|         this.offlineEntries = []; | ||||
| 
 | ||||
|         super.reset(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Type of items that can be held by the entries manager. | ||||
|  */ | ||||
| export type AddonModGlossaryEntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | AddonModGlossaryNewEntryForm; | ||||
| 
 | ||||
| /** | ||||
|  * Type to select the new entry form. | ||||
|  */ | ||||
| export type AddonModGlossaryNewEntryForm = { newEntry: true }; | ||||
| 
 | ||||
| /** | ||||
|  * Fetch mode to sort entries. | ||||
|  */ | ||||
| export type AddonModGlossaryFetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'letter_all'; | ||||
| @ -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 { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { AddonModGlossaryEntriesSource, AddonModGlossaryEntryItem } from './glossary-entries-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of glossary entries. | ||||
|  */ | ||||
| export abstract class AddonModGlossaryEntriesSwipeManager | ||||
|     extends CoreSwipeItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async navigateToNextItem(): Promise<void> { | ||||
|         let delta = -1; | ||||
|         const item = await this.getItemBy(-1); | ||||
| 
 | ||||
|         if (item && this.getSource().isNewEntryForm(item)) { | ||||
|             delta--; | ||||
|         } | ||||
| 
 | ||||
|         await this.navigateToItemBy(delta, 'back'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async navigateToPreviousItem(): Promise<void> { | ||||
|         let delta = 1; | ||||
|         const item = await this.getItemBy(1); | ||||
| 
 | ||||
|         if (item && this.getSource().isNewEntryForm(item)) { | ||||
|             delta++; | ||||
|         } | ||||
| 
 | ||||
|         await this.navigateToItemBy(delta, 'forward'); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -54,7 +54,7 @@ | ||||
|             [component]="component" [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline || hasOfflineRatings"> | ||||
|         </core-course-module-info> | ||||
| 
 | ||||
|         <ion-list *ngIf="!isSearch && entries.offlineEntries.length > 0"> | ||||
|         <ion-list *ngIf="!isSearch && entries && entries.offlineEntries.length > 0"> | ||||
|             <ion-item-divider> | ||||
|                 <ion-label> | ||||
|                     <h2>{{ 'addon.mod_glossary.entriestobesynced' | translate }}</h2> | ||||
| @ -70,7 +70,7 @@ | ||||
|             </ion-item> | ||||
|         </ion-list> | ||||
| 
 | ||||
|         <ion-list *ngIf="entries.onlineEntries.length > 0"> | ||||
|         <ion-list *ngIf="entries && entries.onlineEntries.length > 0"> | ||||
|             <ng-container *ngFor="let entry of entries.onlineEntries; let index = index"> | ||||
|                 <ion-item-divider *ngIf="getDivider && showDivider(entry, entries.onlineEntries[index - 1])"> | ||||
|                     <ion-label> | ||||
| @ -88,11 +88,11 @@ | ||||
|             </ng-container> | ||||
|         </ion-list> | ||||
| 
 | ||||
|         <core-empty-box *ngIf="entries.empty && (!isSearch || hasSearched)" icon="fas-list" | ||||
|         <core-empty-box *ngIf="(!entries || entries.empty) && (!isSearch || hasSearched)" icon="fas-list" | ||||
|             [message]="'addon.mod_glossary.noentriesfound' | translate"> | ||||
|         </core-empty-box> | ||||
| 
 | ||||
|         <core-infinite-loading [enabled]="!entries.completed" [error]="loadMoreError" (action)="loadMoreEntries($event)"> | ||||
|         <core-infinite-loading [enabled]="entries && !entries.completed" [error]="loadMoreError" (action)="loadMoreEntries($event)"> | ||||
|         </core-infinite-loading> | ||||
|     </core-loading> | ||||
| 
 | ||||
|  | ||||
| @ -14,8 +14,9 @@ | ||||
| 
 | ||||
| import { ContextLevel } from '@/core/constants'; | ||||
| import { AfterViewInit, Component, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core'; | ||||
| import { ActivatedRoute, Params } from '@angular/router'; | ||||
| import { CorePageItemsListManager } from '@classes/page-items-list-manager'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; | ||||
| import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; | ||||
| @ -29,16 +30,19 @@ import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { Translate } from '@singletons'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { | ||||
|     AddonModGlossaryEntriesSource, | ||||
|     AddonModGlossaryEntryItem, | ||||
|     AddonModGlossaryFetchMode, | ||||
| } from '../../classes/glossary-entries-source'; | ||||
| import { | ||||
|     AddonModGlossary, | ||||
|     AddonModGlossaryEntry, | ||||
|     AddonModGlossaryEntryWithCategory, | ||||
|     AddonModGlossaryGetEntriesOptions, | ||||
|     AddonModGlossaryGetEntriesWSResponse, | ||||
|     AddonModGlossaryGlossary, | ||||
|     AddonModGlossaryProvider, | ||||
| } from '../../services/glossary'; | ||||
| import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../../services/glossary-offline'; | ||||
| import { AddonModGlossaryOfflineEntry } from '../../services/glossary-offline'; | ||||
| import { | ||||
|     AddonModGlossaryAutoSyncData, | ||||
|     AddonModGlossarySyncProvider, | ||||
| @ -63,23 +67,17 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|     component = AddonModGlossaryProvider.COMPONENT; | ||||
|     moduleName = 'glossary'; | ||||
| 
 | ||||
|     isSearch = false; | ||||
|     hasSearched = false; | ||||
|     canAdd = false; | ||||
|     loadMoreError = false; | ||||
|     loadingMessage?: string; | ||||
|     entries: AddonModGlossaryEntriesManager; | ||||
|     loadingMessage: string; | ||||
|     entries!: AddonModGlossaryEntriesManager; | ||||
|     hasOfflineRatings = false; | ||||
|     glossary?: AddonModGlossaryGlossary; | ||||
| 
 | ||||
|     protected syncEventName = AddonModGlossarySyncProvider.AUTO_SYNCED; | ||||
|     protected fetchFunction?: (options?: AddonModGlossaryGetEntriesOptions) => AddonModGlossaryGetEntriesWSResponse; | ||||
|     protected fetchInvalidate?: () => Promise<void>; | ||||
|     protected addEntryObserver?: CoreEventObserver; | ||||
|     protected fetchMode?: AddonModGlossaryFetchMode; | ||||
|     protected viewMode?: string; | ||||
|     protected fetchedEntriesCanLoadMore = false; | ||||
|     protected fetchedEntries: AddonModGlossaryEntry[] = []; | ||||
|     protected sourceUnsubscribe?: () => void; | ||||
|     protected ratingOfflineObserver?: CoreEventObserver; | ||||
|     protected ratingSyncObserver?: CoreEventObserver; | ||||
| 
 | ||||
| @ -87,26 +85,47 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|     showDivider: (entry: AddonModGlossaryEntry, previous?: AddonModGlossaryEntry) => boolean = () => false; | ||||
| 
 | ||||
|     constructor( | ||||
|         route: ActivatedRoute, | ||||
|         protected route: ActivatedRoute, | ||||
|         protected content?: IonContent, | ||||
|         @Optional() courseContentsPage?: CoreCourseContentsPage, | ||||
|         @Optional() protected courseContentsPage?: CoreCourseContentsPage, | ||||
|     ) { | ||||
|         super('AddonModGlossaryIndexComponent', content, courseContentsPage); | ||||
| 
 | ||||
|         this.entries = new AddonModGlossaryEntriesManager( | ||||
|             route.component, | ||||
|             this, | ||||
|             courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : '', | ||||
|         ); | ||||
|         this.loadingMessage = Translate.instant('core.loading'); | ||||
|     } | ||||
| 
 | ||||
|     get glossary(): AddonModGlossaryGlossary | undefined { | ||||
|         return this.entries.getSource().glossary; | ||||
|     } | ||||
| 
 | ||||
|     get isSearch(): boolean { | ||||
|         return this.entries.getSource().isSearch; | ||||
|     } | ||||
| 
 | ||||
|     get hasSearched(): boolean { | ||||
|         return this.entries.getSource().hasSearched; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         super.ngOnInit(); | ||||
|         await super.ngOnInit(); | ||||
| 
 | ||||
|         this.loadingMessage = Translate.instant('core.loading'); | ||||
|         // Initialize entries manager.
 | ||||
|         const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|             AddonModGlossaryEntriesSource, | ||||
|             [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : ''], | ||||
|         ); | ||||
| 
 | ||||
|         this.entries = new AddonModGlossaryEntriesManager( | ||||
|             source, | ||||
|             this.route.component, | ||||
|         ); | ||||
| 
 | ||||
|         this.sourceUnsubscribe = source.addListener({ | ||||
|             onItemsUpdated: items => this.hasOffline = !!items.find(item => source.isOfflineEntry(item)), | ||||
|         }); | ||||
| 
 | ||||
|         // When an entry is added, we reload the data.
 | ||||
|         this.addEntryObserver = CoreEvents.on(AddonModGlossaryProvider.ADD_ENTRY_EVENT, (data) => { | ||||
| @ -143,11 +162,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.entries.start(this.splitView); | ||||
|         await this.entries.start(this.splitView); | ||||
| 
 | ||||
|         try { | ||||
|             await AddonModGlossary.logView(this.glossary.id, this.viewMode!, this.glossary.name); | ||||
| 
 | ||||
|             CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); | ||||
|         } catch (error) { | ||||
|             // Ignore errors.
 | ||||
| @ -159,14 +176,18 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|      */ | ||||
|     protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<void> { | ||||
|         try { | ||||
|             this.glossary = await AddonModGlossary.getGlossary(this.courseId, this.module.id); | ||||
|             await this.entries.getSource().loadGlossary(); | ||||
| 
 | ||||
|             if (!this.glossary) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             this.description = this.glossary.intro || this.description; | ||||
|             this.canAdd = !!this.glossary.canaddentry || false; | ||||
| 
 | ||||
|             this.dataRetrieved.emit(this.glossary); | ||||
| 
 | ||||
|             if (!this.fetchMode) { | ||||
|             if (!this.entries.getSource().fetchMode) { | ||||
|                 this.switchMode('letter_all'); | ||||
|             } | ||||
| 
 | ||||
| @ -177,7 +198,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
| 
 | ||||
|             const [hasOfflineRatings] = await Promise.all([ | ||||
|                 CoreRatingOffline.hasRatings('mod_glossary', 'entry', ContextLevel.MODULE, this.glossary.coursemodule), | ||||
|                 this.fetchEntries(), | ||||
|                 refresh ? this.entries.reload() : this.entries.load(), | ||||
|             ]); | ||||
| 
 | ||||
|             this.hasOfflineRatings = hasOfflineRatings; | ||||
| @ -186,59 +207,11 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Convenience function to fetch entries. | ||||
|      * | ||||
|      * @param append True if fetched entries are appended to exsiting ones. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async fetchEntries(append: boolean = false): Promise<void> { | ||||
|         if (!this.fetchFunction) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.loadMoreError = false; | ||||
|         const from = append ? this.entries.onlineEntries.length : 0; | ||||
| 
 | ||||
|         const result = await this.fetchFunction({ | ||||
|             from: from, | ||||
|             cmId: this.module.id, | ||||
|         }); | ||||
| 
 | ||||
|         const hasMoreEntries = from + result.entries.length < result.count; | ||||
| 
 | ||||
|         if (append) { | ||||
|             this.entries.setItems(this.entries.items.concat(result.entries), hasMoreEntries); | ||||
|         } else { | ||||
|             this.entries.setOnlineEntries(result.entries, hasMoreEntries); | ||||
|         } | ||||
| 
 | ||||
|         // Now get the ofline entries.
 | ||||
|         // Check if there are responses stored in offline.
 | ||||
|         const offlineEntries = await AddonModGlossaryOffline.getGlossaryNewEntries(this.glossary!.id); | ||||
| 
 | ||||
|         offlineEntries.sort((a, b) => a.concept.localeCompare(b.concept)); | ||||
|         this.hasOffline = !!offlineEntries.length; | ||||
|         this.entries.setOfflineEntries(offlineEntries); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async invalidateContent(): Promise<void> { | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         if (this.fetchInvalidate) { | ||||
|             promises.push(this.fetchInvalidate()); | ||||
|         } | ||||
| 
 | ||||
|         promises.push(AddonModGlossary.invalidateCourseGlossaries(this.courseId)); | ||||
| 
 | ||||
|         if (this.glossary) { | ||||
|             promises.push(AddonModGlossary.invalidateCategories(this.glossary.id)); | ||||
|         } | ||||
| 
 | ||||
|         await Promise.all(promises); | ||||
|         await this.entries.getSource().invalidateCache(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -277,111 +250,52 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|      * @param mode New mode. | ||||
|      */ | ||||
|     protected switchMode(mode: AddonModGlossaryFetchMode): void { | ||||
|         this.fetchMode = mode; | ||||
|         this.isSearch = false; | ||||
|         this.entries.getSource().switchMode(mode); | ||||
| 
 | ||||
|         switch (mode) { | ||||
|             case 'author_all': | ||||
|                 // Browse by author.
 | ||||
|                 this.viewMode = 'author'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByAuthor.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     'ALL', | ||||
|                     'LASTNAME', | ||||
|                     'ASC', | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByAuthor.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     'ALL', | ||||
|                     'LASTNAME', | ||||
|                     'ASC', | ||||
|                 ); | ||||
|                 this.getDivider = (entry) => entry.userfullname; | ||||
|                 this.showDivider = (entry, previous) => !previous || entry.userid != previous.userid; | ||||
|                 break; | ||||
| 
 | ||||
|             case 'cat_all': | ||||
|             case 'cat_all': { | ||||
|                 // Browse by category.
 | ||||
|                 this.viewMode = 'cat'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByCategory.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByCategory.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     AddonModGlossaryProvider.SHOW_ALL_CATEGORIES, | ||||
|                 ); | ||||
|                 this.getDivider = (entry: AddonModGlossaryEntryWithCategory) => entry.categoryname || ''; | ||||
|                 this.showDivider = (entry, previous) => !previous || this.getDivider!(entry) != this.getDivider!(previous); | ||||
|                 const getDivider = (entry: AddonModGlossaryEntryWithCategory) => entry.categoryname || ''; | ||||
| 
 | ||||
|                 this.getDivider = getDivider; | ||||
|                 this.showDivider = (entry, previous) => !previous || getDivider(entry) != getDivider(previous); | ||||
|                 break; | ||||
|             } | ||||
| 
 | ||||
|             case 'newest_first': | ||||
|                 // Newest first.
 | ||||
|                 this.viewMode = 'date'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     'CREATION', | ||||
|                     'DESC', | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     'CREATION', | ||||
|                     'DESC', | ||||
|                 ); | ||||
|                 this.getDivider = undefined; | ||||
|                 this.showDivider = () => false; | ||||
|                 break; | ||||
| 
 | ||||
|             case 'recently_updated': | ||||
|                 // Recently updated.
 | ||||
|                 this.viewMode = 'date'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByDate.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     'UPDATE', | ||||
|                     'DESC', | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByDate.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     'UPDATE', | ||||
|                     'DESC', | ||||
|                 ); | ||||
|                 this.getDivider = undefined; | ||||
|                 this.showDivider = () => false; | ||||
|                 break; | ||||
| 
 | ||||
|             case 'letter_all': | ||||
|             default: | ||||
|             default: { | ||||
|                 // Consider it is 'letter_all'.
 | ||||
|                 this.viewMode = 'letter'; | ||||
|                 this.fetchMode = 'letter_all'; | ||||
|                 this.fetchFunction = AddonModGlossary.getEntriesByLetter.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     'ALL', | ||||
|                 ); | ||||
|                 this.fetchInvalidate = AddonModGlossary.invalidateEntriesByLetter.bind( | ||||
|                     AddonModGlossary.instance, | ||||
|                     this.glossary!.id, | ||||
|                     'ALL', | ||||
|                 ); | ||||
|                 this.getDivider = (entry) => { | ||||
|                 const getDivider = (entry) => { | ||||
|                     // Try to get the first letter without HTML tags.
 | ||||
|                     const noTags = CoreTextUtils.cleanTags(entry.concept); | ||||
| 
 | ||||
|                     return (noTags || entry.concept).substr(0, 1).toUpperCase(); | ||||
|                 }; | ||||
|                 this.showDivider = (entry, previous) => !previous || this.getDivider!(entry) != this.getDivider!(previous); | ||||
| 
 | ||||
|                 this.getDivider = getDivider; | ||||
|                 this.showDivider = (entry, previous) => !previous || getDivider(entry) != getDivider(previous); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Convenience function to load more entries. | ||||
| @ -391,7 +305,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|      */ | ||||
|     async loadMoreEntries(infiniteComplete?: () => void): Promise<void> { | ||||
|         try { | ||||
|             await this.fetchEntries(true); | ||||
|             this.loadMoreError = false; | ||||
| 
 | ||||
|             await this.entries.load(); | ||||
|         } catch (error) { | ||||
|             this.loadMoreError = true; | ||||
|             CoreDomUtils.showErrorModalDefault(error, 'addon.mod_glossary.errorloadingentries', true); | ||||
| @ -406,21 +322,34 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|      * @param event Event. | ||||
|      */ | ||||
|     async openModePicker(event: MouseEvent): Promise<void> { | ||||
|         const mode = await CoreDomUtils.openPopover<AddonModGlossaryFetchMode>({ | ||||
|         if (!this.glossary) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const previousMode = this.entries.getSource().fetchMode; | ||||
|         const newMode = await CoreDomUtils.openPopover<AddonModGlossaryFetchMode>({ | ||||
|             component: AddonModGlossaryModePickerPopoverComponent, | ||||
|             componentProps: { | ||||
|                 browseModes: this.glossary!.browsemodes, | ||||
|                 selectedMode: this.isSearch ? '' : this.fetchMode, | ||||
|                 browseModes: this.glossary.browsemodes, | ||||
|                 selectedMode: this.isSearch ? '' : previousMode, | ||||
|             }, | ||||
|             event, | ||||
|         }); | ||||
| 
 | ||||
|         if (mode) { | ||||
|             if (mode !== this.fetchMode) { | ||||
|                 this.changeFetchMode(mode); | ||||
|             } else if (this.isSearch) { | ||||
|                 this.toggleSearch(); | ||||
|         if (!newMode) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (newMode !== previousMode) { | ||||
|             this.changeFetchMode(newMode); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (this.isSearch) { | ||||
|             this.toggleSearch(); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -429,20 +358,22 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|      */ | ||||
|     toggleSearch(): void { | ||||
|         if (this.isSearch) { | ||||
|             this.isSearch = false; | ||||
|             this.hasSearched = false; | ||||
|             this.entries.setOnlineEntries(this.fetchedEntries, this.fetchedEntriesCanLoadMore); | ||||
|             this.switchMode(this.fetchMode!); | ||||
|         } else { | ||||
|             const fetchMode = this.entries.getSource().fetchMode; | ||||
| 
 | ||||
|             fetchMode && this.switchMode(fetchMode); | ||||
|             this.entries.getSource().stopSearch(this.fetchedEntries, this.fetchedEntriesCanLoadMore); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         // Search for entries. The fetch function will be set when searching.
 | ||||
|         this.fetchedEntries = this.entries.getSource().onlineEntries; | ||||
|         this.fetchedEntriesCanLoadMore = !this.entries.completed; | ||||
|         this.getDivider = undefined; | ||||
|         this.showDivider = () => false; | ||||
|             this.isSearch = true; | ||||
| 
 | ||||
|             this.fetchedEntries = this.entries.onlineEntries; | ||||
|             this.fetchedEntriesCanLoadMore = !this.entries.completed; | ||||
|             this.entries.setItems([], false); | ||||
|         } | ||||
|         this.entries.reset(); | ||||
|         this.entries.getSource().startSearch(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -451,7 +382,6 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|      * @param mode Mode. | ||||
|      */ | ||||
|     changeFetchMode(mode: AddonModGlossaryFetchMode): void { | ||||
|         this.isSearch = false; | ||||
|         this.loadingMessage = Translate.instant('core.loading'); | ||||
|         this.content?.scrollToTop(); | ||||
|         this.switchMode(mode); | ||||
| @ -463,7 +393,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|      * Opens new entry editor. | ||||
|      */ | ||||
|     openNewEntry(): void { | ||||
|         this.entries.select({ newEntry: true }); | ||||
|         this.entries.select(AddonModGlossaryEntriesSource.NEW_ENTRY); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -473,24 +403,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|      */ | ||||
|     search(query: string): void { | ||||
|         this.loadingMessage = Translate.instant('core.searching'); | ||||
|         this.fetchFunction = AddonModGlossary.getEntriesBySearch.bind( | ||||
|             AddonModGlossary.instance, | ||||
|             this.glossary!.id, | ||||
|             query, | ||||
|             true, | ||||
|             'CONCEPT', | ||||
|             'ASC', | ||||
|         ); | ||||
|         this.fetchInvalidate = AddonModGlossary.invalidateEntriesBySearch.bind( | ||||
|             AddonModGlossary.instance, | ||||
|             this.glossary!.id, | ||||
|             query, | ||||
|             true, | ||||
|             'CONCEPT', | ||||
|             'ASC', | ||||
|         ); | ||||
|         this.loaded = false; | ||||
|         this.hasSearched = true; | ||||
| 
 | ||||
|         this.entries.getSource().search(query); | ||||
|         this.loadContent(); | ||||
|     } | ||||
| 
 | ||||
| @ -503,154 +418,44 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity | ||||
|         this.addEntryObserver?.off(); | ||||
|         this.ratingOfflineObserver?.off(); | ||||
|         this.ratingSyncObserver?.off(); | ||||
|         this.sourceUnsubscribe?.call(null); | ||||
|         this.entries.destroy(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Type to select the new entry form. | ||||
|  */ | ||||
| type NewEntryForm = { newEntry: true }; | ||||
| 
 | ||||
| /** | ||||
|  * Type of items that can be held by the entries manager. | ||||
|  */ | ||||
| type EntryItem = AddonModGlossaryEntry | AddonModGlossaryOfflineEntry | NewEntryForm; | ||||
| 
 | ||||
| /** | ||||
|  * Entries manager. | ||||
|  */ | ||||
| class AddonModGlossaryEntriesManager extends CorePageItemsListManager<EntryItem> { | ||||
| class AddonModGlossaryEntriesManager extends CoreListItemsManager<AddonModGlossaryEntryItem, AddonModGlossaryEntriesSource> { | ||||
| 
 | ||||
|     onlineEntries: AddonModGlossaryEntry[] = []; | ||||
|     offlineEntries: AddonModGlossaryOfflineEntry[] = []; | ||||
| 
 | ||||
|     protected glossaryPathPrefix: string; | ||||
|     protected component: AddonModGlossaryIndexComponent; | ||||
| 
 | ||||
|     constructor( | ||||
|         pageComponent: unknown, | ||||
|         component: AddonModGlossaryIndexComponent, | ||||
|         glossaryPathPrefix: string, | ||||
|     ) { | ||||
|         super(pageComponent); | ||||
| 
 | ||||
|         this.component = component; | ||||
|         this.glossaryPathPrefix = glossaryPathPrefix; | ||||
|     get offlineEntries(): AddonModGlossaryOfflineEntry[] { | ||||
|         return this.getSource().offlineEntries; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer NewEntryForm objects. | ||||
|      * | ||||
|      * @param entry Item to check. | ||||
|      * @return Whether the item is a new entry form. | ||||
|      */ | ||||
|     isNewEntryForm(entry: EntryItem): entry is NewEntryForm { | ||||
|         return 'newEntry' in entry; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer entry objects. | ||||
|      * | ||||
|      * @param entry Item to check. | ||||
|      * @return Whether the item is an offline entry. | ||||
|      */ | ||||
|     isOfflineEntry(entry: EntryItem): entry is AddonModGlossaryOfflineEntry { | ||||
|         return !this.isNewEntryForm(entry) && !this.isOnlineEntry(entry); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type guard to infer entry objects. | ||||
|      * | ||||
|      * @param entry Item to check. | ||||
|      * @return Whether the item is an offline entry. | ||||
|      */ | ||||
|     isOnlineEntry(entry: EntryItem): entry is AddonModGlossaryEntry { | ||||
|         return 'id' in entry; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update online entries items. | ||||
|      * | ||||
|      * @param onlineEntries Online entries. | ||||
|      */ | ||||
|     setOnlineEntries(onlineEntries: AddonModGlossaryEntry[], hasMoreItems: boolean = false): void { | ||||
|         this.setItems((<EntryItem[]> this.offlineEntries).concat(onlineEntries), hasMoreItems); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update offline entries items. | ||||
|      * | ||||
|      * @param offlineEntries Offline entries. | ||||
|      */ | ||||
|     setOfflineEntries(offlineEntries: AddonModGlossaryOfflineEntry[]): void { | ||||
|         this.setItems((<EntryItem[]> offlineEntries).concat(this.onlineEntries), this.hasMoreItems); | ||||
|     get onlineEntries(): AddonModGlossaryEntry[] { | ||||
|         return this.getSource().onlineEntries; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     setItems(entries: EntryItem[], hasMoreItems: boolean = false): void { | ||||
|         super.setItems(entries, hasMoreItems); | ||||
| 
 | ||||
|         this.onlineEntries = []; | ||||
|         this.offlineEntries = []; | ||||
|         this.items.forEach(entry => { | ||||
|             if (this.isOfflineEntry(entry)) { | ||||
|                 this.offlineEntries.push(entry); | ||||
|             } else if (this.isOnlineEntry(entry)) { | ||||
|                 this.onlineEntries.push(entry); | ||||
|             } | ||||
|         }); | ||||
|     protected getDefaultItem(): AddonModGlossaryEntryItem | null { | ||||
|         return this.getSource().onlineEntries[0] || null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     resetItems(): void { | ||||
|         super.resetItems(); | ||||
|         this.onlineEntries = []; | ||||
|         this.offlineEntries = []; | ||||
|     protected async logActivity(): Promise<void> { | ||||
|         const glossary = this.getSource().glossary; | ||||
|         const viewMode = this.getSource().viewMode; | ||||
| 
 | ||||
|         if (!glossary || !viewMode) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemPath(entry: EntryItem): string { | ||||
|         if (this.isOnlineEntry(entry)) { | ||||
|             return `${this.glossaryPathPrefix}entry/${entry.id}`; | ||||
|         } | ||||
| 
 | ||||
|         if (this.isOfflineEntry(entry)) { | ||||
|             return `${this.glossaryPathPrefix}edit/${entry.timecreated}`; | ||||
|         } | ||||
| 
 | ||||
|         return `${this.glossaryPathPrefix}edit/0`; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemQueryParams(entry: EntryItem): Params { | ||||
|         const params: Params = { | ||||
|             cmId: this.component.module.id, | ||||
|             courseId: this.component.courseId, | ||||
|         }; | ||||
| 
 | ||||
|         if (this.isOfflineEntry(entry)) { | ||||
|             params.concept = entry.concept; | ||||
|         } | ||||
| 
 | ||||
|         return params; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getDefaultItem(): EntryItem | null { | ||||
|         return this.onlineEntries[0] || null; | ||||
|         await AddonModGlossary.logView(glossary.id, viewMode, glossary.name); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export type AddonModGlossaryFetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'letter_all'; | ||||
|  | ||||
| @ -14,7 +14,7 @@ | ||||
| 
 | ||||
| import { Component, Input, OnInit } from '@angular/core'; | ||||
| import { PopoverController } from '@singletons'; | ||||
| import { AddonModGlossaryFetchMode } from '../index'; | ||||
| import { AddonModGlossaryFetchMode } from '../../classes/glossary-entries-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Component to display the mode picker. | ||||
|  | ||||
| @ -51,10 +51,12 @@ const mainMenuRoutes: Routes = [ | ||||
|     { | ||||
|         path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, | ||||
|         loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), | ||||
|         data: { swipeEnabled: false }, | ||||
|     }, | ||||
|     { | ||||
|         path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, | ||||
|         loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), | ||||
|         data: { swipeEnabled: false }, | ||||
|     }, | ||||
|     { | ||||
|         path: AddonModGlossaryModuleHandlerService.PAGE_NAME, | ||||
| @ -65,10 +67,12 @@ const mainMenuRoutes: Routes = [ | ||||
|             { | ||||
|                 path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, | ||||
|                 loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), | ||||
|                 data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, | ||||
|             }, | ||||
|             { | ||||
|                 path: `${COURSE_CONTENTS_PATH}/${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, | ||||
|                 loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), | ||||
|                 data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, | ||||
|             }, | ||||
|         ], | ||||
|         () => CoreScreen.isMobile, | ||||
| @ -80,10 +84,12 @@ const courseContentsRoutes: Routes = conditionalRoutes( | ||||
|         { | ||||
|             path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/entry/:entryId`, | ||||
|             loadChildren: () => import('./pages/entry/entry.module').then(m => m.AddonModGlossaryEntryPageModule), | ||||
|             data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, | ||||
|         }, | ||||
|         { | ||||
|             path: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/edit/:timecreated`, | ||||
|             loadChildren: () => import('./pages/edit/edit.module').then(m => m.AddonModGlossaryEditPageModule), | ||||
|             data: { glossaryPathPrefix: `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` }, | ||||
|         }, | ||||
|     ], | ||||
|     () => CoreScreen.isTablet, | ||||
|  | ||||
| @ -12,19 +12,21 @@ | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
|     <core-swipe-navigation [manager]="entries"> | ||||
|         <core-loading [hideUntil]="loaded"> | ||||
|             <form #editFormEl *ngIf="glossary"> | ||||
|                 <ion-item> | ||||
|                     <ion-label position="stacked">{{ 'addon.mod_glossary.concept' | translate }}</ion-label> | ||||
|                 <ion-input type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="entry.concept" name="concept"> | ||||
|                     <ion-input type="text" [placeholder]="'addon.mod_glossary.concept' | translate" [(ngModel)]="entry.concept" | ||||
|                         name="concept"> | ||||
|                     </ion-input> | ||||
|                 </ion-item> | ||||
|                 <ion-item> | ||||
|                     <ion-label position="stacked">{{ 'addon.mod_glossary.definition' | translate }}</ion-label> | ||||
|                     <core-rich-text-editor [control]="definitionControl" (contentChanged)="onDefinitionChange($event)" | ||||
|                         [placeholder]="'addon.mod_glossary.definition' | translate" name="addon_mod_glossary_edit" [component]="component" | ||||
|                     [componentId]="cmId" [autoSave]="true" contextLevel="module" [contextInstanceId]="cmId" elementId="definition_editor" | ||||
|                     [draftExtraParams]="editorExtraParams"> | ||||
|                         [componentId]="cmId" [autoSave]="true" contextLevel="module" [contextInstanceId]="cmId" | ||||
|                         elementId="definition_editor" [draftExtraParams]="editorExtraParams"> | ||||
|                     </core-rich-text-editor> | ||||
|                 </ion-item> | ||||
|                 <ion-item *ngIf="categories.length > 0"> | ||||
| @ -80,4 +82,5 @@ | ||||
|                 </ion-button> | ||||
|             </form> | ||||
|         </core-loading> | ||||
|     </core-swipe-navigation> | ||||
| </ion-content> | ||||
|  | ||||
| @ -12,9 +12,11 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit, ViewChild, ElementRef, Optional } from '@angular/core'; | ||||
| import { Component, OnInit, ViewChild, ElementRef, Optional, OnDestroy } from '@angular/core'; | ||||
| import { FormControl } from '@angular/forms'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreSplitViewComponent } from '@components/split-view/split-view'; | ||||
| import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader'; | ||||
| import { CanLeave } from '@guards/can-leave'; | ||||
| @ -26,6 +28,8 @@ import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { Translate } from '@singletons'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { CoreForms } from '@singletons/form'; | ||||
| import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source'; | ||||
| import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager'; | ||||
| import { | ||||
|     AddonModGlossary, | ||||
|     AddonModGlossaryCategory, | ||||
| @ -45,7 +49,7 @@ import { AddonModGlossaryOffline } from '../../services/glossary-offline'; | ||||
|     selector: 'page-addon-mod-glossary-edit', | ||||
|     templateUrl: 'edit.html', | ||||
| }) | ||||
| export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
| export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { | ||||
| 
 | ||||
|     @ViewChild('editFormEl') formElement?: ElementRef; | ||||
| 
 | ||||
| @ -64,6 +68,8 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|         timecreated: 0, | ||||
|     }; | ||||
| 
 | ||||
|     entries?: AddonModGlossaryEditEntriesSwipeManager; | ||||
| 
 | ||||
|     options = { | ||||
|         categories: <string[]> [], | ||||
|         aliases: '', | ||||
| @ -80,18 +86,30 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|     protected originalData?: AddonModGlossaryNewEntryWithFiles; | ||||
|     protected saved = false; | ||||
| 
 | ||||
|     constructor(@Optional() protected splitView: CoreSplitViewComponent) {} | ||||
|     constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {} | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         try { | ||||
|             const routeData = this.route.snapshot.data; | ||||
|             this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|             this.timecreated = CoreNavigator.getRequiredRouteNumberParam('timecreated'); | ||||
|             this.concept = CoreNavigator.getRouteParam<string>('concept') || ''; | ||||
|             this.editorExtraParams.timecreated = this.timecreated; | ||||
| 
 | ||||
|             if (this.timecreated !== 0 && (routeData.swipeEnabled ?? true)) { | ||||
|                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     AddonModGlossaryEntriesSource, | ||||
|                     [this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], | ||||
|                 ); | ||||
| 
 | ||||
|                 this.entries = new AddonModGlossaryEditEntriesSwipeManager(source); | ||||
| 
 | ||||
|                 await this.entries.start(); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModal(error); | ||||
| 
 | ||||
| @ -103,6 +121,13 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|         this.fetchData(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.entries?.destroy(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch required data. | ||||
|      * | ||||
| @ -134,7 +159,11 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async loadOfflineData(): Promise<void> { | ||||
|         const entry = await AddonModGlossaryOffline.getNewEntry(this.glossary!.id, this.concept, this.timecreated); | ||||
|         if (!this.glossary) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const entry = await AddonModGlossaryOffline.getNewEntry(this.glossary.id, this.concept, this.timecreated); | ||||
| 
 | ||||
|         this.entry.concept = entry.concept || ''; | ||||
|         this.entry.definition = entry.definition || ''; | ||||
| @ -159,7 +188,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
| 
 | ||||
|         // Treat offline attachments if any.
 | ||||
|         if (entry.attachments?.offline) { | ||||
|             this.attachments = await AddonModGlossaryHelper.getStoredFiles(this.glossary!.id, entry.concept, entry.timecreated); | ||||
|             this.attachments = await AddonModGlossaryHelper.getStoredFiles(this.glossary.id, entry.concept, entry.timecreated); | ||||
| 
 | ||||
|             this.originalData.files = this.attachments.slice(); | ||||
|         } | ||||
| @ -236,6 +265,10 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|         definition = CoreTextUtils.formatHtmlLines(definition); | ||||
| 
 | ||||
|         try { | ||||
|             if (!this.glossary) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // Upload attachments first if any.
 | ||||
|             const { saveOffline, attachmentsResult } = await this.uploadAttachments(timecreated); | ||||
| 
 | ||||
| @ -244,7 +277,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|                 categories: this.options.categories.join(','), | ||||
|             }; | ||||
| 
 | ||||
|             if (this.glossary!.usedynalink) { | ||||
|             if (this.glossary.usedynalink) { | ||||
|                 options.usedynalink = this.options.usedynalink ? 1 : 0; | ||||
|                 if (this.options.usedynalink) { | ||||
|                     options.casesensitive = this.options.casesensitive ? 1 : 0; | ||||
| @ -253,9 +286,9 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|             } | ||||
| 
 | ||||
|             if (saveOffline) { | ||||
|                 if (this.entry && !this.glossary!.allowduplicatedentries) { | ||||
|                 if (this.entry && !this.glossary.allowduplicatedentries) { | ||||
|                     // Check if the entry is duplicated in online or offline mode.
 | ||||
|                     const isUsed = await AddonModGlossary.isConceptUsed(this.glossary!.id, this.entry.concept, { | ||||
|                     const isUsed = await AddonModGlossary.isConceptUsed(this.glossary.id, this.entry.concept, { | ||||
|                         timeCreated: this.entry.timecreated, | ||||
|                         cmId: this.cmId, | ||||
|                     }); | ||||
| @ -268,7 +301,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
| 
 | ||||
|                 // Save entry in offline.
 | ||||
|                 await AddonModGlossaryOffline.addNewEntry( | ||||
|                     this.glossary!.id, | ||||
|                     this.glossary.id, | ||||
|                     this.entry.concept, | ||||
|                     definition, | ||||
|                     this.courseId, | ||||
| @ -283,7 +316,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|                 // Try to send it to server.
 | ||||
|                 // Don't allow offline if there are attachments since they were uploaded fine.
 | ||||
|                 await AddonModGlossary.addEntry( | ||||
|                     this.glossary!.id, | ||||
|                     this.glossary.id, | ||||
|                     this.entry.concept, | ||||
|                     definition, | ||||
|                     this.courseId, | ||||
| @ -293,7 +326,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|                         timeCreated: timecreated, | ||||
|                         discardEntry: this.entry, | ||||
|                         allowOffline: !this.attachments.length, | ||||
|                         checkDuplicates: !this.glossary!.allowduplicatedentries, | ||||
|                         checkDuplicates: !this.glossary.allowduplicatedentries, | ||||
|                     }, | ||||
|                 ); | ||||
|             } | ||||
| @ -303,12 +336,12 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
| 
 | ||||
|             if (entryId) { | ||||
|                 // Data sent to server, delete stored files (if any).
 | ||||
|                 AddonModGlossaryHelper.deleteStoredFiles(this.glossary!.id, this.entry.concept, timecreated); | ||||
|                 AddonModGlossaryHelper.deleteStoredFiles(this.glossary.id, this.entry.concept, timecreated); | ||||
|                 CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'glossary' }); | ||||
|             } | ||||
| 
 | ||||
|             CoreEvents.trigger(AddonModGlossaryProvider.ADD_ENTRY_EVENT, { | ||||
|                 glossaryId: this.glossary!.id, | ||||
|                 glossaryId: this.glossary.id, | ||||
|                 entryId: entryId, | ||||
|             }, CoreSites.getCurrentSiteId()); | ||||
| 
 | ||||
| @ -342,7 +375,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|     protected async uploadAttachments( | ||||
|         timecreated: number, | ||||
|     ): Promise<{saveOffline: boolean; attachmentsResult?: number | CoreFileUploaderStoreFilesResult}> { | ||||
|         if (!this.attachments.length) { | ||||
|         if (!this.attachments.length || !this.glossary) { | ||||
|             return { | ||||
|                 saveOffline: false, | ||||
|             }; | ||||
| @ -352,7 +385,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|             const attachmentsResult = await CoreFileUploader.uploadOrReuploadFiles( | ||||
|                 this.attachments, | ||||
|                 AddonModGlossaryProvider.COMPONENT, | ||||
|                 this.glossary!.id, | ||||
|                 this.glossary.id, | ||||
|             ); | ||||
| 
 | ||||
|             return { | ||||
| @ -362,7 +395,7 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|         } catch { | ||||
|             // Cannot upload them in online, save them in offline.
 | ||||
|             const attachmentsResult = await AddonModGlossaryHelper.storeFiles( | ||||
|                 this.glossary!.id, | ||||
|                 this.glossary.id, | ||||
|                 this.entry.concept, | ||||
|                 timecreated, | ||||
|                 this.attachments, | ||||
| @ -387,3 +420,17 @@ export class AddonModGlossaryEditPage implements OnInit, CanLeave { | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of glossary entries. | ||||
|  */ | ||||
| class AddonModGlossaryEditEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||
|         return `${this.getSource().GLOSSARY_PATH_PREFIX}edit/${route.params.timecreated}`; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -12,6 +12,7 @@ | ||||
|     </ion-toolbar> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
|     <core-swipe-navigation [manager]="entries"> | ||||
|         <ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="doRefresh($event.target)"> | ||||
|             <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|         </ion-refresher> | ||||
| @ -41,8 +42,8 @@ | ||||
|                 </ion-item> | ||||
|                 <ion-item class="ion-text-wrap"> | ||||
|                     <ion-label> | ||||
|                     <core-format-text [component]="component" [componentId]="componentId" [text]="entry.definition" contextLevel="module" | ||||
|                         [contextInstanceId]="componentId" [courseId]="courseId"> | ||||
|                         <core-format-text [component]="component" [componentId]="componentId" [text]="entry.definition" | ||||
|                             contextLevel="module" [contextInstanceId]="componentId" [courseId]="courseId"> | ||||
|                         </core-format-text> | ||||
|                     </ion-label> | ||||
|                 </ion-item> | ||||
| @ -70,8 +71,8 @@ | ||||
|                     [aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale" [userId]="entry.userid" (onUpdate)="ratingUpdated()"> | ||||
|                 </core-rating-rate> | ||||
|                 <core-rating-aggregate *ngIf="glossary && ratingInfo" [ratingInfo]="ratingInfo" contextLevel="module" | ||||
|                 [instanceId]="glossary.coursemodule" [itemId]="entry.id" [courseId]="glossary.course" [aggregateMethod]="glossary.assessed" | ||||
|                 [scaleId]="glossary.scale"> | ||||
|                     [instanceId]="glossary.coursemodule" [itemId]="entry.id" [courseId]="glossary.course" | ||||
|                     [aggregateMethod]="glossary.assessed" [scaleId]="glossary.scale"> | ||||
|                 </core-rating-aggregate> | ||||
|             </ng-container> | ||||
| 
 | ||||
| @ -81,4 +82,5 @@ | ||||
|                 </ion-item> | ||||
|             </ion-card> | ||||
|         </core-loading> | ||||
|     </core-swipe-navigation> | ||||
| </ion-content> | ||||
|  | ||||
| @ -12,7 +12,9 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit, ViewChild } from '@angular/core'; | ||||
| import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreCommentsCommentsComponent } from '@features/comments/components/comments/comments'; | ||||
| import { CoreComments } from '@features/comments/services/comments'; | ||||
| import { CoreRatingInfo } from '@features/rating/services/rating'; | ||||
| @ -21,6 +23,8 @@ import { IonRefresher } from '@ionic/angular'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { AddonModGlossaryEntriesSource } from '../../classes/glossary-entries-source'; | ||||
| import { AddonModGlossaryEntriesSwipeManager } from '../../classes/glossary-entries-swipe-manager'; | ||||
| import { | ||||
|     AddonModGlossary, | ||||
|     AddonModGlossaryEntry, | ||||
| @ -35,13 +39,14 @@ import { | ||||
|     selector: 'page-addon-mod-glossary-entry', | ||||
|     templateUrl: 'entry.html', | ||||
| }) | ||||
| export class AddonModGlossaryEntryPage implements OnInit { | ||||
| export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|     @ViewChild(CoreCommentsCommentsComponent) comments?: CoreCommentsCommentsComponent; | ||||
| 
 | ||||
|     component = AddonModGlossaryProvider.COMPONENT; | ||||
|     componentId?: number; | ||||
|     entry?: AddonModGlossaryEntry; | ||||
|     entries?: AddonModGlossaryEntryEntriesSwipeManager; | ||||
|     glossary?: AddonModGlossaryGlossary; | ||||
|     loaded = false; | ||||
|     showAuthor = false; | ||||
| @ -53,15 +58,30 @@ export class AddonModGlossaryEntryPage implements OnInit { | ||||
| 
 | ||||
|     protected entryId!: number; | ||||
| 
 | ||||
|     constructor(protected route: ActivatedRoute) {} | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         try { | ||||
|             const routeData = this.route.snapshot.data; | ||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|             this.entryId = CoreNavigator.getRequiredRouteNumberParam('entryId'); | ||||
|             this.tagsEnabled = CoreTag.areTagsAvailableInSite(); | ||||
|             this.commentsEnabled = !CoreComments.areCommentsDisabledInSite(); | ||||
| 
 | ||||
|             if (routeData.swipeEnabled ?? true) { | ||||
|                 const cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); | ||||
|                 const source = CoreItemsManagerSourcesTracker.getOrCreateSource( | ||||
|                     AddonModGlossaryEntriesSource, | ||||
|                     [this.courseId, cmId, routeData.glossaryPathPrefix ?? ''], | ||||
|                 ); | ||||
| 
 | ||||
|                 this.entries = new AddonModGlossaryEntryEntriesSwipeManager(source); | ||||
| 
 | ||||
|                 await this.entries.start(); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModal(error); | ||||
| 
 | ||||
| @ -73,16 +93,23 @@ export class AddonModGlossaryEntryPage implements OnInit { | ||||
|         try { | ||||
|             await this.fetchEntry(); | ||||
| 
 | ||||
|             if (!this.glossary) { | ||||
|             if (!this.glossary || !this.componentId) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(this.entryId, this.componentId!, this.glossary.name)); | ||||
|             await CoreUtils.ignoreErrors(AddonModGlossary.logEntryView(this.entryId, this.componentId, this.glossary.name)); | ||||
|         } finally { | ||||
|             this.loaded = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.entries?.destroy(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Refresh the data. | ||||
|      * | ||||
| @ -152,3 +179,17 @@ export class AddonModGlossaryEntryPage implements OnInit { | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of glossary entries. | ||||
|  */ | ||||
| class AddonModGlossaryEntryEntriesSwipeManager extends AddonModGlossaryEntriesSwipeManager { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||
|         return `${this.getSource().GLOSSARY_PATH_PREFIX}entry/${route.params.entryId}`; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -12,12 +12,14 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Params } from '@angular/router'; | ||||
| 
 | ||||
| /** | ||||
|  * Updates listener. | ||||
|  */ | ||||
| export interface CoreItemsListSourceListener<Item> { | ||||
|     onItemsUpdated(items: Item[], hasMoreItems: boolean): void; | ||||
|     onReset(): void; | ||||
|     onItemsUpdated?(items: Item[], hasMoreItems: boolean): void; | ||||
|     onReset?(): void; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -35,9 +37,10 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|         return args.map(argument => String(argument)).join('-'); | ||||
|     } | ||||
| 
 | ||||
|     private items: Item[] | null = null; | ||||
|     private hasMoreItems = true; | ||||
|     private listeners: CoreItemsListSourceListener<Item>[] = []; | ||||
|     protected items: Item[] | null = null; | ||||
|     protected hasMoreItems = true; | ||||
|     protected listeners: CoreItemsListSourceListener<Item>[] = []; | ||||
|     protected dirty = false; | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether any page has been loaded. | ||||
| @ -57,6 +60,17 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|         return !this.hasMoreItems; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set whether the source as dirty. | ||||
|      * | ||||
|      * When a source is dirty, the next load request will reload items from the beginning. | ||||
|      * | ||||
|      * @param dirty Whether source should be marked as dirty or not. | ||||
|      */ | ||||
|     setDirty(dirty: boolean): void { | ||||
|         this.dirty = dirty; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get collection items. | ||||
|      * | ||||
| @ -76,7 +90,12 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|             return 0; | ||||
|         } | ||||
| 
 | ||||
|         return Math.ceil(this.items.length / this.getPageLength()); | ||||
|         const pageLength = this.getPageLength(); | ||||
|         if (pageLength === null) { | ||||
|             return 1; | ||||
|         } | ||||
| 
 | ||||
|         return Math.ceil(this.items.length / pageLength); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -85,8 +104,9 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|     reset(): void { | ||||
|         this.items = null; | ||||
|         this.hasMoreItems = true; | ||||
|         this.dirty = false; | ||||
| 
 | ||||
|         this.listeners.forEach(listener => listener.onReset()); | ||||
|         this.listeners.forEach(listener => listener.onReset?.call(listener)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -122,36 +142,67 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|     async reload(): Promise<void> { | ||||
|         const { items, hasMoreItems } = await this.loadPageItems(0); | ||||
| 
 | ||||
|         this.setItems(items, hasMoreItems); | ||||
|         this.dirty = false; | ||||
|         this.setItems(items, hasMoreItems ?? false); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load items for the next page, if any. | ||||
|      * Load more items, if any. | ||||
|      */ | ||||
|     async loadNextPage(): Promise<void> { | ||||
|     async load(): Promise<void> { | ||||
|         if (this.dirty) { | ||||
|             const { items, hasMoreItems } = await this.loadPageItems(0); | ||||
| 
 | ||||
|             this.dirty = false; | ||||
|             this.setItems(items, hasMoreItems ?? false); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.hasMoreItems) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded()); | ||||
| 
 | ||||
|         this.setItems((this.items ?? []).concat(items), hasMoreItems); | ||||
|         this.setItems((this.items ?? []).concat(items), hasMoreItems ?? false); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the query parameters to use when navigating to an item page. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Query parameters to use when navigating to the item page. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     getItemQueryParams(item: Item): Params { | ||||
|         return {}; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path to use when navigating to an item page. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Path to use when navigating to the item page. | ||||
|      */ | ||||
|     abstract getItemPath(item: Item): string; | ||||
| 
 | ||||
|     /** | ||||
|      * Load page items. | ||||
|      * | ||||
|      * @param page Page number (starting at 0). | ||||
|      * @return Page items data. | ||||
|      */ | ||||
|     protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems: boolean }>; | ||||
|     protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the length of each page in the collection. | ||||
|      * | ||||
|      * @return Page length. | ||||
|      * @return Page length; null for collections that don't support pagination. | ||||
|      */ | ||||
|     protected abstract getPageLength(): number; | ||||
|     protected getPageLength(): number | null { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update the collection items. | ||||
| @ -163,7 +214,7 @@ export abstract class CoreItemsManagerSource<Item = unknown> { | ||||
|         this.items = items; | ||||
|         this.hasMoreItems = hasMoreItems; | ||||
| 
 | ||||
|         this.listeners.forEach(listener => listener.onItemsUpdated(items, hasMoreItems)); | ||||
|         this.listeners.forEach(listener => listener.onItemsUpdated?.call(listener, items, hasMoreItems)); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -18,6 +18,7 @@ type SourceConstructor<T extends CoreItemsManagerSource = CoreItemsManagerSource | ||||
|     getSourceId(...args: unknown[]): string; | ||||
|     new (...args: unknown[]): T; | ||||
| }; | ||||
| type SourceConstuctorInstance<T> = T extends { new(...args: unknown[]): infer P } ? P : never; | ||||
| type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] }; | ||||
| type Instances = Record<string, InstanceTracking>; | ||||
| 
 | ||||
| @ -36,14 +37,14 @@ export class CoreItemsManagerSourcesTracker { | ||||
|      * @param constructorArguments Arguments to create a new instance, used to find out if an instance already exists. | ||||
|      * @returns Source. | ||||
|      */ | ||||
|     static getOrCreateSource<T extends CoreItemsManagerSource>( | ||||
|         constructor: SourceConstructor<T>, | ||||
|         constructorArguments: ConstructorParameters<SourceConstructor<T>>, | ||||
|     ): T  { | ||||
|     static getOrCreateSource<T extends CoreItemsManagerSource, C extends SourceConstructor<T>>( | ||||
|         constructor: C, | ||||
|         constructorArguments: ConstructorParameters<C>, | ||||
|     ): SourceConstuctorInstance<C> { | ||||
|         const id = constructor.getSourceId(...constructorArguments); | ||||
|         const constructorInstances = this.getConstructorInstances(constructor); | ||||
| 
 | ||||
|         return constructorInstances[id]?.instance as T | ||||
|         return constructorInstances[id]?.instance as SourceConstuctorInstance<C> | ||||
|             ?? this.createInstance(id, constructor, constructorArguments); | ||||
|     } | ||||
| 
 | ||||
| @ -57,7 +58,7 @@ export class CoreItemsManagerSourcesTracker { | ||||
|         const constructorInstances = this.getConstructorInstances(source.constructor as SourceConstructor); | ||||
|         const instanceId = this.instanceIds.get(source); | ||||
| 
 | ||||
|         if (!instanceId) { | ||||
|         if (instanceId === undefined) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
| @ -82,7 +83,7 @@ export class CoreItemsManagerSourcesTracker { | ||||
|         const instanceId = this.instanceIds.get(source); | ||||
|         const index = constructorInstances?.[instanceId ?? '']?.references.indexOf(reference) ?? -1; | ||||
| 
 | ||||
|         if (!constructorInstances || !instanceId || index === -1) { | ||||
|         if (!constructorInstances || instanceId === undefined || index === -1) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|  | ||||
| @ -12,7 +12,7 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; | ||||
| 
 | ||||
| import { CoreItemsManagerSource } from './items-manager-source'; | ||||
| @ -21,13 +21,13 @@ import { CoreItemsManagerSourcesTracker } from './items-manager-sources-tracker' | ||||
| /** | ||||
|  * Helper to manage a collection of items in a page. | ||||
|  */ | ||||
| export abstract class CoreItemsManager<Item = unknown> { | ||||
| export abstract class CoreItemsManager<Item = unknown, Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item>> { | ||||
| 
 | ||||
|     protected source?: { instance: CoreItemsManagerSource<Item>; unsubscribe: () => void }; | ||||
|     protected source?: { instance: Source; unsubscribe: () => void }; | ||||
|     protected itemsMap: Record<string, Item> | null = null; | ||||
|     protected selectedItem: Item | null = null; | ||||
| 
 | ||||
|     constructor(source: CoreItemsManagerSource<Item>) { | ||||
|     constructor(source: Source) { | ||||
|         this.setSource(source); | ||||
|     } | ||||
| 
 | ||||
| @ -36,7 +36,7 @@ export abstract class CoreItemsManager<Item = unknown> { | ||||
|      * | ||||
|      * @returns Source. | ||||
|      */ | ||||
|     getSource(): CoreItemsManagerSource<Item> { | ||||
|     getSource(): Source { | ||||
|         if (!this.source) { | ||||
|             throw new Error('Source is missing from items manager'); | ||||
|         } | ||||
| @ -49,7 +49,7 @@ export abstract class CoreItemsManager<Item = unknown> { | ||||
|      * | ||||
|      * @param newSource New source. | ||||
|      */ | ||||
|     setSource(newSource: CoreItemsManagerSource<Item> | null): void { | ||||
|     setSource(newSource: Source | null): void { | ||||
|         if (this.source) { | ||||
|             CoreItemsManagerSourcesTracker.removeReference(this.source.instance, this); | ||||
| 
 | ||||
| @ -92,31 +92,26 @@ export abstract class CoreItemsManager<Item = unknown> { | ||||
|      */ | ||||
|     protected abstract getCurrentPageRoute(): ActivatedRoute | null; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path to use when navigating to an item page. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Path to use when navigating to the item page. | ||||
|      */ | ||||
|     protected abstract getItemPath(item: Item): string; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the path of the selected item given the current route. | ||||
|      * | ||||
|      * @param route Page route. | ||||
|      * @return Path of the selected item in the given route. | ||||
|      */ | ||||
|     protected abstract getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null; | ||||
|     protected abstract getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null; | ||||
| 
 | ||||
|     /** | ||||
|      * Get the query parameters to use when navigating to an item page. | ||||
|      * Get the path of the selected item. | ||||
|      * | ||||
|      * @param item Item. | ||||
|      * @return Query parameters to use when navigating to the item page. | ||||
|      * @param route Page route, if any. | ||||
|      * @return Path of the selected item. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     protected getItemQueryParams(item: Item): Params { | ||||
|         return {}; | ||||
|     protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { | ||||
|         if (!route) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|         return this.getSelectedItemPathFromRoute(route); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -152,7 +147,7 @@ export abstract class CoreItemsManager<Item = unknown> { | ||||
|         } | ||||
| 
 | ||||
|         // If this item is already selected, do nothing.
 | ||||
|         const itemPath = this.getItemPath(item); | ||||
|         const itemPath = this.getSource().getItemPath(item); | ||||
|         const selectedItemPath = this.getSelectedItemPath(route.snapshot); | ||||
| 
 | ||||
|         if (selectedItemPath === itemPath) { | ||||
| @ -160,7 +155,7 @@ export abstract class CoreItemsManager<Item = unknown> { | ||||
|         } | ||||
| 
 | ||||
|         // Navigate to item.
 | ||||
|         const params = this.getItemQueryParams(item); | ||||
|         const params = this.getSource().getItemQueryParams(item); | ||||
|         const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; | ||||
| 
 | ||||
|         await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options }); | ||||
| @ -173,7 +168,7 @@ export abstract class CoreItemsManager<Item = unknown> { | ||||
|      */ | ||||
|     protected onSourceItemsUpdated(items: Item[]): void { | ||||
|         this.itemsMap = items.reduce((map, item) => { | ||||
|             map[this.getItemPath(item)] = item; | ||||
|             map[this.getSource().getItemPath(item)] = item; | ||||
| 
 | ||||
|             return map; | ||||
|         }, {}); | ||||
|  | ||||
| @ -26,13 +26,16 @@ import { CoreItemsManagerSource } from './items-manager-source'; | ||||
| /** | ||||
|  * Helper class to manage the state and routing of a list of items in a page. | ||||
|  */ | ||||
| export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsManager<Item> { | ||||
| export class CoreListItemsManager< | ||||
|     Item = unknown, | ||||
|     Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item> | ||||
| > extends CoreItemsManager<Item, Source> { | ||||
| 
 | ||||
|     protected pageRouteLocator?: unknown | ActivatedRoute; | ||||
|     protected splitView?: CoreSplitViewComponent; | ||||
|     protected splitViewOutletSubscription?: Subscription; | ||||
| 
 | ||||
|     constructor(source: CoreItemsManagerSource<Item>, pageRouteLocator: unknown | ActivatedRoute) { | ||||
|     constructor(source: Source, pageRouteLocator: unknown | ActivatedRoute) { | ||||
|         super(source); | ||||
| 
 | ||||
|         this.pageRouteLocator = pageRouteLocator; | ||||
| @ -67,15 +70,6 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana | ||||
|         // Calculate current selected item.
 | ||||
|         this.updateSelectedItem(); | ||||
| 
 | ||||
|         // Select default item if none is selected on a non-mobile layout.
 | ||||
|         if (!CoreScreen.isMobile && this.selectedItem === null && !splitView.isNested) { | ||||
|             const defaultItem = this.getDefaultItem(); | ||||
| 
 | ||||
|             if (defaultItem) { | ||||
|                 this.select(defaultItem); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Log activity.
 | ||||
|         await CoreUtils.ignoreErrors(this.logActivity()); | ||||
|     } | ||||
| @ -146,10 +140,10 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load items for the next page, if any. | ||||
|      * Load more items, if any. | ||||
|      */ | ||||
|     async loadNextPage(): Promise<void> { | ||||
|         await this.getSource().loadNextPage(); | ||||
|     async load(): Promise<void> { | ||||
|         await this.getSource().load(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -172,6 +166,25 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana | ||||
|         return !!this.splitView && !this.splitView?.isNested; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected updateSelectedItem(route: ActivatedRouteSnapshot | null = null): void { | ||||
|         super.updateSelectedItem(route); | ||||
| 
 | ||||
|         if (CoreScreen.isMobile || this.selectedItem !== null || this.splitView?.isNested) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const defaultItem = this.getDefaultItem(); | ||||
| 
 | ||||
|         if (!defaultItem) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.select(defaultItem); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the item that should be selected by default. | ||||
|      */ | ||||
| @ -193,10 +206,12 @@ export abstract class CoreListItemsManager<Item = unknown> extends CoreItemsMana | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { | ||||
|     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||
|         const segments: UrlSegment[] = []; | ||||
| 
 | ||||
|         while ((route = route?.firstChild)) { | ||||
|         while (route.firstChild) { | ||||
|             route = route.firstChild; | ||||
| 
 | ||||
|             segments.push(...route.url); | ||||
|         } | ||||
| 
 | ||||
|  | ||||
| @ -12,16 +12,21 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot, UrlSegment } from '@angular/router'; | ||||
| 
 | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| 
 | ||||
| import { CoreItemsManager } from './items-manager'; | ||||
| import { CoreItemsManagerSource } from './items-manager-source'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper class to manage the state and routing of a swipeable page. | ||||
|  */ | ||||
| export abstract class CoreSwipeItemsManager<Item = unknown> extends CoreItemsManager<Item> { | ||||
| export class CoreSwipeItemsManager< | ||||
|     Item = unknown, | ||||
|     Source extends CoreItemsManagerSource<Item> = CoreItemsManagerSource<Item> | ||||
| > | ||||
|     extends CoreItemsManager<Item, Source> { | ||||
| 
 | ||||
|     /** | ||||
|      * Process page started operations. | ||||
| @ -51,6 +56,25 @@ export abstract class CoreSwipeItemsManager<Item = unknown> extends CoreItemsMan | ||||
|         return CoreNavigator.getCurrentRoute(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||
|         const segments: UrlSegment[] = []; | ||||
| 
 | ||||
|         while (route) { | ||||
|             segments.push(...route.url); | ||||
| 
 | ||||
|             if (!route.firstChild) { | ||||
|                 break; | ||||
|             } | ||||
| 
 | ||||
|             route = route.firstChild; | ||||
|         } | ||||
| 
 | ||||
|         return segments.map(segment => segment.path).join('/').replace(/\/+/, '/').trim() || null; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Navigate to an item by an offset. | ||||
|      * | ||||
| @ -86,7 +110,7 @@ export abstract class CoreSwipeItemsManager<Item = unknown> extends CoreItemsMan | ||||
|         const item = items?.[index + delta] ?? null; | ||||
| 
 | ||||
|         if (!item && !this.getSource().isCompleted()) { | ||||
|             await this.getSource().loadNextPage(); | ||||
|             await this.getSource().load(); | ||||
| 
 | ||||
|             return this.getItemBy(delta); | ||||
|         } | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| <ion-slides [options]="{ allowTouchMove: !!manager }" (swipeleft)="swipeLeft()" (swiperight)="swipeRight()"> | ||||
| <ion-slides [options]="{ allowTouchMove: enabled }" (swipeleft)="swipeLeft()" (swiperight)="swipeRight()"> | ||||
|     <ion-slide> | ||||
|         <ng-content></ng-content> | ||||
|     </ion-slide> | ||||
|  | ||||
| @ -5,3 +5,15 @@ ion-slides { | ||||
| ion-slide { | ||||
|     align-items: start; | ||||
| } | ||||
| 
 | ||||
| ::ng-deep { | ||||
| 
 | ||||
|     core-loading .core-loading-content { | ||||
|         width: 100%; | ||||
|     } | ||||
| 
 | ||||
|     ion-refresher.refresher-native { | ||||
|         z-index: 2; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -14,6 +14,7 @@ | ||||
| 
 | ||||
| import { Component, Input } from '@angular/core'; | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreScreen } from '@services/screen'; | ||||
| 
 | ||||
| @Component({ | ||||
|     selector: 'core-swipe-navigation', | ||||
| @ -24,10 +25,18 @@ export class CoreSwipeNavigationComponent { | ||||
| 
 | ||||
|     @Input() manager?: CoreSwipeItemsManager; | ||||
| 
 | ||||
|     get enabled(): boolean { | ||||
|         return CoreScreen.isMobile && !!this.manager; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Swipe to previous item. | ||||
|      */ | ||||
|     swipeLeft(): void { | ||||
|         if (!this.enabled) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.manager?.navigateToPreviousItem(); | ||||
|     } | ||||
| 
 | ||||
| @ -35,6 +44,10 @@ export class CoreSwipeNavigationComponent { | ||||
|      * Swipe to next item. | ||||
|      */ | ||||
|     swipeRight(): void { | ||||
|         if (!this.enabled) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.manager?.navigateToNextItem(); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -12,6 +12,7 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Params } from '@angular/router'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| 
 | ||||
| import { CoreUser, CoreUserData, CoreUserParticipant, CoreUserProvider } from '../services/user'; | ||||
| @ -40,6 +41,20 @@ export class CoreUserParticipantsSource extends CoreItemsManagerSource<CoreUserP | ||||
|         this.SEARCH_QUERY = searchQuery; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemPath(user: CoreUserParticipant | CoreUserData): string { | ||||
|         return user.id.toString(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     getItemQueryParams(): Params { | ||||
|         return { search: this.SEARCH_QUERY }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|  | ||||
| @ -13,7 +13,6 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| 
 | ||||
| import { CoreApp } from '@services/app'; | ||||
| @ -50,7 +49,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | ||||
|             this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); | ||||
|             this.participants = new CoreUserParticipantsManager( | ||||
|                 CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]), | ||||
|                 this, | ||||
|                 CoreUserParticipantsPage, | ||||
|             ); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModal(error); | ||||
| @ -186,7 +185,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | ||||
|     private async fetchParticipants(reload: boolean): Promise<void> { | ||||
|         reload | ||||
|             ? await this.participants.reload() | ||||
|             : await this.participants.loadNextPage(); | ||||
|             : await this.participants.load(); | ||||
| 
 | ||||
|         this.fetchMoreParticipantsFailed = false; | ||||
|     } | ||||
| @ -196,35 +195,13 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro | ||||
| /** | ||||
|  * Helper to manage the list of participants. | ||||
|  */ | ||||
| class CoreUserParticipantsManager extends CoreListItemsManager<CoreUserParticipant | CoreUserData> { | ||||
| 
 | ||||
|     page: CoreUserParticipantsPage; | ||||
| 
 | ||||
|     constructor(source: CoreUserParticipantsSource, page: CoreUserParticipantsPage) { | ||||
|         super(source, CoreUserParticipantsPage); | ||||
| 
 | ||||
|         this.page = page; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemPath(participant: CoreUserParticipant | CoreUserData): string { | ||||
|         return participant.id.toString(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemQueryParams(): Params { | ||||
|         return { search: this.page.searchQuery }; | ||||
|     } | ||||
| class CoreUserParticipantsManager extends CoreListItemsManager<CoreUserParticipant | CoreUserData, CoreUserParticipantsSource> { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async logActivity(): Promise<void> { | ||||
|         await CoreUser.logParticipantsView(this.page.courseId); | ||||
|         await CoreUser.logParticipantsView(this.getSource().COURSE_ID); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -12,7 +12,7 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; | ||||
| import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| @ -21,7 +21,7 @@ import { CoreSite } from '@classes/site'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreEventObserver, CoreEvents } from '@singletons/events'; | ||||
| import { CoreUser, CoreUserBasicData, CoreUserProfile, CoreUserProvider } from '@features/user/services/user'; | ||||
| import { CoreUser, CoreUserProfile, CoreUserProvider } from '@features/user/services/user'; | ||||
| import { CoreUserHelper } from '@features/user/services/user-helper'; | ||||
| import { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| @ -30,7 +30,6 @@ import { CoreCourses } from '@features/courses/services/courses'; | ||||
| import { CoreSwipeItemsManager } from '@classes/items-management/swipe-items-manager'; | ||||
| import { CoreUserParticipantsSource } from '@features/user/classes/participants-source'; | ||||
| import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; | ||||
| import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; | ||||
| 
 | ||||
| @Component({ | ||||
|     selector: 'page-core-user-profile', | ||||
| @ -57,7 +56,6 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { | ||||
|     communicationHandlers: CoreUserProfileHandlerData[] = []; | ||||
| 
 | ||||
|     users?: CoreUserSwipeItemsManager; | ||||
|     usersQueryParams: Params = {}; | ||||
| 
 | ||||
|     constructor(private route: ActivatedRoute) { | ||||
|         this.obsProfileRefreshed = CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => { | ||||
| @ -93,9 +91,8 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { | ||||
|         if (this.courseId && this.route.snapshot.data.swipeManagerSource === 'participants') { | ||||
|             const search = CoreNavigator.getRouteParam('search'); | ||||
|             const source = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, search]); | ||||
|             this.users = new CoreUserSwipeItemsManager(source, this); | ||||
|             this.users = new CoreUserSwipeItemsManager(source); | ||||
| 
 | ||||
|             this.usersQueryParams.search = search; | ||||
|             this.users.start(); | ||||
|         } | ||||
| 
 | ||||
| @ -227,38 +224,12 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { | ||||
| /** | ||||
|  * Helper to manage swiping within a collection of users. | ||||
|  */ | ||||
| class CoreUserSwipeItemsManager extends CoreSwipeItemsManager<CoreUserBasicData> { | ||||
| 
 | ||||
|     page: CoreUserProfilePage; | ||||
| 
 | ||||
|     constructor(source: CoreItemsManagerSource<CoreUserBasicData>, page: CoreUserProfilePage) { | ||||
|         super(source); | ||||
| 
 | ||||
|         this.page = page; | ||||
|     } | ||||
| class CoreUserSwipeItemsManager extends CoreSwipeItemsManager { | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemPath(item: CoreUserBasicData): string { | ||||
|         return String(item.id); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getItemQueryParams(): Params { | ||||
|         return this.page.usersQueryParams; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { | ||||
|         if (!route) { | ||||
|             return null; | ||||
|         } | ||||
| 
 | ||||
|     protected getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null { | ||||
|         return route.params.userId; | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -19,9 +19,6 @@ | ||||
|                 right: calc(50% - 12px -  var(--core-avatar-size) / 2) !important; | ||||
|             } | ||||
|         } | ||||
|         core-loading .core-loading-content { | ||||
|             width: 100%; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -397,8 +397,14 @@ export class CoreUserDelegateService extends CoreDelegate<CoreUserProfileHandler | ||||
|      */ | ||||
|     protected clearHandlerCache(courseId?: number, userId?: number): void { | ||||
|         if (courseId && userId) { | ||||
|             const cacheKey = this.getCacheKey(courseId, userId); | ||||
| 
 | ||||
|             Object.keys(this.enabledHandlers).forEach((name) => { | ||||
|                 delete this.enabledForUserCache[name][this.getCacheKey(courseId, userId)]; | ||||
|                 const cache = this.enabledForUserCache[name]; | ||||
| 
 | ||||
|                 if (cache) { | ||||
|                     delete cache[cacheKey]; | ||||
|                 } | ||||
|             }); | ||||
|         } else { | ||||
|             this.enabledForUserCache = {}; | ||||
|  | ||||
| @ -290,7 +290,7 @@ export class CoreNavigatorService { | ||||
|      * @param routeOptions Optional routeOptions to get the params or route value from. If missing, it will autodetect. | ||||
|      * @return Value of the parameter, undefined if not found. | ||||
|      */ | ||||
|     getRouteParam<T = unknown>(name: string, routeOptions: CoreNavigatorCurrentRouteOptions = {}): T | undefined { | ||||
|     getRouteParam<T = string>(name: string, routeOptions: CoreNavigatorCurrentRouteOptions = {}): T | undefined { | ||||
|         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|         let value: any; | ||||
| 
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user