MOBILE-4038 timeline: Refactor using OnPush
parent
8dd0ef41a1
commit
70e11f9ea4
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 }}
|
||||
</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>
|
||||
|
|
|
@ -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 || '';
|
||||
|
||||
this.fetchContent();
|
||||
searchChanged(search: string): void {
|
||||
this.search$.next(search);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
async invalidateContent(): Promise<void> {
|
||||
await CoreUtils.allPromises([
|
||||
AddonBlockTimeline.invalidateActionEventsByTimesort(),
|
||||
AddonBlockTimeline.invalidateActionEventsByCourses(),
|
||||
CoreCourses.invalidateUserCourses(),
|
||||
CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions(),
|
||||
CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.join(',')),
|
||||
]);
|
||||
}
|
||||
|
||||
export type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & {
|
||||
events?: AddonCalendarEvent[];
|
||||
canLoadMore?: number;
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
|
@ -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];
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -282,7 +282,7 @@ export type AddonCalendarEventDBRecord = {
|
|||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
eventtype: AddonCalendarEventType;
|
||||
eventtype: AddonCalendarEventType | string;
|
||||
timestart: number;
|
||||
timeduration: number;
|
||||
categoryid?: number;
|
||||
|
|
|
@ -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?.();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
|
@ -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']);
|
||||
});
|
||||
|
||||
});
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue