MOBILE-4038 timeline: Refactor using OnPush

main
Noel De Martin 2022-06-14 09:20:00 +02:00
parent 8dd0ef41a1
commit 70e11f9ea4
23 changed files with 927 additions and 464 deletions

View File

@ -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.
*

View File

@ -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<void> {
if (this.loaded) {
return this.refreshContent().finally(() => {
refresher?.complete();
done && done();
});
}
}
/**
* @inheritdoc
*/
async invalidateContent(): Promise<void> {
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<void> {
protected async fetchContent(): Promise<void> {
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;
}
/**

View File

@ -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<void> {
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<string, AddonBlockTimelineDayEvents>);
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;
};

View File

@ -7,7 +7,7 @@
</h3>
</ion-label>
</ion-item>
<ion-item-group *ngFor="let dayEvents of filteredEvents">
<ion-item-group *ngFor="let dayEvents of events">
<ion-item>
<ion-label>
<h4 [class.core-bold]="!course">{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }}</h4>
@ -30,15 +30,15 @@
<p class="item-heading addon-block-timeline-activity-name-with-status">
<span>
<core-format-text [text]="event.activityname || event.name" contextLevel="module"
[contextInstanceId]="event.id" [courseId]="event.course && event.course.id">
[contextInstanceId]="event.id" [courseId]="event.course?.id">
</core-format-text>
</span>
<ion-badge *ngIf="event.overdue" color="danger">{{ 'addon.block_timeline.overdue' | translate }}
</ion-badge>
</p>
<p *ngIf="(showCourse && event.course) || event.activitystr"
<p *ngIf="(showInlineCourse && event.course) || event.activitystr"
class="addon-block-timeline-activity-course-activity">
<span *ngIf="showCourse && event.course">
<span *ngIf="showInlineCourse && event.course">
<core-format-text [text]="event.course.fullnamedisplay" contextLevel="course"
[contextInstanceId]="event.course.id">
</core-format-text>
@ -52,7 +52,7 @@
</ion-col>
</ion-row>
</ion-col>
<ion-col class="addon-block-timeline-activity-action ion-no-padding" *ngIf="event.action?.actionable">
<ion-col class="addon-block-timeline-activity-action ion-no-padding" *ngIf="event.action && event.action.actionable">
<ion-button fill="outline" (click)="action($event, event.action.url)" [title]="event.action.name" class="chip">
{{event.action.name}}
<ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount">
@ -66,18 +66,10 @@
</ng-container>
</ion-item-group>
<div class="ion-padding ion-text-center" *ngIf="canLoadMore && !empty">
<div class="ion-padding ion-text-center" *ngIf="canLoadMore">
<!-- Button and spinner to show more attempts. -->
<ion-button expand="block" (click)="loadMoreEvents()" fill="outline" *ngIf="!loadingMore">
<ion-button expand="block" (click)="loadMore.emit()" fill="outline" *ngIf="!loadingMore">
{{ 'core.loadmore' | translate }}
</ion-button>
<ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner>
</div>
<ion-item *ngIf="empty && course">
<ion-label class="ion-text-wrap">
<p>{{'addon.block_timeline.noevents' | translate}}</p>
</ion-label>
</ion-item>
<core-empty-box *ngIf="empty && !course" image="assets/img/icons/activities.svg" [message]="'addon.block_timeline.noevents' | translate">
</core-empty-box>

View File

@ -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<number, AddonBlockTimelineEvent[]> = {};
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<AddonCalendarEvent, 'eventtype'> & {
eventtype: string;
iconUrl?: string;
iconTitle?: string;
};
type AddonBlockTimelineEventFilteredEvent = {
events: AddonBlockTimelineEvent[];
dayTimestamp: number;
};

View File

@ -4,70 +4,51 @@
</ion-label>
</ion-item-divider>
<core-loading [hideUntil]="loaded">
<ion-row class="ion-hide-md-up addon-block-timeline-filter" *ngIf="searchEnabled">
<ion-row class="ion-hide-md-up addon-block-timeline-filter" *ngIf="(search$ | async) !== null">
<ion-col>
<!-- Filter courses. -->
<core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"
<core-search-box (onSubmit)="searchChanged($event)" (onClear)="searchChanged('')"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" [lengthCheck]="2"
searchArea="AddonBlockTimeline"></core-search-box>
</ion-col>
</ion-row>
<ion-row class="ion-justify-content-between ion-align-items-center addon-block-timeline-filter">
<ion-col size="auto">
<core-combobox [selection]="filter" (onChange)="switchFilter($event)">
<ion-select-option class="ion-text-wrap" value="all">
{{ 'core.all' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="overdue">
{{ 'addon.block_timeline.overdue' | translate }}
<core-combobox [formControl]="filter" (onChange)="filterChanged($event)">
<ion-select-option *ngFor="let option of statusFilterOptions; last as last"
[attr.class]="'ion-text-wrap' + last ? ' core-select-option-border-bottom' : ''" [value]="option.value">
{{ option.name | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap core-select-option-title" disabled value="disabled">
{{ 'addon.block_timeline.duedate' | translate }}
{{ ' addon.block_timeline.duedate' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next7days">
{{ 'addon.block_timeline.next7days' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next30days">
{{ 'addon.block_timeline.next30days' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next3months">
{{ 'addon.block_timeline.next3months' | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="next6months">
{{ 'addon.block_timeline.next6months' | translate }}
<ion-select-option *ngFor="let option of dateFilterOptions" class="ion-text-wrap" [value]="option.value">
{{ option.name | translate }}
</ion-select-option>
</core-combobox>
</ion-col>
<ion-col class="ion-hide-md-down" *ngIf="searchEnabled">
<ion-col class="ion-hide-md-down" *ngIf="(search$ | async) !== null">
<!-- Filter courses. -->
<core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"
<core-search-box (onSubmit)="searchChanged($event)" (onClear)="searchChanged('')"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" [lengthCheck]="2"
searchArea="AddonBlockTimeline"></core-search-box>
</ion-col>
<ion-col size="auto">
<core-combobox [label]="'core.sortby' | translate" [selection]="sort" (onChange)="switchSort($event)"
<core-combobox [label]="'core.sortby' | translate" [formControl]="sort" (onChange)="sortChanged($event)"
icon="fas-sort-amount-down-alt">
<ion-select-option class="ion-text-wrap" value="sortbydates">
{{'addon.block_timeline.sortbydates' | translate}}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="sortbycourses">
{{'addon.block_timeline.sortbycourses' | translate}}
<ion-select-option *ngFor="let option of sortOptions" class="ion-text-wrap" [value]="option.value">
{{ option.name | translate }}
</ion-select-option>
</core-combobox>
</ion-col>
</ion-row>
<core-loading [hideUntil]="timeline.loaded" [hidden]="sort != 'sortbydates'">
<addon-block-timeline-events [events]="timeline.events" [canLoadMore]="timeline.canLoadMore" (loadMore)="loadMore()"
[from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events>
</core-loading>
<core-loading [hideUntil]="timelineCourses.loaded" [hidden]="sort != 'sortbycourses'">
<ng-container *ngFor="let course of timelineCourses.courses">
<addon-block-timeline-events [events]="course.events" [canLoadMore]="course.canLoadMore" (loadMore)="loadMore(course)"
[course]="course" [from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events>
<ng-container *ngIf="sections$ | async as sections">
<ng-container *ngFor="let section of sections">
<addon-block-timeline-events *ngIf="section.data$ | async as data" [events]="data.events"
[showInlineCourse]="(sort$ | async) === AddonBlockTimelineSort.ByDates" [canLoadMore]="data.canLoadMore"
[loadingMore]="data.loadingMore" (loadMore)="section.loadMore()" [course]="section.course"> </addon-block-timeline-events>
</ng-container>
<core-empty-box *ngIf="timelineCourses.courses.length == 0" image="assets/img/icons/courses.svg"
<core-empty-box *ngIf="sections && sections.length === 0" image="assets/img/icons/courses.svg"
[message]="'addon.block_timeline.noevents' | translate"></core-empty-box>
</core-loading>
</ng-container>
</core-loading>

View File

@ -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: <AddonCalendarEvent[]> [],
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<AddonBlockTimelineSort>;
sortOptions!: AddonBlockTimelineOption<AddonBlockTimelineSort>[];
filter = new FormControl();
filter$!: Observable<AddonBlockTimelineFilter>;
statusFilterOptions!: AddonBlockTimelineOption<AddonBlockTimelineFilter>[];
dateFilterOptions!: AddonBlockTimelineOption<AddonBlockTimelineFilter>[];
search$: Subject<string | null>;
sections$!: Observable<AddonBlockTimelineSection[]>;
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<void> {
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<void> {
const promises: Promise<void>[] = [];
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<AddonBlockTimelineFilter, AddonBlockTimelineDateRange> = {
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<AddonBlockTimelineSort>;
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<AddonBlockTimelineSection[]> {
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<AddonBlockTimelineSection[]> {
// 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: Value;
name: string;
}

View File

@ -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.

View File

@ -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

View File

@ -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];

View File

@ -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<AddonCalendarCalendarEvent> &
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.

View File

@ -282,7 +282,7 @@ export type AddonCalendarEventDBRecord = {
id: number;
name: string;
description: string;
eventtype: AddonCalendarEventType;
eventtype: AddonCalendarEventType | string;
timestart: number;
timeduration: number;
categoryid?: number;

View File

@ -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<void> {
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?.();
}
}

View File

@ -5,7 +5,7 @@
<div class="select-icon-inner"></div>
</div>
</ion-button>
<ion-select *ngIf="interface != 'modal'" class="ion-text-start" [(ngModel)]="selection" (ngModelChange)="onChange.emit(selection)"
<ion-select *ngIf="interface != 'modal'" class="ion-text-start" [(ngModel)]="selection" (ngModelChange)="onValueChanged(selection)"
[interface]="interface" [attr.aria-label]="label + ': ' + selection" [disabled]="disabled" [hidden]="!!icon">
<ng-content></ng-content>
</ion-select>

View File

@ -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<void> {
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<void> {
protected async refreshContent(): Promise<void> {
// 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<void> {
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<void> {
protected async loadContent(): Promise<void> {
// 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<void> {
protected async fetchContent(): Promise<void> {
return;
}
}
/**
* Interface for block components.
*/
export interface ICoreBlockComponent {
/**
* Perform the invalidate content function.
*/
invalidateContent(): Promise<void>;
}

View File

@ -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<CoreBlockBaseComponent>;
@ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent<ICoreBlockComponent>;
@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<string, unknown>; // Any extra data to be passed to the block.
componentClass?: Type<unknown>; // The class of the component to render.
componentClass?: Type<ICoreBlockComponent>; // The class of the component to render.
data: Record<string, unknown> = {}; // 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<void> {
if (this.dynamicComponent) {
await this.dynamicComponent.callComponentMethod('doRefresh', refresher, done, showErrors);
}
}
/**
* Invalidate some data.
*

View File

@ -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<CoreBlockBaseComponent>;
component: Type<ICoreBlockComponent>;
/**
* Data to pass to the component. All the properties in this object will be passed to the component as inputs.

View File

@ -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<void> {
if (typeof value === 'string' && value.length === 0) {
return;
}
siteId = siteId || CoreSites.getCurrentSiteId();
const result = await this.fixCoursesByFieldParams(field, value, siteId);

View File

@ -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<CoreBlockHandlerData> {
const className = this.handlerSchema.displaydata?.class || 'block_' + block.name;
let component: Type<CoreBlockBaseComponent> | undefined;
let component: Type<ICoreBlockComponent> | undefined;
if (this.handlerSchema.displaydata?.type == 'title') {
component = CoreSitePluginsOnlyTitleBlockComponent;

View File

@ -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<unknown>[]): Promise<void> {
async allPromises(promises: unknown[]): Promise<void> {
if (!promises || !promises.length) {
return Promise.resolve();
return;
}
const getPromiseError = async (promise): Promise<Error | void> => {
const getPromiseError = async (promise: unknown): Promise<Error | void> => {
try {
await promise;
} catch (error) {

View File

@ -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<T = unknown>(control: FormControl): Observable<T> {
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<T>(): OperatorFunction<Promise<T>, 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<T>(onSubscribed: () => T): OperatorFunction<T, T> {
return source => new Observable(subscriber => {
subscriber.next(onSubscribed());
return source.subscribe(value => subscriber.next(value));
});
}

View File

@ -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<FormControl>({
get value() {
return value;
},
setValue(newValue) {
value = newValue;
valueChanges.next(newValue);
},
valueChanges,
});
// Act.
control.setValue('two');
formControlValue<string>(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']);
});
});

View File

@ -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<string> {
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<CoreCoursesDashboardPage>(
'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<T = unknown>(selector: string, className: string): T | null {
this.log('Action - Get Angular instance ' + selector + ', ' + className);
// eslint-disable-next-line @typescript-eslint/no-explicit-any