diff --git a/src/addons/badges/classes/user-badges-source.ts b/src/addons/badges/classes/user-badges-source.ts new file mode 100644 index 000000000..ea36a237f --- /dev/null +++ b/src/addons/badges/classes/user-badges-source.ts @@ -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 { + + 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 }; + } + +} diff --git a/src/addons/badges/pages/issued-badge/issued-badge.html b/src/addons/badges/pages/issued-badge/issued-badge.html index d4865c4c9..4e7ac334e 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.html +++ b/src/addons/badges/pages/issued-badge/issued-badge.html @@ -10,239 +10,241 @@ - - - - - - - - - - {{ 'addon.badges.expired' | translate }} - - - - - - - - -

{{ 'addon.badges.recipientdetails' | translate}}

-
-
- - -

{{ 'core.name' | translate}}

-

{{ user.fullname }}

-
-
-
- - - - + + + + + + + -

{{ 'addon.badges.issuerdetails' | translate}}

-
-
- - -

{{ 'addon.badges.issuername' | translate}}

-

{{ badge.issuername }}

-
-
- - -

{{ 'addon.badges.contact' | translate}}

-

- {{ badge.issuercontact }} -

+ + + {{ 'addon.badges.expired' | translate }} +
- + -

{{ 'addon.badges.badgedetails' | translate}}

+

{{ 'addon.badges.recipientdetails' | translate}}

- +

{{ 'core.name' | translate}}

-

{{ badge.name }}

-
-
- - -

{{ 'addon.badges.version' | translate}}

-

{{ badge.version }}

-
-
- - -

{{ 'addon.badges.language' | translate}}

-

{{ badge.language }}

-
-
- - -

{{ 'core.description' | translate}}

-

{{ badge.description }}

-
-
- - -

{{ 'addon.badges.imageauthorname' | translate}}

-

{{ badge.imageauthorname }}

-
-
- - -

{{ 'addon.badges.imageauthoremail' | translate}}

-

- {{ badge.imageauthoremail }} -

-
-
- - -

{{ 'addon.badges.imageauthorurl' | translate}}

-

{{ badge.imageauthorurl }}

-
-
- - -

{{ 'addon.badges.imagecaption' | translate}}

-

{{ badge.imagecaption }}

-
-
- - -

{{ 'core.course' | translate}}

-

- - -

-
-
- -
- - - - -

{{ 'addon.badges.issuancedetails' | translate}}

-
-
- - -

{{ 'addon.badges.dateawarded' | translate}}

-

{{badge.dateissued * 1000 | coreFormatDate }}

-
-
- - -

{{ 'addon.badges.expirydate' | translate}}

-

- {{ badge.dateexpire * 1000 | coreFormatDate }} - - {{ 'addon.badges.warnexpired' | translate }} - -

-
-
- -
- - - - - -

{{ 'addon.badges.bendorsement' | translate}}

-
-
- - -

{{ 'addon.badges.issuername' | translate}}

-

{{ badge.endorsement.issuername }}

-
-
- - -

{{ 'addon.badges.issueremail' | translate}}

-

- - {{ badge.endorsement.issueremail }} - -

-
-
- - -

{{ 'addon.badges.issuerurl' | translate}}

-

{{ badge.endorsement.issuerurl }}

-
-
- - -

{{ 'addon.badges.dateawarded' | translate}}

-

{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}

-
-
- - -

{{ 'addon.badges.claimid' | translate}}

-

{{ badge.endorsement.claimid }}

-
-
- - -

{{ 'addon.badges.claimcomment' | translate}}

-

{{ badge.endorsement.claimcomment }}

+

{{ user.fullname }}

- - - - -

{{ 'addon.badges.relatedbages' | translate}}

-
-
- - -

{{ relatedBadge.name }}

-
-
- - -

{{ 'addon.badges.norelated' | translate}}

-
-
-
+ + + + +

{{ 'addon.badges.issuerdetails' | translate}}

+
+
+ + +

{{ 'addon.badges.issuername' | translate}}

+

{{ badge.issuername }}

+
+
+ + +

{{ 'addon.badges.contact' | translate}}

+

+ {{ badge.issuercontact }} +

+
+
+
- - - - -

{{ 'addon.badges.alignment' | translate}}

-
-
- - -

{{ alignment.targetname }}

-
-
- - -

{{ 'addon.badges.noalignment' | translate}}

-
-
-
-
-
+ + + +

{{ 'addon.badges.badgedetails' | translate}}

+
+
+ + +

{{ 'core.name' | translate}}

+

{{ badge.name }}

+
+
+ + +

{{ 'addon.badges.version' | translate}}

+

{{ badge.version }}

+
+
+ + +

{{ 'addon.badges.language' | translate}}

+

{{ badge.language }}

+
+
+ + +

{{ 'core.description' | translate}}

+

{{ badge.description }}

+
+
+ + +

{{ 'addon.badges.imageauthorname' | translate}}

+

{{ badge.imageauthorname }}

+
+
+ + +

{{ 'addon.badges.imageauthoremail' | translate}}

+

+ {{ badge.imageauthoremail }} +

+
+
+ + +

{{ 'addon.badges.imageauthorurl' | translate}}

+

{{ badge.imageauthorurl }}

+
+
+ + +

{{ 'addon.badges.imagecaption' | translate}}

+

{{ badge.imagecaption }}

+
+
+ + +

{{ 'core.course' | translate}}

+

+ + +

+
+
+ +
+ + + + +

{{ 'addon.badges.issuancedetails' | translate}}

+
+
+ + +

{{ 'addon.badges.dateawarded' | translate}}

+

{{badge.dateissued * 1000 | coreFormatDate }}

+
+
+ + +

{{ 'addon.badges.expirydate' | translate}}

+

+ {{ badge.dateexpire * 1000 | coreFormatDate }} + + {{ 'addon.badges.warnexpired' | translate }} + +

+
+
+ +
+ + + + + +

{{ 'addon.badges.bendorsement' | translate}}

+
+
+ + +

{{ 'addon.badges.issuername' | translate}}

+

{{ badge.endorsement.issuername }}

+
+
+ + +

{{ 'addon.badges.issueremail' | translate}}

+

+ + {{ badge.endorsement.issueremail }} + +

+
+
+ + +

{{ 'addon.badges.issuerurl' | translate}}

+

{{ badge.endorsement.issuerurl }}

+
+
+ + +

{{ 'addon.badges.dateawarded' | translate}}

+

{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}

+
+
+ + +

{{ 'addon.badges.claimid' | translate}}

+

{{ badge.endorsement.claimid }}

+
+
+ + +

{{ 'addon.badges.claimcomment' | translate}}

+

{{ badge.endorsement.claimcomment }}

+
+
+
+ + + + + +

{{ 'addon.badges.relatedbages' | translate}}

+
+
+ + +

{{ relatedBadge.name }}

+
+
+ + +

{{ 'addon.badges.norelated' | translate}}

+
+
+
+ + + + + +

{{ 'addon.badges.alignment' | translate}}

+
+
+ + +

{{ alignment.targetname }}

+
+
+ + +

{{ 'addon.badges.noalignment' | translate}}

+
+
+
+ + +
diff --git a/src/addons/badges/pages/issued-badge/issued-badge.page.ts b/src/addons/badges/pages/issued-badge/issued-badge.page.ts index 959ac2dac..907e04c89 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.page.ts +++ b/src/addons/badges/pages/issued-badge/issued-badge.page.ts @@ -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(); } /** diff --git a/src/addons/badges/pages/user-badges/user-badges.page.ts b/src/addons/badges/pages/user-badges/user-badges.page.ts index b5eef4a15..e614c3bac 100644 --- a/src/addons/badges/pages/user-badges/user-badges.page.ts +++ b/src/addons/badges/pages/user-badges/user-badges.page.ts @@ -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; @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 { - 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 { - const badges = await AddonBadges.getUserBadges(this.badges.courseId, this.badges.userId); - - this.badges.setItems(badges); - } - -} - -/** - * Helper class to manage badges. - */ -class AddonBadgesUserBadgesManager extends CorePageItemsListManager { - - 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, - }; - } - } diff --git a/src/addons/mod/assign/classes/submissions-source.ts b/src/addons/mod/assign/classes/submissions-source.ts new file mode 100644 index 000000000..fce121386 --- /dev/null +++ b/src/addons/mod/assign/classes/submissions-source.ts @@ -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 { + + /** + * @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 { + 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 { + // 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[] = []; + 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), + 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. +}; diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.html b/src/addons/mod/assign/pages/submission-list/submission-list.html index 3d9cb57a0..0a6635371 100644 --- a/src/addons/mod/assign/pages/submission-list/submission-list.html +++ b/src/addons/mod/assign/pages/submission-list/submission-list.html @@ -16,10 +16,10 @@ - + - + @@ -32,7 +32,7 @@ {{ 'core.groupsvisible' | translate }} - {{groupOpt.name}} diff --git a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts index 90f02a382..c8cb848f9 100644 --- a/src/addons/mod/assign/pages/submission-list/submission-list.page.ts +++ b/src/addons/mod/assign/pages/submission-list/submission-list.page.ts @@ -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; // 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( 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; } + } + + get assign(): AddonModAssignAssign | undefined { + return this.submissions.getSource().assign; + } + + 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', + ); - 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); - } - } else { - this.title = Translate.instant('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 { 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 { - 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[] = []; - 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), - 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 { - const promises: Promise[] = []; - - 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 { + 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 { - - 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. -}; diff --git a/src/addons/mod/assign/pages/submission-review/submission-review.html b/src/addons/mod/assign/pages/submission-review/submission-review.html index 227dfca1d..3655e0816 100644 --- a/src/addons/mod/assign/pages/submission-review/submission-review.html +++ b/src/addons/mod/assign/pages/submission-review/submission-review.html @@ -20,12 +20,14 @@ - - - - - - - - + + + + + + + + + diff --git a/src/addons/mod/assign/pages/submission-review/submission-review.ts b/src/addons/mod/assign/pages/submission-review/submission-review.ts index 803074c9f..015c73425 100644 --- a/src/addons/mod/assign/pages/submission-review/submission-review.ts +++ b/src/addons/mod/assign/pages/submission-review/submission-review.ts @@ -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; + } + +} diff --git a/src/addons/mod/forum/classes/forum-discussions-source.ts b/src/addons/mod/forum/classes/forum-discussions-source.ts new file mode 100644 index 000000000..7a08b4e8d --- /dev/null +++ b/src/addons/mod/forum/classes/forum-discussions-source.ts @@ -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 { + + 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 { + 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 { + 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; diff --git a/src/addons/mod/forum/classes/forum-discussions-swipe-manager.ts b/src/addons/mod/forum/classes/forum-discussions-swipe-manager.ts new file mode 100644 index 000000000..d409a6f58 --- /dev/null +++ b/src/addons/mod/forum/classes/forum-discussions-swipe-manager.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { 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 { + + /** + * @inheritdoc + */ + async navigateToNextItem(): Promise { + 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 { + let delta = 1; + const item = await this.getItemBy(1); + + if (item && this.getSource().isNewDiscussionForm(item)) { + delta++; + } + + await this.navigateToItemBy(delta, 'forward'); + } + +} diff --git a/src/addons/mod/forum/components/index/index.html b/src/addons/mod/forum/components/index/index.html index e61fce1ef..d282b828e 100644 --- a/src/addons/mod/forum/components/index/index.html +++ b/src/addons/mod/forum/components/index/index.html @@ -10,11 +10,11 @@ - - @@ -32,11 +32,11 @@ - + - + - + -
+
- @@ -96,17 +97,16 @@ {{ discussion.groupname }}

-

+

{{discussion.created * 1000 | coreFormatDate: "strftimerecentfull"}}

-

+

{{ 'core.notsent' | translate }}

- + {{ 'addon.mod_forum.lastpost' | translate }} @@ -134,7 +134,7 @@ -
diff --git a/src/addons/mod/forum/components/index/index.ts b/src/addons/mod/forum/components/index/index.ts index 2312f7ff4..e7a778d3b 100644 --- a/src/addons/mod/forum/components/index/index.ts +++ b/src/addons/mod/forum/components/index/index.ts @@ -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 { 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 { - this.discussions.fetchFailed = false; - - const promises: Promise[] = []; - - 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 { - 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 { - 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 { 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 { - 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 { + 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 { +class AddonModForumDiscussionsManager extends CoreListItemsManager { - 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 { + const forum = this.getSource().forum; - if (this.isOfflineDiscussion(discussion)) { - return `new/${discussion.timecreated}`; - } + if (!forum) { + return; + } - return 'new/0'; - }; + CoreUtils.ignoreErrors( + AddonModForum.instance + .logView(forum.id, forum.name) + .then(async () => { + CoreCourse.checkModuleCompletion(this.page.courseId, this.page.module.completiondata); - return this.discussionsPathPrefix + getRelativePath(); + return; + }), + ); } } diff --git a/src/addons/mod/forum/forum.module.ts b/src/addons/mod/forum/forum.module.ts index ec0c89af1..aa1fd3a18 100644 --- a/src/addons/mod/forum/forum.module.ts +++ b/src/addons/mod/forum/forum.module.ts @@ -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, diff --git a/src/addons/mod/forum/pages/discussion/discussion.html b/src/addons/mod/forum/pages/discussion/discussion.html index 8753e0b5d..e022d7c11 100644 --- a/src/addons/mod/forum/pages/discussion/discussion.html +++ b/src/addons/mod/forum/pages/discussion/discussion.html @@ -56,72 +56,74 @@ - - - + + + + - - - - - - {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }} - - - - - - - - {{ availabilityMessage }} - - - - - - - {{ 'addon.mod_forum.discussionlocked' | translate }} - - - -
- - -
- - - - - - - - - - - - - - - - - - - + + + + + + {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }} + -
- - - + + + + + + {{ availabilityMessage }} + + + + + + + {{ 'addon.mod_forum.discussionlocked' | translate }} + + + +
+ +
- - + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + diff --git a/src/addons/mod/forum/pages/discussion/discussion.page.ts b/src/addons/mod/forum/pages/discussion/discussion.page.ts index e1ef3b5a8..103b9355b 100644 --- a/src/addons/mod/forum/pages/discussion/discussion.page.ts +++ b/src/addons/mod/forum/pages/discussion/discussion.page.ts @@ -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 = {}; 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 { 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 - - - - - -
- - {{ 'addon.mod_forum.subject' | translate }} - - - - - {{ 'addon.mod_forum.message' | translate }} - - - - - - - -

{{ 'addon.mod_forum.advanced' | translate }}

-
-
-
- - {{ 'addon.mod_forum.posttomygroups' | translate }} - - - - {{ 'addon.mod_forum.group' | translate }} - - {{ group.name }} - + + + + + + + + {{ 'addon.mod_forum.subject' | translate }} + + - {{ 'addon.mod_forum.discussionsubscription' | translate }} - + {{ 'addon.mod_forum.message' | translate }} + + - - {{ 'addon.mod_forum.discussionpinned' | translate }} - + + + + +

{{ 'addon.mod_forum.advanced' | translate }}

+
- - -
- - - - - - {{ 'addon.mod_forum.posttoforum' | translate }} - - - - {{ 'core.discard' | translate }} - - - - -
-
+
+ + {{ 'addon.mod_forum.posttomygroups' | translate }} + + + + {{ 'addon.mod_forum.group' | translate }} + + {{ group.name }} + + + + {{ 'addon.mod_forum.discussionsubscription' | translate }} + + + + {{ 'addon.mod_forum.discussionpinned' | translate }} + + + + +
+ + + + + + {{ 'addon.mod_forum.posttoforum' | translate }} + + + + {{ 'core.discard' | translate }} + + + + + + +
diff --git a/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts b/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts index f5495fb71..7e9efa79a 100644 --- a/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts +++ b/src/addons/mod/forum/pages/new-discussion/new-discussion.page.ts @@ -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; protected forceLeave = false; - constructor(@Optional() protected splitView: CoreSplitViewComponent) {} + constructor(protected route: ActivatedRoute, @Optional() protected splitView: CoreSplitViewComponent) {} /** * Component being initialized. */ - ngOnInit(): void { + async ngOnInit(): Promise { 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}`; + } + +} diff --git a/src/addons/mod/glossary/classes/glossary-entries-source.ts b/src/addons/mod/glossary/classes/glossary-entries-source.ts new file mode 100644 index 000000000..42fbe67c5 --- /dev/null +++ b/src/addons/mod/glossary/classes/glossary-entries-source.ts @@ -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 { + + 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; + + 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 { + this.glossary = await AddonModGlossary.getGlossary(this.COURSE_ID, this.CM_ID); + } + + /** + * Invalidate glossary cache. + */ + async invalidateCache(): Promise { + 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'; diff --git a/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts b/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts new file mode 100644 index 000000000..45015a760 --- /dev/null +++ b/src/addons/mod/glossary/classes/glossary-entries-swipe-manager.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { 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 { + + /** + * @inheritdoc + */ + async navigateToNextItem(): Promise { + 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 { + let delta = 1; + const item = await this.getItemBy(1); + + if (item && this.getSource().isNewEntryForm(item)) { + delta++; + } + + await this.navigateToItemBy(delta, 'forward'); + } + +} diff --git a/src/addons/mod/glossary/components/index/addon-mod-glossary-index.html b/src/addons/mod/glossary/components/index/addon-mod-glossary-index.html index b0095c394..c8a816a25 100644 --- a/src/addons/mod/glossary/components/index/addon-mod-glossary-index.html +++ b/src/addons/mod/glossary/components/index/addon-mod-glossary-index.html @@ -54,7 +54,7 @@ [component]="component" [componentId]="componentId" [courseId]="courseId" [hasDataToSync]="hasOffline || hasOfflineRatings"> - +

{{ 'addon.mod_glossary.entriestobesynced' | translate }}

@@ -70,7 +70,7 @@
- + @@ -88,11 +88,11 @@ - - + diff --git a/src/addons/mod/glossary/components/index/index.ts b/src/addons/mod/glossary/components/index/index.ts index 41db3e7d1..6f250813b 100644 --- a/src/addons/mod/glossary/components/index/index.ts +++ b/src/addons/mod/glossary/components/index/index.ts @@ -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; 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 { - 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 { 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 { - 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 { - const promises: Promise[] = []; - - 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,109 +250,50 @@ 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; + } } } @@ -391,7 +305,9 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity */ async loadMoreEntries(infiniteComplete?: () => void): Promise { 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 { - const mode = await CoreDomUtils.openPopover({ + if (!this.glossary) { + return; + } + + const previousMode = this.entries.getSource().fetchMode; + const newMode = await CoreDomUtils.openPopover({ 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 { - // Search for entries. The fetch function will be set when searching. - this.getDivider = undefined; - this.showDivider = () => false; - this.isSearch = true; + const fetchMode = this.entries.getSource().fetchMode; - this.fetchedEntries = this.entries.onlineEntries; - this.fetchedEntriesCanLoadMore = !this.entries.completed; - this.entries.setItems([], false); + 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.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 { +class AddonModGlossaryEntriesManager extends CoreListItemsManager { - 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(( this.offlineEntries).concat(onlineEntries), hasMoreItems); - } - - /** - * Update offline entries items. - * - * @param offlineEntries Offline entries. - */ - setOfflineEntries(offlineEntries: AddonModGlossaryOfflineEntry[]): void { - this.setItems(( 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 { + const glossary = this.getSource().glossary; + const viewMode = this.getSource().viewMode; - /** - * @inheritdoc - */ - protected getItemPath(entry: EntryItem): string { - if (this.isOnlineEntry(entry)) { - return `${this.glossaryPathPrefix}entry/${entry.id}`; + if (!glossary || !viewMode) { + return; } - 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'; diff --git a/src/addons/mod/glossary/components/mode-picker/mode-picker.ts b/src/addons/mod/glossary/components/mode-picker/mode-picker.ts index e3e08071b..d808d3b76 100644 --- a/src/addons/mod/glossary/components/mode-picker/mode-picker.ts +++ b/src/addons/mod/glossary/components/mode-picker/mode-picker.ts @@ -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. diff --git a/src/addons/mod/glossary/glossary.module.ts b/src/addons/mod/glossary/glossary.module.ts index 9a4b09793..1b3e27e42 100644 --- a/src/addons/mod/glossary/glossary.module.ts +++ b/src/addons/mod/glossary/glossary.module.ts @@ -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, diff --git a/src/addons/mod/glossary/pages/edit/edit.html b/src/addons/mod/glossary/pages/edit/edit.html index 55faf51fe..4b195ebab 100644 --- a/src/addons/mod/glossary/pages/edit/edit.html +++ b/src/addons/mod/glossary/pages/edit/edit.html @@ -12,72 +12,75 @@ - -
- - {{ 'addon.mod_glossary.concept' | translate }} - - - - - {{ 'addon.mod_glossary.definition' | translate }} - - - - - - {{ 'addon.mod_glossary.categories' | translate }} - - - - {{ category.name }} - - - - - - {{ 'addon.mod_glossary.aliases' | translate }} - - - - - - -

{{ 'addon.mod_glossary.attachment' | translate }}

-
-
- - - + + + + + {{ 'addon.mod_glossary.concept' | translate }} + + + + + {{ 'addon.mod_glossary.definition' | translate }} + + + + + + {{ 'addon.mod_glossary.categories' | translate }} + + + + {{ category.name }} + + + + + + {{ 'addon.mod_glossary.aliases' | translate }} + + + + -

{{ 'addon.mod_glossary.linking' | translate }}

+

{{ 'addon.mod_glossary.attachment' | translate }}

- - {{ 'addon.mod_glossary.entryusedynalink' | translate }} - - - - {{ 'addon.mod_glossary.casesensitive' | translate }} - - - - - {{ 'addon.mod_glossary.fullmatch' | translate }} - - -
- - {{ 'core.save' | translate }} - -
-
+ + + + + +

{{ 'addon.mod_glossary.linking' | translate }}

+
+
+ + {{ 'addon.mod_glossary.entryusedynalink' | translate }} + + + + {{ 'addon.mod_glossary.casesensitive' | translate }} + + + + + {{ 'addon.mod_glossary.fullmatch' | translate }} + + +
+ + {{ 'core.save' | translate }} + + + +
diff --git a/src/addons/mod/glossary/pages/edit/edit.ts b/src/addons/mod/glossary/pages/edit/edit.ts index 33f384fc3..dfb06aa5d 100644 --- a/src/addons/mod/glossary/pages/edit/edit.ts +++ b/src/addons/mod/glossary/pages/edit/edit.ts @@ -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: [], 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 { 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('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 { - 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}`; + } + +} diff --git a/src/addons/mod/glossary/pages/entry/entry.html b/src/addons/mod/glossary/pages/entry/entry.html index 03737a4a1..e5d870d40 100644 --- a/src/addons/mod/glossary/pages/entry/entry.html +++ b/src/addons/mod/glossary/pages/entry/entry.html @@ -12,73 +12,75 @@ - - - + + + + - - - - - -

- + + + + + +

+ + +

+

{{ entry.userfullname }}

+
+ {{ entry.timemodified | coreDateDayOrTime }} +
+ + +

+ + +

+
+ {{ entry.timemodified | coreDateDayOrTime }} +
+ + + -

-

{{ entry.userfullname }}

-
- {{ entry.timemodified | coreDateDayOrTime }} -
- - -

- - -

-
- {{ entry.timemodified | coreDateDayOrTime }} -
- - - - - - -
- - -
- - -
{{ 'core.tag.tags' | translate }}:
- -
-
- - -

{{ 'addon.mod_glossary.entrypendingapproval' | translate }}

-
-
- - - - - - -
+ + +
+ + +
+ + +
{{ 'core.tag.tags' | translate }}:
+ +
+
+ + +

{{ 'addon.mod_glossary.entrypendingapproval' | translate }}

+
+
+ + + + + + + - - - {{ 'addon.mod_glossary.errorloadingentry' | translate }} - - -
+ + + {{ 'addon.mod_glossary.errorloadingentry' | translate }} + + + +
diff --git a/src/addons/mod/glossary/pages/entry/entry.ts b/src/addons/mod/glossary/pages/entry/entry.ts index ae3c836e4..d88b470c1 100644 --- a/src/addons/mod/glossary/pages/entry/entry.ts +++ b/src/addons/mod/glossary/pages/entry/entry.ts @@ -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 { 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}`; + } + +} diff --git a/src/core/classes/items-management/items-manager-source.ts b/src/core/classes/items-management/items-manager-source.ts index 141cc6f4c..29acf445f 100644 --- a/src/core/classes/items-management/items-manager-source.ts +++ b/src/core/classes/items-management/items-manager-source.ts @@ -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 { - onItemsUpdated(items: Item[], hasMoreItems: boolean): void; - onReset(): void; + onItemsUpdated?(items: Item[], hasMoreItems: boolean): void; + onReset?(): void; } /** @@ -35,9 +37,10 @@ export abstract class CoreItemsManagerSource { return args.map(argument => String(argument)).join('-'); } - private items: Item[] | null = null; - private hasMoreItems = true; - private listeners: CoreItemsListSourceListener[] = []; + protected items: Item[] | null = null; + protected hasMoreItems = true; + protected listeners: CoreItemsListSourceListener[] = []; + protected dirty = false; /** * Check whether any page has been loaded. @@ -57,6 +60,17 @@ export abstract class CoreItemsManagerSource { 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 { 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 { 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 { async reload(): Promise { 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 { + async load(): Promise { + 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 { this.items = items; this.hasMoreItems = hasMoreItems; - this.listeners.forEach(listener => listener.onItemsUpdated(items, hasMoreItems)); + this.listeners.forEach(listener => listener.onItemsUpdated?.call(listener, items, hasMoreItems)); } } diff --git a/src/core/classes/items-management/items-manager-sources-tracker.ts b/src/core/classes/items-management/items-manager-sources-tracker.ts index 997f5c556..e3c2f16b9 100644 --- a/src/core/classes/items-management/items-manager-sources-tracker.ts +++ b/src/core/classes/items-management/items-manager-sources-tracker.ts @@ -18,6 +18,7 @@ type SourceConstructor = T extends { new(...args: unknown[]): infer P } ? P : never; type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] }; type Instances = Record; @@ -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( - constructor: SourceConstructor, - constructorArguments: ConstructorParameters>, - ): T { + static getOrCreateSource>( + constructor: C, + constructorArguments: ConstructorParameters, + ): SourceConstuctorInstance { const id = constructor.getSourceId(...constructorArguments); const constructorInstances = this.getConstructorInstances(constructor); - return constructorInstances[id]?.instance as T + return constructorInstances[id]?.instance as SourceConstuctorInstance ?? 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; } diff --git a/src/core/classes/items-management/items-manager.ts b/src/core/classes/items-management/items-manager.ts index 8ec06d964..f2be8bd67 100644 --- a/src/core/classes/items-management/items-manager.ts +++ b/src/core/classes/items-management/items-manager.ts @@ -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 { +export abstract class CoreItemsManager = CoreItemsManagerSource> { - protected source?: { instance: CoreItemsManagerSource; unsubscribe: () => void }; + protected source?: { instance: Source; unsubscribe: () => void }; protected itemsMap: Record | null = null; protected selectedItem: Item | null = null; - constructor(source: CoreItemsManagerSource) { + constructor(source: Source) { this.setSource(source); } @@ -36,7 +36,7 @@ export abstract class CoreItemsManager { * * @returns Source. */ - getSource(): CoreItemsManagerSource { + getSource(): Source { if (!this.source) { throw new Error('Source is missing from items manager'); } @@ -49,7 +49,7 @@ export abstract class CoreItemsManager { * * @param newSource New source. */ - setSource(newSource: CoreItemsManagerSource | null): void { + setSource(newSource: Source | null): void { if (this.source) { CoreItemsManagerSourcesTracker.removeReference(this.source.instance, this); @@ -92,31 +92,26 @@ export abstract class CoreItemsManager { */ 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 { } // 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 { } // 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 { */ protected onSourceItemsUpdated(items: Item[]): void { this.itemsMap = items.reduce((map, item) => { - map[this.getItemPath(item)] = item; + map[this.getSource().getItemPath(item)] = item; return map; }, {}); diff --git a/src/core/classes/items-management/list-items-manager.ts b/src/core/classes/items-management/list-items-manager.ts index ff1b87ec8..baacfdefc 100644 --- a/src/core/classes/items-management/list-items-manager.ts +++ b/src/core/classes/items-management/list-items-manager.ts @@ -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 extends CoreItemsManager { +export class CoreListItemsManager< + Item = unknown, + Source extends CoreItemsManagerSource = CoreItemsManagerSource +> extends CoreItemsManager { protected pageRouteLocator?: unknown | ActivatedRoute; protected splitView?: CoreSplitViewComponent; protected splitViewOutletSubscription?: Subscription; - constructor(source: CoreItemsManagerSource, pageRouteLocator: unknown | ActivatedRoute) { + constructor(source: Source, pageRouteLocator: unknown | ActivatedRoute) { super(source); this.pageRouteLocator = pageRouteLocator; @@ -67,15 +70,6 @@ export abstract class CoreListItemsManager 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 extends CoreItemsMana } /** - * Load items for the next page, if any. + * Load more items, if any. */ - async loadNextPage(): Promise { - await this.getSource().loadNextPage(); + async load(): Promise { + await this.getSource().load(); } /** @@ -172,6 +166,25 @@ export abstract class CoreListItemsManager 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 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); } diff --git a/src/core/classes/items-management/swipe-items-manager.ts b/src/core/classes/items-management/swipe-items-manager.ts index 18cb6b40c..918cab741 100644 --- a/src/core/classes/items-management/swipe-items-manager.ts +++ b/src/core/classes/items-management/swipe-items-manager.ts @@ -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 extends CoreItemsManager { +export class CoreSwipeItemsManager< + Item = unknown, + Source extends CoreItemsManagerSource = CoreItemsManagerSource +> + extends CoreItemsManager { /** * Process page started operations. @@ -51,6 +56,25 @@ export abstract class CoreSwipeItemsManager 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 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); } diff --git a/src/core/components/swipe-navigation/swipe-navigation.html b/src/core/components/swipe-navigation/swipe-navigation.html index 1519bb5e4..1a47ecb89 100644 --- a/src/core/components/swipe-navigation/swipe-navigation.html +++ b/src/core/components/swipe-navigation/swipe-navigation.html @@ -1,4 +1,4 @@ - + diff --git a/src/core/components/swipe-navigation/swipe-navigation.scss b/src/core/components/swipe-navigation/swipe-navigation.scss index 4baf55d1c..05c7d60e5 100644 --- a/src/core/components/swipe-navigation/swipe-navigation.scss +++ b/src/core/components/swipe-navigation/swipe-navigation.scss @@ -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; + } + +} diff --git a/src/core/components/swipe-navigation/swipe-navigation.ts b/src/core/components/swipe-navigation/swipe-navigation.ts index a87c10b7f..57dab122d 100644 --- a/src/core/components/swipe-navigation/swipe-navigation.ts +++ b/src/core/components/swipe-navigation/swipe-navigation.ts @@ -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(); } diff --git a/src/core/features/user/classes/participants-source.ts b/src/core/features/user/classes/participants-source.ts index 594ff4817..9aa6e4db1 100644 --- a/src/core/features/user/classes/participants-source.ts +++ b/src/core/features/user/classes/participants-source.ts @@ -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 { 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 { - - 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 { /** * @inheritdoc */ protected async logActivity(): Promise { - await CoreUser.logParticipantsView(this.page.courseId); + await CoreUser.logParticipantsView(this.getSource().COURSE_ID); } } diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index 054ee18bc..bcfc6bad3 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -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 { - - page: CoreUserProfilePage; - - constructor(source: CoreItemsManagerSource, 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; } diff --git a/src/core/features/user/pages/profile/profile.scss b/src/core/features/user/pages/profile/profile.scss index eac82607b..41723e265 100644 --- a/src/core/features/user/pages/profile/profile.scss +++ b/src/core/features/user/pages/profile/profile.scss @@ -19,9 +19,6 @@ right: calc(50% - 12px - var(--core-avatar-size) / 2) !important; } } - core-loading .core-loading-content { - width: 100%; - } } } diff --git a/src/core/features/user/services/user-delegate.ts b/src/core/features/user/services/user-delegate.ts index b16a36787..df503bc7d 100644 --- a/src/core/features/user/services/user-delegate.ts +++ b/src/core/features/user/services/user-delegate.ts @@ -397,8 +397,14 @@ export class CoreUserDelegateService extends CoreDelegate { - delete this.enabledForUserCache[name][this.getCacheKey(courseId, userId)]; + const cache = this.enabledForUserCache[name]; + + if (cache) { + delete cache[cacheKey]; + } }); } else { this.enabledForUserCache = {}; diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index 5cc058cb8..8d7d02c81 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -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(name: string, routeOptions: CoreNavigatorCurrentRouteOptions = {}): T | undefined { + getRouteParam(name: string, routeOptions: CoreNavigatorCurrentRouteOptions = {}): T | undefined { // eslint-disable-next-line @typescript-eslint/no-explicit-any let value: any;