diff --git a/src/addons/badges/classes/user-badges-source.ts b/src/addons/badges/classes/user-badges-source.ts index ea36a237f..e7aac1ed7 100644 --- a/src/addons/badges/classes/user-badges-source.ts +++ b/src/addons/badges/classes/user-badges-source.ts @@ -13,13 +13,13 @@ // limitations under the License. import { Params } from '@angular/router'; -import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; +import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; import { AddonBadges, AddonBadgesUserBadge } from '../services/badges'; /** * Provides a collection of user badges. */ -export class AddonBadgesUserBadgesSource extends CoreItemsManagerSource { +export class AddonBadgesUserBadgesSource extends CoreRoutedItemsManagerSource { readonly COURSE_ID: number; readonly USER_ID: number; 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 907e04c89..c48a21034 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.page.ts +++ b/src/addons/badges/pages/issued-badge/issued-badge.page.ts @@ -24,8 +24,8 @@ import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/ 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'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; /** * Page that displays the list of calendar events. @@ -61,7 +61,10 @@ export class AddonBadgesIssuedBadgePage implements OnInit { this.badgeLoaded = true; }); - const source = CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [this.courseId, this.userId]); + const source = CoreRoutedItemsManagerSourcesTracker.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 e614c3bac..31ba89d84 100644 --- a/src/addons/badges/pages/user-badges/user-badges.page.ts +++ b/src/addons/badges/pages/user-badges/user-badges.page.ts @@ -23,7 +23,7 @@ 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'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; /** * Page that displays the list of calendar events. @@ -49,7 +49,7 @@ export class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestroy { } this.badges = new CoreListItemsManager( - CoreItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [courseId, userId]), + CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(AddonBadgesUserBadgesSource, [courseId, userId]), AddonBadgesUserBadgesPage, ); } diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html index f102136be..40ba979c3 100644 --- a/src/addons/calendar/components/calendar/addon-calendar-calendar.html +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -19,7 +19,8 @@

{{ periodName }} - + +

@@ -30,14 +31,14 @@ - - + +
- + {{ day.fullname | translate }} @@ -46,7 +47,7 @@
- + @@ -104,7 +105,7 @@
-
-
+ + diff --git a/src/addons/calendar/components/calendar/calendar.ts b/src/addons/calendar/components/calendar/calendar.ts index 02939b032..6a0f3a4c5 100644 --- a/src/addons/calendar/components/calendar/calendar.ts +++ b/src/addons/calendar/components/calendar/calendar.ts @@ -42,7 +42,9 @@ import { AddonCalendarOffline } from '../../services/calendar-offline'; import { CoreCategoryData, CoreCourses } from '@features/courses/services/courses'; import { CoreApp } from '@services/app'; import { CoreLocalNotifications } from '@services/local-notifications'; -import { IonSlides } from '@ionic/angular'; +import { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent } from '@components/swipe-slides/swipe-slides'; +import { CoreSwipeSlidesDynamicItemsManagerSource } from '@classes/items-management/slides-dynamic-items-manager-source'; +import { CoreSwipeSlidesDynamicItemsManager } from '@classes/items-management/slides-dynamic-items-manager'; /** * Component that displays a calendar. @@ -54,7 +56,7 @@ import { IonSlides } from '@ionic/angular'; }) export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestroy { - @ViewChild(IonSlides) slides?: IonSlides; + @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent; @Input() initialYear?: number; // Initial year to load. @Input() initialMonth?: number; // Initial month to load. @@ -65,22 +67,12 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro @Output() onDayClicked = new EventEmitter<{day: number; month: number; year: number}>(); periodName?: string; - preloadedMonths: PreloadedMonth[] = []; + manager?: CoreSwipeSlidesDynamicItemsManager; loaded = false; - monthLoaded = false; - timeFormat?: string; isCurrentMonth = false; isPastMonth = false; - protected visibleMonth: YearAndMonth; - protected categoriesRetrieved = false; - protected categories: { [id: number]: CoreCategoryData } = {}; protected currentSiteId: string; - protected offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = - {}; // Offline events classified in month & day. - - protected offlineEditedEventsIds: number[] = []; // IDs of events edited in offline. - protected deletedEvents: number[] = []; // Events deleted in offline. protected currentTime?: number; protected differ: KeyValueDiffer; // To detect changes in the data input. // Observers. @@ -95,7 +87,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro if (CoreLocalNotifications.isAvailable()) { // Re-schedule events if default time changes. this.obsDefaultTimeChange = CoreEvents.on(AddonCalendarProvider.DEFAULT_NOTIFICATION_TIME_CHANGED, () => { - this.preloadedMonths.forEach((month) => { + this.manager?.getSource().getItems()?.forEach((month) => { if (!month.loaded) { return; } @@ -121,30 +113,32 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro this.undeleteEvent(data.eventId); // Remove it from the list of deleted events if it's there. - const index = this.deletedEvents.indexOf(data.eventId); - if (index != -1) { - this.deletedEvents.splice(index, 1); + const index = this.manager?.getSource().deletedEvents.indexOf(data.eventId); + if (index !== undefined && index != -1) { + this.manager?.getSource().deletedEvents.splice(index, 1); } }, this.currentSiteId, ); this.differ = differs.find([]).create(); - - const now = new Date(); - - this.visibleMonth = { - year: now.getFullYear(), - monthNumber: now.getMonth() + 1, - }; } /** * Component loaded. */ ngOnInit(): void { - this.visibleMonth.year = this.initialYear ?? this.visibleMonth.year; - this.visibleMonth.monthNumber = this.initialMonth ?? this.visibleMonth.monthNumber; + const now = new Date(); + + this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate); + this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : + CoreUtils.isTrueOrOne(this.displayNavButtons); + + const source = new AddonCalendarMonthSlidesItemsManagerSource(this, { + year: this.initialYear ?? now.getFullYear(), + monthNumber: this.initialMonth ?? now.getMonth() + 1, + }); + this.manager = new CoreSwipeSlidesDynamicItemsManager(source); this.calculateIsCurrentMonth(); @@ -155,15 +149,13 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). */ ngDoCheck(): void { - this.canNavigate = typeof this.canNavigate == 'undefined' ? true : CoreUtils.isTrueOrOne(this.canNavigate); - this.displayNavButtons = typeof this.displayNavButtons == 'undefined' ? true : - CoreUtils.isTrueOrOne(this.displayNavButtons); + const items = this.manager?.getSource().getItems(); - if (this.preloadedMonths.length) { + if (items?.length) { // Check if there's any change in the filter object. const changes = this.differ.diff(this.filter || {}); if (changes) { - this.preloadedMonths.forEach((month) => { + items.forEach((month) => { if (month.loaded) { this.filterEvents(month.weeks); } @@ -172,47 +164,20 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } } + get timeFormat(): string { + return this.manager?.getSource().timeFormat || 'core.strftimetime'; + } + /** * Fetch contacts. * * @return Promise resolved when done. */ async fetchData(): Promise { - const promises: Promise[] = []; - - promises.push(this.loadCategories()); - - // Get offline events. - promises.push(AddonCalendarOffline.getAllEditedEvents().then((events) => { - // Classify them by month. - this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events); - - // Get the IDs of events edited in offline. - const filtered = events.filter((event) => event.id > 0); - this.offlineEditedEventsIds = filtered.map((event) => event.id); - - return; - })); - - // Get events deleted in offline. - promises.push(AddonCalendarOffline.getAllDeletedEventsIds().then((ids) => { - this.deletedEvents = ids; - - return; - })); - - // Get time format to use. - promises.push(AddonCalendar.getCalendarTimeFormat().then((value) => { - this.timeFormat = value; - - return; - })); - try { - await Promise.all(promises); - - await this.viewMonth(); + await this.manager?.getSource().fetchData(); + await this.manager?.getSource().load(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); } @@ -221,20 +186,310 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } /** - * Load or preload a month. + * Load current month and preload next and previous ones. * - * @param year Year. - * @param monthNumber Month number. - * @param preload Whether to "preload" the month. When preloading, no events will be fetched. * @return Promise resolved when done. */ - async loadMonth(month: YearAndMonth, preload = false): Promise { - // Check if it's already loaded. - const existingMonth = this.findPreloadedMonth(month); - if (existingMonth && ((existingMonth.loaded && !existingMonth.needsRefresh) || preload)) { + async viewMonth(month: YearAndMonth): Promise { + // Calculate the period name. We don't use the one in result because it's in server's language. + this.periodName = CoreTimeUtils.userDate( + new Date(month.year, month.monthNumber - 1).getTime(), + 'core.strftimemonthyear', + ); + this.calculateIsCurrentMonth(); + } + + /** + * Filter events based on the filter popover. + * + * @param weeks Weeks with the events to filter. + */ + filterEvents(weeks: AddonCalendarWeek[]): void { + weeks.forEach((week) => { + week.days.forEach((day) => { + day.filteredEvents = AddonCalendarHelper.getFilteredEvents( + day.eventsFormated || [], + this.filter, + this.manager?.getSource().categories || {}, + ); + + // Re-calculate some properties. + AddonCalendarHelper.calculateDayData(day, day.filteredEvents); + }); + }); + } + + /** + * Refresh events. + * + * @param afterChange Whether the refresh is done after an event has changed or has been synced. + * @return Promise resolved when done. + */ + async refreshData(): Promise { + const visibleMonth = this.slides?.getCurrentItem() || null; + + await this.manager?.getSource().invalidateContent(visibleMonth); + + await this.fetchData(); + } + + /** + * Load next month. + */ + loadNext(): void { + this.slides?.slideNext(); + } + + /** + * Load previous month. + */ + loadPrevious(): void { + this.slides?.slidePrev(); + } + + /** + * An event was clicked. + * + * @param calendarEvent Calendar event.. + * @param event Mouse event. + */ + eventClicked(calendarEvent: AddonCalendarEventToDisplay, event: Event): void { + this.onEventClicked.emit(calendarEvent.id); + event.stopPropagation(); + } + + /** + * A day was clicked. + * + * @param day Day. + */ + dayClicked(day: number): void { + const visibleMonth = this.slides?.getCurrentItem(); + if (!visibleMonth) { return; } + this.onDayClicked.emit({ day: day, month: visibleMonth.monthNumber, year: visibleMonth.year }); + } + + /** + * Check if user is viewing the current month. + */ + calculateIsCurrentMonth(): void { + const visibleMonth = this.slides?.getCurrentItem(); + if (!visibleMonth) { + return; + } + + this.currentTime = CoreTimeUtils.timestamp(); + this.isCurrentMonth = CoreTimeUtils.isCurrentMonth(visibleMonth); + this.isPastMonth = CoreTimeUtils.isCurrentMonth(visibleMonth); + } + + /** + * Go to current month. + */ + async goToCurrentMonth(): Promise { + const now = new Date(); + const currentMonth = { + monthNumber: now.getMonth() + 1, + year: now.getFullYear(), + }; + + this.slides?.slideToItem(currentMonth); + } + + /** + * Undelete a certain event. + * + * @param eventId Event ID. + */ + protected async undeleteEvent(eventId: number): Promise { + this.manager?.getSource().getItems()?.forEach((month) => { + if (!month.loaded) { + return; + } + + month.weeks.forEach((week) => { + week.days.forEach((day) => { + const event = day.eventsFormated?.find((event) => event.id == eventId); + + if (event) { + event.deleted = false; + } + }); + }); + }); + } + + /** + * Returns if the event is in the past or not. + * + * @param event Event object. + * @return True if it's in the past. + */ + isEventPast(event: { timestart: number; timeduration: number}): boolean { + return (event.timestart + event.timeduration) < (this.currentTime || CoreTimeUtils.timestamp()); + } + + /** + * Slide has changed. + * + * @param data Data about new item. + */ + slideChanged(data: CoreSwipeCurrentItemData): void { + this.viewMonth(data.item); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.undeleteEventObserver?.off(); + this.obsDefaultTimeChange?.off(); + } + +} + +/** + * Preloaded month. + */ +type PreloadedMonth = YearAndMonth & { + loaded: boolean; // Whether the events have been loaded. + weekDays: AddonCalendarWeekDaysTranslationKeys[]; + weeks: AddonCalendarWeek[]; + needsRefresh?: boolean; // Whether the events needs to be re-loaded. +}; + +// CoreSwipeSlidesDynamicItemsManagerSource + +/** + * Helper to manage swiping within a collection of chapters. + */ +class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicItemsManagerSource { + + monthLoaded = false; + categories: { [id: number]: CoreCategoryData } = {}; + // Offline events classified in month & day. + offlineEvents: { [monthId: string]: { [day: number]: AddonCalendarEventToDisplay[] } } = {}; + offlineEditedEventsIds: number[] = []; // IDs of events edited in offline. + deletedEvents: number[] = []; // Events deleted in offline. + timeFormat?: string; + + protected calendarComponent: AddonCalendarCalendarComponent; + protected categoriesRetrieved = false; + + constructor(component: AddonCalendarCalendarComponent, initialMonth: YearAndMonth) { + super(initialMonth); + + this.calendarComponent = component; + } + + /** + * Fetch data. + * + * @return Promise resolved when done. + */ + async fetchData(): Promise { + await Promise.all([ + this.loadCategories(), + this.loadOfflineEvents(), + this.loadOfflineDeletedEvents(), + this.loadTimeFormat(), + ]); + } + + /** + * Load categories to be able to filter events. + * + * @return Promise resolved when done. + */ + async loadCategories(): Promise { + if (this.categoriesRetrieved) { + // Already retrieved, stop. + return; + } + + try { + const cats = await CoreCourses.getCategories(0, true); + this.categoriesRetrieved = true; + this.categories = {}; + + // Index categories by ID. + cats.forEach((category) => { + this.categories[category.id] = category; + }); + } catch { + // Ignore errors. + } + } + + /** + * Load events created or edited in offline. + * + * @return Promise resolved when done. + */ + async loadOfflineEvents(): Promise { + // Get offline events. + const events = await AddonCalendarOffline.getAllEditedEvents(); + + // Classify them by month. + this.offlineEvents = AddonCalendarHelper.classifyIntoMonths(events); + + // Get the IDs of events edited in offline. + const filtered = events.filter((event) => event.id > 0); + this.offlineEditedEventsIds = filtered.map((event) => event.id); + } + + /** + * Load events deleted in offline. + * + * @return Promise resolved when done. + */ + async loadOfflineDeletedEvents(): Promise { + this.deletedEvents = await AddonCalendarOffline.getAllDeletedEventsIds(); + } + + /** + * Load time format. + * + * @return Promise resolved when done. + */ + async loadTimeFormat(): Promise { + this.timeFormat = await AddonCalendar.getCalendarTimeFormat(); + } + + /** + * @inheritdoc + */ + getItemId(item: YearAndMonth): string | number { + return `${item.year}#${item.monthNumber}`; + } + + /** + * @inheritdoc + */ + getPreviousItem(item: YearAndMonth): YearAndMonth | null { + return CoreTimeUtils.getPreviousMonth(item); + } + + /** + * @inheritdoc + */ + getNextItem(item: YearAndMonth): YearAndMonth | null { + return CoreTimeUtils.getNextMonth(item); + } + + /** + * @inheritdoc + */ + async loadItemData(month: YearAndMonth, preload = false): Promise { + // Check if it's already loaded. + const existingMonth = this.getItem(month); + if (existingMonth && ((existingMonth.loaded && !existingMonth.needsRefresh) || preload)) { + return existingMonth; + } + if (!preload) { this.monthLoaded = false; } @@ -286,7 +541,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro if (day.istoday) { day.eventsFormated?.forEach((event) => { - event.ispast = this.isEventPast(event); + event.ispast = this.calendarComponent.isEventPast(event); }); } } @@ -297,7 +552,7 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro // Merge the online events with offline data. this.mergeEvents(month, weeks); // Filter events by course. - this.filterEvents(weeks); + this.calendarComponent.filterEvents(weeks); } if (existingMonth) { @@ -306,47 +561,16 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro existingMonth.weeks = weeks; existingMonth.weekDays = weekDays; - return; + return existingMonth; } // Add the preloaded month at the right position. - const preloadedMonth: PreloadedMonth = { + return { ...month, loaded: !preload, weeks, weekDays, }; - const previousMonth = CoreTimeUtils.getPreviousMonth(month); - const nextMonth = CoreTimeUtils.getNextMonth(month); - const activeIndex = await this.slides?.getActiveIndex(); - - const added = this.preloadedMonths.some((month, index) => { - let positionToInsert = -1; - if (CoreTimeUtils.isSameMonth(month, previousMonth)) { - // Previous month found, add the month after it. - positionToInsert = index + 1; - } - - if (CoreTimeUtils.isSameMonth(month, nextMonth)) { - // Next month found, add the month before it. - positionToInsert = index; - } - - if (positionToInsert > -1) { - this.preloadedMonths.splice(positionToInsert, 0, preloadedMonth); - if (activeIndex !== undefined && positionToInsert <= activeIndex) { - // Added a slide before the active one, keep current slide. - this.slides?.slideTo(activeIndex + 1, 0, false); - } - - return true; - } - }); - - if (!added) { - // Previous and next months not found, this probably means the array is still empty. Add it at the end. - this.preloadedMonths.push(preloadedMonth); - } } finally { if (!preload) { this.monthLoaded = true; @@ -354,168 +578,13 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } } - /** - * Load current month and preload next and previous ones. - * - * @return Promise resolved when done. - */ - async viewMonth(): Promise { - // Calculate the period name. We don't use the one in result because it's in server's language. - this.periodName = CoreTimeUtils.userDate( - new Date(this.visibleMonth.year, this.visibleMonth.monthNumber - 1).getTime(), - 'core.strftimemonthyear', - ); - this.calculateIsCurrentMonth(); - - try { - // Load current month, and preload next and previous ones. - await Promise.all([ - this.loadMonth(this.visibleMonth, false), - this.loadMonth(CoreTimeUtils.getPreviousMonth(this.visibleMonth), true), - this.loadMonth(CoreTimeUtils.getNextMonth(this.visibleMonth), true), - ]); - } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'addon.calendar.errorloadevents', true); - } - } - - /** - * Load categories to be able to filter events. - * - * @return Promise resolved when done. - */ - protected async loadCategories(): Promise { - if (this.categoriesRetrieved) { - // Already retrieved, stop. - return; - } - - try { - const cats = await CoreCourses.getCategories(0, true); - this.categoriesRetrieved = true; - this.categories = {}; - - // Index categories by ID. - cats.forEach((category) => { - this.categories[category.id] = category; - }); - } catch { - // Ignore errors. - } - } - - /** - * Filter events based on the filter popover. - * - * @param weeks Weeks with the events to filter. - */ - filterEvents(weeks: AddonCalendarWeek[]): void { - weeks.forEach((week) => { - week.days.forEach((day) => { - day.filteredEvents = AddonCalendarHelper.getFilteredEvents( - day.eventsFormated || [], - this.filter, - this.categories, - ); - - // Re-calculate some properties. - AddonCalendarHelper.calculateDayData(day, day.filteredEvents); - }); - }); - } - - /** - * Refresh events. - * - * @param afterChange Whether the refresh is done after an event has changed or has been synced. - * @return Promise resolved when done. - */ - async refreshData(): Promise { - const promises: Promise[] = []; - - promises.push(AddonCalendar.invalidateMonthlyEvents(this.visibleMonth.year, this.visibleMonth.monthNumber)); - promises.push(CoreCourses.invalidateCategories(0, true)); - promises.push(AddonCalendar.invalidateTimeFormat()); - - this.categoriesRetrieved = false; // Get categories again. - - const preloadedMonth = this.findPreloadedMonth(this.visibleMonth); - if (preloadedMonth) { - preloadedMonth.needsRefresh = true; - } - - await Promise.all(promises); - - this.fetchData(); - } - - /** - * Load next month. - */ - loadNext(): void { - this.slides?.slideNext(); - } - - /** - * Load previous month. - */ - loadPrevious(): void { - this.slides?.slidePrev(); - } - - /** - * An event was clicked. - * - * @param calendarEvent Calendar event.. - * @param event Mouse event. - */ - eventClicked(calendarEvent: AddonCalendarEventToDisplay, event: Event): void { - this.onEventClicked.emit(calendarEvent.id); - event.stopPropagation(); - } - - /** - * A day was clicked. - * - * @param day Day. - */ - dayClicked(day: number): void { - this.onDayClicked.emit({ day: day, month: this.visibleMonth.monthNumber, year: this.visibleMonth.year }); - } - - /** - * Check if user is viewing the current month. - */ - calculateIsCurrentMonth(): void { - this.currentTime = CoreTimeUtils.timestamp(); - this.isCurrentMonth = CoreTimeUtils.isCurrentMonth(this.visibleMonth); - this.isPastMonth = CoreTimeUtils.isCurrentMonth(this.visibleMonth); - } - - /** - * Go to current month. - */ - async goToCurrentMonth(): Promise { - - const now = new Date(); - const currentMonth = { - monthNumber: now.getMonth() + 1, - year: now.getFullYear(), - }; - - const index = this.preloadedMonths.findIndex((month) => CoreTimeUtils.isSameMonth(month, currentMonth)); - if (index > -1) { - this.slides?.slideTo(index); - } - } - /** * Merge online events with the offline events of that period. * * @param month Month. * @param weeks Weeks with the events to filter. */ - protected mergeEvents(month: YearAndMonth, weeks: AddonCalendarWeek[]): void { + mergeEvents(month: YearAndMonth, weeks: AddonCalendarWeek[]): void { const monthOfflineEvents: { [day: number]: AddonCalendarEventToDisplay[] } = this.offlineEvents[AddonCalendarHelper.getMonthId(month.year, month.monthNumber)]; @@ -551,86 +620,27 @@ export class AddonCalendarCalendarComponent implements OnInit, DoCheck, OnDestro } /** - * Undelete a certain event. - * - * @param eventId Event ID. - */ - protected async undeleteEvent(eventId: number): Promise { - this.preloadedMonths.forEach((month) => { - if (!month.loaded) { - return; - } - - month.weeks.forEach((week) => { - week.days.forEach((day) => { - const event = day.eventsFormated?.find((event) => event.id == eventId); - - if (event) { - event.deleted = false; - } - }); - }); - }); - } - - /** - * Returns if the event is in the past or not. - * - * @param event Event object. - * @return True if it's in the past. - */ - protected isEventPast(event: { timestart: number; timeduration: number}): boolean { - return (event.timestart + event.timeduration) < (this.currentTime || CoreTimeUtils.timestamp()); - } - - /** - * Slide has changed. + * Invalidate content. * + * @param visibleMonth The current visible month. * @return Promise resolved when done. */ - async slideChanged(): Promise { - if (!this.slides) { - return; + async invalidateContent(visibleMonth: PreloadedMonth | null): Promise { + const promises: Promise[] = []; + + if (visibleMonth) { + promises.push(AddonCalendar.invalidateMonthlyEvents(visibleMonth.year, visibleMonth.monthNumber)); + } + promises.push(CoreCourses.invalidateCategories(0, true)); + promises.push(AddonCalendar.invalidateTimeFormat()); + + this.categoriesRetrieved = false; // Get categories again. + + if (visibleMonth) { + visibleMonth.needsRefresh = true; } - const index = await this.slides.getActiveIndex(); - const preloadedMonth = this.preloadedMonths[index]; - if (!preloadedMonth) { - return; - } - - this.visibleMonth.year = preloadedMonth.year; - this.visibleMonth.monthNumber = preloadedMonth.monthNumber; - - await this.viewMonth(); - } - - /** - * Find a certain preloaded month. - * - * @param month Month to search. - * @return Preloaded month, undefined if not found. - */ - protected findPreloadedMonth(month: YearAndMonth): PreloadedMonth | undefined { - return this.preloadedMonths.find(preloadedMonth => CoreTimeUtils.isSameMonth(month, preloadedMonth)); - } - - /** - * Component destroyed. - */ - ngOnDestroy(): void { - this.undeleteEventObserver?.off(); - this.obsDefaultTimeChange?.off(); + await Promise.all(promises); } } - -/** - * Preloaded month. - */ -type PreloadedMonth = YearAndMonth & { - loaded: boolean; // Whether the events have been loaded. - weekDays: AddonCalendarWeekDaysTranslationKeys[]; - weeks: AddonCalendarWeek[]; - needsRefresh?: boolean; // Whether the events needs to be re-loaded. -}; diff --git a/src/addons/mod/assign/classes/submissions-source.ts b/src/addons/mod/assign/classes/submissions-source.ts index b0905d5fe..a1c36e70c 100644 --- a/src/addons/mod/assign/classes/submissions-source.ts +++ b/src/addons/mod/assign/classes/submissions-source.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Params } from '@angular/router'; -import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; +import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; import { CoreGroupInfo, CoreGroups } from '@services/groups'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; @@ -33,7 +33,7 @@ import { AddonModAssignSync, AddonModAssignSyncProvider } from '../services/assi /** * Provides a collection of assignment submissions. */ -export class AddonModAssignSubmissionsSource extends CoreItemsManagerSource { +export class AddonModAssignSubmissionsSource extends CoreRoutedItemsManagerSource { /** * @inheritdoc 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 c8cb848f9..e0b09e3b2 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,8 +13,8 @@ // limitations under the License. import { Component, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; -import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { IonRefresher } from '@ionic/angular'; import { CoreGroupInfo } from '@services/groups'; @@ -86,7 +86,7 @@ export class AddonModAssignSubmissionListPage implements AfterViewInit, OnDestro const courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); const groupId = CoreNavigator.getRouteNumberParam('groupId') || 0; const selectedStatus = CoreNavigator.getRouteParam('status'); - const submissionsSource = CoreItemsManagerSourcesTracker.getOrCreateSource( + const submissionsSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModAssignSubmissionsSource, [courseId, moduleId, selectedStatus], ); 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 015c73425..7f2e278cd 100644 --- a/src/addons/mod/assign/pages/submission-review/submission-review.ts +++ b/src/addons/mod/assign/pages/submission-review/submission-review.ts @@ -14,7 +14,7 @@ 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 { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-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'; @@ -64,7 +64,7 @@ export class AddonModAssignSubmissionReviewPage implements OnInit, OnDestroy, Ca this.blindId = CoreNavigator.getRouteNumberParam('blindId', { params }); const groupId = CoreNavigator.getRequiredRouteNumberParam('groupId'); const selectedStatus = CoreNavigator.getRouteParam('selectedStatus'); - const submissionsSource = CoreItemsManagerSourcesTracker.getOrCreateSource( + const submissionsSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModAssignSubmissionsSource, [this.courseId, this.moduleId, selectedStatus], ); diff --git a/src/addons/mod/book/components/index/addon-mod-book-index.html b/src/addons/mod/book/components/index/addon-mod-book-index.html index e85705f95..83787d103 100644 --- a/src/addons/mod/book/components/index/addon-mod-book-index.html +++ b/src/addons/mod/book/components/index/addon-mod-book-index.html @@ -40,18 +40,18 @@ previousTranslate="addon.mod_book.navprevtitle" nextTranslate="addon.mod_book.navnexttitle" (action)="changeChapter($event.id)"> - - + +
- -
+
{{ 'core.tag.tags' | translate }}: - +
- - + +
diff --git a/src/addons/mod/book/components/index/index.ts b/src/addons/mod/book/components/index/index.ts index 34ac81fa0..4c9f35cbd 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Component, Optional, Input, OnInit, ViewChild, ElementRef } from '@angular/core'; -import { IonContent, IonSlides } from '@ionic/angular'; import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/main-resource-component'; import { AddonModBookProvider, @@ -29,10 +28,13 @@ import { CoreCourseContentsPage } from '@features/course/pages/contents/contents import { CoreUtils } from '@services/utils/utils'; import { CoreCourse } from '@features/course/services/course'; import { AddonModBookTocComponent } from '../toc/toc'; -import { CoreConstants } from '@/core/constants'; import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar'; import { CoreError } from '@classes/errors/error'; import { Translate } from '@singletons'; +import { CoreSwipeCurrentItemData, CoreSwipeSlidesComponent, CoreSwipeSlidesOptions } from '@components/swipe-slides/swipe-slides'; +import { CoreSwipeSlidesItemsManagerSource } from '@classes/items-management/slides-items-manager-source'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreSwipeSlidesItemsManager } from '@classes/items-management/slides-items-manager'; /** * Component that displays a book. @@ -40,38 +42,32 @@ import { Translate } from '@singletons'; @Component({ selector: 'addon-mod-book-index', templateUrl: 'addon-mod-book-index.html', - styleUrls: ['index.scss'], }) export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit { - @ViewChild(IonSlides) slides?: IonSlides; + @ViewChild(CoreSwipeSlidesComponent) slides?: CoreSwipeSlidesComponent; @Input() initialChapterId?: number; // The initial chapter ID to load. component = AddonModBookProvider.COMPONENT; - loadedChapters: LoadedChapter[] = []; + manager?: CoreSwipeSlidesItemsManager; previousChapter?: AddonModBookTocChapter; nextChapter?: AddonModBookTocChapter; tagsEnabled = false; warning = ''; - tags?: CoreTagItem[]; displayNavBar = true; navigationItems: CoreNavigationBarItem[] = []; displayTitlesInNavBar = false; - slidesOpts = { - initialSlide: 0, + slidesOpts: CoreSwipeSlidesOptions = { autoHeight: true, + scrollOnChange: 'top', }; - protected chapters: AddonModBookTocChapter[] = []; protected currentChapter?: number; - protected book?: AddonModBookBookWSData; - protected contentsMap: AddonModBookContentsMap = {}; protected element: HTMLElement; constructor( elementRef: ElementRef, - protected content?: IonContent, @Optional() courseContentsPage?: CoreCourseContentsPage, ) { super('AddonModBookIndexComponent', courseContentsPage); @@ -86,9 +82,25 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp super.ngOnInit(); this.tagsEnabled = CoreTag.areTagsAvailableInSite(); + const source = new AddonModBookSlidesItemsManagerSource( + this.courseId, + this.module, + this.tagsEnabled, + this.initialChapterId, + ); + this.manager = new CoreSwipeSlidesItemsManager(source); + this.loadContent(); } + get book(): AddonModBookBookWSData | undefined { + return this.manager?.getSource().book; + } + + get chapters(): AddonModBookTocChapter[] { + return this.manager?.getSource().chapters || []; + } + /** * Show the TOC. */ @@ -121,10 +133,7 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp return; } - const index = this.loadedChapters.findIndex(chapter => chapter.id === chapterId); - if (index > -1) { - this.slides?.slideTo(index); - } + this.slides?.slideToItem({ id: chapterId }); } /** @@ -132,8 +141,8 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp * * @return Resolved when done. */ - protected invalidateContent(): Promise { - return AddonModBook.invalidateContent(this.module.id, this.courseId); + protected async invalidateContent(): Promise { + await this.manager?.getSource().invalidateContent(); } /** @@ -144,100 +153,35 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp */ protected async fetchContent(refresh = false): Promise { try { - const downloadResult = await this.downloadResourceIfNeeded(refresh); - - await this.loadBookData(); - - // Get contents. No need to refresh, it has been done in downloadResourceIfNeeded. - const contents = await CoreCourse.getModuleContents(this.module, this.courseId); - - this.contentsMap = AddonModBook.getContentsMap(contents); - this.chapters = AddonModBook.getTocList(contents); - - if (typeof this.currentChapter == 'undefined' && typeof this.initialChapterId != 'undefined' && this.chapters) { - // Initial chapter set. Validate that the chapter exists. - const index = this.chapters.findIndex((chapter) => chapter.id == this.initialChapterId); - - if (index >= 0) { - this.currentChapter = this.initialChapterId; - this.slidesOpts.initialSlide = index; - } - } - - if (this.currentChapter === undefined) { - // Load the first chapter. - this.currentChapter = AddonModBook.getFirstChapter(this.chapters); - } - - if (this.currentChapter === undefined) { + const source = this.manager?.getSource(); + if (!source) { return; } - await this.loadChapters(); + const downloadResult = await this.downloadResourceIfNeeded(refresh); - // Show chapter. - await this.viewChapter(this.currentChapter, refresh); + const book = await source.loadBookData(); - this.warning = downloadResult?.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error!) : ''; + if (book) { + this.dataRetrieved.emit(book); + + this.description = book.intro; + this.displayNavBar = book.navstyle != AddonModBookNavStyle.TOC_ONLY; + this.displayTitlesInNavBar = book.navstyle == AddonModBookNavStyle.TEXT; + } + + // Get contents. No need to refresh, it has been done in downloadResourceIfNeeded. + await source.loadContents(); + + await source.load(); + + this.warning = downloadResult?.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error || '') : ''; } finally { // Pass false because downloadResourceIfNeeded already invalidates and refresh data if refresh=true. this.fillContextMenu(false); } } - /** - * Load book data from WS. - * - * @return Promise resolved when done. - */ - protected async loadBookData(): Promise { - this.book = await AddonModBook.getBook(this.courseId, this.module.id); - - this.dataRetrieved.emit(this.book); - - this.description = this.book.intro; - this.displayNavBar = this.book.navstyle != AddonModBookNavStyle.TOC_ONLY; - this.displayTitlesInNavBar = this.book.navstyle == AddonModBookNavStyle.TEXT; - } - - /** - * Load book chapters. - * - * @return Promise resolved when done. - */ - protected async loadChapters(): Promise { - try { - const newChapters = await Promise.all(this.chapters.map(async (chapter) => { - const content = await AddonModBook.getChapterContent(this.contentsMap, chapter.id, this.module.id); - - return { - id: chapter.id, - content, - tags: this.tagsEnabled ? this.contentsMap[chapter.id].tags : [], - }; - })); - - let newIndex = -1; - if (this.loadedChapters.length && newChapters.length != this.loadedChapters.length) { - // Number of chapters has changed. Search the chapter to display, otherwise it could change automatically. - newIndex = this.chapters.findIndex((chapter) => chapter.id === this.currentChapter); - } - - this.loadedChapters = newChapters; - - if (newIndex > -1) { - this.slides?.slideTo(newIndex, 0, false); - } - } catch (exception) { - const error = exception ?? new CoreError(Translate.instant('addon.mod_book.errorchapter')); - if (!error.message) { - error.message = Translate.instant('addon.mod_book.errorchapter'); - } - - throw error; - } - } - /** * View a book chapter. * @@ -271,24 +215,10 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp /** * Slide has changed. * - * @return Promise resolved when done. + * @param data Data about new item. */ - async slideChanged(): Promise { - if (!this.slides) { - return; - } - - const scrollElement = await this.content?.getScrollElement(); - const container = this.element.querySelector('.addon-mod_book-container'); - - if (container && (!scrollElement || CoreDomUtils.isElementOutsideOfScreen(scrollElement, container, 'top'))) { - // Scroll to top. - container.scrollIntoView({ behavior: 'smooth' }); - } - - const index = await this.slides.getActiveIndex(); - - this.viewChapter(this.loadedChapters[index].id, true); + slideChanged(data: CoreSwipeCurrentItemData): void { + this.viewChapter(data.item.id, true); } /** @@ -313,3 +243,90 @@ type LoadedChapter = { content: string; tags?: CoreTagItem[]; }; + +/** + * Helper to manage swiping within a collection of chapters. + */ +class AddonModBookSlidesItemsManagerSource extends CoreSwipeSlidesItemsManagerSource { + + readonly COURSE_ID: number; + readonly MODULE: CoreCourseModule; + readonly TAGS_ENABLED: boolean; + + book?: AddonModBookBookWSData; + chapters: AddonModBookTocChapter[] = []; + contentsMap: AddonModBookContentsMap = {}; + + constructor(courseId: number, module: CoreCourseModule, tagsEnabled: boolean, initialChapterId?: number) { + super(initialChapterId ? { id: initialChapterId } : undefined); + + this.COURSE_ID = courseId; + this.MODULE = module; + this.TAGS_ENABLED = tagsEnabled; + } + + /** + * @inheritdoc + */ + getItemId(item: LoadedChapter): string | number { + return item.id; + } + + /** + * Load book data from WS. + * + * @return Promise resolved when done. + */ + async loadBookData(): Promise { + this.book = await AddonModBook.getBook(this.COURSE_ID, this.MODULE.id); + + return this.book; + } + + /** + * Load module contents. + */ + async loadContents(): Promise { + // Get contents. No need to refresh, it has been done in downloadResourceIfNeeded. + const contents = await CoreCourse.getModuleContents(this.MODULE, this.COURSE_ID); + + this.contentsMap = AddonModBook.getContentsMap(contents); + this.chapters = AddonModBook.getTocList(contents); + } + + /** + * @inheritdoc + */ + protected async loadItems(): Promise { + try { + const newChapters = await Promise.all(this.chapters.map(async (chapter) => { + const content = await AddonModBook.getChapterContent(this.contentsMap, chapter.id, this.MODULE.id); + + return { + id: chapter.id, + content, + tags: this.TAGS_ENABLED ? this.contentsMap[chapter.id].tags : [], + }; + })); + + return newChapters; + } catch (exception) { + const error = exception ?? new CoreError(Translate.instant('addon.mod_book.errorchapter')); + if (!error.message) { + error.message = Translate.instant('addon.mod_book.errorchapter'); + } + + throw error; + } + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + invalidateContent(): Promise { + return AddonModBook.invalidateContent(this.MODULE.id, this.COURSE_ID); + } + +} diff --git a/src/addons/mod/forum/classes/forum-discussions-source.ts b/src/addons/mod/forum/classes/forum-discussions-source.ts index 7a08b4e8d..26bd22967 100644 --- a/src/addons/mod/forum/classes/forum-discussions-source.ts +++ b/src/addons/mod/forum/classes/forum-discussions-source.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Params } from '@angular/router'; -import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; +import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; import { CoreUser } from '@features/user/services/user'; import { AddonModForum, @@ -24,7 +24,7 @@ import { } from '../services/forum'; import { AddonModForumOffline, AddonModForumOfflineDiscussion } from '../services/forum-offline'; -export class AddonModForumDiscussionsSource extends CoreItemsManagerSource { +export class AddonModForumDiscussionsSource extends CoreRoutedItemsManagerSource { static readonly NEW_DISCUSSION: AddonModForumNewDiscussionForm = { newDiscussion: true }; diff --git a/src/addons/mod/forum/components/index/index.ts b/src/addons/mod/forum/components/index/index.ts index e7a778d3b..696f762da 100644 --- a/src/addons/mod/forum/components/index/index.ts +++ b/src/addons/mod/forum/components/index/index.ts @@ -57,7 +57,7 @@ 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'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; /** * Component that displays a forum entry page. @@ -149,7 +149,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom await super.ngOnInit(); // Initialize discussions manager. - const source = CoreItemsManagerSourcesTracker.getOrCreateSource( + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModForumDiscussionsSource, [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModForumModuleHandlerService.PAGE_NAME}/` : ''], ); diff --git a/src/addons/mod/forum/pages/discussion/discussion.page.ts b/src/addons/mod/forum/pages/discussion/discussion.page.ts index 103b9355b..0b6428ae0 100644 --- a/src/addons/mod/forum/pages/discussion/discussion.page.ts +++ b/src/addons/mod/forum/pages/discussion/discussion.page.ts @@ -15,7 +15,7 @@ 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 { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-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'; @@ -145,7 +145,7 @@ export class AddonModForumDiscussionPage implements OnInit, AfterViewInit, OnDes if (this.courseId && this.cmId && (routeData.swipeEnabled ?? true)) { this.discussions = new AddonModForumDiscussionDiscussionsSwipeManager( - CoreItemsManagerSourcesTracker.getOrCreateSource( + CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModForumDiscussionsSource, [this.courseId, this.cmId, routeData.discussionsPathPrefix ?? ''], ), 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 7e9efa79a..699eaf7ec 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 @@ -42,8 +42,8 @@ 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'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; type NewDiscussionData = { subject: string; @@ -117,7 +117,7 @@ export class AddonModForumNewDiscussionPage implements OnInit, OnDestroy, CanLea this.timeCreated = CoreNavigator.getRequiredRouteNumberParam('timeCreated'); if (this.timeCreated !== 0 && (routeData.swipeEnabled ?? true)) { - const source = CoreItemsManagerSourcesTracker.getOrCreateSource( + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModForumDiscussionsSource, [this.courseId, this.cmId, routeData.discussionsPathPrefix ?? ''], ); diff --git a/src/addons/mod/glossary/classes/glossary-entries-source.ts b/src/addons/mod/glossary/classes/glossary-entries-source.ts index 42fbe67c5..d1dd67b5c 100644 --- a/src/addons/mod/glossary/classes/glossary-entries-source.ts +++ b/src/addons/mod/glossary/classes/glossary-entries-source.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Params } from '@angular/router'; -import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; +import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; import { AddonModGlossary, AddonModGlossaryEntry, @@ -27,7 +27,7 @@ import { AddonModGlossaryOffline, AddonModGlossaryOfflineEntry } from '../servic /** * Provides a collection of glossary entries. */ -export class AddonModGlossaryEntriesSource extends CoreItemsManagerSource { +export class AddonModGlossaryEntriesSource extends CoreRoutedItemsManagerSource { static readonly NEW_ENTRY: AddonModGlossaryNewEntryForm = { newEntry: true }; diff --git a/src/addons/mod/glossary/components/index/index.ts b/src/addons/mod/glossary/components/index/index.ts index 6f250813b..7599c7afc 100644 --- a/src/addons/mod/glossary/components/index/index.ts +++ b/src/addons/mod/glossary/components/index/index.ts @@ -15,8 +15,8 @@ import { ContextLevel } from '@/core/constants'; import { AfterViewInit, Component, OnDestroy, OnInit, Optional, ViewChild } from '@angular/core'; 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 { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; 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'; @@ -113,7 +113,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity await super.ngOnInit(); // Initialize entries manager. - const source = CoreItemsManagerSourcesTracker.getOrCreateSource( + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModGlossaryEntriesSource, [this.courseId, this.module.id, this.courseContentsPage ? `${AddonModGlossaryModuleHandlerService.PAGE_NAME}/` : ''], ); diff --git a/src/addons/mod/glossary/pages/edit/edit.ts b/src/addons/mod/glossary/pages/edit/edit.ts index dfb06aa5d..2edc0d440 100644 --- a/src/addons/mod/glossary/pages/edit/edit.ts +++ b/src/addons/mod/glossary/pages/edit/edit.ts @@ -16,7 +16,7 @@ import { Component, OnInit, ViewChild, ElementRef, Optional, OnDestroy } from '@ 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 { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-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'; @@ -101,7 +101,7 @@ export class AddonModGlossaryEditPage implements OnInit, OnDestroy, CanLeave { this.editorExtraParams.timecreated = this.timecreated; if (this.timecreated !== 0 && (routeData.swipeEnabled ?? true)) { - const source = CoreItemsManagerSourcesTracker.getOrCreateSource( + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModGlossaryEntriesSource, [this.courseId, this.cmId, routeData.glossaryPathPrefix ?? ''], ); diff --git a/src/addons/mod/glossary/pages/entry/entry.ts b/src/addons/mod/glossary/pages/entry/entry.ts index d88b470c1..41dfb3b8c 100644 --- a/src/addons/mod/glossary/pages/entry/entry.ts +++ b/src/addons/mod/glossary/pages/entry/entry.ts @@ -14,7 +14,7 @@ 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 { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-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'; @@ -73,7 +73,7 @@ export class AddonModGlossaryEntryPage implements OnInit, OnDestroy { if (routeData.swipeEnabled ?? true) { const cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); - const source = CoreItemsManagerSourcesTracker.getOrCreateSource( + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( AddonModGlossaryEntriesSource, [this.courseId, cmId, routeData.glossaryPathPrefix ?? ''], ); diff --git a/src/core/classes/items-management/items-manager-source.ts b/src/core/classes/items-management/items-manager-source.ts index 29acf445f..c6b8fd275 100644 --- a/src/core/classes/items-management/items-manager-source.ts +++ b/src/core/classes/items-management/items-manager-source.ts @@ -12,13 +12,11 @@ // 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; + onItemsUpdated?(items: Item[]): void; onReset?(): void; } @@ -27,39 +25,19 @@ export interface CoreItemsListSourceListener { */ export abstract class CoreItemsManagerSource { - /** - * Get a string to identify instances constructed with the given arguments as being reusable. - * - * @param args Constructor arguments. - * @returns Id. - */ - static getSourceId(...args: unknown[]): string { - return args.map(argument => String(argument)).join('-'); - } - protected items: Item[] | null = null; - protected hasMoreItems = true; protected listeners: CoreItemsListSourceListener[] = []; protected dirty = false; /** - * Check whether any page has been loaded. + * Check whether any item has been loaded. * - * @returns Whether any page has been loaded. + * @returns Whether any item has been loaded. */ isLoaded(): boolean { return this.items !== null; } - /** - * Check whether there are more pages to be loaded. - * - * @return Whether there are more pages to be loaded. - */ - isCompleted(): boolean { - return !this.hasMoreItems; - } - /** * Set whether the source as dirty. * @@ -80,35 +58,21 @@ export abstract class CoreItemsManagerSource { return this.items; } - /** - * Get the count of pages that have been loaded. - * - * @returns Pages loaded. - */ - getPagesLoaded(): number { - if (this.items === null) { - return 0; - } - - const pageLength = this.getPageLength(); - if (pageLength === null) { - return 1; - } - - return Math.ceil(this.items.length / pageLength); - } - /** * Reset collection data. */ reset(): void { this.items = null; - this.hasMoreItems = true; this.dirty = false; this.listeners.forEach(listener => listener.onReset?.call(listener)); } + /** + * Load items. + */ + abstract load(): Promise; + /** * Register a listener. * @@ -136,85 +100,22 @@ export abstract class CoreItemsManagerSource { this.listeners.splice(index, 1); } - /** - * Reload the collection, this resets the data to the first page. - */ - async reload(): Promise { - const { items, hasMoreItems } = await this.loadPageItems(0); - - this.dirty = false; - this.setItems(items, hasMoreItems ?? false); - } - - /** - * Load more items, if any. - */ - 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 ?? 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 }>; - - /** - * Get the length of each page in the collection. - * - * @return Page length; null for collections that don't support pagination. - */ - protected getPageLength(): number | null { - return null; - } - /** * Update the collection items. * * @param items Items. - * @param hasMoreItems Whether there are more pages to be loaded. */ - protected setItems(items: Item[], hasMoreItems: boolean): void { + protected setItems(items: Item[]): void { this.items = items; - this.hasMoreItems = hasMoreItems; - this.listeners.forEach(listener => listener.onItemsUpdated?.call(listener, items, hasMoreItems)); + this.notifyItemsUpdated(); + } + + /** + * Notify that items have been updated. + */ + protected notifyItemsUpdated(): void { + this.listeners.forEach(listener => listener.onItemsUpdated?.call(listener, this.items)); } } diff --git a/src/core/classes/items-management/items-manager.ts b/src/core/classes/items-management/items-manager.ts index f2be8bd67..dda251c1e 100644 --- a/src/core/classes/items-management/items-manager.ts +++ b/src/core/classes/items-management/items-manager.ts @@ -12,16 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; -import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; - import { CoreItemsManagerSource } from './items-manager-source'; -import { CoreItemsManagerSourcesTracker } from './items-manager-sources-tracker'; /** * Helper to manage a collection of items in a page. */ -export abstract class CoreItemsManager = CoreItemsManagerSource> { +export abstract class CoreItemsManager< + Item = unknown, + Source extends CoreItemsManagerSource = CoreItemsManagerSource, +> { protected source?: { instance: Source; unsubscribe: () => void }; protected itemsMap: Record | null = null; @@ -51,8 +50,6 @@ export abstract class CoreItemsManager = {}, - ): Promise { - // Get current route in the page. - const route = this.getCurrentPageRoute(); - - if (route === null) { - return; - } - - // If this item is already selected, do nothing. - const itemPath = this.getSource().getItemPath(item); - const selectedItemPath = this.getSelectedItemPath(route.snapshot); - - if (selectedItemPath === itemPath) { - return; - } - - // Navigate to item. - const params = this.getSource().getItemQueryParams(item); - const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; - - await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options }); + setSelectedItem(item: Item | null): void { + this.selectedItem = item; } /** @@ -166,14 +103,9 @@ export abstract class CoreItemsManager { - map[this.getSource().getItemPath(item)] = item; - - return map; - }, {}); - - this.updateSelectedItem(); + // Nothing to do. } /** diff --git a/src/core/classes/items-management/list-items-manager.ts b/src/core/classes/items-management/list-items-manager.ts index baacfdefc..9229b1de4 100644 --- a/src/core/classes/items-management/list-items-manager.ts +++ b/src/core/classes/items-management/list-items-manager.ts @@ -20,16 +20,16 @@ import { CoreNavigator } from '@services/navigator'; import { CoreScreen } from '@services/screen'; import { CoreUtils } from '@services/utils/utils'; -import { CoreItemsManager } from './items-manager'; -import { CoreItemsManagerSource } from './items-manager-source'; +import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; +import { CoreRoutedItemsManager } from './routed-items-manager'; /** * Helper class to manage the state and routing of a list of items in a page. */ export class CoreListItemsManager< Item = unknown, - Source extends CoreItemsManagerSource = CoreItemsManagerSource -> extends CoreItemsManager { + Source extends CoreRoutedItemsManagerSource = CoreRoutedItemsManagerSource +> extends CoreRoutedItemsManager { protected pageRouteLocator?: unknown | ActivatedRoute; protected splitView?: CoreSplitViewComponent; diff --git a/src/core/classes/items-management/routed-items-manager-source.ts b/src/core/classes/items-management/routed-items-manager-source.ts new file mode 100644 index 000000000..6b53c913a --- /dev/null +++ b/src/core/classes/items-management/routed-items-manager-source.ts @@ -0,0 +1,150 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Params } from '@angular/router'; +import { CoreItemsManagerSource } from './items-manager-source'; + +/** + * Routed items collection source data. + */ +export abstract class CoreRoutedItemsManagerSource extends CoreItemsManagerSource { + + protected hasMoreItems = true; + + /** + * Get a string to identify instances constructed with the given arguments as being reusable. + * + * @param args Constructor arguments. + * @returns Id. + */ + static getSourceId(...args: unknown[]): string { + return args.map(argument => String(argument)).join('-'); + } + + /** + * Check whether there are more pages to be loaded. + * + * @return Whether there are more pages to be loaded. + */ + isCompleted(): boolean { + return !this.hasMoreItems; + } + + /** + * Get the count of pages that have been loaded. + * + * @returns Pages loaded. + */ + getPagesLoaded(): number { + if (this.items === null) { + return 0; + } + + const pageLength = this.getPageLength(); + if (pageLength === null) { + return 1; + } + + return Math.ceil(this.items.length / pageLength); + } + + /** + * Reset collection data. + */ + reset(): void { + this.hasMoreItems = true; + + super.reset(); + } + + /** + * Reload the collection, this resets the data to the first page. + */ + async reload(): Promise { + this.dirty = true; + + await this.load(); + } + + /** + * Load more items, if any. + */ + 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 ?? false); + } + + /** + * Load page items. + * + * @param page Page number (starting at 0). + * @return Page items data. + */ + protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>; + + /** + * Get the length of each page in the collection. + * + * @return Page length; null for collections that don't support pagination. + */ + protected getPageLength(): number | null { + return null; + } + + /** + * Update the collection items. + * + * @param items Items. + * @param hasMoreItems Whether there are more pages to be loaded. + */ + protected setItems(items: Item[], hasMoreItems = false): void { + this.hasMoreItems = hasMoreItems; + + super.setItems(items); + } + + /** + * 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; + +} diff --git a/src/core/classes/items-management/items-manager-sources-tracker.ts b/src/core/classes/items-management/routed-items-manager-sources-tracker.ts similarity index 83% rename from src/core/classes/items-management/items-manager-sources-tracker.ts rename to src/core/classes/items-management/routed-items-manager-sources-tracker.ts index e3c2f16b9..6d0e0b740 100644 --- a/src/core/classes/items-management/items-manager-sources-tracker.ts +++ b/src/core/classes/items-management/routed-items-manager-sources-tracker.ts @@ -12,23 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreItemsManagerSource } from './items-manager-source'; +import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; -type SourceConstructor = { +type SourceConstructor = { getSourceId(...args: unknown[]): string; new (...args: unknown[]): T; }; type SourceConstuctorInstance = T extends { new(...args: unknown[]): infer P } ? P : never; -type InstanceTracking = { instance: CoreItemsManagerSource; references: unknown[] }; +type InstanceTracking = { instance: CoreRoutedItemsManagerSource; references: unknown[] }; type Instances = Record; /** - * Tracks CoreItemsManagerSource instances to reuse between pages. + * Tracks CoreRoutedItemsManagerSource instances to reuse between pages. */ -export class CoreItemsManagerSourcesTracker { +export class CoreRoutedItemsManagerSourcesTracker { private static instances: WeakMap = new WeakMap(); - private static instanceIds: WeakMap = new WeakMap(); + private static instanceIds: WeakMap = new WeakMap(); /** * Create an instance of the given source or retrieve one if it's already in use. @@ -37,7 +37,7 @@ export class CoreItemsManagerSourcesTracker { * @param constructorArguments Arguments to create a new instance, used to find out if an instance already exists. * @returns Source. */ - static getOrCreateSource>( + static getOrCreateSource>( constructor: C, constructorArguments: ConstructorParameters, ): SourceConstuctorInstance { @@ -54,7 +54,7 @@ export class CoreItemsManagerSourcesTracker { * @param source Source. * @param reference Object referncing this source. */ - static addReference(source: CoreItemsManagerSource, reference: unknown): void { + static addReference(source: CoreRoutedItemsManagerSource, reference: unknown): void { const constructorInstances = this.getConstructorInstances(source.constructor as SourceConstructor); const instanceId = this.instanceIds.get(source); @@ -78,7 +78,7 @@ export class CoreItemsManagerSourcesTracker { * @param source Source. * @param reference Object that was referncing this source. */ - static removeReference(source: CoreItemsManagerSource, reference: unknown): void { + static removeReference(source: CoreRoutedItemsManagerSource, reference: unknown): void { const constructorInstances = this.instances.get(source.constructor as SourceConstructor); const instanceId = this.instanceIds.get(source); const index = constructorInstances?.[instanceId ?? '']?.references.indexOf(reference) ?? -1; @@ -127,7 +127,7 @@ export class CoreItemsManagerSourcesTracker { * @param constructorArguments Source constructor arguments. * @returns Source instance. */ - private static createInstance( + private static createInstance( id: string, constructor: SourceConstructor, constructorArguments: ConstructorParameters>, diff --git a/src/core/classes/items-management/routed-items-manager.ts b/src/core/classes/items-management/routed-items-manager.ts new file mode 100644 index 000000000..fe8aa8d38 --- /dev/null +++ b/src/core/classes/items-management/routed-items-manager.ts @@ -0,0 +1,135 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { CoreItemsManager } from './items-manager'; + +import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; +import { CoreRoutedItemsManagerSourcesTracker } from './routed-items-manager-sources-tracker'; + +/** + * Helper to manage a collection of items in a page. + */ +export abstract class CoreRoutedItemsManager< + Item = unknown, + Source extends CoreRoutedItemsManagerSource = CoreRoutedItemsManagerSource, +> extends CoreItemsManager { + + /** + * @inheritdoc + */ + setSource(newSource: Source | null): void { + if (this.source) { + CoreRoutedItemsManagerSourcesTracker.removeReference(this.source.instance, this); + } + + if (newSource) { + CoreRoutedItemsManagerSourcesTracker.addReference(newSource, this); + } + + super.setSource(newSource); + } + + /** + * Get page route. + * + * @returns Current page route, if any. + */ + protected abstract getCurrentPageRoute(): ActivatedRoute | null; + + /** + * 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 getSelectedItemPathFromRoute(route: ActivatedRouteSnapshot): string | null; + + /** + * Get the path of the selected item. + * + * @param route Page route, if any. + * @return Path of the selected item. + */ + protected getSelectedItemPath(route?: ActivatedRouteSnapshot | null): string | null { + if (!route) { + return null; + } + + return this.getSelectedItemPathFromRoute(route); + } + + /** + * Update the selected item given the current route. + * + * @param route Current route. + */ + protected updateSelectedItem(route: ActivatedRouteSnapshot | null = null): void { + route = route ?? this.getCurrentPageRoute()?.snapshot ?? null; + + const selectedItemPath = this.getSelectedItemPath(route); + + const selectedItem = selectedItemPath + ? this.itemsMap?.[selectedItemPath] ?? null + : null; + this.setSelectedItem(selectedItem); + } + + /** + * Navigate to an item in the collection. + * + * @param item Item. + * @param options Navigation options. + */ + protected async navigateToItem( + item: Item, + options: Pick = {}, + ): Promise { + // Get current route in the page. + const route = this.getCurrentPageRoute(); + + if (route === null) { + return; + } + + // If this item is already selected, do nothing. + const itemPath = this.getSource().getItemPath(item); + const selectedItemPath = this.getSelectedItemPath(route.snapshot); + + if (selectedItemPath === itemPath) { + return; + } + + // Navigate to item. + const params = this.getSource().getItemQueryParams(item); + const pathPrefix = selectedItemPath ? selectedItemPath.split('/').fill('../').join('') : ''; + + await CoreNavigator.navigate(pathPrefix + itemPath, { params, ...options }); + } + + /** + * @inheritdoc + */ + protected onSourceItemsUpdated(items: Item[]): void { + this.itemsMap = items.reduce((map, item) => { + map[this.getSource().getItemPath(item)] = item; + + return map; + }, {}); + + this.updateSelectedItem(); + } + +} diff --git a/src/core/classes/items-management/slides-dynamic-items-manager-source.ts b/src/core/classes/items-management/slides-dynamic-items-manager-source.ts new file mode 100644 index 000000000..9422f7514 --- /dev/null +++ b/src/core/classes/items-management/slides-dynamic-items-manager-source.ts @@ -0,0 +1,141 @@ +// (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 { CoreSwipeSlidesItemsManagerSource } from './slides-items-manager-source'; + +/** + * Items collection source data for "swipe slides". + */ +export abstract class CoreSwipeSlidesDynamicItemsManagerSource extends CoreSwipeSlidesItemsManagerSource { + + /** + * @inheritdoc + */ + async load(): Promise { + if (this.initialItem) { + // Load the initial item. + await this.loadItem(this.initialItem); + } + + this.setInitialized(); + } + + /** + * @inheritdoc + */ + protected async loadItems(): Promise { + // Not used in dynamic slides. + return []; + } + + /** + * Load a certain item and preload next and previous ones. + * + * @param item Basic data about the item to load. + * @return Promise resolved when done. + */ + async loadItem(item: Partial): Promise { + const previousItem = this.getPreviousItem(item); + const nextItem = this.getNextItem(item); + + await Promise.all([ + this.loadItemInList(item, false), + previousItem ? this.loadItemInList(previousItem, true) : undefined, + nextItem ? this.loadItemInList(nextItem, true) : undefined, + ]); + } + + /** + * Load or preload a certain item and add it to the list. + * + * @param item Basic data about the item to load. + * @param preload Whether to preload. + * @return Promise resolved when done. + */ + async loadItemInList(item: Partial, preload = false): Promise { + const preloadedItem = await this.loadItemData(item, preload); + if (!preloadedItem) { + return; + } + + // Add the item at the right position. + const existingItem = this.getItem(item); + if (existingItem) { + // Already in list, no need to add it. + return; + } + + if (!this.items) { + this.items = []; + } + + const previousItem = this.getPreviousItem(item); + const nextItem = this.getNextItem(item); + const previousItemId = previousItem ? this.getItemId(previousItem) : null; + const nextItemId = nextItem ? this.getItemId(nextItem) : null; + + const added = this.items.some((item, index) => { + const itemId = this.getItemId(item); + let positionToInsert = -1; + if (itemId === previousItemId) { + // Previous item found, add the item after it. + positionToInsert = index + 1; + } + + if (itemId === nextItemId) { + // Next item found, add the item before it. + positionToInsert = index; + } + + if (positionToInsert > -1) { + this.items?.splice(positionToInsert, 0, preloadedItem); + + return true; + } + }); + + if (!added) { + // Previous and next items not found, this probably means the array is still empty. Add it at the end. + this.items.push(preloadedItem); + } + + this.notifyItemsUpdated(); + } + + /** + * Load or preload a certain item data. + * + * @param item Basic data about the item to load. + * @param preload Whether to preload. + * @return Promise resolved with item. Resolve with null if item is not valid (e.g. there are no more items). + */ + abstract loadItemData(item: Partial, preload: boolean): Promise; + + /** + * Return the data to identify the previous item. + * + * @param item Data about the item. + * @return Previous item data. Null if no previous item. + */ + abstract getPreviousItem(item: Partial): Partial | null; + + /** + * Return the data to identify the next item. + * + * @param item Data about the item. + * @return Next item data. Null if no next item. + */ + abstract getNextItem(item: Partial): Partial | null; + +} diff --git a/src/core/classes/items-management/slides-dynamic-items-manager.ts b/src/core/classes/items-management/slides-dynamic-items-manager.ts new file mode 100644 index 000000000..cacae1282 --- /dev/null +++ b/src/core/classes/items-management/slides-dynamic-items-manager.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSwipeSlidesDynamicItemsManagerSource } from './slides-dynamic-items-manager-source'; +import { CoreSwipeSlidesItemsManager } from './slides-items-manager'; + +/** + * Helper class to manage items for core-swipe-slides. + */ +export class CoreSwipeSlidesDynamicItemsManager< + Item = unknown, + Source extends CoreSwipeSlidesDynamicItemsManagerSource = CoreSwipeSlidesDynamicItemsManagerSource, +> extends CoreSwipeSlidesItemsManager { + + /** + * @inheritdoc + */ + setSelectedItem(item: Item | null): void { + super.setSelectedItem(item); + + if (item) { + // Load the item if not loaded yet. + this.getSource().loadItem(item); + } + } + +} diff --git a/src/core/classes/items-management/slides-items-manager-source.ts b/src/core/classes/items-management/slides-items-manager-source.ts new file mode 100644 index 000000000..e45c9d5b9 --- /dev/null +++ b/src/core/classes/items-management/slides-items-manager-source.ts @@ -0,0 +1,121 @@ +// (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 { CoreItemsManagerSource } from './items-manager-source'; + +/** + * Items collection source data for "swipe slides". + */ +export abstract class CoreSwipeSlidesItemsManagerSource extends CoreItemsManagerSource { + + protected initialItem?: Partial; + protected initialized = false; + protected initializePromise: Promise; + protected resolveInitialize!: () => void; + + constructor(initialItem?: Partial) { + super(); + + this.initialItem = initialItem; + this.initializePromise = new Promise(resolve => this.resolveInitialize = resolve); + } + + /** + * @inheritdoc + */ + isLoaded(): boolean { + return this.initialized && super.isLoaded(); + } + + /** + * Return a promise that is resolved when the source is initialized. + * + * @return Promise. + */ + waitForInitialized(): Promise { + return this.initializePromise; + } + + /** + * Mark the source as initialized. + */ + protected setInitialized(): void { + this.initialized = true; + this.resolveInitialize(); + } + + /** + * @inheritdoc + */ + async load(): Promise { + const items = await this.loadItems(); + + this.setItems(items); + this.setInitialized(); + } + + /** + * Load items. + * + * @return Items list. + */ + protected abstract loadItems(): Promise; + + /** + * Get a certain item. + * + * @param item Partial data about the item to search. + * @return Item, null if not found. + */ + getItem(item: Partial): Item | null { + const index = this.getItemPosition(item); + + return this.items?.[index] ?? null; + } + + /** + * Get a certain item position. + * + * @param item Item to search. + * @return Item position, -1 if not found. + */ + getItemPosition(item: Partial): number { + const itemId = this.getItemId(item); + const index = this.items?.findIndex((listItem) => itemId === this.getItemId(listItem)); + + return index ?? -1; + } + + /** + * Get initial item position. + * + * @return Initial item position. + */ + getInitialPosition(): number { + if (!this.initialItem) { + return 0; + } + + return this.getItemPosition(this.initialItem); + } + + /** + * Get the ID of an item. + * + * @param item Data about the item. + * @return Item ID. + */ + abstract getItemId(item: Partial): string | number; + +} diff --git a/src/core/classes/items-management/slides-items-manager.ts b/src/core/classes/items-management/slides-items-manager.ts new file mode 100644 index 000000000..8c01ef571 --- /dev/null +++ b/src/core/classes/items-management/slides-items-manager.ts @@ -0,0 +1,47 @@ +// (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 { CoreItemsManager } from './items-manager'; +import { CoreSwipeSlidesItemsManagerSource } from './slides-items-manager-source'; + +/** + * Helper class to manage items for core-swipe-slides. + */ +export class CoreSwipeSlidesItemsManager< + Item = unknown, + Source extends CoreSwipeSlidesItemsManagerSource = CoreSwipeSlidesItemsManagerSource, +> extends CoreItemsManager { + + /** + * @inheritdoc + */ + protected onSourceItemsUpdated(items: Item[]): void { + this.itemsMap = items.reduce((map, item) => { + map[this.getSource().getItemId(item)] = item; + + return map; + }, {}); + } + + /** + * Get item by ID. + * + * @param id ID + * @return Item, null if not found. + */ + getItemById(id: string): Item | null { + return this.itemsMap?.[id] ?? null; + } + +} diff --git a/src/core/classes/items-management/swipe-items-manager.ts b/src/core/classes/items-management/swipe-items-manager.ts index 918cab741..0290206ef 100644 --- a/src/core/classes/items-management/swipe-items-manager.ts +++ b/src/core/classes/items-management/swipe-items-manager.ts @@ -16,17 +16,17 @@ import { ActivatedRoute, ActivatedRouteSnapshot, UrlSegment } from '@angular/rou import { CoreNavigator } from '@services/navigator'; -import { CoreItemsManager } from './items-manager'; -import { CoreItemsManagerSource } from './items-manager-source'; +import { CoreRoutedItemsManager } from './routed-items-manager'; +import { CoreRoutedItemsManagerSource } from './routed-items-manager-source'; /** * Helper class to manage the state and routing of a swipeable page. */ export class CoreSwipeItemsManager< Item = unknown, - Source extends CoreItemsManagerSource = CoreItemsManagerSource + Source extends CoreRoutedItemsManagerSource = CoreRoutedItemsManagerSource > - extends CoreItemsManager { + extends CoreRoutedItemsManager { /** * Process page started operations. diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index d39a0e057..ae31faa41 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -60,6 +60,7 @@ import { CoreComboboxComponent } from './combobox/combobox'; import { CoreSpacerComponent } from './spacer/spacer'; import { CoreHorizontalScrollControlsComponent } from './horizontal-scroll-controls/horizontal-scroll-controls'; import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-with-spinner'; +import { CoreSwipeSlidesComponent } from './swipe-slides/swipe-slides'; @NgModule({ declarations: [ @@ -94,6 +95,7 @@ import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-wit CoreSplitViewComponent, CoreStyleComponent, CoreSwipeNavigationComponent, + CoreSwipeSlidesComponent, CoreTabComponent, CoreTabsComponent, CoreTabsOutletComponent, @@ -143,6 +145,7 @@ import { CoreButtonWithSpinnerComponent } from './button-with-spinner/button-wit CoreSplitViewComponent, CoreStyleComponent, CoreSwipeNavigationComponent, + CoreSwipeSlidesComponent, CoreTabComponent, CoreTabsComponent, CoreTabsOutletComponent, diff --git a/src/core/components/swipe-slides/swipe-slides.html b/src/core/components/swipe-slides/swipe-slides.html new file mode 100644 index 000000000..461366344 --- /dev/null +++ b/src/core/components/swipe-slides/swipe-slides.html @@ -0,0 +1,6 @@ + + + + + diff --git a/src/addons/mod/book/components/index/index.scss b/src/core/components/swipe-slides/swipe-slides.scss similarity index 100% rename from src/addons/mod/book/components/index/index.scss rename to src/core/components/swipe-slides/swipe-slides.scss diff --git a/src/core/components/swipe-slides/swipe-slides.ts b/src/core/components/swipe-slides/swipe-slides.ts new file mode 100644 index 000000000..ac88f6e8e --- /dev/null +++ b/src/core/components/swipe-slides/swipe-slides.ts @@ -0,0 +1,263 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Component, ContentChild, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, TemplateRef, ViewChild, +} from '@angular/core'; +import { CoreSwipeSlidesItemsManager } from '@classes/items-management/slides-items-manager'; +import { IonContent, IonSlides } from '@ionic/angular'; +import { CoreDomUtils } from '@services/utils/dom'; + +/** + * Helper component to display swipable slides. + */ +@Component({ + selector: 'core-swipe-slides', + templateUrl: 'swipe-slides.html', + styleUrls: ['swipe-slides.scss'], +}) +export class CoreSwipeSlidesComponent implements OnChanges, OnDestroy { + + @Input() manager?: CoreSwipeSlidesItemsManager; + @Input() options: CoreSwipeSlidesOptions = {}; + @Output() onInit = new EventEmitter>(); + @Output() onWillChange = new EventEmitter>(); + @Output() onDidChange = new EventEmitter>(); + + @ViewChild(IonSlides) slides?: IonSlides; + @ContentChild(TemplateRef) template?: TemplateRef; // Template defined by the content. + + protected hostElement: HTMLElement; + protected unsubscribe?: () => void; + + constructor( + elementRef: ElementRef, + protected content?: IonContent, + ) { + this.hostElement = elementRef.nativeElement; + } + + /** + * @inheritdoc + */ + ngOnChanges(): void { + if (!this.unsubscribe && this.manager) { + this.initialize(this.manager); + } + } + + /** + * Initialize some properties based on the manager. + */ + protected async initialize(manager: CoreSwipeSlidesItemsManager): Promise { + this.unsubscribe = manager.getSource().addListener({ + onItemsUpdated: () => this.onItemsUpdated(), + }); + + // Don't call default callbacks on init, emit our own events instead. + // This is because default callbacks aren't triggered for position 0, and to prevent auto scroll on init. + this.options.runCallbacksOnInit = false; + + await manager.getSource().waitForInitialized(); + + if (this.options.initialSlide === undefined) { + // Calculate the initial slide. + const position = manager.getSource().getInitialPosition(); + this.options.initialSlide = position > - 1 ? position : 0; + } + + // Emit change events with the initial item. + const items = manager.getSource().getItems(); + if (!items || !items.length) { + return; + } + + // Validate that the initial position is inside the valid range. + let initialPosition = this.options.initialSlide as number; + if (initialPosition < 0) { + initialPosition = 0; + } else if (initialPosition >= items.length) { + initialPosition = items.length - 1; + } + + const initialItemData = { + index: initialPosition, + item: items[initialPosition], + }; + + manager.setSelectedItem(items[initialPosition]); + this.onWillChange.emit(initialItemData); + this.onDidChange.emit(initialItemData); + } + + /** + * Slide to a certain position. + * + * @param index Position. + * @param speed Animation speed. + * @param runCallbacks Whether to run callbacks. + */ + slideToIndex(index: number, speed?: number, runCallbacks?: boolean): void { + this.slides?.slideTo(index, speed, runCallbacks); + } + + /** + * Slide to a certain item. + * + * @param item Item. + * @param speed Animation speed. + * @param runCallbacks Whether to run callbacks. + */ + slideToItem(item: Partial, speed?: number, runCallbacks?: boolean): void { + const index = this.manager?.getSource().getItemPosition(item) ?? -1; + if (index != -1) { + this.slides?.slideTo(index, speed, runCallbacks); + } + } + + /** + * Slide to next slide. + * + * @param speed Animation speed. + * @param runCallbacks Whether to run callbacks. + */ + slideNext(speed?: number, runCallbacks?: boolean): void { + this.slides?.slideNext(speed, runCallbacks); + } + + /** + * Slide to previous slide. + * + * @param speed Animation speed. + * @param runCallbacks Whether to run callbacks. + */ + slidePrev(speed?: number, runCallbacks?: boolean): void { + this.slides?.slidePrev(speed, runCallbacks); + } + + /** + * Get current item. + * + * @return Current item. Undefined if no current item yet. + */ + getCurrentItem(): Item | null { + return this.manager?.getSelectedItem() || null; + } + + /** + * Called when items list has been updated. + * + * @param items New items. + */ + protected onItemsUpdated(): void { + const currentItem = this.getCurrentItem(); + + if (!currentItem || !this.manager) { + return; + } + + // Keep the same slide in case the list has changed. + const newIndex = this.manager.getSource().getItemPosition(currentItem) ?? -1; + if (newIndex != -1) { + this.slides?.slideTo(newIndex, 0, false); + } + } + + /** + * Slide will change. + */ + async slideWillChange(): Promise { + const currentItemData = await this.getCurrentSlideItemData(); + if (!currentItemData) { + return; + } + + this.manager?.setSelectedItem(currentItemData.item); + + this.onWillChange.emit(currentItemData); + + if (this.options.scrollOnChange !== 'top') { + return; + } + + // Scroll top. This can be improved in the future to keep the scroll for each slide. + const scrollElement = await this.content?.getScrollElement(); + + if (!scrollElement || CoreDomUtils.isElementOutsideOfScreen(scrollElement, this.hostElement, 'top')) { + // Scroll to top. + this.hostElement.scrollIntoView({ behavior: 'smooth' }); + } + } + + /** + * Slide did change. + */ + async slideDidChange(): Promise { + const currentItemData = await this.getCurrentSlideItemData(); + if (!currentItemData) { + return; + } + + this.onDidChange.emit(currentItemData); + } + + /** + * Get current item and index based on current slide. + * + * @return Promise resolved with current item data. Null if not found. + */ + protected async getCurrentSlideItemData(): Promise | null> { + if (!this.slides || !this.manager) { + return null; + } + + const index = await this.slides.getActiveIndex(); + const items = this.manager.getSource().getItems(); + const currentItem = items && items[index]; + + if (!currentItem) { + return null; + } + + return { + item: currentItem, + index, + }; + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.unsubscribe && this.unsubscribe(); + } + +} + +/** + * Options to pass to the component. + * + * @todo Change unknown with the right type once Swiper library is used. + */ +export type CoreSwipeSlidesOptions = Record & { + scrollOnChange?: 'top' | 'none'; // Scroll behaviour on change slide. By default, none. +}; + +/** + * Data about current item. + */ +export type CoreSwipeCurrentItemData = { + index: number; + item: Item; +}; diff --git a/src/core/features/user/classes/participants-source.ts b/src/core/features/user/classes/participants-source.ts index 9aa6e4db1..892341ab1 100644 --- a/src/core/features/user/classes/participants-source.ts +++ b/src/core/features/user/classes/participants-source.ts @@ -13,14 +13,14 @@ // limitations under the License. import { Params } from '@angular/router'; -import { CoreItemsManagerSource } from '@classes/items-management/items-manager-source'; +import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; import { CoreUser, CoreUserData, CoreUserParticipant, CoreUserProvider } from '../services/user'; /** * Provides a collection of course participants. */ -export class CoreUserParticipantsSource extends CoreItemsManagerSource { +export class CoreUserParticipantsSource extends CoreRoutedItemsManagerSource { /** * @inheritdoc diff --git a/src/core/features/user/pages/participants/participants.page.ts b/src/core/features/user/pages/participants/participants.page.ts index a15ed9a11..3ff362630 100644 --- a/src/core/features/user/pages/participants/participants.page.ts +++ b/src/core/features/user/pages/participants/participants.page.ts @@ -23,7 +23,7 @@ import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreUser, CoreUserParticipant, CoreUserData } from '@features/user/services/user'; import { CoreUtils } from '@services/utils/utils'; import { CoreUserParticipantsSource } from '@features/user/classes/participants-source'; -import { CoreItemsManagerSourcesTracker } from '@classes/items-management/items-manager-sources-tracker'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; /** * Page that displays the list of course participants. @@ -48,7 +48,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro try { this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); this.participants = new CoreUserParticipantsManager( - CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]), + CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]), CoreUserParticipantsPage, ); } catch (error) { @@ -107,7 +107,7 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro return; } - const newSource = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]); + const newSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId]); this.searchQuery = null; this.searchInProgress = false; @@ -124,7 +124,10 @@ export class CoreUserParticipantsPage implements OnInit, AfterViewInit, OnDestro async search(query: string): Promise { CoreApp.closeKeyboard(); - const newSource = CoreItemsManagerSourcesTracker.getOrCreateSource(CoreUserParticipantsSource, [this.courseId, query]); + const newSource = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( + CoreUserParticipantsSource, + [this.courseId, query], + ); this.searchInProgress = true; this.searchQuery = query; diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index bcfc6bad3..ee7c6e39c 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -29,7 +29,7 @@ import { CoreNavigator } from '@services/navigator'; 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 { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; @Component({ selector: 'page-core-user-profile', @@ -90,7 +90,10 @@ 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]); + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource( + CoreUserParticipantsSource, + [this.courseId, search], + ); this.users = new CoreUserSwipeItemsManager(source); this.users.start();