diff --git a/UPGRADE.md b/UPGRADE.md index 9069c61a506..ac115ffac84 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -8,6 +8,7 @@ For more information about upgrading, read the official documentation: https://m - "model" has been removed for site plugins because it isn't needed and it's not compatible with Angular 18. "model" is meant to support 2-way data binding in custom components, and site plugins cannot create components. - The (onChange) output in core-combobox has been deprecated, please use (selectionChange) instead. - The CoreUserOfflineProvider service has been renamed to CoreUserPreferencesOfflineService and is no longer available for plugins. To read or write preferences please use the new CoreUserPreferencesService service. + - The AddonCalendarCalendarComponent no longer checks for changes inside the 'filter' input. If you change a property of the object passed to the 'filter' input, make sure to create a new object to make sure Angular detects the changes. E.g. this.filter = { ...this.filter, courseId: 1 }; 5.0.0 ===== diff --git a/src/addons/badges/pages/user-badges/user-badges.ts b/src/addons/badges/pages/user-badges/user-badges.ts index 7da7715c68c..8b28cf92bd0 100644 --- a/src/addons/badges/pages/user-badges/user-badges.ts +++ b/src/addons/badges/pages/user-badges/user-badges.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AfterViewInit, Component, OnDestroy, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, OnDestroy, viewChild } from '@angular/core'; import { AddonBadges, AddonBadgesUserBadge } from '../../services/badges'; import { CoreSites } from '@services/sites'; import { CorePromiseUtils } from '@singletons/promise-utils'; @@ -42,7 +42,7 @@ export default class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestr currentTime = 0; badges: CoreListItemsManager; - @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + readonly splitView = viewChild.required(CoreSplitViewComponent); protected logView: () => void; @@ -77,7 +77,7 @@ export default class AddonBadgesUserBadgesPage implements AfterViewInit, OnDestr async ngAfterViewInit(): Promise { await this.fetchInitialBadges(); - this.badges.start(this.splitView); + this.badges.start(this.splitView()); } /** diff --git a/src/addons/block/completionstatus/services/block-handler.ts b/src/addons/block/completionstatus/services/block-handler.ts index 7d12ab7be20..6ddafa3da63 100644 --- a/src/addons/block/completionstatus/services/block-handler.ts +++ b/src/addons/block/completionstatus/services/block-handler.ts @@ -18,6 +18,7 @@ import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler import { CoreCourseBlock } from '@features/course/services/course'; import { makeSingleton } from '@singletons'; import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; +import { CoreCourseCompletion } from '@features/course/services/course-completion'; import { ContextLevel } from '@/core/constants'; /** @@ -33,7 +34,7 @@ export class AddonBlockCompletionStatusHandlerService extends CoreBlockBaseHandl * @inheritdoc */ async isEnabled(): Promise { - return AddonCourseCompletion.isCompletionEnabledInSite(); + return CoreCourseCompletion.isCompletionEnabledInSite(); } /** diff --git a/src/addons/block/myoverview/constants.ts b/src/addons/block/myoverview/constants.ts new file mode 100644 index 00000000000..0d3e1cd212c --- /dev/null +++ b/src/addons/block/myoverview/constants.ts @@ -0,0 +1,15 @@ +// (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. + +export const ADDON_BLOCK_MYOVERVIEW_BLOCK_NAME = 'myoverview'; diff --git a/src/addons/block/myoverview/services/block-handler.ts b/src/addons/block/myoverview/services/block-handler.ts index 972de32e8c9..a55e4acf2b5 100644 --- a/src/addons/block/myoverview/services/block-handler.ts +++ b/src/addons/block/myoverview/services/block-handler.ts @@ -18,6 +18,7 @@ import { CoreBlockHandlerData } from '@features/block/services/block-delegate'; import { CoreCourses } from '@features/courses/services/courses'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; import { makeSingleton } from '@singletons'; +import { ADDON_BLOCK_MYOVERVIEW_BLOCK_NAME } from '../constants'; /** * Block handler. @@ -26,7 +27,7 @@ import { makeSingleton } from '@singletons'; export class AddonBlockMyOverviewHandlerService extends CoreBlockBaseHandler { name = 'AddonBlockMyOverview'; - blockName = 'myoverview'; + blockName = ADDON_BLOCK_MYOVERVIEW_BLOCK_NAME; /** * @inheritdoc diff --git a/src/addons/block/selfcompletion/services/block-handler.ts b/src/addons/block/selfcompletion/services/block-handler.ts index c0228067b5b..7de905d5771 100644 --- a/src/addons/block/selfcompletion/services/block-handler.ts +++ b/src/addons/block/selfcompletion/services/block-handler.ts @@ -18,6 +18,7 @@ import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler import { CoreCourseBlock } from '@features/course/services/course'; import { makeSingleton } from '@singletons'; import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; +import { CoreCourseCompletion } from '@features/course/services/course-completion'; import { ContextLevel } from '@/core/constants'; /** @@ -33,7 +34,7 @@ export class AddonBlockSelfCompletionHandlerService extends CoreBlockBaseHandler * @inheritdoc */ async isEnabled(): Promise { - return AddonCourseCompletion.isCompletionEnabledInSite(); + return CoreCourseCompletion.isCompletionEnabledInSite(); } /** diff --git a/src/addons/block/timeline/classes/section.ts b/src/addons/block/timeline/classes/section.ts index b5457b82445..e8937b67010 100644 --- a/src/addons/block/timeline/classes/section.ts +++ b/src/addons/block/timeline/classes/section.ts @@ -12,78 +12,59 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { AddonBlockTimeline } from '@addons/block/timeline/services/timeline'; +import { AddonBlockTimeline, AddonBlockTimelineActionEvents } from '@addons/block/timeline/services/timeline'; import { AddonCalendarEvent } from '@addons/calendar/services/calendar'; +import { signal } from '@angular/core'; import { CoreCourseModuleHelper } from '@features/course/services/course-module-helper'; import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; import { CoreTime } from '@singletons/time'; -import { BehaviorSubject, Observable } from 'rxjs'; /** * A collection of events displayed in the timeline block. */ export class AddonBlockTimelineSection { - search: string | null; - overdue: boolean; - dateRange: AddonBlockTimelineDateRange; - course?: CoreEnrolledCourseDataWithOptions; - - private dataSubject$: BehaviorSubject; + readonly events = signal([]); + readonly lastEventId = signal(undefined); + readonly canLoadMore = signal(false); + readonly loadingMore = signal(false); constructor( - search: string | null, - overdue: boolean, - dateRange: AddonBlockTimelineDateRange, - course?: CoreEnrolledCourseDataWithOptions, - courseEvents?: AddonCalendarEvent[], - canLoadMore?: number, + public search: string, + public overdue: boolean, + public dateRange: AddonBlockTimelineDateRange, + public course?: CoreEnrolledCourseDataWithOptions, ) { - this.search = search; - this.overdue = overdue; - this.dateRange = dateRange; - this.course = course; - this.dataSubject$ = new BehaviorSubject({ - events: [], - lastEventId: canLoadMore, - canLoadMore: typeof canLoadMore !== 'undefined', - loadingMore: false, - }); - - if (courseEvents) { - // eslint-disable-next-line promise/catch-or-return - this.reduceEvents(courseEvents, overdue, dateRange).then(events => this.dataSubject$.next({ - ...this.dataSubject$.value, - events, - })); - } - } - - get data$(): Observable { - return this.dataSubject$; } /** * Load more events. */ async loadMore(): Promise { - this.dataSubject$.next({ - ...this.dataSubject$.value, - loadingMore: true, - }); - - const lastEventId = this.dataSubject$.value.lastEventId; - const { events, canLoadMore } = this.course - ? await AddonBlockTimeline.getActionEventsByCourse(this.course.id, lastEventId, this.search ?? '') - : await AddonBlockTimeline.getActionEventsByTimesort(lastEventId, this.search ?? ''); - - this.dataSubject$.next({ - events: this.dataSubject$.value.events.concat(await this.reduceEvents(events, this.overdue, this.dateRange)), - lastEventId: canLoadMore, - canLoadMore: canLoadMore !== undefined, - loadingMore: false, - }); + this.loadingMore.set(true); + + const result = this.course + ? await AddonBlockTimeline.getActionEventsByCourse(this.course.id, this.lastEventId(), this.search) + : await AddonBlockTimeline.getActionEventsByTimesort(this.lastEventId(), this.search); + + await this.addEvents(result); + } + + /** + * Add events to the section. + * + * @param actionEvents Action events object with events to be added and additional info. + */ + async addEvents(actionEvents: AddonBlockTimelineActionEvents): Promise { + const { events, lastEventId, canLoadMore } = actionEvents; + + const newEvents = await this.reduceEvents(events, this.overdue, this.dateRange); + + this.events.update((events) => events.concat(newEvents)); + this.lastEventId.set(lastEventId); + this.canLoadMore.set(canLoadMore); + this.loadingMore.set(false); } /** @@ -177,16 +158,6 @@ export class AddonBlockTimelineSection { } -/** - * Section data. - */ -export type AddonBlockTimelineSectionData = { - events: AddonBlockTimelineDayEvents[]; - lastEventId?: number; - canLoadMore: boolean; - loadingMore: boolean; -}; - /** * Timestamps to use during event filtering. */ diff --git a/src/addons/block/timeline/components/events/addon-block-timeline-events.html b/src/addons/block/timeline/components/events/addon-block-timeline-events.html index 91a05bcc860..f18251f5e70 100644 --- a/src/addons/block/timeline/components/events/addon-block-timeline-events.html +++ b/src/addons/block/timeline/components/events/addon-block-timeline-events.html @@ -1,89 +1,91 @@ -@if (course) { +@if (course()) {

{{ 'core.courses.aria:coursename' | translate }} - +

} - - - - @if (course) { -

- } @else { -

- } - - {{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }} - -
-
- - +@for (dayEvents of events(); track dayEvents.dayTimestamp) { + + - - - {{event.timesort * 1000 | coreFormatDate:"strftimetime24" }} - @if (event.iconUrl) { - - } - - -

- - - - @if (event.overdue) { - {{ 'addon.block_timeline.overdue' | translate }} - + @if (course()) { +

+ } @else { +

+ } + + {{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }} + +
+
+ @for (event of dayEvents.events; track event.id) { + + + + + {{event.timesort * 1000 | coreFormatDate:"strftimetime24" }} + @if (event.iconUrl) { + } -

- @if (showInlineCourse && event.course) { -

+ + +

- + + @if (event.overdue) { + {{ 'addon.block_timeline.overdue' | translate }} + + }

- } - @if (event.activitystr) { -

- - @if (event.activitystr) { - - } - -

- } -
-
- @if (event.action && event.action.actionable) { -
- - {{event.action.name}} - @if (event.action.showitemcount) { - - {{event.action.itemcount}} - + @if (showInlineCourse() && event.course) { +

+ + + +

} -
-
- } -
-
-
-
+ @if (event.activitystr) { +

+ + + +

+ } + + + @if (event.action && event.action.actionable) { +
+ + {{event.action.name}} + @if (event.action.showitemcount) { + + {{event.action.itemcount}} + + } + +
+ } + + + } + +} -@if (canLoadMore) { +@if (canLoadMore()) {
- @if (loadingMore) { + @if (loadingMore()) { } @else { diff --git a/src/addons/block/timeline/components/events/events.ts b/src/addons/block/timeline/components/events/events.ts index 556b31ead47..9ec3a91dcdc 100644 --- a/src/addons/block/timeline/components/events/events.ts +++ b/src/addons/block/timeline/components/events/events.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, OnInit, HostBinding } from '@angular/core'; +import { Component, OnInit, input, output, signal } from '@angular/core'; import { CoreSites } from '@services/sites'; import { CoreLoadings } from '@services/overlays/loadings'; import { CoreText } from '@singletons/text'; @@ -32,28 +32,27 @@ import { CoreContentLinksHelper } from '@features/contentlinks/services/contentl imports: [ CoreSharedModule, ], + host: { + '[attr.data-course-id]': 'course()?.id ?? null', + }, }) export class AddonBlockTimelineEventsComponent implements OnInit { - @Input() events: AddonBlockTimelineDayEvents[] = []; // The events to render. - @Input() course?: CoreEnrolledCourseDataWithOptions; // Whether to show the course name. - @Input({ transform: toBoolean }) showInlineCourse = true; // Whether to show the course name within event items. - @Input({ transform: toBoolean }) canLoadMore = false; // Whether more events can be loaded. - @Input({ transform: toBoolean }) loadingMore = false; // Whether loading is ongoing. - @Output() loadMore = new EventEmitter(); // Notify that more events should be loaded. + readonly events = input.required(); // The events to render. + readonly course = input.required(); // The course the events belong to. + readonly showInlineCourse = input(true, { transform: toBoolean }); // Whether to show the course name within event items. + readonly canLoadMore = input(false, { transform: toBoolean }); // Whether more events can be loaded. + readonly loadingMore = input(false, { transform: toBoolean }); // Whether loading is ongoing. + readonly loadMore = output(); // Notify that more events should be loaded. - colorizeIcons = false; - - @HostBinding('attr.data-course-id') protected get courseId(): number | null { - return this.course?.id ?? null; - } + readonly colorizeIcons = signal(false); /** * @inheritdoc */ ngOnInit(): void { // Only colorize icons on 4.0 to 4.3 sites. - this.colorizeIcons = !CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('4.4'); + this.colorizeIcons.set(!CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('4.4')); } /** @@ -61,7 +60,6 @@ export class AddonBlockTimelineEventsComponent implements OnInit { * * @param event Click event. * @param url Url of the action. - * @returns Promise resolved when done. */ async action(event: Event, url: string): Promise { event.preventDefault(); diff --git a/src/addons/block/timeline/components/timeline/addon-block-timeline.html b/src/addons/block/timeline/components/timeline/addon-block-timeline.html index 5406056b69c..dc9aeac0885 100644 --- a/src/addons/block/timeline/components/timeline/addon-block-timeline.html +++ b/src/addons/block/timeline/components/timeline/addon-block-timeline.html @@ -4,60 +4,61 @@

{{ 'addon.block_timeline.pluginname' | translate }}

- @if ((search$ | async) !== null) { + @if (search() !== null) { - } - - - {{ option.name | translate }} - + @for (option of statusFilterOptions; let last = $last; track option.value) { + + {{ option.name | translate }} + + } {{ 'addon.block_timeline.duedate' | translate }} - - {{ option.name | translate }} - + @for (option of dateFilterOptions; track option.value) { + + {{ option.name | translate }} + + } - @if ((search$ | async) !== null) { + @if (search() !== null) { - + } - - - {{ option.name | translate }} - + @for (option of sortOptions; track option.value) { + + {{ option.name | translate }} + + } - @if (sections$ | async; as sections) { - @for (section of sections; track section.course?.id || section.dateRange.from) { - @if (section.data$ | async; as data) { - - } - } - @if (sections && sections.length === 0) { - + @for (section of sections(); track section.course?.id || section.dateRange.from) { + @if (section) { + } - + } @empty { + } diff --git a/src/addons/block/timeline/components/timeline/timeline.ts b/src/addons/block/timeline/components/timeline/timeline.ts index a153bd19ec6..bb22a2a39e5 100644 --- a/src/addons/block/timeline/components/timeline/timeline.ts +++ b/src/addons/block/timeline/components/timeline/timeline.ts @@ -12,20 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, OnInit, signal, untracked } from '@angular/core'; import { CoreSites } from '@services/sites'; -import { ICoreBlockComponent } from '@features/block/classes/base-block-component'; +import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; import { AddonBlockTimeline } from '../../services/timeline'; import { CorePromiseUtils } from '@singletons/promise-utils'; -import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; +import { + CoreCoursesHelper, + CoreEnrolledCourseDataWithExtraInfoAndOptions, +} from '@features/courses/services/courses-helper'; import { CoreCourses } from '@features/courses/services/courses'; import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; -import { BehaviorSubject, combineLatest, Observable, of, Subject } from 'rxjs'; -import { catchError, distinctUntilChanged, map, share, tap, mergeAll } from 'rxjs/operators'; import { AddonBlockTimelineDateRange, AddonBlockTimelineSection } from '@addons/block/timeline/classes/section'; import { FormControl } from '@angular/forms'; -import { formControlValue, resolved } from '@/core/utils/rxjs'; -import { CoreLogger } from '@singletons/logger'; import { CoreSharedModule } from '@/core/shared.module'; import { AddonBlockTimelineEventsComponent } from '../events/events'; import { CoreAlerts } from '@services/overlays/alerts'; @@ -47,29 +46,76 @@ import { Translate } from '@singletons'; AddonBlockTimelineEventsComponent, ], }) -export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent { - - sort = new FormControl(AddonBlockTimelineSort.ByDates); - sort$!: Observable; - sortOptions!: AddonBlockTimelineOption[]; - filter = new FormControl(AddonBlockTimelineFilter.Next30Days); - filter$!: Observable; - statusFilterOptions!: AddonBlockTimelineOption[]; - dateFilterOptions!: AddonBlockTimelineOption[]; - search$: Subject; - sections$!: Observable; - loaded = false; +export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit { + + protected static readonly SORT_CONFIG_KEY = 'AddonBlockTimelineSort'; + protected static readonly FILTER_CONFIG_KEY = 'AddonBlockTimelineFilter'; + + readonly sortFormControl = new FormControl(AddonBlockTimelineSort.ByDates, { nonNullable: true }); + readonly sort = signal(this.sortFormControl.value); + readonly sortOptions: AddonBlockTimelineOption[]; + + readonly filterFormControl = new FormControl(AddonBlockTimelineFilter.Next30Days, { nonNullable: true }); + readonly filter = signal(this.filterFormControl.value); + readonly dateFilterOptions: readonly AddonBlockTimelineOption[]; + readonly statusFilterOptions: readonly AddonBlockTimelineOption[] = [ + { value: AddonBlockTimelineFilter.All, name: 'core.all' }, + { value: AddonBlockTimelineFilter.Overdue, name: 'addon.block_timeline.overdue' }, + ]; + + readonly search = signal(''); + + protected readonly courses = signal([]); + readonly sections = signal([]); + + static readonly FILTER_RANGES: Record = { + all: { from: -14 }, + overdue: { from: -14, to: 1 }, + next7days: { from: 0, to: 7 }, + next30days: { from: 0, to: 30 }, + next3months: { from: 0, to: 90 }, + next6months: { from: 0, to: 180 }, + }; + + readonly init = signal(false); - protected logger: CoreLogger; - protected courseIdsToInvalidate: number[] = []; + // Will prevent toast from showing the first time it loads. + protected showUpdateToast = false; + + loaded = false; protected fetchContentDefaultError = 'Error getting timeline data.'; constructor() { - this.logger = CoreLogger.getInstance('AddonBlockTimelineComponent'); - this.search$ = new BehaviorSubject(null); - this.initializeSort(); - this.initializeFilter(); - this.initializeSections(); + super(); + this.sortOptions = Object.values(AddonBlockTimelineSort).map(value => ({ + value, + name: `addon.block_timeline.${value}`, + })); + this.dateFilterOptions = [ + AddonBlockTimelineFilter.Next7Days, + AddonBlockTimelineFilter.Next30Days, + AddonBlockTimelineFilter.Next3Months, + AddonBlockTimelineFilter.Next6Months, + ].map(value => ({ + value, + name: `addon.block_timeline.${value}`, + })); + + effect(async () => { + const filter = this.filter(); + const search = this.search(); + const sort = this.sort(); + + // This is probably not the best way to do this, but we need to wait for the sort and filters to be loaded + // otherwise the effect is run twice since the formcontrols are initialized with default values. + if (!this.init()) { + return; + } + + untracked(async () => { + await this.loadSections(filter, sort, search ?? ''); + }); + }); } get AddonBlockTimelineSort(): typeof AddonBlockTimelineSort { @@ -81,15 +127,18 @@ export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent */ async ngOnInit(): Promise { const currentSite = CoreSites.getRequiredCurrentSite(); - const [sort, filter, search] = await Promise.all([ - currentSite.getLocalSiteConfig('AddonBlockTimelineSort', AddonBlockTimelineSort.ByDates), - currentSite.getLocalSiteConfig('AddonBlockTimelineFilter', AddonBlockTimelineFilter.Next30Days), - currentSite.isVersionGreaterEqualThan('4.0') ? '' : null, + const [sort, filter] = await Promise.all([ + currentSite.getLocalSiteConfig(AddonBlockTimelineComponent.SORT_CONFIG_KEY, AddonBlockTimelineSort.ByDates), + currentSite.getLocalSiteConfig(AddonBlockTimelineComponent.FILTER_CONFIG_KEY, AddonBlockTimelineFilter.Next30Days), ]); - this.sort.setValue(sort); - this.filter.setValue(filter); - this.search$.next(search); + this.sortFormControl.setValue(sort); + this.filterFormControl.setValue(filter); + + // Null means search is not available. + const search = currentSite.isVersionGreaterEqualThan('4.0') ? '' : null; + this.search.set(search); + this.init.set(true); } /** @@ -98,7 +147,8 @@ export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent * @param sort New sort. */ sortChanged(sort: AddonBlockTimelineSort): void { - CoreSites.getRequiredCurrentSite().setLocalSiteConfig('AddonBlockTimelineSort', sort); + this.sort.set(sort); + CoreSites.getRequiredCurrentSite().setLocalSiteConfig(AddonBlockTimelineComponent.SORT_CONFIG_KEY, sort); } /** @@ -107,126 +157,78 @@ export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent * @param filter New filter. */ filterChanged(filter: AddonBlockTimelineFilter): void { - CoreSites.getRequiredCurrentSite().setLocalSiteConfig('AddonBlockTimelineFilter', filter); - } - - /** - * Search text changed. - * - * @param search New search. - */ - searchChanged(search: string): void { - this.search$.next(search); + this.filter.set(filter); + CoreSites.getRequiredCurrentSite().setLocalSiteConfig(AddonBlockTimelineComponent.FILTER_CONFIG_KEY, filter); } /** * @inheritdoc */ async invalidateContent(): Promise { + const courseIds = this.courses().map(course => course.id); await CorePromiseUtils.allPromises([ AddonBlockTimeline.invalidateActionEventsByTimesort(), AddonBlockTimeline.invalidateActionEventsByCourses(), CoreCourses.invalidateUserCourses(), CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions(), - CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.join(',')), + CoreCourses.invalidateCoursesByField('ids', courseIds.join(',')), ]); - } - /** - * Initialize sort properties. - */ - protected initializeSort(): void { - this.sort$ = formControlValue(this.sort); - this.sortOptions = Object.values(AddonBlockTimelineSort).map(value => ({ - value, - name: `addon.block_timeline.${value}`, - })); + this.courses.set([]); } /** - * Initialize filter properties. + * @inheritdoc */ - protected initializeFilter(): void { - this.filter$ = formControlValue(this.filter); - this.statusFilterOptions = [ - { value: AddonBlockTimelineFilter.All, name: 'core.all' }, - { value: AddonBlockTimelineFilter.Overdue, name: 'addon.block_timeline.overdue' }, - ]; - this.dateFilterOptions = [ - AddonBlockTimelineFilter.Next7Days, - AddonBlockTimelineFilter.Next30Days, - AddonBlockTimelineFilter.Next3Months, - AddonBlockTimelineFilter.Next6Months, - ] - .map(value => ({ - value, - name: `addon.block_timeline.${value}`, - })); + protected async fetchContent(): Promise { + await this.loadSections(this.filter(), this.sort(), this.search() ?? ''); } /** - * Initialize sections properties. + * Load sections using search parameters. + * + * @param filter Filter. + * @param sort Sort. + * @param search Search. */ - protected initializeSections(): void { - const filtersRange: Record = { - all: { from: -14 }, - overdue: { from: -14, to: 1 }, - next7days: { from: 0, to: 7 }, - next30days: { from: 0, to: 30 }, - next3months: { from: 0, to: 90 }, - next6months: { from: 0, to: 180 }, - }; - const sortValue = this.sort.valueChanges as Observable; - const courses = sortValue.pipe( - distinctUntilChanged(), - map(async sort => { - switch (sort) { - case AddonBlockTimelineSort.ByDates: - return []; - case AddonBlockTimelineSort.ByCourses: - return CoreCoursesHelper.getUserCoursesWithOptions(); - } - }), - resolved(), - map(courses => { - this.courseIdsToInvalidate = courses.map(course => course.id); - - return courses; - }), - ); - - this.sections$ = combineLatest([this.filter$, sortValue, this.search$, courses]).pipe( - map(async ([filter, sort, search, courses]) => { - const includeOverdue = filter === AddonBlockTimelineFilter.Overdue; - const dateRange = filtersRange[filter]; - - switch (sort) { - case AddonBlockTimelineSort.ByDates: - return this.getSectionsByDates(search, includeOverdue, dateRange); - case AddonBlockTimelineSort.ByCourses: - return this.getSectionsByCourse(search, includeOverdue, dateRange, courses); - } - }), - resolved(), - mergeAll(), - tap((sections) => { - if (this.loaded) { - CoreToasts.show({ - cssClass: 'sr-only', - message: Translate.instant('core.resultsfound', { $a: sections.length }), - }); - } - }), - catchError(error => { - // An error ocurred in the function, log the error and just resolve the observable so the workflow continues. - this.logger.error(error); - CoreAlerts.showError(error, { default: this.fetchContentDefaultError }); - - return of([] as AddonBlockTimelineSection[]); - }), - share(), - tap(() => (this.loaded = true)), - ); + async loadSections( + filter: AddonBlockTimelineFilter, + sort: AddonBlockTimelineSort, + search: string, + ): Promise { + this.loaded = false; + + const includeOverdue = filter === AddonBlockTimelineFilter.Overdue; + const dateRange = AddonBlockTimelineComponent.FILTER_RANGES[filter]; + + try { + let sections: AddonBlockTimelineSection[] = []; + switch (sort) { + case AddonBlockTimelineSort.ByDates: + sections = await this.getSectionsByDates(search, includeOverdue, dateRange); + break; + case AddonBlockTimelineSort.ByCourses: + sections = await this.getSectionsByCourse(search, includeOverdue, dateRange); + break; + } + this.sections.set(sections); + + if (this.showUpdateToast) { + const events = sections.reduce((acc, section) => acc + section.events().length, 0); + CoreToasts.show({ + cssClass: 'sr-only', + message: Translate.instant('core.resultsfound', { $a: events }), + }); + } + } catch (error) { + // An error ocurred in the function, log the error and just resolve the observable so the workflow continues. + CoreAlerts.showError(error, { default: this.fetchContentDefaultError }); + + this.sections.set([]); + } finally { + this.loaded = true; + this.showUpdateToast = true; + } } /** @@ -238,15 +240,15 @@ export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent * @returns Sections. */ protected async getSectionsByDates( - search: string | null, + search: string, overdue: boolean, dateRange: AddonBlockTimelineDateRange, - ): Promise> { + ): Promise { const section = new AddonBlockTimelineSection(search, overdue, dateRange); await section.loadMore(); - return section.data$.pipe(map(({ events }) => events.length > 0 ? [section] : [])); + return section.events().length > 0 ? [section] : []; } /** @@ -255,48 +257,50 @@ export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent * @param search Search string. * @param overdue Whether to filter overdue events or not. * @param dateRange Date range to filter events by. - * @param courses Courses. * @returns Sections. */ protected async getSectionsByCourse( - search: string | null, + search: string, overdue: boolean, dateRange: AddonBlockTimelineDateRange, - courses: CoreEnrolledCourseDataWithOptions[], - ): Promise> { + ): Promise { + let courses = this.courses(); + + if (courses.length === 0) { + // Load courses when sorting by courses unless they are already loaded and no empty. + courses = await CoreCoursesHelper.getUserCoursesWithOptions(); + this.courses.set(courses); + } + + if (!courses || courses.length === 0) { + return []; + } + // Do not filter courses by date because they can contain activities due. const courseIds = courses.map(course => course.id); const gracePeriod = await this.getCoursesGracePeriod(); - const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(courseIds, search ?? ''); - const sectionObservables = courses + const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(courseIds, search); + const sections = await Promise.all(courses .filter( course => !course.hidden && !CoreCoursesHelper.isFutureCourse(course, gracePeriod.after, gracePeriod.before) && courseEvents[course.id].events.length > 0, ) - .map(course => { + .map(async course => { const section = new AddonBlockTimelineSection( search, overdue, dateRange, course, - courseEvents[course.id].events, - courseEvents[course.id].canLoadMore, ); - return section.data$.pipe(map(({ events }) => events.length > 0 ? section : null)); - }); + await section.addEvents(courseEvents[course.id]); - if (sectionObservables.length === 0) { - return of([]); - } + return section.events().length > 0 ? section : null; + })); - return combineLatest(sectionObservables).pipe( - map(sections => sections.filter( - (section: AddonBlockTimelineSection | null): section is AddonBlockTimelineSection => !!section, - )), - ); + return sections.filter((section): section is AddonBlockTimelineSection => !!section); } /** diff --git a/src/addons/block/timeline/constants.ts b/src/addons/block/timeline/constants.ts new file mode 100644 index 00000000000..1ff8e2b5e9f --- /dev/null +++ b/src/addons/block/timeline/constants.ts @@ -0,0 +1,15 @@ +// (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. + +export const ADDON_BLOCK_TIMELINE_BLOCK_NAME = 'timeline'; diff --git a/src/addons/block/timeline/services/block-handler.ts b/src/addons/block/timeline/services/block-handler.ts index ac3f2878252..b312b5ee533 100644 --- a/src/addons/block/timeline/services/block-handler.ts +++ b/src/addons/block/timeline/services/block-handler.ts @@ -19,6 +19,7 @@ import { CoreCourses } from '@features/courses/services/courses'; import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; import { makeSingleton } from '@singletons'; import { CoreCoursesDashboard } from '@features/courses/services/dashboard'; +import { ADDON_BLOCK_TIMELINE_BLOCK_NAME } from '../constants'; /** * Block handler. @@ -27,7 +28,7 @@ import { CoreCoursesDashboard } from '@features/courses/services/dashboard'; export class AddonBlockTimelineHandlerService extends CoreBlockBaseHandler { name = 'AddonBlockTimeline'; - blockName = 'timeline'; + blockName = ADDON_BLOCK_TIMELINE_BLOCK_NAME; /** * @inheritdoc diff --git a/src/addons/block/timeline/services/timeline.ts b/src/addons/block/timeline/services/timeline.ts index 5ea17cc60b5..8a8dab945dd 100644 --- a/src/addons/block/timeline/services/timeline.ts +++ b/src/addons/block/timeline/services/timeline.ts @@ -47,7 +47,7 @@ export class AddonBlockTimelineProvider { afterEventId?: number, searchValue = '', siteId?: string, - ): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> { + ): Promise { const site = await CoreSites.getSite(siteId); const time = this.getDayStart(-14); // Check two weeks ago. @@ -101,7 +101,7 @@ export class AddonBlockTimelineProvider { courseIds: number[], searchValue = '', siteId?: string, - ): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> { + ): Promise<{[courseId: string]: AddonBlockTimelineActionEvents }> { if (courseIds.length === 0) { return {}; } @@ -130,7 +130,7 @@ export class AddonBlockTimelineProvider { preSets, ); - const courseEvents: {[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } } = {}; + const courseEvents: {[courseId: string]: AddonBlockTimelineActionEvents } = {}; events.groupedbycourse.forEach((course) => { courseEvents[course.courseid] = this.treatCourseEvents(course, time); @@ -160,7 +160,7 @@ export class AddonBlockTimelineProvider { afterEventId?: number, searchValue = '', siteId?: string, - ): Promise<{ events: AddonCalendarEvent[]; canLoadMore?: number }> { + ): Promise { const site = await CoreSites.getSite(siteId); const timesortfrom = this.getDayStart(-14); // Check two weeks ago. @@ -193,14 +193,15 @@ export class AddonBlockTimelineProvider { preSets, ); - const canLoadMore = result.events.length >= limitnum ? result.lastid : undefined; + const lastEventId = result.events.length >= limitnum ? result.lastid : undefined; // Filter events by time in case it uses cache. const events = result.events.filter((element) => element.timesort >= timesortfrom); return { events, - canLoadMore, + lastEventId, + canLoadMore: lastEventId !== undefined, }; } @@ -254,21 +255,20 @@ export class AddonBlockTimelineProvider { * * @param course Object containing response course events info. * @param timeFrom Current time to filter events from. - * @returns Object with course events and last loaded event id if more can be loaded. + * @returns Object with course events and whether more events can be loaded. */ - protected treatCourseEvents( - course: AddonBlockTimelineEvents, - timeFrom: number, - ): { events: AddonCalendarEvent[]; canLoadMore?: number } { + protected treatCourseEvents(course: AddonBlockTimelineEvents, timeFrom: number): AddonBlockTimelineActionEvents { - const canLoadMore: number | undefined = + const lastEventId: number | undefined = course.events.length >= AddonBlockTimelineProvider.EVENTS_LIMIT_PER_COURSE ? course.lastid : undefined; + const canLoadMore = lastEventId !== undefined; // Filter events by time in case it uses cache. course.events = course.events.filter((element) => element.timesort >= timeFrom); return { events: course.events, + lastEventId, canLoadMore, }; } @@ -360,3 +360,9 @@ export type AddonBlockTimelineEvents = { firstid: number; // Firstid. lastid: number; // Lastid. }; + +export type AddonBlockTimelineActionEvents = { + events: AddonCalendarEvent[]; + lastEventId: number | undefined; + canLoadMore: boolean; +}; diff --git a/src/addons/blog/pages/edit-entry/edit-entry.ts b/src/addons/blog/pages/edit-entry/edit-entry.ts index f2acf6e7eb7..2fdb366a671 100644 --- a/src/addons/blog/pages/edit-entry/edit-entry.ts +++ b/src/addons/blog/pages/edit-entry/edit-entry.ts @@ -23,7 +23,7 @@ import { AddonBlogPublishState, } from '@addons/blog/services/blog'; import { AddonBlogOffline } from '@addons/blog/services/blog-offline'; -import { Component, computed, ElementRef, OnDestroy, OnInit, signal, ViewChild } from '@angular/core'; +import { Component, computed, ElementRef, OnDestroy, OnInit, signal, viewChild } from '@angular/core'; import { AddonBlogSync } from '@addons/blog/services/blog-sync'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { CoreError } from '@classes/errors/error'; @@ -57,7 +57,7 @@ import { DEFAULT_TEXT_FORMAT } from '@singletons/text'; }) export default class AddonBlogEditEntryPage implements CanLeave, OnInit, OnDestroy { - @ViewChild('editEntryForm') formElement!: ElementRef; + readonly formElement = viewChild.required>('editEntryForm'); publishState = AddonBlogPublishState; form = new FormGroup({ @@ -431,7 +431,7 @@ export default class AddonBlogEditEntryPage implements CanLeave, OnInit, OnDestr await CoreAlerts.confirmLeaveWithChanges(); } - CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormCancelledEvent(this.formElement(), CoreSites.getCurrentSiteId()); return true; } @@ -482,7 +482,7 @@ export default class AddonBlogEditEntryPage implements CanLeave, OnInit, OnDestr CoreEvents.trigger(ADDON_BLOG_ENTRY_UPDATED); this.forceLeave = true; - CoreForms.triggerFormSubmittedEvent(this.formElement, true, CoreSites.getCurrentSiteId()); + CoreForms.triggerFormSubmittedEvent(this.formElement(), true, CoreSites.getCurrentSiteId()); return CoreNavigator.back(); } diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html index e683ab44127..1bac49de9ad 100644 --- a/src/addons/calendar/components/calendar/addon-calendar-calendar.html +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -1,7 +1,7 @@ - @if (canNavigate && !selectedMonthIsCurrent() && displayNavButtons) { + @if (canNavigate() && !selectedMonthIsCurrent() && displayNavButtons()) { } @@ -44,7 +44,7 @@

- @if (canNavigate) { + @if (canNavigate()) { - @if (canNavigate) { + @if (canNavigate()) {