Merge pull request #3330 from NoelDeMartin/MOBILE-4038

MOBILE-4038 timeline: Refactor using OnPush
main
Alfonso Salces 2022-07-06 11:46:02 +02:00 committed by GitHub
commit 93cfcd4ae0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 968 additions and 511 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 * 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. * 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 * @param string $locator Element locator
* @throws DriverException If the press doesn't work * @throws DriverException If the press doesn't work
*/ */
@ -558,6 +558,26 @@ class behat_app extends behat_app_helper {
$this->wait_for_pending_js(); $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. * 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 { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion';
import { IonSearchbar } from '@ionic/angular'; import { IonRefresher, IonSearchbar } from '@ionic/angular';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] = const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] =
@ -86,6 +86,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
protected currentSite!: CoreSite; protected currentSite!: CoreSite;
protected allCourses: CoreEnrolledCourseDataWithOptions[] = []; protected allCourses: CoreEnrolledCourseDataWithOptions[] = [];
protected prefetchIconsInitialized = false; protected prefetchIconsInitialized = false;
protected isDirty = false;
protected isDestroyed = false; protected isDestroyed = false;
protected coursesObserver?: CoreEventObserver; protected coursesObserver?: CoreEventObserver;
protected updateSiteObserver?: 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 * @inheritdoc
*/ */
async invalidateContent(): Promise<void> { async invalidateContent(): Promise<void> {
this.isDirty = true;
const courseIds = this.allCourses.map((course) => course.id); const courseIds = this.allCourses.map((course) => course.id);
await this.invalidateCourses(courseIds); await this.invalidateCourses(courseIds);
@ -207,7 +225,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
/** /**
* @inheritdoc * @inheritdoc
*/ */
protected async fetchContent(refresh?: boolean): Promise<void> { protected async fetchContent(): Promise<void> {
const config = this.block.configsRecord; const config = this.block.configsRecord;
const showCategories = config?.displaycategories?.value == '1'; const showCategories = config?.displaycategories?.value == '1';
@ -218,15 +236,15 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
undefined, undefined,
showCategories, showCategories,
{ {
readingStrategy: refresh ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined, readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined,
}, },
); );
this.hasCourses = this.allCourses.length > 0; this.hasCourses = this.allCourses.length > 0;
try { try {
this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', refresh), 10); this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', this.isDirty), 10);
this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', refresh), 10); this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', this.isDirty), 10);
} catch { } catch {
this.gradePeriodAfter = 0; this.gradePeriodAfter = 0;
this.gradePeriodBefore = 0; this.gradePeriodBefore = 0;
@ -235,6 +253,8 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem
this.loadSort(); this.loadSort();
this.loadLayouts(config?.layouts?.value.split(',')); this.loadLayouts(config?.layouts?.value.split(','));
this.loadFilters(config); this.loadFilters(config);
this.isDirty = false;
} }
/** /**

View File

@ -54,15 +54,15 @@ export class AddonBlockRecentlyAccessedItemsProvider {
const cmIds: number[] = []; const cmIds: number[] = [];
items = await Promise.all(items.map(async (item) => { items = items.map((item) => {
const modicon = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'src'); const modicon = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'src');
item.iconUrl = await CoreCourse.getModuleIconSrc(item.modname, modicon || undefined); item.iconUrl = CoreCourse.getModuleIconSrc(item.modname, modicon || undefined);
item.iconTitle = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'title'); item.iconTitle = item.icon && CoreDomUtils.getHTMLElementAttribute(item.icon, 'title');
cmIds.push(item.cmid); cmIds.push(item.cmid);
return item; return item;
})); });
// Check if the viewed module should be updated for each activity. // Check if the viewed module should be updated for each activity.
const lastViewedMap = await CoreCourse.getCertainModulesViewed(cmIds, site.getId()); const lastViewedMap = await CoreCourse.getCertainModulesViewed(cmIds, site.getId());

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> </h3>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item-group *ngFor="let dayEvents of filteredEvents"> <ion-item-group *ngFor="let dayEvents of events">
<ion-item> <ion-item>
<ion-label> <ion-label>
<h4 [class.core-bold]="!course">{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }}</h4> <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"> <p class="item-heading addon-block-timeline-activity-name-with-status">
<span> <span>
<core-format-text [text]="event.activityname || event.name" contextLevel="module" <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> </core-format-text>
</span> </span>
<ion-badge *ngIf="event.overdue" color="danger">{{ 'addon.block_timeline.overdue' | translate }} <ion-badge *ngIf="event.overdue" color="danger">{{ 'addon.block_timeline.overdue' | translate }}
</ion-badge> </ion-badge>
</p> </p>
<p *ngIf="(showCourse && event.course) || event.activitystr" <p *ngIf="(showInlineCourse && event.course) || event.activitystr"
class="addon-block-timeline-activity-course-activity"> 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" <core-format-text [text]="event.course.fullnamedisplay" contextLevel="course"
[contextInstanceId]="event.course.id"> [contextInstanceId]="event.course.id">
</core-format-text> </core-format-text>
@ -52,7 +52,7 @@
</ion-col> </ion-col>
</ion-row> </ion-row>
</ion-col> </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"> <ion-button fill="outline" (click)="action($event, event.action.url)" [title]="event.action.name" class="chip">
{{event.action.name}} {{event.action.name}}
<ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount"> <ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount">
@ -66,18 +66,10 @@
</ng-container> </ng-container>
</ion-item-group> </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. --> <!-- 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 }} {{ 'core.loadmore' | translate }}
</ion-button> </ion-button>
<ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner> <ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner>
</div> </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 // See the License for the specific language governing permissions and
// limitations under the License. // 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 { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text'; 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 { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper';
import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; 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. * 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', templateUrl: 'addon-block-timeline-events.html',
styleUrls: ['events.scss'], 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() course?: CoreEnrolledCourseDataWithOptions; // Whether to show the course name.
@Input() from = 0; // Number of days from today to offset the events. @Input() showInlineCourse = true; // Whether to show the course name within event items.
@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() canLoadMore = false; // Whether more events can be loaded. @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. @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
*/
async ngOnChanges(changes: {[name: string]: SimpleChange}): Promise<void> {
this.showCourse = !this.course;
if (changes.events || changes.from || changes.to) {
if (this.events) {
const filteredEvents = await 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 async filterEventsByTime(): Promise<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 await Promise.all(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(async (event) => {
event.iconUrl = await 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. * 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-label>
</ion-item-divider> </ion-item-divider>
<core-loading [hideUntil]="loaded"> <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> <ion-col>
<!-- Filter courses. --> <!-- Filter courses. -->
<core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()" <core-search-box (onSubmit)="searchChanged($event)" (onClear)="searchChanged('')"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2" [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" [lengthCheck]="2"
searchArea="AddonBlockTimeline"></core-search-box> searchArea="AddonBlockTimeline"></core-search-box>
</ion-col> </ion-col>
</ion-row> </ion-row>
<ion-row class="ion-justify-content-between ion-align-items-center addon-block-timeline-filter"> <ion-row class="ion-justify-content-between ion-align-items-center addon-block-timeline-filter">
<ion-col size="auto"> <ion-col size="auto">
<core-combobox [selection]="filter" (onChange)="switchFilter($event)"> <core-combobox [formControl]="filter" (onChange)="filterChanged($event)">
<ion-select-option class="ion-text-wrap" value="all"> <ion-select-option *ngFor="let option of statusFilterOptions; last as last"
{{ 'core.all' | translate }} [attr.class]="'ion-text-wrap' + last ? ' core-select-option-border-bottom' : ''" [value]="option.value">
</ion-select-option> {{ option.name | translate }}
<ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="overdue">
{{ 'addon.block_timeline.overdue' | translate }}
</ion-select-option> </ion-select-option>
<ion-select-option class="ion-text-wrap core-select-option-title" disabled value="disabled"> <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>
<ion-select-option class="ion-text-wrap" value="next7days"> <ion-select-option *ngFor="let option of dateFilterOptions" class="ion-text-wrap" [value]="option.value">
{{ 'addon.block_timeline.next7days' | translate }} {{ option.name | 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> </ion-select-option>
</core-combobox> </core-combobox>
</ion-col> </ion-col>
<ion-col class="ion-hide-md-down" *ngIf="searchEnabled"> <ion-col class="ion-hide-md-down" *ngIf="(search$ | async) !== null">
<!-- Filter courses. --> <!-- Filter courses. -->
<core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()" <core-search-box (onSubmit)="searchChanged($event)" (onClear)="searchChanged('')"
[placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2" [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" [lengthCheck]="2"
searchArea="AddonBlockTimeline"></core-search-box> searchArea="AddonBlockTimeline"></core-search-box>
</ion-col> </ion-col>
<ion-col size="auto"> <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"> icon="fas-sort-amount-down-alt">
<ion-select-option class="ion-text-wrap" value="sortbydates"> <ion-select-option *ngFor="let option of sortOptions" class="ion-text-wrap" [value]="option.value">
{{'addon.block_timeline.sortbydates' | translate}} {{ option.name | translate }}
</ion-select-option>
<ion-select-option class="ion-text-wrap" value="sortbycourses">
{{'addon.block_timeline.sortbycourses' | translate}}
</ion-select-option> </ion-select-option>
</core-combobox> </core-combobox>
</ion-col> </ion-col>
</ion-row> </ion-row>
<ng-container *ngIf="sections$ | async as sections">
<ng-container *ngFor="let section of sections">
<core-loading [hideUntil]="timeline.loaded" [hidden]="sort != 'sortbydates'"> <addon-block-timeline-events *ngIf="section.data$ | async as data" [events]="data.events"
<addon-block-timeline-events [events]="timeline.events" [canLoadMore]="timeline.canLoadMore" (loadMore)="loadMore()" [showInlineCourse]="(sort$ | async) === AddonBlockTimelineSort.ByDates" [canLoadMore]="data.canLoadMore"
[from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events> [loadingMore]="data.loadingMore" (loadMore)="section.loadMore()" [course]="section.course"> </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> </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> [message]="'addon.block_timeline.noevents' | translate"></core-empty-box>
</core-loading> </ng-container>
</core-loading> </core-loading>

View File

@ -12,18 +12,21 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { CoreSites } from '@services/sites'; 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 { AddonBlockTimeline } from '../../services/timeline';
import { AddonCalendarEvent } from '@addons/calendar/services/calendar';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper';
import { CoreSite } from '@classes/site';
import { CoreCourses } from '@features/courses/services/courses'; import { CoreCourses } from '@features/courses/services/courses';
import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; 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. * Component to render a timeline block.
@ -32,251 +35,286 @@ import { CoreNavigator } from '@services/navigator';
selector: 'addon-block-timeline', selector: 'addon-block-timeline',
templateUrl: 'addon-block-timeline.html', templateUrl: 'addon-block-timeline.html',
styleUrls: ['timeline.scss'], styleUrls: ['timeline.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit { export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent {
sort = 'sortbydates'; sort = new FormControl();
filter = 'next30days'; sort$!: Observable<AddonBlockTimelineSort>;
currentSite!: CoreSite; sortOptions!: AddonBlockTimelineOption<AddonBlockTimelineSort>[];
timeline: { filter = new FormControl();
events: AddonCalendarEvent[]; filter$!: Observable<AddonBlockTimelineFilter>;
loaded: boolean; statusFilterOptions!: AddonBlockTimelineOption<AddonBlockTimelineFilter>[];
canLoadMore?: number; dateFilterOptions!: AddonBlockTimelineOption<AddonBlockTimelineFilter>[];
} = { search$: Subject<string | null>;
events: <AddonCalendarEvent[]> [], sections$!: Observable<AddonBlockTimelineSection[]>;
loaded: false, loaded = false;
};
timelineCourses: {
courses: AddonBlockTimelineCourse[];
loaded: boolean;
canLoadMore?: number;
} = {
courses: [],
loaded: false,
};
dataFrom?: number;
dataTo?: number;
overdue = false;
searchEnabled = false;
searchText = '';
protected logger: CoreLogger;
protected courseIdsToInvalidate: number[] = []; protected courseIdsToInvalidate: number[] = [];
protected fetchContentDefaultError = 'Error getting timeline data.'; protected fetchContentDefaultError = 'Error getting timeline data.';
protected gradePeriodAfter = 0;
protected gradePeriodBefore = 0;
constructor() { 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 * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
try { const currentSite = CoreSites.getRequiredCurrentSite();
this.currentSite = CoreSites.getRequiredCurrentSite(); const [sort, filter, search] = await Promise.all([
} catch (error) { currentSite.getLocalSiteConfig('AddonBlockTimelineSort', AddonBlockTimelineSort.ByDates),
CoreDomUtils.showErrorModal(error); currentSite.getLocalSiteConfig('AddonBlockTimelineFilter', AddonBlockTimelineFilter.Next30Days),
currentSite.isVersionGreaterEqualThan('4.0') ? '' : null,
]);
CoreNavigator.back(); this.sort.setValue(sort);
this.filter.setValue(filter);
return; this.search$.next(search);
}
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();
} }
/** /**
* Perform the invalidate content function. * Sort changed.
* *
* @return Resolved when done. * @param sort New sort.
*/ */
invalidateContent(): Promise<void> { sortChanged(sort: AddonBlockTimelineSort): void {
const promises: Promise<void>[] = []; CoreSites.getRequiredCurrentSite().setLocalSiteConfig('AddonBlockTimelineSort', sort);
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);
} }
/** /**
* Fetch the courses for my overview. * Filter changed.
*
* @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.
* *
* @param filter New filter. * @param filter New filter.
*/ */
switchFilter(filter: string): void { filterChanged(filter: AddonBlockTimelineFilter): void {
this.filter = filter; CoreSites.getRequiredCurrentSite().setLocalSiteConfig('AddonBlockTimelineFilter', 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();
}
} }
/** /**
* Search text changed. * Search text changed.
* *
* @param searchValue Search value * @param search New search.
*/ */
searchTextChanged(searchValue = ''): void { searchChanged(search: string): void {
this.searchText = searchValue || ''; 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(',')),
]);
} }
export type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & { /**
events?: AddonCalendarEvent[]; * Initialize sort properties.
canLoadMore?: number; */
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;
}

View File

@ -107,6 +107,10 @@ export class AddonBlockTimelineProvider {
searchValue = '', searchValue = '',
siteId?: string, siteId?: string,
): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> { ): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> {
if (courseIds.length === 0) {
return {};
}
const site = await CoreSites.getSite(siteId); const site = await CoreSites.getSite(siteId);
const time = this.getDayStart(-14); // Check two weeks ago. 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

@ -529,9 +529,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI
day.eventsFormated = day.eventsFormated || []; day.eventsFormated = day.eventsFormated || [];
day.filteredEvents = day.filteredEvents || []; day.filteredEvents = day.filteredEvents || [];
// Format online events. // Format online events.
const onlineEventsFormatted = await Promise.all( const onlineEventsFormatted = day.events.map((event) => AddonCalendarHelper.formatEventData(event));
day.events.map(async (event) => AddonCalendarHelper.formatEventData(event)),
);
day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted); day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted);

View File

@ -165,7 +165,7 @@ export class AddonCalendarUpcomingEventsComponent implements OnInit, DoCheck, On
async fetchEvents(): Promise<void> { async fetchEvents(): Promise<void> {
// Don't pass courseId and categoryId, we'll filter them locally. // Don't pass courseId and categoryId, we'll filter them locally.
const result = await AddonCalendar.getUpcomingEvents(); const result = await AddonCalendar.getUpcomingEvents();
this.onlineEvents = await Promise.all(result.events.map((event) => AddonCalendarHelper.formatEventData(event))); this.onlineEvents = result.events.map((event) => AddonCalendarHelper.formatEventData(event));
// Merge the online events with offline data. // Merge the online events with offline data.
this.events = this.mergeEvents(); this.events = this.mergeEvents();
// Filter events by course. // Filter events by course.

View File

@ -669,9 +669,7 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte
try { try {
// Don't pass courseId and categoryId, we'll filter them locally. // Don't pass courseId and categoryId, we'll filter them locally.
result = await AddonCalendar.getDayEvents(day.moment.year(), day.moment.month() + 1, day.moment.date()); result = await AddonCalendar.getDayEvents(day.moment.year(), day.moment.month() + 1, day.moment.date());
preloadedDay.onlineEvents = await Promise.all( preloadedDay.onlineEvents = result.events.map((event) => AddonCalendarHelper.formatEventData(event));
result.events.map((event) => AddonCalendarHelper.formatEventData(event)),
);
} catch (error) { } catch (error) {
// Allow navigating to non-cached days in offline (behave as if using emergency cache). // Allow navigating to non-cached days in offline (behave as if using emergency cache).
if (CoreNetwork.isOnline()) { if (CoreNetwork.isOnline()) {

View File

@ -198,7 +198,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
// Get the event data. // Get the event data.
if (this.eventId >= 0) { if (this.eventId >= 0) {
const event = await AddonCalendar.getEventById(this.eventId); const event = await AddonCalendar.getEventById(this.eventId);
this.event = await AddonCalendarHelper.formatEventData(event); this.event = AddonCalendarHelper.formatEventData(event);
} }
try { try {

View File

@ -64,7 +64,7 @@ export class AddonCalendarHelperProvider {
* @param eventType Type of the event. * @param eventType Type of the event.
* @return Event icon. * @return Event icon.
*/ */
getEventIcon(eventType: AddonCalendarEventType): string { getEventIcon(eventType: AddonCalendarEventType | string): string {
if (this.eventTypeIcons.length == 0) { if (this.eventTypeIcons.length == 0) {
CoreUtils.enumKeys(AddonCalendarEventType).forEach((name) => { CoreUtils.enumKeys(AddonCalendarEventType).forEach((name) => {
const value = AddonCalendarEventType[name]; const value = AddonCalendarEventType[name];
@ -164,9 +164,9 @@ export class AddonCalendarHelperProvider {
* *
* @param event Event to format. * @param event Event to format.
*/ */
async formatEventData( formatEventData(
event: AddonCalendarEvent | AddonCalendarEventBase | AddonCalendarGetEventsEvent, event: AddonCalendarEvent | AddonCalendarEventBase | AddonCalendarGetEventsEvent,
): Promise<AddonCalendarEventToDisplay> { ): AddonCalendarEventToDisplay {
const eventFormatted: AddonCalendarEventToDisplay = { const eventFormatted: AddonCalendarEventToDisplay = {
...event, ...event,
@ -181,7 +181,7 @@ export class AddonCalendarHelperProvider {
}; };
if (event.modulename) { if (event.modulename) {
eventFormatted.eventIcon = await CoreCourse.getModuleIconSrc(event.modulename); eventFormatted.eventIcon = CoreCourse.getModuleIconSrc(event.modulename);
eventFormatted.moduleIcon = eventFormatted.eventIcon; eventFormatted.moduleIcon = eventFormatted.eventIcon;
eventFormatted.iconTitle = CoreCourse.translateModuleName(event.modulename); eventFormatted.iconTitle = CoreCourse.translateModuleName(event.modulename);
} }

View File

@ -796,7 +796,7 @@ export class AddonCalendarProvider {
* @param event The event to get its type. * @param event The event to get its type.
* @return Event type. * @return Event type.
*/ */
getEventType(event: { modulename?: string; eventtype: AddonCalendarEventType}): string { getEventType(event: { modulename?: string; eventtype: AddonCalendarEventType | string }): string {
if (event.modulename) { if (event.modulename) {
return 'course'; return 'course';
} }
@ -1878,7 +1878,7 @@ export type AddonCalendarEventBase = {
activityname?: string; // Activityname. activityname?: string; // Activityname.
activitystr?: string; // Activitystr. activitystr?: string; // Activitystr.
instance?: number; // Instance. instance?: number; // Instance.
eventtype: AddonCalendarEventType; // Eventtype. eventtype: AddonCalendarEventType | string; // Eventtype.
timestart: number; // Timestart. timestart: number; // Timestart.
timeduration: number; // Timeduration. timeduration: number; // Timeduration.
timesort: number; // Timesort. timesort: number; // Timesort.
@ -2284,7 +2284,7 @@ export type AddonCalendarEventToDisplay = Partial<AddonCalendarCalendarEvent> &
timestart: number; timestart: number;
timeduration: number; timeduration: number;
eventcount: number; eventcount: number;
eventtype: AddonCalendarEventType; eventtype: AddonCalendarEventType | string;
courseid?: number; courseid?: number;
offline?: boolean; offline?: boolean;
showDate?: boolean; // Calculated in the app. Whether date should be shown before this event. 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; id: number;
name: string; name: string;
description: string; description: string;
eventtype: AddonCalendarEventType; eventtype: AddonCalendarEventType | string;
timestart: number; timestart: number;
timeduration: number; timeduration: number;
categoryid?: number; categoryid?: number;

View File

@ -46,7 +46,7 @@ export class AddonModLabelModuleHandlerService extends CoreModuleHandlerBase imp
/** /**
* @inheritdoc * @inheritdoc
*/ */
async getData(module: CoreCourseModuleData): Promise<CoreCourseModuleHandlerData> { getData(module: CoreCourseModuleData): CoreCourseModuleHandlerData {
// Remove the description from the module so it isn't rendered twice. // Remove the description from the module so it isn't rendered twice.
const title = module.description || ''; const title = module.description || '';
module.description = ''; module.description = '';

View File

@ -104,13 +104,11 @@ export class AddonModResourceModuleHandlerService extends CoreModuleHandlerBase
// Ignore errors. // Ignore errors.
}); });
this.getIconSrc(module).then((icon) => { try {
handlerData.icon = icon; handlerData.icon = this.getIconSrc(module);
} catch {
return;
}).catch(() => {
// Ignore errors. // Ignore errors.
}); }
return handlerData; return handlerData;
} }
@ -227,13 +225,13 @@ export class AddonModResourceModuleHandlerService extends CoreModuleHandlerBase
/** /**
* @inheritdoc * @inheritdoc
*/ */
async getIconSrc(module?: CoreCourseModuleData): Promise<string | undefined> { getIconSrc(module?: CoreCourseModuleData): string | undefined {
if (!module) { if (!module) {
return; return;
} }
if (CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('4.0')) { if (CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('4.0')) {
return await CoreCourse.getModuleIconSrc(module.modname, module.modicon); return CoreCourse.getModuleIconSrc(module.modname, module.modicon);
} }
let mimetypeIcon = ''; let mimetypeIcon = '';
@ -251,7 +249,7 @@ export class AddonModResourceModuleHandlerService extends CoreModuleHandlerBase
mimetypeIcon = CoreMimetypeUtils.getFileIcon(file.filename || ''); mimetypeIcon = CoreMimetypeUtils.getFileIcon(file.filename || '');
} }
return await CoreCourse.getModuleIconSrc(module.modname, module.modicon, mimetypeIcon); return CoreCourse.getModuleIconSrc(module.modname, module.modicon, mimetypeIcon);
} }
/** /**

View File

@ -55,7 +55,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple
/** /**
* @inheritdoc * @inheritdoc
*/ */
async getData(module: CoreCourseModuleData): Promise<CoreCourseModuleHandlerData> { getData(module: CoreCourseModuleData): CoreCourseModuleHandlerData {
/** /**
* Open the URL. * Open the URL.
@ -80,7 +80,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple
}; };
const handlerData: CoreCourseModuleHandlerData = { const handlerData: CoreCourseModuleHandlerData = {
icon: await CoreCourse.getModuleIconSrc(module.modname, module.modicon), icon: CoreCourse.getModuleIconSrc(module.modname, module.modicon),
title: module.name, title: module.name,
class: 'addon-mod_url-handler', class: 'addon-mod_url-handler',
showDownloadButton: false, showDownloadButton: false,
@ -109,7 +109,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple
}], }],
}; };
this.hideLinkButton(module).then(async (hideButton) => { this.hideLinkButton(module).then((hideButton) => {
if (!handlerData.buttons) { if (!handlerData.buttons) {
return; return;
} }
@ -120,7 +120,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple
const icon = AddonModUrl.guessIcon(module.contents[0].fileurl); const icon = AddonModUrl.guessIcon(module.contents[0].fileurl);
// Calculate the icon to use. // Calculate the icon to use.
handlerData.icon = await CoreCourse.getModuleIconSrc(module.modname, module.modicon, icon); handlerData.icon = CoreCourse.getModuleIconSrc(module.modname, module.modicon, icon);
} }
return; return;

View File

@ -17,6 +17,7 @@ import { Translate } from '@singletons';
import { ModalOptions } from '@ionic/core'; import { ModalOptions } from '@ionic/core';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { IonSelect } from '@ionic/angular'; import { IonSelect } from '@ionic/angular';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
/** /**
* Component that show a combo select button (combobox). * Component that show a combo select button (combobox).
@ -40,8 +41,15 @@ import { IonSelect } from '@ionic/angular';
selector: 'core-combobox', selector: 'core-combobox',
templateUrl: 'core-combobox.html', templateUrl: 'core-combobox.html',
styleUrls: ['combobox.scss'], styleUrls: ['combobox.scss'],
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi:true,
useExisting: CoreComboboxComponent,
},
],
}) })
export class CoreComboboxComponent { export class CoreComboboxComponent implements ControlValueAccessor {
@ViewChild(IonSelect) select!: IonSelect; @ViewChild(IonSelect) select!: IonSelect;
@ -58,6 +66,49 @@ export class CoreComboboxComponent {
expanded = false; 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. * Shows combobox modal.
* *
@ -65,6 +116,8 @@ export class CoreComboboxComponent {
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async openSelect(event?: UIEvent): Promise<void> { async openSelect(event?: UIEvent): Promise<void> {
this.touch();
if (this.interface == 'modal') { if (this.interface == 'modal') {
if (this.expanded || !this.modalOptions) { if (this.expanded || !this.modalOptions) {
return; return;
@ -79,11 +132,23 @@ export class CoreComboboxComponent {
this.expanded = false; this.expanded = false;
if (data) { if (data) {
this.onChange.emit(data); this.onValueChanged(data);
} }
} else if (this.select) { } else if (this.select) {
this.select.open(event); 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 class="select-icon-inner"></div>
</div> </div>
</ion-button> </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"> [interface]="interface" [attr.aria-label]="label + ': ' + selection" [disabled]="disabled" [hidden]="!!icon">
<ng-content></ng-content> <ng-content></ng-content>
</ion-select> </ion-select>

View File

@ -18,7 +18,6 @@ import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils'; import { CoreUtils } from '@services/utils/utils';
import { CoreTextUtils } from '@services/utils/text'; import { CoreTextUtils } from '@services/utils/text';
import { CoreCourseBlock } from '../../course/services/course'; import { CoreCourseBlock } from '../../course/services/course';
import { IonRefresher } from '@ionic/angular';
import { Params } from '@angular/router'; import { Params } from '@angular/router';
import { ContextLevel } from '@/core/constants'; import { ContextLevel } from '@/core/constants';
import { CoreNavigationOptions } from '@services/navigator'; import { CoreNavigationOptions } from '@services/navigator';
@ -29,7 +28,7 @@ import { CoreNavigationOptions } from '@services/navigator';
@Component({ @Component({
template: '', template: '',
}) })
export abstract class CoreBlockBaseComponent implements OnInit { export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent {
@Input() title!: string; // The block title. @Input() title!: string; // The block title.
@Input() block!: CoreCourseBlock; // The block to render. @Input() block!: CoreCourseBlock; // The block to render.
@ -65,30 +64,12 @@ export abstract class CoreBlockBaseComponent implements OnInit {
await this.loadContent(); 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. * Perform the refresh content function.
* *
* @param showErrors Wether to show errors to the user or hide them.
* @return Resolved when done. * @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. // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
try { try {
await this.invalidateContent(); await this.invalidateContent();
@ -97,13 +78,11 @@ export abstract class CoreBlockBaseComponent implements OnInit {
this.logger.error(ex); this.logger.error(ex);
} }
await this.loadContent(true, showErrors); await this.loadContent();
} }
/** /**
* Perform the invalidate content function. * @inheritdoc
*
* @return Resolved when done.
*/ */
async invalidateContent(): Promise<void> { async invalidateContent(): Promise<void> {
return; return;
@ -111,16 +90,11 @@ export abstract class CoreBlockBaseComponent implements OnInit {
/** /**
* Loads the component contents and shows the corresponding error. * 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(): Promise<void> {
protected async loadContent(refresh?: boolean, showErrors: boolean = false): Promise<void> {
// Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs. // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
try { try {
await this.fetchContent(refresh); await this.fetchContent();
} catch (error) { } catch (error) {
// An error ocurred in the function, log the error and just resolve the promise so the workflow continues. // An error ocurred in the function, log the error and just resolve the promise so the workflow continues.
this.logger.error(error); this.logger.error(error);
@ -135,12 +109,22 @@ export abstract class CoreBlockBaseComponent implements OnInit {
/** /**
* Download the component contents. * Download the component contents.
* *
* @param refresh Whether we're refreshing data.
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
// eslint-disable-next-line @typescript-eslint/no-unused-vars protected async fetchContent(): Promise<void> {
protected async fetchContent(refresh: boolean = false): Promise<void> {
return; 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 { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { CoreCourseBlock } from '@/core/features/course/services/course'; import { CoreCourseBlock } from '@/core/features/course/services/course';
import { IonRefresher } from '@ionic/angular'; import type { ICoreBlockComponent } from '@features/block/classes/base-block-component';
import type { CoreBlockBaseComponent } from '@features/block/classes/base-block-component';
/** /**
* Component to render a block. * 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 { 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() block!: CoreCourseBlock; // The block to render.
@Input() contextLevel!: string; // The context where the block will be used. @Input() contextLevel!: string; // The context where the block will be used.
@Input() instanceId!: number; // The instance ID associated with the context level. @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. @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. data: Record<string, unknown> = {}; // Data to pass to the component.
class?: string; // CSS class to apply to the block. class?: string; // CSS class to apply to the block.
loaded = false; loaded = false;
@ -133,24 +132,6 @@ export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck {
delete this.blockSubscription; 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. * Invalidate some data.
* *

View File

@ -22,7 +22,7 @@ import { Params } from '@angular/router';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { CoreBlockDefaultHandler } from './handlers/default-block'; import { CoreBlockDefaultHandler } from './handlers/default-block';
import { CoreNavigationOptions } from '@services/navigator'; 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. * Interface that all blocks must implement.
@ -66,7 +66,7 @@ export interface CoreBlockHandlerData {
* The component to render the contents of the block. * 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. * 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. * Data to pass to the component. All the properties in this object will be passed to the component as inputs.

View File

@ -34,14 +34,14 @@ export class CoreModuleHandlerBase implements Partial<CoreCourseModuleHandler> {
/** /**
* @inheritdoc * @inheritdoc
*/ */
async getData( getData(
module: CoreCourseModuleData, module: CoreCourseModuleData,
courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars
sectionId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars sectionId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars
forCoursePage?: boolean, // eslint-disable-line @typescript-eslint/no-unused-vars forCoursePage?: boolean, // eslint-disable-line @typescript-eslint/no-unused-vars
): Promise<CoreCourseModuleHandlerData> { ): Promise<CoreCourseModuleHandlerData> | CoreCourseModuleHandlerData {
return { return {
icon: await CoreCourse.getModuleIconSrc(module.modname, module.modicon), icon: CoreCourse.getModuleIconSrc(module.modname, module.modicon),
title: module.name, title: module.name,
class: 'addon-mod_' + module.modname + '-handler', class: 'addon-mod_' + module.modname + '-handler',
showDownloadButton: true, showDownloadButton: true,

View File

@ -783,7 +783,7 @@ export class CoreCourseProvider {
* @param modicon The mod icon string to use in case we are not using a core activity. * @param modicon The mod icon string to use in case we are not using a core activity.
* @return The IMG src. * @return The IMG src.
*/ */
async getModuleIconSrc(moduleName: string, modicon?: string, mimetypeIcon = ''): Promise<string> { getModuleIconSrc(moduleName: string, modicon?: string, mimetypeIcon = ''): string {
if (mimetypeIcon) { if (mimetypeIcon) {
return mimetypeIcon; return mimetypeIcon;
} }

View File

@ -41,12 +41,12 @@ export class CoreCourseModuleDefaultHandler implements CoreCourseModuleHandler {
/** /**
* @inheritdoc * @inheritdoc
*/ */
async getData( getData(
module: CoreCourseModuleData, module: CoreCourseModuleData,
): Promise<CoreCourseModuleHandlerData> { ): CoreCourseModuleHandlerData {
// Return the default data. // Return the default data.
const defaultData: CoreCourseModuleHandlerData = { const defaultData: CoreCourseModuleHandlerData = {
icon: await CoreCourse.getModuleIconSrc(module.modname, module.modicon), icon: CoreCourse.getModuleIconSrc(module.modname, module.modicon),
title: module.name, title: module.name,
class: 'core-course-default-handler core-course-module-' + module.modname + '-handler', class: 'core-course-default-handler core-course-module-' + module.modname + '-handler',
action: async (event: Event, module: CoreCourseModuleData, courseId: number, options?: CoreNavigationOptions) => { action: async (event: Event, module: CoreCourseModuleData, courseId: number, options?: CoreNavigationOptions) => {

View File

@ -391,7 +391,7 @@ export class CoreCourseModuleDelegateService extends CoreDelegate<CoreCourseModu
async getModuleIconSrc(modname: string, modicon?: string, module?: CoreCourseModuleData): Promise<string> { async getModuleIconSrc(modname: string, modicon?: string, module?: CoreCourseModuleData): Promise<string> {
const icon = await this.executeFunctionOnEnabled<Promise<string>>(modname, 'getIconSrc', [module]); const icon = await this.executeFunctionOnEnabled<Promise<string>>(modname, 'getIconSrc', [module]);
return icon || await CoreCourse.getModuleIconSrc(modname, modicon) || ''; return icon || CoreCourse.getModuleIconSrc(modname, modicon) || '';
} }
/** /**

View File

@ -1005,6 +1005,10 @@ export class CoreCoursesProvider {
* @return Promise resolved when the data is invalidated. * @return Promise resolved when the data is invalidated.
*/ */
async invalidateCoursesByField(field: string = '', value: number | string = '', siteId?: string): Promise<void> { async invalidateCoursesByField(field: string = '', value: number | string = '', siteId?: string): Promise<void> {
if (typeof value === 'string' && value.length === 0) {
return;
}
siteId = siteId || CoreSites.getCurrentSiteId(); siteId = siteId || CoreSites.getCurrentSiteId();
const result = await this.fixCoursesByFieldParams(field, value, siteId); const result = await this.fixCoursesByFieldParams(field, value, siteId);

View File

@ -191,7 +191,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy {
*/ */
private async fetchGrades(): Promise<void> { private async fetchGrades(): Promise<void> {
const table = await CoreGrades.getCourseGradesTable(this.courseId, this.userId); const table = await CoreGrades.getCourseGradesTable(this.courseId, this.userId);
const formattedTable = await CoreGradesHelper.formatGradesTable(table); const formattedTable = CoreGradesHelper.formatGradesTable(table);
this.title = formattedTable.rows[0]?.gradeitem ?? Translate.instant('core.grades.grades'); this.title = formattedTable.rows[0]?.gradeitem ?? Translate.instant('core.grades.grades');
this.columns = formattedTable.columns; this.columns = formattedTable.columns;

View File

@ -98,7 +98,7 @@ export class CoreGradesHelperProvider {
* @param tableRow JSON object representing row of grades table data. * @param tableRow JSON object representing row of grades table data.
* @return Formatted row object. * @return Formatted row object.
*/ */
protected async formatGradeRowForTable(tableRow: CoreGradesTableRow): Promise<CoreGradesFormattedTableRow> { protected formatGradeRowForTable(tableRow: CoreGradesTableRow): CoreGradesFormattedTableRow {
const row: CoreGradesFormattedTableRow = {}; const row: CoreGradesFormattedTableRow = {};
for (let name in tableRow) { for (let name in tableRow) {
const column: CoreGradesTableColumn = tableRow[name]; const column: CoreGradesTableColumn = tableRow[name];
@ -116,7 +116,7 @@ export class CoreGradesHelperProvider {
row.colspan = itemNameColumn.colspan; row.colspan = itemNameColumn.colspan;
row.rowspan = tableRow.leader?.rowspan || 1; row.rowspan = tableRow.leader?.rowspan || 1;
await this.setRowIcon(row, content); this.setRowIcon(row, content);
row.rowclass = itemNameColumn.class.indexOf('leveleven') < 0 ? 'odd' : 'even'; row.rowclass = itemNameColumn.class.indexOf('leveleven') < 0 ? 'odd' : 'even';
row.rowclass += itemNameColumn.class.indexOf('hidden') >= 0 ? ' hidden' : ''; row.rowclass += itemNameColumn.class.indexOf('hidden') >= 0 ? ' hidden' : '';
row.rowclass += itemNameColumn.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : ''; row.rowclass += itemNameColumn.class.indexOf('dimmed_text') >= 0 ? ' dimmed_text' : '';
@ -182,7 +182,7 @@ export class CoreGradesHelperProvider {
* @param table JSON object representing a table with data. * @param table JSON object representing a table with data.
* @return Formatted HTML table. * @return Formatted HTML table.
*/ */
async formatGradesTable(table: CoreGradesTable): Promise<CoreGradesFormattedTable> { formatGradesTable(table: CoreGradesTable): CoreGradesFormattedTable {
const maxDepth = table.maxdepth; const maxDepth = table.maxdepth;
const formatted: CoreGradesFormattedTable = { const formatted: CoreGradesFormattedTable = {
columns: [], columns: [],
@ -202,7 +202,7 @@ export class CoreGradesHelperProvider {
feedback: false, feedback: false,
contributiontocoursetotal: false, contributiontocoursetotal: false,
}; };
formatted.rows = await Promise.all(table.tabledata.map(row => this.formatGradeRowForTable(row))); formatted.rows = table.tabledata.map(row => this.formatGradeRowForTable(row));
// Get a row with some info. // Get a row with some info.
let normalRow = formatted.rows.find( let normalRow = formatted.rows.find(
@ -474,7 +474,7 @@ export class CoreGradesHelperProvider {
// Find href containing "/mod/xxx/xxx.php". // Find href containing "/mod/xxx/xxx.php".
const regex = /href="([^"]*\/mod\/[^"|^/]*\/[^"|^.]*\.php[^"]*)/; const regex = /href="([^"]*\/mod\/[^"|^/]*\/[^"|^.]*\.php[^"]*)/;
return await Promise.all(table.tabledata.filter((row) => { return table.tabledata.filter((row) => {
if (row.itemname && row.itemname.content) { if (row.itemname && row.itemname.content) {
const matches = row.itemname.content.match(regex); const matches = row.itemname.content.match(regex);
@ -486,7 +486,7 @@ export class CoreGradesHelperProvider {
} }
return false; return false;
}).map((row) => this.formatGradeRowForTable(row))); }).map((row) => this.formatGradeRowForTable(row));
} }
/** /**
@ -589,7 +589,7 @@ export class CoreGradesHelperProvider {
* @param text HTML where the image will be rendered. * @param text HTML where the image will be rendered.
* @return Row object with the image. * @return Row object with the image.
*/ */
protected async setRowIcon<T extends CoreGradesFormattedRowCommonData>(row: T, text: string): Promise<T> { protected setRowIcon<T extends CoreGradesFormattedRowCommonData>(row: T, text: string): T {
text = text.replace('%2F', '/').replace('%2f', '/'); text = text.replace('%2F', '/').replace('%2f', '/');
if (text.indexOf('/agg_mean') > -1) { if (text.indexOf('/agg_mean') > -1) {
row.itemtype = 'agg_mean'; row.itemtype = 'agg_mean';
@ -621,7 +621,7 @@ export class CoreGradesHelperProvider {
row.itemtype = 'mod'; row.itemtype = 'mod';
row.itemmodule = module[1]; row.itemmodule = module[1];
row.iconAlt = CoreCourse.translateModuleName(row.itemmodule) || ''; row.iconAlt = CoreCourse.translateModuleName(row.itemmodule) || '';
row.image = await CoreCourse.getModuleIconSrc( row.image = CoreCourse.getModuleIconSrc(
module[1], module[1],
CoreDomUtils.convertToElement(text).querySelector('img')?.getAttribute('src') ?? undefined, CoreDomUtils.convertToElement(text).querySelector('img')?.getAttribute('src') ?? undefined,
); );

View File

@ -14,7 +14,7 @@
import { Type } from '@angular/core'; import { Type } from '@angular/core';
import { CoreError } from '@classes/errors/error'; 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 { CoreBlockPreRenderedComponent } from '@features/block/components/pre-rendered-block/pre-rendered-block';
import { CoreBlockDelegate, CoreBlockHandler, CoreBlockHandlerData } from '@features/block/services/block-delegate'; import { CoreBlockDelegate, CoreBlockHandler, CoreBlockHandlerData } from '@features/block/services/block-delegate';
import { CoreCourseBlock } from '@features/course/services/course'; import { CoreCourseBlock } from '@features/course/services/course';
@ -52,7 +52,7 @@ export class CoreSitePluginsBlockHandler extends CoreSitePluginsBaseHandler impl
instanceId: number, instanceId: number,
): Promise<CoreBlockHandlerData> { ): Promise<CoreBlockHandlerData> {
const className = this.handlerSchema.displaydata?.class || 'block_' + block.name; 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') { if (this.handlerSchema.displaydata?.type == 'title') {
component = CoreSitePluginsOnlyTitleBlockComponent; component = CoreSitePluginsOnlyTitleBlockComponent;

View File

@ -76,7 +76,7 @@ export class CoreSitePluginsModuleHandler extends CoreSitePluginsBaseHandler imp
module.description = ''; module.description = '';
return { return {
icon: await CoreCourse.getModuleIconSrc(module.modname, icon), icon: CoreCourse.getModuleIconSrc(module.modname, icon),
title: title || '', title: title || '',
a11yTitle: '', a11yTitle: '',
class: this.handlerSchema.displaydata?.class, class: this.handlerSchema.displaydata?.class,
@ -87,7 +87,7 @@ export class CoreSitePluginsModuleHandler extends CoreSitePluginsBaseHandler imp
const showDowloadButton = this.handlerSchema.downloadbutton; const showDowloadButton = this.handlerSchema.downloadbutton;
const handlerData: CoreCourseModuleHandlerData = { const handlerData: CoreCourseModuleHandlerData = {
title: module.name, title: module.name,
icon: await CoreCourse.getModuleIconSrc(module.modname, icon), icon: CoreCourse.getModuleIconSrc(module.modname, icon),
class: this.handlerSchema.displaydata?.class, class: this.handlerSchema.displaydata?.class,
showDownloadButton: showDowloadButton !== undefined ? showDowloadButton : hasOffline, showDownloadButton: showDowloadButton !== undefined ? showDowloadButton : hasOffline,
}; };

View File

@ -81,12 +81,12 @@ export class CoreUtilsProvider {
* @param promises Promises. * @param promises Promises.
* @return Promise resolved if all promises are resolved and rejected if at least 1 promise fails. * @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) { if (!promises || !promises.length) {
return Promise.resolve(); return;
} }
const getPromiseError = async (promise): Promise<Error | void> => { const getPromiseError = async (promise: unknown): Promise<Error | void> => {
try { try {
await promise; await promise;
} catch (error) { } 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 { CoreLoadingComponent } from '@components/loading/loading';
import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreComponentsRegistry } from '@singletons/components-registry';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { IonRefresher } from '@ionic/angular';
import { CoreCoursesDashboardPage } from '@features/courses/pages/dashboard/dashboard';
/** /**
* Behat runtime servive with public API. * Behat runtime servive with public API.
@ -52,6 +54,7 @@ export class TestsBehatRuntime {
log: TestsBehatRuntime.log, log: TestsBehatRuntime.log,
press: TestsBehatRuntime.press, press: TestsBehatRuntime.press,
pressStandard: TestsBehatRuntime.pressStandard, pressStandard: TestsBehatRuntime.pressStandard,
pullToRefresh: TestsBehatRuntime.pullToRefresh,
scrollTo: TestsBehatRuntime.scrollTo, scrollTo: TestsBehatRuntime.scrollTo,
setField: TestsBehatRuntime.setField, setField: TestsBehatRuntime.setField,
handleCustomURL: TestsBehatRuntime.handleCustomURL, 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. * Gets the currently displayed page header.
* *
@ -403,7 +439,7 @@ export class TestsBehatRuntime {
* @param className Constructor class name * @param className Constructor class name
* @return Component instance * @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); this.log('Action - Get Angular instance ' + selector + ', ' + className);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any