From 70e11f9ea48e812b12ccee402ccc65bd6695913c Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 14 Jun 2022 09:20:00 +0200 Subject: [PATCH] MOBILE-4038 timeline: Refactor using OnPush --- .../tests/behat/behat_app.php | 22 +- .../components/myoverview/myoverview.ts | 30 +- src/addons/block/timeline/classes/section.ts | 200 ++++++++ .../events/addon-block-timeline-events.html | 22 +- .../timeline/components/events/events.ts | 117 +---- .../timeline/addon-block-timeline.html | 65 +-- .../timeline/components/timeline/timeline.ts | 472 ++++++++++-------- .../block/timeline/services/timeline.ts | 4 + .../timeline/tests/behat/basic_usage.feature | 92 ++++ .../calendar/services/calendar-helper.ts | 2 +- src/addons/calendar/services/calendar.ts | 6 +- .../calendar/services/database/calendar.ts | 2 +- src/core/components/combobox/combobox.ts | 69 ++- .../components/combobox/core-combobox.html | 2 +- .../block/classes/base-block-component.ts | 54 +- .../features/block/components/block/block.ts | 25 +- .../features/block/services/block-delegate.ts | 4 +- src/core/features/courses/services/courses.ts | 4 + .../classes/handlers/block-handler.ts | 4 +- src/core/services/utils/utils.ts | 6 +- src/core/utils/rxjs.ts | 62 +++ src/core/utils/tests/rxjs.test.ts | 89 ++++ src/testing/services/behat-runtime.ts | 38 +- 23 files changed, 927 insertions(+), 464 deletions(-) create mode 100644 src/addons/block/timeline/classes/section.ts create mode 100644 src/addons/block/timeline/tests/behat/basic_usage.feature create mode 100644 src/core/utils/rxjs.ts create mode 100644 src/core/utils/tests/rxjs.test.ts diff --git a/local-moodleappbehat/tests/behat/behat_app.php b/local-moodleappbehat/tests/behat/behat_app.php index 6a881543d..2130ce540 100644 --- a/local-moodleappbehat/tests/behat/behat_app.php +++ b/local-moodleappbehat/tests/behat/behat_app.php @@ -538,7 +538,7 @@ class behat_app extends behat_app_helper { * Note it is difficult to use the standard 'click on' or 'press' steps because those do not * distinguish visible items and the app always has many non-visible items in the DOM. * - * @Then /^I press (".+") in the app$/ + * @When /^I press (".+") in the app$/ * @param string $locator Element locator * @throws DriverException If the press doesn't work */ @@ -558,6 +558,26 @@ class behat_app extends behat_app_helper { $this->wait_for_pending_js(); } + /** + * Performs a pull to refresh gesture. + * + * @When /^I pull to refresh in the app$/ + * @throws DriverException If the gesture is not available + */ + public function i_pull_to_refresh_in_the_app() { + $this->spin(function() { + $result = $this->js('await window.behat.pullToRefresh();'); + + if ($result !== 'OK') { + throw new DriverException('Error pulling to refresh - ' . $result); + } + + return true; + }); + + $this->wait_for_pending_js(); + } + /** * Select an item from a list of options, such as a radio button. * diff --git a/src/addons/block/myoverview/components/myoverview/myoverview.ts b/src/addons/block/myoverview/components/myoverview/myoverview.ts index 1f2e35109..3819990c7 100644 --- a/src/addons/block/myoverview/components/myoverview/myoverview.ts +++ b/src/addons/block/myoverview/components/myoverview/myoverview.ts @@ -26,7 +26,7 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; -import { IonSearchbar } from '@ionic/angular'; +import { IonRefresher, IonSearchbar } from '@ionic/angular'; import { CoreNavigator } from '@services/navigator'; const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] = @@ -86,6 +86,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem protected currentSite!: CoreSite; protected allCourses: CoreEnrolledCourseDataWithOptions[] = []; protected prefetchIconsInitialized = false; + protected isDirty = false; protected isDestroyed = false; protected coursesObserver?: CoreEventObserver; protected updateSiteObserver?: CoreEventObserver; @@ -158,10 +159,27 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem }); } + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: IonRefresher, done?: () => void): Promise { + if (this.loaded) { + return this.refreshContent().finally(() => { + refresher?.complete(); + done && done(); + }); + } + } + /** * @inheritdoc */ async invalidateContent(): Promise { + this.isDirty = true; const courseIds = this.allCourses.map((course) => course.id); await this.invalidateCourses(courseIds); @@ -207,7 +225,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem /** * @inheritdoc */ - protected async fetchContent(refresh?: boolean): Promise { + protected async fetchContent(): Promise { const config = this.block.configsRecord; const showCategories = config?.displaycategories?.value == '1'; @@ -218,15 +236,15 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem undefined, showCategories, { - readingStrategy: refresh ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined, + readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined, }, ); this.hasCourses = this.allCourses.length > 0; try { - this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', refresh), 10); - this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', refresh), 10); + this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', this.isDirty), 10); + this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', this.isDirty), 10); } catch { this.gradePeriodAfter = 0; this.gradePeriodBefore = 0; @@ -235,6 +253,8 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem this.loadSort(); this.loadLayouts(config?.layouts?.value.split(',')); this.loadFilters(config); + + this.isDirty = false; } /** diff --git a/src/addons/block/timeline/classes/section.ts b/src/addons/block/timeline/classes/section.ts new file mode 100644 index 000000000..72301c42c --- /dev/null +++ b/src/addons/block/timeline/classes/section.ts @@ -0,0 +1,200 @@ +// (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 { AddonBlockTimeline } from '@addons/block/timeline/services/timeline'; +import { AddonCalendarEvent } from '@addons/calendar/services/calendar'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; +import { CoreTimeUtils } from '@services/utils/time'; +import { BehaviorSubject } from 'rxjs'; + +/** + * A collection of events displayed in the timeline block. + */ +export class AddonBlockTimelineSection { + + search: string | null; + overdue: boolean; + dateRange: AddonBlockTimelineDateRange; + course?: CoreEnrolledCourseDataWithOptions; + data$: BehaviorSubject<{ + events: AddonBlockTimelineDayEvents[]; + lastEventId?: number; + canLoadMore: boolean; + loadingMore: boolean; + }>; + + constructor( + search: string | null, + overdue: boolean, + dateRange: AddonBlockTimelineDateRange, + course?: CoreEnrolledCourseDataWithOptions, + courseEvents?: AddonCalendarEvent[], + canLoadMore?: number, + ) { + this.search = search; + this.overdue = overdue; + this.dateRange = dateRange; + this.course = course; + this.data$ = new BehaviorSubject({ + events: courseEvents ? this.reduceEvents(courseEvents, overdue, dateRange) : [], + lastEventId: canLoadMore, + canLoadMore: typeof canLoadMore !== 'undefined', + loadingMore: false, + }); + } + + /** + * Load more events. + */ + async loadMore(): Promise { + this.data$.next({ + ...this.data$.value, + loadingMore: true, + }); + + const lastEventId = this.data$.value.lastEventId; + const { events, canLoadMore } = this.course + ? await AddonBlockTimeline.getActionEventsByCourse(this.course.id, lastEventId, this.search ?? '') + : await AddonBlockTimeline.getActionEventsByTimesort(lastEventId, this.search ?? ''); + + this.data$.next({ + events: this.data$.value.events.concat(this.reduceEvents(events, this.overdue, this.dateRange)), + lastEventId: canLoadMore, + canLoadMore: canLoadMore !== undefined, + loadingMore: false, + }); + } + + /** + * Reduce a list of events to a list of events classified by day. + * + * @param events Events. + * @param overdue Whether to filter overdue events or not. + * @param dateRange Date range to filter events. + * @returns Day events list. + */ + private reduceEvents( + events: AddonCalendarEvent[], + overdue: boolean, + { from, to }: AddonBlockTimelineDateRange, + ): AddonBlockTimelineDayEvents[] { + const filterDates: AddonBlockTimelineFilterDates = { + now: CoreTimeUtils.timestamp(), + midnight: AddonBlockTimeline.getDayStart(), + start: AddonBlockTimeline.getDayStart(from), + end: typeof to === 'number' ? AddonBlockTimeline.getDayStart(to) : undefined, + }; + const eventsByDates = events + .filter((event) => this.filterEvent(event, overdue, filterDates)) + .map((event) => this.mapToTimelineEvent(event, filterDates.now)) + .reduce((filteredEvents, event) => { + const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort); + + filteredEvents[dayTimestamp] = filteredEvents[dayTimestamp] ?? { + dayTimestamp, + events: [], + } as AddonBlockTimelineDayEvents; + + filteredEvents[dayTimestamp].events.push(event); + + return filteredEvents; + }, {} as Record); + + return Object.values(eventsByDates); + } + + /** + * Check whether to include an event in the section or not. + * + * @param event Event. + * @param overdue Whether to filter overdue events or not. + * @param filterDates Filter dates. + * @returns Whetehr to include the event or not. + */ + private filterEvent( + event: AddonCalendarEvent, + overdue: boolean, + { now, midnight, start, end }: AddonBlockTimelineFilterDates, + ): boolean { + if (start > event.timesort || (end && event.timesort >= end)) { + return false; + } + + // Already calculated on 4.0 onwards but this will be live. + if (event.eventtype === 'open' || event.eventtype === 'opensubmission') { + const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort); + + return dayTimestamp > midnight; + } + + // When filtering by overdue, we fetch all events due today, in case any have elapsed already and are overdue. + // This means if filtering by overdue, some events fetched might not be required (eg if due later today). + return !overdue || event.timesort < now; + } + + /** + * Map a calendar event to a timeline event. + * + * @param event Calendar event. + * @param now Current time. + * @returns Timeline event. + */ + private mapToTimelineEvent(event: AddonCalendarEvent, now: number): AddonBlockTimelineEvent { + const modulename = event.modulename || event.icon.component; + + return { + ...event, + modulename, + overdue: event.timesort < now, + iconUrl: CoreCourse.getModuleIconSrc(event.icon.component), + iconTitle: CoreCourse.translateModuleName(modulename), + } as AddonBlockTimelineEvent; + } + +} + +/** + * Timestamps to use during event filtering. + */ +export type AddonBlockTimelineFilterDates = { + now: number; + midnight: number; + start: number; + end?: number; +}; + +/** + * Date range. + */ +export type AddonBlockTimelineDateRange = { + from: number; + to?: number; +}; + +/** + * Timeline event. + */ +export type AddonBlockTimelineEvent = AddonCalendarEvent & { + iconUrl?: string; + iconTitle?: string; +}; + +/** + * List of events in a day. + */ +export type AddonBlockTimelineDayEvents = { + events: AddonBlockTimelineEvent[]; + dayTimestamp: number; +}; 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 5244492db..4ce38eaa3 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 @@ -7,7 +7,7 @@ - +

{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }}

@@ -30,15 +30,15 @@

+ [contextInstanceId]="event.id" [courseId]="event.course?.id"> {{ 'addon.block_timeline.overdue' | translate }}

-

- + @@ -52,7 +52,7 @@ - + {{event.action.name}} @@ -66,18 +66,10 @@ -

+
- + {{ 'core.loadmore' | translate }}
- - - -

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

-
-
- - diff --git a/src/addons/block/timeline/components/events/events.ts b/src/addons/block/timeline/components/events/events.ts index 368c010e8..ea416854e 100644 --- a/src/addons/block/timeline/components/events/events.ts +++ b/src/addons/block/timeline/components/events/events.ts @@ -12,16 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, OnChanges, EventEmitter, SimpleChange } from '@angular/core'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; -import { CoreTimeUtils } from '@services/utils/time'; -import { CoreCourse } from '@features/course/services/course'; import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; -import { AddonCalendarEvent } from '@addons/calendar/services/calendar'; import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; -import { AddonBlockTimeline } from '../../services/timeline'; +import { AddonBlockTimelineDayEvents } from '@addons/block/timeline/classes/section'; /** * Directive to render a list of events in course overview. @@ -31,106 +28,15 @@ import { AddonBlockTimeline } from '../../services/timeline'; templateUrl: 'addon-block-timeline-events.html', styleUrls: ['events.scss'], }) -export class AddonBlockTimelineEventsComponent implements OnChanges { +export class AddonBlockTimelineEventsComponent { - @Input() events: AddonBlockTimelineEvent[] = []; // The events to render. + @Input() events: AddonBlockTimelineDayEvents[] = []; // The events to render. @Input() course?: CoreEnrolledCourseDataWithOptions; // Whether to show the course name. - @Input() from = 0; // Number of days from today to offset the events. - @Input() to?: number; // Number of days from today to limit the events to. If not defined, no limit. - @Input() overdue = false; // If filtering overdue events or not. + @Input() showInlineCourse = true; // Whether to show the course name within event items. @Input() canLoadMore = false; // Whether more events can be loaded. + @Input() loadingMore = false; // Whether loading is ongoing. @Output() loadMore = new EventEmitter(); // Notify that more events should be loaded. - showCourse = false; // Whether to show the course name. - empty = true; - loadingMore = false; - filteredEvents: AddonBlockTimelineEventFilteredEvent[] = []; - - /** - * @inheritdoc - */ - ngOnChanges(changes: {[name: string]: SimpleChange}): void { - this.showCourse = !this.course; - - if (changes.events || changes.from || changes.to) { - if (this.events) { - const filteredEvents = this.filterEventsByTime(); - this.empty = !filteredEvents || filteredEvents.length <= 0; - - const eventsByDay: Record = {}; - filteredEvents.forEach((event) => { - const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort); - - if (eventsByDay[dayTimestamp]) { - eventsByDay[dayTimestamp].push(event); - } else { - eventsByDay[dayTimestamp] = [event]; - } - }); - - this.filteredEvents = Object.keys(eventsByDay).map((key) => { - const dayTimestamp = parseInt(key); - - return { - dayTimestamp, - events: eventsByDay[dayTimestamp], - }; - }); - this.loadingMore = false; - } else { - this.empty = true; - } - } - } - - /** - * Filter the events by time. - * - * @return Filtered events. - */ - protected filterEventsByTime(): AddonBlockTimelineEvent[] { - const start = AddonBlockTimeline.getDayStart(this.from); - const end = this.to !== undefined - ? AddonBlockTimeline.getDayStart(this.to) - : undefined; - - const now = CoreTimeUtils.timestamp(); - const midnight = AddonBlockTimeline.getDayStart(); - - return this.events.filter((event) => { - if (start > event.timesort || (end && event.timesort >= end)) { - return false; - } - - // Already calculated on 4.0 onwards but this will be live. - event.overdue = event.timesort < now; - - if (event.eventtype === 'open' || event.eventtype === 'opensubmission') { - const dayTimestamp = CoreTimeUtils.getMidnightForTimestamp(event.timesort); - - return dayTimestamp > midnight; - } - - // When filtering by overdue, we fetch all events due today, in case any have elapsed already and are overdue. - // This means if filtering by overdue, some events fetched might not be required (eg if due later today). - return (!this.overdue || event.overdue); - }).map((event) => { - event.iconUrl = CoreCourse.getModuleIconSrc(event.icon.component); - event.modulename = event.modulename || event.icon.component; - event.iconTitle = CoreCourse.translateModuleName(event.modulename); - - return event; - }); - } - - /** - * Load more events clicked. - */ - loadMoreEvents(): void { - this.loadingMore = true; - this.loadMore.emit(); - } - /** * Action clicked. * @@ -157,14 +63,3 @@ export class AddonBlockTimelineEventsComponent implements OnChanges { } } - -type AddonBlockTimelineEvent = Omit & { - eventtype: string; - iconUrl?: string; - iconTitle?: string; -}; - -type AddonBlockTimelineEventFilteredEvent = { - events: AddonBlockTimelineEvent[]; - dayTimestamp: number; -}; 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 e518b440c..c6d5514af 100644 --- a/src/addons/block/timeline/components/timeline/addon-block-timeline.html +++ b/src/addons/block/timeline/components/timeline/addon-block-timeline.html @@ -4,70 +4,51 @@ - + - - - - {{ 'core.all' | translate }} - - - {{ 'addon.block_timeline.overdue' | translate }} + + + {{ option.name | translate }} - {{ 'addon.block_timeline.duedate' | translate }} + {{ ' addon.block_timeline.duedate' | translate }} - - {{ 'addon.block_timeline.next7days' | translate }} - - - {{ 'addon.block_timeline.next30days' | translate }} - - - {{ 'addon.block_timeline.next3months' | translate }} - - - {{ 'addon.block_timeline.next6months' | translate }} + + {{ option.name | translate }} - + - - - - {{'addon.block_timeline.sortbydates' | translate}} - - - {{'addon.block_timeline.sortbycourses' | translate}} + + {{ option.name | translate }} - - - - - - - - + + + - - + diff --git a/src/addons/block/timeline/components/timeline/timeline.ts b/src/addons/block/timeline/components/timeline/timeline.ts index 6fcadf44c..70bbad552 100644 --- a/src/addons/block/timeline/components/timeline/timeline.ts +++ b/src/addons/block/timeline/components/timeline/timeline.ts @@ -12,18 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { CoreSites } from '@services/sites'; -import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; +import { ICoreBlockComponent } from '@features/block/classes/base-block-component'; import { AddonBlockTimeline } from '../../services/timeline'; -import { AddonCalendarEvent } from '@addons/calendar/services/calendar'; import { CoreUtils } from '@services/utils/utils'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; -import { CoreSite } from '@classes/site'; import { CoreCourses } from '@features/courses/services/courses'; import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; -import { CoreNavigator } from '@services/navigator'; +import { BehaviorSubject, combineLatest, Observable, of, Subject } from 'rxjs'; +import { catchError, distinctUntilChanged, map, share, tap } 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'; /** * Component to render a timeline block. @@ -32,251 +35,286 @@ import { CoreNavigator } from '@services/navigator'; selector: 'addon-block-timeline', templateUrl: 'addon-block-timeline.html', styleUrls: ['timeline.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit { +export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent { - sort = 'sortbydates'; - filter = 'next30days'; - currentSite!: CoreSite; - timeline: { - events: AddonCalendarEvent[]; - loaded: boolean; - canLoadMore?: number; - } = { - events: [], - loaded: false, - }; - - timelineCourses: { - courses: AddonBlockTimelineCourse[]; - loaded: boolean; - canLoadMore?: number; - } = { - courses: [], - loaded: false, - }; - - dataFrom?: number; - dataTo?: number; - overdue = false; - - searchEnabled = false; - searchText = ''; + sort = new FormControl(); + sort$!: Observable; + sortOptions!: AddonBlockTimelineOption[]; + filter = new FormControl(); + filter$!: Observable; + statusFilterOptions!: AddonBlockTimelineOption[]; + dateFilterOptions!: AddonBlockTimelineOption[]; + search$: Subject; + sections$!: Observable; + loaded = false; + protected logger: CoreLogger; protected courseIdsToInvalidate: number[] = []; protected fetchContentDefaultError = 'Error getting timeline data.'; - protected gradePeriodAfter = 0; - protected gradePeriodBefore = 0; constructor() { - super('AddonBlockTimelineComponent'); + this.logger = CoreLogger.getInstance('AddonBlockTimelineComponent'); + this.search$ = new BehaviorSubject(null); + this.initializeSort(); + this.initializeFilter(); + this.initializeSections(); + } + + get AddonBlockTimelineSort(): typeof AddonBlockTimelineSort { + return AddonBlockTimelineSort; } /** * @inheritdoc */ async ngOnInit(): Promise { - try { - this.currentSite = CoreSites.getRequiredCurrentSite(); - } catch (error) { - CoreDomUtils.showErrorModal(error); + 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, + ]); - CoreNavigator.back(); - - return; - } - - this.filter = await this.currentSite.getLocalSiteConfig('AddonBlockTimelineFilter', this.filter); - this.switchFilter(this.filter); - - this.sort = await this.currentSite.getLocalSiteConfig('AddonBlockTimelineSort', this.sort); - - this.searchEnabled = this.currentSite.isVersionGreaterEqualThan('4.0'); - - super.ngOnInit(); + this.sort.setValue(sort); + this.filter.setValue(filter); + this.search$.next(search); } /** - * Perform the invalidate content function. + * Sort changed. * - * @return Resolved when done. + * @param sort New sort. */ - invalidateContent(): Promise { - const promises: Promise[] = []; - - promises.push(AddonBlockTimeline.invalidateActionEventsByTimesort()); - promises.push(AddonBlockTimeline.invalidateActionEventsByCourses()); - promises.push(CoreCourses.invalidateUserCourses()); - promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions()); - if (this.courseIdsToInvalidate.length > 0) { - promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.join(','))); - } - - return CoreUtils.allPromises(promises); + sortChanged(sort: AddonBlockTimelineSort): void { + CoreSites.getRequiredCurrentSite().setLocalSiteConfig('AddonBlockTimelineSort', sort); } /** - * Fetch the courses for my overview. - * - * @return Promise resolved when done. - */ - protected async fetchContent(): Promise { - if (this.sort == 'sortbydates') { - return this.fetchMyOverviewTimeline().finally(() => { - this.timeline.loaded = true; - }); - } - - if (this.sort == 'sortbycourses') { - return this.fetchMyOverviewTimelineByCourses().finally(() => { - this.timelineCourses.loaded = true; - }); - } - } - - /** - * Load more events. - * - * @param course Course. If defined, it will update the course events, timeline otherwise. - * @return Promise resolved when done. - */ - async loadMore(course?: AddonBlockTimelineCourse): Promise { - try { - if (course) { - const courseEvents = - await AddonBlockTimeline.getActionEventsByCourse(course.id, course.canLoadMore, this.searchText); - course.events = course.events?.concat(courseEvents.events); - course.canLoadMore = courseEvents.canLoadMore; - } else { - await this.fetchMyOverviewTimeline(this.timeline.canLoadMore); - } - } catch (error) { - CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError); - } - } - - /** - * Fetch the timeline. - * - * @param afterEventId The last event id. - * @return Promise resolved when done. - */ - protected async fetchMyOverviewTimeline(afterEventId?: number): Promise { - const events = await AddonBlockTimeline.getActionEventsByTimesort(afterEventId, this.searchText); - - this.timeline.events = afterEventId ? this.timeline.events.concat(events.events) : events.events; - this.timeline.canLoadMore = events.canLoadMore; - } - - /** - * Fetch the timeline by courses. - * - * @return Promise resolved when done. - */ - protected async fetchMyOverviewTimelineByCourses(): Promise { - try { - this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter'), 10); - this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore'), 10); - } catch { - this.gradePeriodAfter = 0; - this.gradePeriodBefore = 0; - } - - // Do not filter courses by date because they can contain activities due. - this.timelineCourses.courses = await CoreCoursesHelper.getUserCoursesWithOptions(); - this.courseIdsToInvalidate = this.timelineCourses.courses.map((course) => course.id); - - // Filter only in progress courses. - this.timelineCourses.courses = this.timelineCourses.courses.filter((course) => - !course.hidden && - !CoreCoursesHelper.isPastCourse(course, this.gradePeriodAfter) && - !CoreCoursesHelper.isFutureCourse(course, this.gradePeriodAfter, this.gradePeriodBefore)); - - if (this.timelineCourses.courses.length > 0) { - const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(this.courseIdsToInvalidate, this.searchText); - - this.timelineCourses.courses = this.timelineCourses.courses.filter((course) => { - if (courseEvents[course.id].events.length == 0) { - return false; - } - - course.events = courseEvents[course.id].events; - course.canLoadMore = courseEvents[course.id].canLoadMore; - - return true; - }); - } - } - - /** - * Change timeline filter being viewed. + * Filter changed. * * @param filter New filter. */ - switchFilter(filter: string): void { - this.filter = filter; - this.currentSite.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter); - this.overdue = this.filter === 'overdue'; - - switch (this.filter) { - case 'overdue': - this.dataFrom = -14; - this.dataTo = 1; - break; - case 'next7days': - this.dataFrom = 0; - this.dataTo = 7; - break; - case 'next30days': - this.dataFrom = 0; - this.dataTo = 30; - break; - case 'next3months': - this.dataFrom = 0; - this.dataTo = 90; - break; - case 'next6months': - this.dataFrom = 0; - this.dataTo = 180; - break; - default: - case 'all': - this.dataFrom = -14; - this.dataTo = undefined; - break; - } - } - - /** - * Change timeline sort being viewed. - * - * @param sort New sorting. - */ - switchSort(sort: string): void { - this.sort = sort; - this.currentSite.setLocalSiteConfig('AddonBlockTimelineSort', this.sort); - - if (!this.timeline.loaded && this.sort == 'sortbydates') { - this.fetchContent(); - } else if (!this.timelineCourses.loaded && this.sort == 'sortbycourses') { - this.fetchContent(); - } + filterChanged(filter: AddonBlockTimelineFilter): void { + CoreSites.getRequiredCurrentSite().setLocalSiteConfig('AddonBlockTimelineFilter', filter); } /** * Search text changed. * - * @param searchValue Search value + * @param search New search. */ - searchTextChanged(searchValue = ''): void { - this.searchText = searchValue || ''; + searchChanged(search: string): void { + this.search$.next(search); + } - this.fetchContent(); + /** + * @inheritdoc + */ + async invalidateContent(): Promise { + await CoreUtils.allPromises([ + AddonBlockTimeline.invalidateActionEventsByTimesort(), + AddonBlockTimeline.invalidateActionEventsByCourses(), + CoreCourses.invalidateUserCourses(), + CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions(), + CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.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}`, + })); + } + + /** + * Initialize filter properties. + */ + 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}`, + })); + } + + /** + * Initialize sections properties. + */ + 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(), + catchError(error => { + // An error ocurred in the function, log the error and just resolve the observable so the workflow continues. + this.logger.error(error); + + // Error getting data, fail. + CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError, true); + + return of([]); + }), + share(), + tap(() => (this.loaded = true)), + ); + } + + /** + * Get sections sorted by dates. + * + * @param search Search string. + * @param overdue Whether to filter overdue events or not. + * @param dateRange Date range to filter events by. + * @returns Sections. + */ + protected async getSectionsByDates( + search: string | null, + overdue: boolean, + dateRange: AddonBlockTimelineDateRange, + ): Promise { + const section = new AddonBlockTimelineSection(search, overdue, dateRange); + + await section.loadMore(); + + return section.data$.value.events.length > 0 ? [section] : []; + } + + /** + * Get sections sorted by courses. + * + * @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, + overdue: boolean, + dateRange: AddonBlockTimelineDateRange, + courses: CoreEnrolledCourseDataWithOptions[], + ): Promise { + // 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 ?? ''); + + return courses + .filter( + course => + !course.hidden && + !CoreCoursesHelper.isPastCourse(course, gracePeriod.after) && + !CoreCoursesHelper.isFutureCourse(course, gracePeriod.after, gracePeriod.before) && + courseEvents[course.id].events.length > 0, + ) + .map(course => new AddonBlockTimelineSection( + search, + overdue, + dateRange, + course, + courseEvents[course.id].events, + courseEvents[course.id].canLoadMore, + )) + .filter(section => section.data$.value.events.length > 0); + } + + /** + * Get courses grace period for the current site. + * + * @returns Courses grace period. + */ + protected async getCoursesGracePeriod(): Promise<{ before: number; after: number }> { + try { + const currentSite = CoreSites.getRequiredCurrentSite(); + + return { + before: parseInt(await currentSite.getConfig('coursegraceperiodbefore'), 10), + after: parseInt(await currentSite.getConfig('coursegraceperiodafter'), 10), + }; + } catch { + return { before: 0, after: 0 }; + } } } -export type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & { - events?: AddonCalendarEvent[]; - canLoadMore?: number; -}; +/** + * Sort options. + */ +export enum AddonBlockTimelineSort { + ByDates = 'sortbydates', + ByCourses = 'sortbycourses', +} + +/** + * Filter options. + */ +export const enum AddonBlockTimelineFilter { + All = 'all', + Overdue = 'overdue', + Next7Days = 'next7days', + Next30Days = 'next30days', + Next3Months = 'next3months', + Next6Months = 'next6months', +} + +/** + * Select option. + */ +export interface AddonBlockTimelineOption { + value: Value; + name: string; +} diff --git a/src/addons/block/timeline/services/timeline.ts b/src/addons/block/timeline/services/timeline.ts index c37f36591..8b7f41bd7 100644 --- a/src/addons/block/timeline/services/timeline.ts +++ b/src/addons/block/timeline/services/timeline.ts @@ -107,6 +107,10 @@ export class AddonBlockTimelineProvider { searchValue = '', siteId?: string, ): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> { + if (courseIds.length === 0) { + return {}; + } + const site = await CoreSites.getSite(siteId); const time = this.getDayStart(-14); // Check two weeks ago. diff --git a/src/addons/block/timeline/tests/behat/basic_usage.feature b/src/addons/block/timeline/tests/behat/basic_usage.feature new file mode 100644 index 000000000..ebfd10e03 --- /dev/null +++ b/src/addons/block/timeline/tests/behat/basic_usage.feature @@ -0,0 +1,92 @@ +@app @javascript +Feature: Timeline block. + + Background: + Given the following "users" exist: + | username | + | student1 | + And the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + | Course 2 | C2 | + | Course 3 | C3 | + | Course 4 | C4 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student1 | C2 | student | + | student1 | C3 | student | + | student1 | Acceptance test site | student | + And the following "activities" exist: + | activity | course | idnumber | name | duedate | + | assign | Acceptance test site | assign00 | Assignment 00 | ##tomorrow## | + | assign | C2 | assign01 | Assignment 01 | ##yesterday## | + | assign | C1 | assign02 | Assignment 02 | ##tomorrow## | + | assign | C1 | assign03 | Assignment 03 | ##tomorrow## | + | assign | C2 | assign04 | Assignment 04 | ##+2 days## | + | assign | C1 | assign05 | Assignment 05 | ##+5 days## | + | assign | C2 | assign06 | Assignment 06 | ##+1 month## | + | assign | C2 | assign07 | Assignment 07 | ##+1 month## | + | assign | C3 | assign08 | Assignment 08 | ##+1 month## | + | assign | C2 | assign09 | Assignment 09 | ##+1 month## | + | assign | C1 | assign10 | Assignment 10 | ##+1 month## | + | assign | C1 | assign11 | Assignment 11 | ##+6 months## | + | assign | C1 | assign12 | Assignment 12 | ##+6 months## | + | assign | C1 | assign13 | Assignment 13 | ##+6 months## | + | assign | C2 | assign14 | Assignment 14 | ##+6 months## | + | assign | C2 | assign15 | Assignment 15 | ##+6 months## | + | assign | C2 | assign16 | Assignment 16 | ##+6 months## | + | assign | C3 | assign17 | Assignment 17 | ##+6 months## | + | assign | C3 | assign18 | Assignment 18 | ##+6 months## | + | assign | C3 | assign19 | Assignment 19 | ##+6 months## | + | assign | C1 | assign20 | Assignment 20 | ##+1 year## | + | assign | C1 | assign21 | Assignment 21 | ##+1 year## | + | assign | C2 | assign22 | Assignment 22 | ##+1 year## | + | assign | C2 | assign23 | Assignment 23 | ##+1 year## | + | assign | C3 | assign24 | Assignment 24 | ##+1 year## | + | assign | C3 | assign25 | Assignment 25 | ##+1 year## | + + Scenario: See courses inside block + Given I entered the app as "student1" + Then I should find "Assignment 00" within "Timeline" "ion-card" in the app + And I should find "Assignment 02" within "Timeline" "ion-card" in the app + And I should find "Assignment 05" within "Timeline" "ion-card" in the app + And I should find "Course 1" within "Timeline" "ion-card" in the app + And I should find "Course 2" within "Timeline" "ion-card" in the app + But I should not find "Assignment 01" within "Timeline" "ion-card" in the app + And I should not find "Course 3" within "Timeline" "ion-card" in the app + + When I press "Next 30 days" in the app + And I press "Overdue" in the app + Then I should find "Assignment 01" within "Timeline" "ion-card" in the app + And I should find "Course 2" within "Timeline" "ion-card" in the app + But I should not find "Assignment 00" within "Timeline" "ion-card" in the app + And I should not find "Assignment 02" within "Timeline" "ion-card" in the app + And I should not find "Course 1" within "Timeline" "ion-card" in the app + And I should not find "Course 3" within "Timeline" "ion-card" in the app + + When I press "Overdue" in the app + And I press "All" in the app + Then I should find "Assignment 19" within "Timeline" "ion-card" in the app + And I should find "Course 3" within "Timeline" "ion-card" in the app + But I should not find "Assignment 20" within "Timeline" "ion-card" in the app + + When I press "Load more" in the app + Then I should find "Assignment 21" within "Timeline" "ion-card" in the app + And I should find "Assignment 25" within "Timeline" "ion-card" in the app + + When I press "All" in the app + And I press "Next 7 days" in the app + And I press "Sort by" in the app + And I press "Sort by courses" in the app + Then I should find "Course 1" "h3" within "Timeline" "ion-card" in the app + And I should find "Course 2" "h3" within "Timeline" "ion-card" in the app + And I should find "Assignment 02" within "Timeline" "ion-card" in the app + And I should find "Assignment 04" within "Timeline" "ion-card" in the app + But I should not find "Course 3" within "Timeline" "ion-card" in the app + + When the following "activities" exist: + | activity | course | idnumber | name | duedate | + | assign | C1 | newassign | New Assignment | ##tomorrow## | + And I pull to refresh in the app + Then I should find "New Assignment" in the app diff --git a/src/addons/calendar/services/calendar-helper.ts b/src/addons/calendar/services/calendar-helper.ts index 378fd4ef5..26bbe55ea 100644 --- a/src/addons/calendar/services/calendar-helper.ts +++ b/src/addons/calendar/services/calendar-helper.ts @@ -64,7 +64,7 @@ export class AddonCalendarHelperProvider { * @param eventType Type of the event. * @return Event icon. */ - getEventIcon(eventType: AddonCalendarEventType): string { + getEventIcon(eventType: AddonCalendarEventType | string): string { if (this.eventTypeIcons.length == 0) { CoreUtils.enumKeys(AddonCalendarEventType).forEach((name) => { const value = AddonCalendarEventType[name]; diff --git a/src/addons/calendar/services/calendar.ts b/src/addons/calendar/services/calendar.ts index 67a808231..3e20b9706 100644 --- a/src/addons/calendar/services/calendar.ts +++ b/src/addons/calendar/services/calendar.ts @@ -796,7 +796,7 @@ export class AddonCalendarProvider { * @param event The event to get its type. * @return Event type. */ - getEventType(event: { modulename?: string; eventtype: AddonCalendarEventType}): string { + getEventType(event: { modulename?: string; eventtype: AddonCalendarEventType | string }): string { if (event.modulename) { return 'course'; } @@ -1878,7 +1878,7 @@ export type AddonCalendarEventBase = { activityname?: string; // Activityname. activitystr?: string; // Activitystr. instance?: number; // Instance. - eventtype: AddonCalendarEventType; // Eventtype. + eventtype: AddonCalendarEventType | string; // Eventtype. timestart: number; // Timestart. timeduration: number; // Timeduration. timesort: number; // Timesort. @@ -2284,7 +2284,7 @@ export type AddonCalendarEventToDisplay = Partial & timestart: number; timeduration: number; eventcount: number; - eventtype: AddonCalendarEventType; + eventtype: AddonCalendarEventType | string; courseid?: number; offline?: boolean; showDate?: boolean; // Calculated in the app. Whether date should be shown before this event. diff --git a/src/addons/calendar/services/database/calendar.ts b/src/addons/calendar/services/database/calendar.ts index 1349eb9be..16da851ac 100644 --- a/src/addons/calendar/services/database/calendar.ts +++ b/src/addons/calendar/services/database/calendar.ts @@ -282,7 +282,7 @@ export type AddonCalendarEventDBRecord = { id: number; name: string; description: string; - eventtype: AddonCalendarEventType; + eventtype: AddonCalendarEventType | string; timestart: number; timeduration: number; categoryid?: number; diff --git a/src/core/components/combobox/combobox.ts b/src/core/components/combobox/combobox.ts index 83d1f4877..3a549827b 100644 --- a/src/core/components/combobox/combobox.ts +++ b/src/core/components/combobox/combobox.ts @@ -17,6 +17,7 @@ import { Translate } from '@singletons'; import { ModalOptions } from '@ionic/core'; import { CoreDomUtils } from '@services/utils/dom'; import { IonSelect } from '@ionic/angular'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; /** * Component that show a combo select button (combobox). @@ -40,8 +41,15 @@ import { IonSelect } from '@ionic/angular'; selector: 'core-combobox', templateUrl: 'core-combobox.html', styleUrls: ['combobox.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi:true, + useExisting: CoreComboboxComponent, + }, + ], }) -export class CoreComboboxComponent { +export class CoreComboboxComponent implements ControlValueAccessor { @ViewChild(IonSelect) select!: IonSelect; @@ -58,6 +66,49 @@ export class CoreComboboxComponent { expanded = false; + protected touched = false; + protected formOnChange?: (value: unknown) => void; + protected formOnTouched?: () => void; + + /** + * @inheritdoc + */ + writeValue(selection: string): void { + this.selection = selection; + } + + /** + * @inheritdoc + */ + registerOnChange(onChange: (value: unknown) => void): void { + this.formOnChange = onChange; + } + + /** + * @inheritdoc + */ + registerOnTouched(onTouched: () => void): void { + this.formOnTouched = onTouched; + } + + /** + * @inheritdoc + */ + setDisabledState(disabled: boolean): void { + this.disabled = disabled; + } + + /** + * Callback when the selected value changes. + * + * @param selection Selected value. + */ + onValueChanged(selection: unknown): void { + this.touch(); + this.onChange.emit(selection); + this.formOnChange?.(selection); + } + /** * Shows combobox modal. * @@ -65,6 +116,8 @@ export class CoreComboboxComponent { * @return Promise resolved when done. */ async openSelect(event?: UIEvent): Promise { + this.touch(); + if (this.interface == 'modal') { if (this.expanded || !this.modalOptions) { return; @@ -79,11 +132,23 @@ export class CoreComboboxComponent { this.expanded = false; if (data) { - this.onChange.emit(data); + this.onValueChanged(data); } } else if (this.select) { this.select.open(event); } } + /** + * Mark as touched. + */ + protected touch(): void { + if (this.touched) { + return; + } + + this.touched = true; + this.formOnTouched?.(); + } + } diff --git a/src/core/components/combobox/core-combobox.html b/src/core/components/combobox/core-combobox.html index 02b0cec27..6142c71c0 100644 --- a/src/core/components/combobox/core-combobox.html +++ b/src/core/components/combobox/core-combobox.html @@ -5,7 +5,7 @@
- diff --git a/src/core/features/block/classes/base-block-component.ts b/src/core/features/block/classes/base-block-component.ts index 3888bc932..b73e0e517 100644 --- a/src/core/features/block/classes/base-block-component.ts +++ b/src/core/features/block/classes/base-block-component.ts @@ -18,7 +18,6 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreTextUtils } from '@services/utils/text'; import { CoreCourseBlock } from '../../course/services/course'; -import { IonRefresher } from '@ionic/angular'; import { Params } from '@angular/router'; import { ContextLevel } from '@/core/constants'; import { CoreNavigationOptions } from '@services/navigator'; @@ -29,7 +28,7 @@ import { CoreNavigationOptions } from '@services/navigator'; @Component({ template: '', }) -export abstract class CoreBlockBaseComponent implements OnInit { +export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent { @Input() title!: string; // The block title. @Input() block!: CoreCourseBlock; // The block to render. @@ -65,30 +64,12 @@ export abstract class CoreBlockBaseComponent implements OnInit { await this.loadContent(); } - /** - * Refresh the data. - * - * @param refresher Refresher. - * @param done Function to call when done. - * @param showErrors If show errors to the user of hide them. - * @return Promise resolved when done. - */ - async doRefresh(refresher?: IonRefresher, done?: () => void, showErrors: boolean = false): Promise { - if (this.loaded) { - return this.refreshContent(showErrors).finally(() => { - refresher?.complete(); - done && done(); - }); - } - } - /** * Perform the refresh content function. * - * @param showErrors Wether to show errors to the user or hide them. * @return Resolved when done. */ - protected async refreshContent(showErrors: boolean = false): Promise { + protected async refreshContent(): Promise { // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs. try { await this.invalidateContent(); @@ -97,13 +78,11 @@ export abstract class CoreBlockBaseComponent implements OnInit { this.logger.error(ex); } - await this.loadContent(true, showErrors); + await this.loadContent(); } /** - * Perform the invalidate content function. - * - * @return Resolved when done. + * @inheritdoc */ async invalidateContent(): Promise { return; @@ -111,16 +90,11 @@ export abstract class CoreBlockBaseComponent implements OnInit { /** * Loads the component contents and shows the corresponding error. - * - * @param refresh Whether we're refreshing data. - * @param showErrors Wether to show errors to the user or hide them. - * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async loadContent(refresh?: boolean, showErrors: boolean = false): Promise { + protected async loadContent(): Promise { // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs. try { - await this.fetchContent(refresh); + await this.fetchContent(); } catch (error) { // An error ocurred in the function, log the error and just resolve the promise so the workflow continues. this.logger.error(error); @@ -135,12 +109,22 @@ export abstract class CoreBlockBaseComponent implements OnInit { /** * Download the component contents. * - * @param refresh Whether we're refreshing data. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async fetchContent(refresh: boolean = false): Promise { + protected async fetchContent(): Promise { return; } } + +/** + * Interface for block components. + */ +export interface ICoreBlockComponent { + + /** + * Perform the invalidate content function. + */ + invalidateContent(): Promise; + +} diff --git a/src/core/features/block/components/block/block.ts b/src/core/features/block/components/block/block.ts index 893d12a29..fd51ca57d 100644 --- a/src/core/features/block/components/block/block.ts +++ b/src/core/features/block/components/block/block.ts @@ -17,8 +17,7 @@ import { CoreBlockDelegate } from '../../services/block-delegate'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; import { Subscription } from 'rxjs'; import { CoreCourseBlock } from '@/core/features/course/services/course'; -import { IonRefresher } from '@ionic/angular'; -import type { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; +import type { ICoreBlockComponent } from '@features/block/classes/base-block-component'; /** * Component to render a block. @@ -30,14 +29,14 @@ import type { CoreBlockBaseComponent } from '@features/block/classes/base-block- }) export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { - @ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent; + @ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent; @Input() block!: CoreCourseBlock; // The block to render. @Input() contextLevel!: string; // The context where the block will be used. @Input() instanceId!: number; // The instance ID associated with the context level. @Input() extraData!: Record; // Any extra data to be passed to the block. - componentClass?: Type; // The class of the component to render. + componentClass?: Type; // The class of the component to render. data: Record = {}; // Data to pass to the component. class?: string; // CSS class to apply to the block. loaded = false; @@ -133,24 +132,6 @@ export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { delete this.blockSubscription; } - /** - * Refresh the data. - * - * @param refresher Refresher. Please pass this only if the refresher should finish when this function finishes. - * @param done Function to call when done. - * @param showErrors If show errors to the user of hide them. - * @return Promise resolved when done. - */ - async doRefresh( - refresher?: IonRefresher, - done?: () => void, - showErrors: boolean = false, - ): Promise { - if (this.dynamicComponent) { - await this.dynamicComponent.callComponentMethod('doRefresh', refresher, done, showErrors); - } - } - /** * Invalidate some data. * diff --git a/src/core/features/block/services/block-delegate.ts b/src/core/features/block/services/block-delegate.ts index f68be3e11..8a5b28b8d 100644 --- a/src/core/features/block/services/block-delegate.ts +++ b/src/core/features/block/services/block-delegate.ts @@ -22,7 +22,7 @@ import { Params } from '@angular/router'; import { makeSingleton } from '@singletons'; import { CoreBlockDefaultHandler } from './handlers/default-block'; import { CoreNavigationOptions } from '@services/navigator'; -import type { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; +import type { ICoreBlockComponent } from '@features/block/classes/base-block-component'; /** * Interface that all blocks must implement. @@ -66,7 +66,7 @@ export interface CoreBlockHandlerData { * The component to render the contents of the block. * It's recommended to return the class of the component, but you can also return an instance of the component. */ - component: Type; + component: Type; /** * Data to pass to the component. All the properties in this object will be passed to the component as inputs. diff --git a/src/core/features/courses/services/courses.ts b/src/core/features/courses/services/courses.ts index 8466592eb..7604f7653 100644 --- a/src/core/features/courses/services/courses.ts +++ b/src/core/features/courses/services/courses.ts @@ -1005,6 +1005,10 @@ export class CoreCoursesProvider { * @return Promise resolved when the data is invalidated. */ async invalidateCoursesByField(field: string = '', value: number | string = '', siteId?: string): Promise { + if (typeof value === 'string' && value.length === 0) { + return; + } + siteId = siteId || CoreSites.getCurrentSiteId(); const result = await this.fixCoursesByFieldParams(field, value, siteId); diff --git a/src/core/features/siteplugins/classes/handlers/block-handler.ts b/src/core/features/siteplugins/classes/handlers/block-handler.ts index d99fa9550..34fcb8727 100644 --- a/src/core/features/siteplugins/classes/handlers/block-handler.ts +++ b/src/core/features/siteplugins/classes/handlers/block-handler.ts @@ -14,7 +14,7 @@ import { Type } from '@angular/core'; import { CoreError } from '@classes/errors/error'; -import type { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; +import type { ICoreBlockComponent } from '@features/block/classes/base-block-component'; import { CoreBlockPreRenderedComponent } from '@features/block/components/pre-rendered-block/pre-rendered-block'; import { CoreBlockDelegate, CoreBlockHandler, CoreBlockHandlerData } from '@features/block/services/block-delegate'; import { CoreCourseBlock } from '@features/course/services/course'; @@ -52,7 +52,7 @@ export class CoreSitePluginsBlockHandler extends CoreSitePluginsBaseHandler impl instanceId: number, ): Promise { const className = this.handlerSchema.displaydata?.class || 'block_' + block.name; - let component: Type | undefined; + let component: Type | undefined; if (this.handlerSchema.displaydata?.type == 'title') { component = CoreSitePluginsOnlyTitleBlockComponent; diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 9de9a1b9e..27e73a7da 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -81,12 +81,12 @@ export class CoreUtilsProvider { * @param promises Promises. * @return Promise resolved if all promises are resolved and rejected if at least 1 promise fails. */ - async allPromises(promises: Promise[]): Promise { + async allPromises(promises: unknown[]): Promise { if (!promises || !promises.length) { - return Promise.resolve(); + return; } - const getPromiseError = async (promise): Promise => { + const getPromiseError = async (promise: unknown): Promise => { try { await promise; } catch (error) { diff --git a/src/core/utils/rxjs.ts b/src/core/utils/rxjs.ts new file mode 100644 index 000000000..509f28d82 --- /dev/null +++ b/src/core/utils/rxjs.ts @@ -0,0 +1,62 @@ +// (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 { FormControl } from '@angular/forms'; +import { Observable, OperatorFunction } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +/** + * Create an observable that emits the current form control value. + * + * @param control Form control. + * @returns Form control value observable. + */ +export function formControlValue(control: FormControl): Observable { + return control.valueChanges.pipe( + startWithOnSubscribed(() => control.value), + filter(value => value !== null), + ); +} + +/** + * Observable operator that waits for a promise to resolve before emitting the result. + * + * @returns Operator. + */ +export function resolved(): OperatorFunction, T> { + return source => new Observable(subscriber => { + const subscription = source.subscribe(async promise => { + const value = await promise; + + return subscriber.next(value); + }); + + return subscription; + }); +} + +/** + * Same as the built-in startWith operator, but evaluates the starting value for each subscriber + * on subscription. + * + * @param onSubscribed Callback to calculate the starting value. + * @returns Operator. + */ +export function startWithOnSubscribed(onSubscribed: () => T): OperatorFunction { + return source => new Observable(subscriber => { + subscriber.next(onSubscribed()); + + return source.subscribe(value => subscriber.next(value)); + }); +} diff --git a/src/core/utils/tests/rxjs.test.ts b/src/core/utils/tests/rxjs.test.ts new file mode 100644 index 000000000..738fd389b --- /dev/null +++ b/src/core/utils/tests/rxjs.test.ts @@ -0,0 +1,89 @@ +// (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 { formControlValue, resolved, startWithOnSubscribed } from '@/core/utils/rxjs'; +import { mock } from '@/testing/utils'; +import { FormControl } from '@angular/forms'; +import { of, Subject } from 'rxjs'; + +describe('RXJS Utils', () => { + + it('Emits filtered form values', () => { + // Arrange. + let value = 'one'; + const emited: string[] = []; + const valueChanges = new Subject(); + const control = mock({ + get value() { + return value; + }, + setValue(newValue) { + value = newValue; + + valueChanges.next(newValue); + }, + valueChanges, + }); + + // Act. + control.setValue('two'); + + formControlValue(control).subscribe(value => emited.push(value)); + + control.setValue(null); + control.setValue('three'); + + // Assert. + expect(emited).toEqual(['two', 'three']); + }); + + it('Emits resolved values', async () => { + // Arrange. + const emited: string[] = []; + const promises = [ + Promise.resolve('one'), + Promise.resolve('two'), + Promise.resolve('three'), + ]; + const source = of(...promises); + + // Act. + source.pipe(resolved()).subscribe(value => emited.push(value)); + + // Assert. + await Promise.all(promises); + + expect(emited).toEqual(['one', 'two', 'three']); + }); + + it('Adds starting values on subscription', () => { + // Arrange. + let store = 'one'; + const emited: string[] = []; + const source = of('final'); + + // Act. + const withStore = source.pipe(startWithOnSubscribed(() => store)); + + store = 'two'; + withStore.subscribe(value => emited.push(value)); + + store = 'three'; + withStore.subscribe(value => emited.push(value)); + + // Assert. + expect(emited).toEqual(['two', 'final', 'three', 'final']); + }); + +}); diff --git a/src/testing/services/behat-runtime.ts b/src/testing/services/behat-runtime.ts index c99c0b996..4a6866900 100644 --- a/src/testing/services/behat-runtime.ts +++ b/src/testing/services/behat-runtime.ts @@ -28,6 +28,8 @@ import { CoreCronDelegate } from '@services/cron'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreDom } from '@singletons/dom'; +import { IonRefresher } from '@ionic/angular'; +import { CoreCoursesDashboardPage } from '@features/courses/pages/dashboard/dashboard'; /** * Behat runtime servive with public API. @@ -52,6 +54,7 @@ export class TestsBehatRuntime { log: TestsBehatRuntime.log, press: TestsBehatRuntime.press, pressStandard: TestsBehatRuntime.pressStandard, + pullToRefresh: TestsBehatRuntime.pullToRefresh, scrollTo: TestsBehatRuntime.scrollTo, setField: TestsBehatRuntime.setField, handleCustomURL: TestsBehatRuntime.handleCustomURL, @@ -349,6 +352,39 @@ export class TestsBehatRuntime { } } + /** + * Trigger a pull to refresh gesture in the current page. + * + * @return OK if successful, or ERROR: followed by message + */ + static async pullToRefresh(): Promise { + this.log('Action - pullToRefresh'); + + try { + // TODO We should generalize this to work with other pages. It's not possible to use + // an IonRefresher instance because it doesn't expose any methods to trigger refresh, + // so we'll have to find another way. + + const dashboard = this.getAngularInstance( + 'page-core-courses-dashboard', + 'CoreCoursesDashboardPage', + ); + + if (!dashboard) { + return 'ERROR: It\'s not possible to pull to refresh the current page ' + + '(the dashboard page is the only one supported at the moment).'; + } + + await new Promise(resolve => { + dashboard.refreshDashboard({ complete: resolve } as IonRefresher); + }); + + return 'OK'; + } catch (error) { + return 'ERROR: ' + error.message; + } + } + /** * Gets the currently displayed page header. * @@ -403,7 +439,7 @@ export class TestsBehatRuntime { * @param className Constructor class name * @return Component instance */ - static getAngularInstance(selector: string, className: string): unknown { + static getAngularInstance(selector: string, className: string): T | null { this.log('Action - Get Angular instance ' + selector + ', ' + className); // eslint-disable-next-line @typescript-eslint/no-explicit-any