forked from EVOgeek/Vmeda.Online
		
	Merge pull request #3330 from NoelDeMartin/MOBILE-4038
MOBILE-4038 timeline: Refactor using OnPush
This commit is contained in:
		
						commit
						93cfcd4ae0
					
				| @ -538,7 +538,7 @@ class behat_app extends behat_app_helper { | ||||
|      * Note it is difficult to use the standard 'click on' or 'press' steps because those do not | ||||
|      * distinguish visible items and the app always has many non-visible items in the DOM. | ||||
|      * | ||||
|      * @Then /^I press (".+") in the app$/ | ||||
|      * @When /^I press (".+") in the app$/ | ||||
|      * @param string $locator Element locator | ||||
|      * @throws DriverException If the press doesn't work | ||||
|      */ | ||||
| @ -558,6 +558,26 @@ class behat_app extends behat_app_helper { | ||||
|         $this->wait_for_pending_js(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Performs a pull to refresh gesture. | ||||
|      * | ||||
|      * @When /^I pull to refresh in the app$/ | ||||
|      * @throws DriverException If the gesture is not available | ||||
|      */ | ||||
|     public function i_pull_to_refresh_in_the_app() { | ||||
|         $this->spin(function() { | ||||
|             $result = $this->js('await window.behat.pullToRefresh();'); | ||||
| 
 | ||||
|             if ($result !== 'OK') { | ||||
|                 throw new DriverException('Error pulling to refresh - ' . $result); | ||||
|             } | ||||
| 
 | ||||
|             return true; | ||||
|         }); | ||||
| 
 | ||||
|         $this->wait_for_pending_js(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Select an item from a list of options, such as a radio button. | ||||
|      * | ||||
|  | ||||
| @ -26,7 +26,7 @@ import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; | ||||
| import { IonSearchbar } from '@ionic/angular'; | ||||
| import { IonRefresher, IonSearchbar } from '@ionic/angular'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| 
 | ||||
| const FILTER_PRIORITY: AddonBlockMyOverviewTimeFilters[] = | ||||
| @ -86,6 +86,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|     protected currentSite!: CoreSite; | ||||
|     protected allCourses: CoreEnrolledCourseDataWithOptions[] = []; | ||||
|     protected prefetchIconsInitialized = false; | ||||
|     protected isDirty = false; | ||||
|     protected isDestroyed = false; | ||||
|     protected coursesObserver?: CoreEventObserver; | ||||
|     protected updateSiteObserver?: CoreEventObserver; | ||||
| @ -158,10 +159,27 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Refresh the data. | ||||
|      * | ||||
|      * @param refresher Refresher. | ||||
|      * @param done Function to call when done. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async doRefresh(refresher?: IonRefresher, done?: () => void): Promise<void> { | ||||
|         if (this.loaded) { | ||||
|             return this.refreshContent().finally(() => { | ||||
|                 refresher?.complete(); | ||||
|                 done && done(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async invalidateContent(): Promise<void> { | ||||
|         this.isDirty = true; | ||||
|         const courseIds = this.allCourses.map((course) => course.id); | ||||
| 
 | ||||
|         await this.invalidateCourses(courseIds); | ||||
| @ -207,7 +225,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     protected async fetchContent(refresh?: boolean): Promise<void> { | ||||
|     protected async fetchContent(): Promise<void> { | ||||
|         const config = this.block.configsRecord; | ||||
| 
 | ||||
|         const showCategories = config?.displaycategories?.value == '1'; | ||||
| @ -218,15 +236,15 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|             undefined, | ||||
|             showCategories, | ||||
|             { | ||||
|                 readingStrategy: refresh ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined, | ||||
|                 readingStrategy: this.isDirty ? CoreSitesReadingStrategy.PREFER_NETWORK : undefined, | ||||
|             }, | ||||
|         ); | ||||
| 
 | ||||
|         this.hasCourses = this.allCourses.length > 0; | ||||
| 
 | ||||
|         try { | ||||
|             this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', refresh), 10); | ||||
|             this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', refresh), 10); | ||||
|             this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter', this.isDirty), 10); | ||||
|             this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore', this.isDirty), 10); | ||||
|         } catch { | ||||
|             this.gradePeriodAfter = 0; | ||||
|             this.gradePeriodBefore = 0; | ||||
| @ -235,6 +253,8 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem | ||||
|         this.loadSort(); | ||||
|         this.loadLayouts(config?.layouts?.value.split(',')); | ||||
|         this.loadFilters(config); | ||||
| 
 | ||||
|         this.isDirty = false; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -54,15 +54,15 @@ export class AddonBlockRecentlyAccessedItemsProvider { | ||||
| 
 | ||||
|         const cmIds: number[] = []; | ||||
| 
 | ||||
|         items = await Promise.all(items.map(async (item) => { | ||||
|         items = items.map((item) => { | ||||
|             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'); | ||||
|             cmIds.push(item.cmid); | ||||
| 
 | ||||
|             return item; | ||||
|         })); | ||||
|         }); | ||||
| 
 | ||||
|         // Check if the viewed module should be updated for each activity.
 | ||||
|         const lastViewedMap = await CoreCourse.getCertainModulesViewed(cmIds, site.getId()); | ||||
|  | ||||
							
								
								
									
										200
									
								
								src/addons/block/timeline/classes/section.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								src/addons/block/timeline/classes/section.ts
									
									
									
									
									
										Normal 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; | ||||
| }; | ||||
| @ -7,7 +7,7 @@ | ||||
|         </h3> | ||||
|     </ion-label> | ||||
| </ion-item> | ||||
| <ion-item-group *ngFor="let dayEvents of filteredEvents"> | ||||
| <ion-item-group *ngFor="let dayEvents of events"> | ||||
|     <ion-item> | ||||
|         <ion-label> | ||||
|             <h4 [class.core-bold]="!course">{{ dayEvents.dayTimestamp * 1000 | coreFormatDate:"strftimedaydate" }}</h4> | ||||
| @ -30,15 +30,15 @@ | ||||
|                                 <p class="item-heading addon-block-timeline-activity-name-with-status"> | ||||
|                                     <span> | ||||
|                                         <core-format-text [text]="event.activityname || event.name" contextLevel="module" | ||||
|                                             [contextInstanceId]="event.id" [courseId]="event.course && event.course.id"> | ||||
|                                             [contextInstanceId]="event.id" [courseId]="event.course?.id"> | ||||
|                                         </core-format-text> | ||||
|                                     </span> | ||||
|                                     <ion-badge *ngIf="event.overdue" color="danger">{{ 'addon.block_timeline.overdue' | translate }} | ||||
|                                     </ion-badge> | ||||
|                                 </p> | ||||
|                                 <p *ngIf="(showCourse && event.course) || event.activitystr" | ||||
|                                 <p *ngIf="(showInlineCourse && event.course) || event.activitystr" | ||||
|                                     class="addon-block-timeline-activity-course-activity"> | ||||
|                                     <span *ngIf="showCourse && event.course"> | ||||
|                                     <span *ngIf="showInlineCourse && event.course"> | ||||
|                                         <core-format-text [text]="event.course.fullnamedisplay" contextLevel="course" | ||||
|                                             [contextInstanceId]="event.course.id"> | ||||
|                                         </core-format-text> | ||||
| @ -52,7 +52,7 @@ | ||||
|                             </ion-col> | ||||
|                         </ion-row> | ||||
|                     </ion-col> | ||||
|                     <ion-col class="addon-block-timeline-activity-action ion-no-padding" *ngIf="event.action?.actionable"> | ||||
|                     <ion-col class="addon-block-timeline-activity-action ion-no-padding" *ngIf="event.action && event.action.actionable"> | ||||
|                         <ion-button fill="outline" (click)="action($event, event.action.url)" [title]="event.action.name" class="chip"> | ||||
|                             {{event.action.name}} | ||||
|                             <ion-badge slot="end" class="ion-margin-start" *ngIf="event.action.showitemcount"> | ||||
| @ -66,18 +66,10 @@ | ||||
|     </ng-container> | ||||
| </ion-item-group> | ||||
| 
 | ||||
| <div class="ion-padding ion-text-center" *ngIf="canLoadMore && !empty"> | ||||
| <div class="ion-padding ion-text-center" *ngIf="canLoadMore"> | ||||
|     <!-- Button and spinner to show more attempts. --> | ||||
|     <ion-button expand="block" (click)="loadMoreEvents()" fill="outline" *ngIf="!loadingMore"> | ||||
|     <ion-button expand="block" (click)="loadMore.emit()" fill="outline" *ngIf="!loadingMore"> | ||||
|         {{ 'core.loadmore' | translate }} | ||||
|     </ion-button> | ||||
|     <ion-spinner *ngIf="loadingMore" [attr.aria-label]="'core.loading' | translate"></ion-spinner> | ||||
| </div> | ||||
| 
 | ||||
| <ion-item *ngIf="empty && course"> | ||||
|     <ion-label class="ion-text-wrap"> | ||||
|         <p>{{'addon.block_timeline.noevents' | translate}}</p> | ||||
|     </ion-label> | ||||
| </ion-item> | ||||
| <core-empty-box *ngIf="empty && !course" image="assets/img/icons/activities.svg" [message]="'addon.block_timeline.noevents' | translate"> | ||||
| </core-empty-box> | ||||
|  | ||||
| @ -12,16 +12,13 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, Input, Output, OnChanges, EventEmitter, SimpleChange } from '@angular/core'; | ||||
| import { Component, Input, Output, EventEmitter } from '@angular/core'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreCourse } from '@features/course/services/course'; | ||||
| import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; | ||||
| import { AddonCalendarEvent } from '@addons/calendar/services/calendar'; | ||||
| import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; | ||||
| import { AddonBlockTimeline } from '../../services/timeline'; | ||||
| import { AddonBlockTimelineDayEvents } from '@addons/block/timeline/classes/section'; | ||||
| 
 | ||||
| /** | ||||
|  * Directive to render a list of events in course overview. | ||||
| @ -31,106 +28,15 @@ import { AddonBlockTimeline } from '../../services/timeline'; | ||||
|     templateUrl: 'addon-block-timeline-events.html', | ||||
|     styleUrls: ['events.scss'], | ||||
| }) | ||||
| export class AddonBlockTimelineEventsComponent implements OnChanges { | ||||
| export class AddonBlockTimelineEventsComponent { | ||||
| 
 | ||||
|     @Input() events: AddonBlockTimelineEvent[] = []; // The events to render.
 | ||||
|     @Input() events: AddonBlockTimelineDayEvents[] = []; // The events to render.
 | ||||
|     @Input() course?: CoreEnrolledCourseDataWithOptions; // Whether to show the course name.
 | ||||
|     @Input() from = 0; // Number of days from today to offset the events.
 | ||||
|     @Input() to?: number; // Number of days from today to limit the events to. If not defined, no limit.
 | ||||
|     @Input() overdue = false; // If filtering overdue events or not.
 | ||||
|     @Input() showInlineCourse = true; // Whether to show the course name within event items.
 | ||||
|     @Input() canLoadMore = false; // Whether more events can be loaded.
 | ||||
|     @Input() loadingMore = false; // Whether loading is ongoing.
 | ||||
|     @Output() loadMore = new EventEmitter(); // Notify that more events should be loaded.
 | ||||
| 
 | ||||
|     showCourse = false; // Whether to show the course name.
 | ||||
|     empty = true; | ||||
|     loadingMore = false; | ||||
|     filteredEvents: AddonBlockTimelineEventFilteredEvent[] = []; | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     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. | ||||
|      * | ||||
| @ -157,14 +63,3 @@ export class AddonBlockTimelineEventsComponent implements OnChanges { | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| type AddonBlockTimelineEvent = Omit<AddonCalendarEvent, 'eventtype'> & { | ||||
|     eventtype: string; | ||||
|     iconUrl?: string; | ||||
|     iconTitle?: string; | ||||
| }; | ||||
| 
 | ||||
| type AddonBlockTimelineEventFilteredEvent = { | ||||
|     events: AddonBlockTimelineEvent[]; | ||||
|     dayTimestamp: number; | ||||
| }; | ||||
|  | ||||
| @ -4,70 +4,51 @@ | ||||
|     </ion-label> | ||||
| </ion-item-divider> | ||||
| <core-loading [hideUntil]="loaded"> | ||||
|     <ion-row class="ion-hide-md-up addon-block-timeline-filter" *ngIf="searchEnabled"> | ||||
|     <ion-row class="ion-hide-md-up addon-block-timeline-filter" *ngIf="(search$ | async) !== null"> | ||||
|         <ion-col> | ||||
|             <!-- Filter courses. --> | ||||
|             <core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()" | ||||
|                 [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2" | ||||
|             <core-search-box (onSubmit)="searchChanged($event)" (onClear)="searchChanged('')" | ||||
|                 [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" [lengthCheck]="2" | ||||
|                 searchArea="AddonBlockTimeline"></core-search-box> | ||||
|         </ion-col> | ||||
|     </ion-row> | ||||
|     <ion-row class="ion-justify-content-between ion-align-items-center addon-block-timeline-filter"> | ||||
|         <ion-col size="auto"> | ||||
|             <core-combobox [selection]="filter" (onChange)="switchFilter($event)"> | ||||
|                 <ion-select-option class="ion-text-wrap" value="all"> | ||||
|                     {{ 'core.all' | translate }} | ||||
|                 </ion-select-option> | ||||
|                 <ion-select-option class="ion-text-wrap core-select-option-border-bottom" value="overdue"> | ||||
|                     {{ 'addon.block_timeline.overdue' | translate }} | ||||
|             <core-combobox [formControl]="filter" (onChange)="filterChanged($event)"> | ||||
|                 <ion-select-option *ngFor="let option of statusFilterOptions; last as last" | ||||
|                     [attr.class]="'ion-text-wrap' + last ? ' core-select-option-border-bottom' : ''" [value]="option.value"> | ||||
|                     {{ option.name | translate }} | ||||
|                 </ion-select-option> | ||||
|                 <ion-select-option class="ion-text-wrap core-select-option-title" disabled value="disabled"> | ||||
|                     {{ 'addon.block_timeline.duedate' | translate }} | ||||
|                     {{ ' addon.block_timeline.duedate' | translate }} | ||||
|                 </ion-select-option> | ||||
|                 <ion-select-option class="ion-text-wrap" value="next7days"> | ||||
|                     {{ 'addon.block_timeline.next7days' | translate }} | ||||
|                 </ion-select-option> | ||||
|                 <ion-select-option class="ion-text-wrap" value="next30days"> | ||||
|                     {{ 'addon.block_timeline.next30days' | translate }} | ||||
|                 </ion-select-option> | ||||
|                 <ion-select-option class="ion-text-wrap" value="next3months"> | ||||
|                     {{ 'addon.block_timeline.next3months' | translate }} | ||||
|                 </ion-select-option> | ||||
|                 <ion-select-option class="ion-text-wrap" value="next6months"> | ||||
|                     {{ 'addon.block_timeline.next6months' | translate }} | ||||
|                 <ion-select-option *ngFor="let option of dateFilterOptions" class="ion-text-wrap" [value]="option.value"> | ||||
|                     {{ option.name | translate }} | ||||
|                 </ion-select-option> | ||||
|             </core-combobox> | ||||
|         </ion-col> | ||||
|         <ion-col class="ion-hide-md-down" *ngIf="searchEnabled"> | ||||
|         <ion-col class="ion-hide-md-down" *ngIf="(search$ | async) !== null"> | ||||
|             <!-- Filter courses. --> | ||||
|             <core-search-box (onSubmit)="searchTextChanged($event)" (onClear)="searchTextChanged()" | ||||
|                 [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" lengthCheck="2" | ||||
|             <core-search-box (onSubmit)="searchChanged($event)" (onClear)="searchChanged('')" | ||||
|                 [placeholder]="'addon.block_timeline.searchevents' | translate" autocorrect="off" spellcheck="false" [lengthCheck]="2" | ||||
|                 searchArea="AddonBlockTimeline"></core-search-box> | ||||
|         </ion-col> | ||||
|         <ion-col size="auto"> | ||||
|             <core-combobox [label]="'core.sortby' | translate" [selection]="sort" (onChange)="switchSort($event)" | ||||
|             <core-combobox [label]="'core.sortby' | translate" [formControl]="sort" (onChange)="sortChanged($event)" | ||||
|                 icon="fas-sort-amount-down-alt"> | ||||
|                 <ion-select-option class="ion-text-wrap" value="sortbydates"> | ||||
|                     {{'addon.block_timeline.sortbydates' | translate}} | ||||
|                 </ion-select-option> | ||||
|                 <ion-select-option class="ion-text-wrap" value="sortbycourses"> | ||||
|                     {{'addon.block_timeline.sortbycourses' | translate}} | ||||
|                 <ion-select-option *ngFor="let option of sortOptions" class="ion-text-wrap" [value]="option.value"> | ||||
|                     {{ option.name | translate }} | ||||
|                 </ion-select-option> | ||||
|             </core-combobox> | ||||
|         </ion-col> | ||||
|     </ion-row> | ||||
| 
 | ||||
| 
 | ||||
|     <core-loading [hideUntil]="timeline.loaded" [hidden]="sort != 'sortbydates'"> | ||||
|         <addon-block-timeline-events [events]="timeline.events" [canLoadMore]="timeline.canLoadMore" (loadMore)="loadMore()" | ||||
|             [from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events> | ||||
|     </core-loading> | ||||
|     <core-loading [hideUntil]="timelineCourses.loaded" [hidden]="sort != 'sortbycourses'"> | ||||
|         <ng-container *ngFor="let course of timelineCourses.courses"> | ||||
|             <addon-block-timeline-events [events]="course.events" [canLoadMore]="course.canLoadMore" (loadMore)="loadMore(course)" | ||||
|                 [course]="course" [from]="dataFrom" [to]="dataTo" [overdue]="overdue"></addon-block-timeline-events> | ||||
|     <ng-container *ngIf="sections$ | async as sections"> | ||||
|         <ng-container *ngFor="let section of sections"> | ||||
|             <addon-block-timeline-events *ngIf="section.data$ | async as data" [events]="data.events" | ||||
|                 [showInlineCourse]="(sort$ | async) === AddonBlockTimelineSort.ByDates" [canLoadMore]="data.canLoadMore" | ||||
|                 [loadingMore]="data.loadingMore" (loadMore)="section.loadMore()" [course]="section.course"> </addon-block-timeline-events> | ||||
|         </ng-container> | ||||
|         <core-empty-box *ngIf="timelineCourses.courses.length == 0" image="assets/img/icons/courses.svg" | ||||
|         <core-empty-box *ngIf="sections && sections.length === 0" image="assets/img/icons/courses.svg" | ||||
|             [message]="'addon.block_timeline.noevents' | translate"></core-empty-box> | ||||
|     </core-loading> | ||||
|     </ng-container> | ||||
| </core-loading> | ||||
|  | ||||
| @ -12,18 +12,21 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; | ||||
| import { ICoreBlockComponent } from '@features/block/classes/base-block-component'; | ||||
| import { AddonBlockTimeline } from '../../services/timeline'; | ||||
| import { AddonCalendarEvent } from '@addons/calendar/services/calendar'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreCoursesHelper, CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; | ||||
| import { CoreSite } from '@classes/site'; | ||||
| import { CoreCourses } from '@features/courses/services/courses'; | ||||
| import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; | ||||
| import { CoreNavigator } from '@services/navigator'; | ||||
| import { BehaviorSubject, combineLatest, Observable, of, Subject } from 'rxjs'; | ||||
| import { catchError, distinctUntilChanged, map, share, tap } from 'rxjs/operators'; | ||||
| import { AddonBlockTimelineDateRange, AddonBlockTimelineSection } from '@addons/block/timeline/classes/section'; | ||||
| import { FormControl } from '@angular/forms'; | ||||
| import { formControlValue, resolved } from '@/core/utils/rxjs'; | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| 
 | ||||
| /** | ||||
|  * Component to render a timeline block. | ||||
| @ -32,251 +35,286 @@ import { CoreNavigator } from '@services/navigator'; | ||||
|     selector: 'addon-block-timeline', | ||||
|     templateUrl: 'addon-block-timeline.html', | ||||
|     styleUrls: ['timeline.scss'], | ||||
|     changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class AddonBlockTimelineComponent extends CoreBlockBaseComponent implements OnInit { | ||||
| export class AddonBlockTimelineComponent implements OnInit, ICoreBlockComponent { | ||||
| 
 | ||||
|     sort = 'sortbydates'; | ||||
|     filter = 'next30days'; | ||||
|     currentSite!: CoreSite; | ||||
|     timeline: { | ||||
|         events: AddonCalendarEvent[]; | ||||
|         loaded: boolean; | ||||
|         canLoadMore?: number; | ||||
|     } = { | ||||
|         events: <AddonCalendarEvent[]> [], | ||||
|         loaded: false, | ||||
|     }; | ||||
| 
 | ||||
|     timelineCourses: { | ||||
|         courses: AddonBlockTimelineCourse[]; | ||||
|         loaded: boolean; | ||||
|         canLoadMore?: number; | ||||
|     } = { | ||||
|         courses: [], | ||||
|         loaded: false, | ||||
|     }; | ||||
| 
 | ||||
|     dataFrom?: number; | ||||
|     dataTo?: number; | ||||
|     overdue = false; | ||||
| 
 | ||||
|     searchEnabled = false; | ||||
|     searchText = ''; | ||||
|     sort = new FormControl(); | ||||
|     sort$!: Observable<AddonBlockTimelineSort>; | ||||
|     sortOptions!: AddonBlockTimelineOption<AddonBlockTimelineSort>[]; | ||||
|     filter = new FormControl(); | ||||
|     filter$!: Observable<AddonBlockTimelineFilter>; | ||||
|     statusFilterOptions!: AddonBlockTimelineOption<AddonBlockTimelineFilter>[]; | ||||
|     dateFilterOptions!: AddonBlockTimelineOption<AddonBlockTimelineFilter>[]; | ||||
|     search$: Subject<string | null>; | ||||
|     sections$!: Observable<AddonBlockTimelineSection[]>; | ||||
|     loaded = false; | ||||
| 
 | ||||
|     protected logger: CoreLogger; | ||||
|     protected courseIdsToInvalidate: number[] = []; | ||||
|     protected fetchContentDefaultError = 'Error getting timeline data.'; | ||||
|     protected gradePeriodAfter = 0; | ||||
|     protected gradePeriodBefore = 0; | ||||
| 
 | ||||
|     constructor() { | ||||
|         super('AddonBlockTimelineComponent'); | ||||
|         this.logger = CoreLogger.getInstance('AddonBlockTimelineComponent'); | ||||
|         this.search$ = new BehaviorSubject(null); | ||||
|         this.initializeSort(); | ||||
|         this.initializeFilter(); | ||||
|         this.initializeSections(); | ||||
|     } | ||||
| 
 | ||||
|     get AddonBlockTimelineSort(): typeof AddonBlockTimelineSort { | ||||
|         return AddonBlockTimelineSort; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async ngOnInit(): Promise<void> { | ||||
|         try { | ||||
|             this.currentSite = CoreSites.getRequiredCurrentSite(); | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModal(error); | ||||
|         const currentSite = CoreSites.getRequiredCurrentSite(); | ||||
|         const [sort, filter, search] = await Promise.all([ | ||||
|             currentSite.getLocalSiteConfig('AddonBlockTimelineSort', AddonBlockTimelineSort.ByDates), | ||||
|             currentSite.getLocalSiteConfig('AddonBlockTimelineFilter', AddonBlockTimelineFilter.Next30Days), | ||||
|             currentSite.isVersionGreaterEqualThan('4.0') ? '' : null, | ||||
|         ]); | ||||
| 
 | ||||
|             CoreNavigator.back(); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.filter = await this.currentSite.getLocalSiteConfig('AddonBlockTimelineFilter', this.filter); | ||||
|         this.switchFilter(this.filter); | ||||
| 
 | ||||
|         this.sort = await this.currentSite.getLocalSiteConfig('AddonBlockTimelineSort', this.sort); | ||||
| 
 | ||||
|         this.searchEnabled = this.currentSite.isVersionGreaterEqualThan('4.0'); | ||||
| 
 | ||||
|         super.ngOnInit(); | ||||
|         this.sort.setValue(sort); | ||||
|         this.filter.setValue(filter); | ||||
|         this.search$.next(search); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Perform the invalidate content function. | ||||
|      * Sort changed. | ||||
|      * | ||||
|      * @return Resolved when done. | ||||
|      * @param sort New sort. | ||||
|      */ | ||||
|     invalidateContent(): Promise<void> { | ||||
|         const promises: Promise<void>[] = []; | ||||
| 
 | ||||
|         promises.push(AddonBlockTimeline.invalidateActionEventsByTimesort()); | ||||
|         promises.push(AddonBlockTimeline.invalidateActionEventsByCourses()); | ||||
|         promises.push(CoreCourses.invalidateUserCourses()); | ||||
|         promises.push(CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions()); | ||||
|         if (this.courseIdsToInvalidate.length > 0) { | ||||
|             promises.push(CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.join(','))); | ||||
|         } | ||||
| 
 | ||||
|         return CoreUtils.allPromises(promises); | ||||
|     sortChanged(sort: AddonBlockTimelineSort): void { | ||||
|         CoreSites.getRequiredCurrentSite().setLocalSiteConfig('AddonBlockTimelineSort', sort); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the courses for my overview. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async fetchContent(): Promise<void> { | ||||
|         if (this.sort == 'sortbydates') { | ||||
|             return this.fetchMyOverviewTimeline().finally(() => { | ||||
|                 this.timeline.loaded = true; | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         if (this.sort == 'sortbycourses') { | ||||
|             return this.fetchMyOverviewTimelineByCourses().finally(() => { | ||||
|                 this.timelineCourses.loaded = true; | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load more events. | ||||
|      * | ||||
|      * @param course Course. If defined, it will update the course events, timeline otherwise. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async loadMore(course?: AddonBlockTimelineCourse): Promise<void> { | ||||
|         try { | ||||
|             if (course) { | ||||
|                 const courseEvents = | ||||
|                     await AddonBlockTimeline.getActionEventsByCourse(course.id, course.canLoadMore, this.searchText); | ||||
|                 course.events = course.events?.concat(courseEvents.events); | ||||
|                 course.canLoadMore = courseEvents.canLoadMore; | ||||
|             } else { | ||||
|                 await this.fetchMyOverviewTimeline(this.timeline.canLoadMore); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the timeline. | ||||
|      * | ||||
|      * @param afterEventId The last event id. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async fetchMyOverviewTimeline(afterEventId?: number): Promise<void> { | ||||
|         const events = await AddonBlockTimeline.getActionEventsByTimesort(afterEventId, this.searchText); | ||||
| 
 | ||||
|         this.timeline.events = afterEventId ? this.timeline.events.concat(events.events) : events.events; | ||||
|         this.timeline.canLoadMore = events.canLoadMore; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the timeline by courses. | ||||
|      * | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     protected async fetchMyOverviewTimelineByCourses(): Promise<void> { | ||||
|         try { | ||||
|             this.gradePeriodAfter = parseInt(await this.currentSite.getConfig('coursegraceperiodafter'), 10); | ||||
|             this.gradePeriodBefore = parseInt(await this.currentSite.getConfig('coursegraceperiodbefore'), 10); | ||||
|         } catch { | ||||
|             this.gradePeriodAfter = 0; | ||||
|             this.gradePeriodBefore = 0; | ||||
|         } | ||||
| 
 | ||||
|         // Do not filter courses by date because they can contain activities due.
 | ||||
|         this.timelineCourses.courses = await CoreCoursesHelper.getUserCoursesWithOptions(); | ||||
|         this.courseIdsToInvalidate = this.timelineCourses.courses.map((course) => course.id); | ||||
| 
 | ||||
|         // Filter only in progress courses.
 | ||||
|         this.timelineCourses.courses = this.timelineCourses.courses.filter((course) => | ||||
|             !course.hidden && | ||||
|             !CoreCoursesHelper.isPastCourse(course, this.gradePeriodAfter) && | ||||
|             !CoreCoursesHelper.isFutureCourse(course, this.gradePeriodAfter, this.gradePeriodBefore)); | ||||
| 
 | ||||
|         if (this.timelineCourses.courses.length > 0) { | ||||
|             const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(this.courseIdsToInvalidate, this.searchText); | ||||
| 
 | ||||
|             this.timelineCourses.courses = this.timelineCourses.courses.filter((course) => { | ||||
|                 if (courseEvents[course.id].events.length == 0) { | ||||
|                     return false; | ||||
|                 } | ||||
| 
 | ||||
|                 course.events = courseEvents[course.id].events; | ||||
|                 course.canLoadMore = courseEvents[course.id].canLoadMore; | ||||
| 
 | ||||
|                 return true; | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Change timeline filter being viewed. | ||||
|      * Filter changed. | ||||
|      * | ||||
|      * @param filter New filter. | ||||
|      */ | ||||
|     switchFilter(filter: string): void { | ||||
|         this.filter = filter; | ||||
|         this.currentSite.setLocalSiteConfig('AddonBlockTimelineFilter', this.filter); | ||||
|         this.overdue = this.filter === 'overdue'; | ||||
| 
 | ||||
|         switch (this.filter) { | ||||
|             case 'overdue': | ||||
|                 this.dataFrom = -14; | ||||
|                 this.dataTo = 1; | ||||
|                 break; | ||||
|             case 'next7days': | ||||
|                 this.dataFrom = 0; | ||||
|                 this.dataTo = 7; | ||||
|                 break; | ||||
|             case 'next30days': | ||||
|                 this.dataFrom = 0; | ||||
|                 this.dataTo = 30; | ||||
|                 break; | ||||
|             case 'next3months': | ||||
|                 this.dataFrom = 0; | ||||
|                 this.dataTo = 90; | ||||
|                 break; | ||||
|             case 'next6months': | ||||
|                 this.dataFrom = 0; | ||||
|                 this.dataTo = 180; | ||||
|                 break; | ||||
|             default: | ||||
|             case 'all': | ||||
|                 this.dataFrom = -14; | ||||
|                 this.dataTo = undefined; | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Change timeline sort being viewed. | ||||
|      * | ||||
|      * @param sort New sorting. | ||||
|      */ | ||||
|     switchSort(sort: string): void { | ||||
|         this.sort = sort; | ||||
|         this.currentSite.setLocalSiteConfig('AddonBlockTimelineSort', this.sort); | ||||
| 
 | ||||
|         if (!this.timeline.loaded && this.sort == 'sortbydates') { | ||||
|             this.fetchContent(); | ||||
|         } else if (!this.timelineCourses.loaded && this.sort == 'sortbycourses') { | ||||
|             this.fetchContent(); | ||||
|         } | ||||
|     filterChanged(filter: AddonBlockTimelineFilter): void { | ||||
|         CoreSites.getRequiredCurrentSite().setLocalSiteConfig('AddonBlockTimelineFilter', filter); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Search text changed. | ||||
|      * | ||||
|      * @param searchValue Search value | ||||
|      * @param search New search. | ||||
|      */ | ||||
|     searchTextChanged(searchValue = ''): void { | ||||
|         this.searchText = searchValue || ''; | ||||
|     searchChanged(search: string): void { | ||||
|         this.search$.next(search); | ||||
|     } | ||||
| 
 | ||||
|         this.fetchContent(); | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async invalidateContent(): Promise<void> { | ||||
|         await CoreUtils.allPromises([ | ||||
|             AddonBlockTimeline.invalidateActionEventsByTimesort(), | ||||
|             AddonBlockTimeline.invalidateActionEventsByCourses(), | ||||
|             CoreCourses.invalidateUserCourses(), | ||||
|             CoreCourseOptionsDelegate.clearAndInvalidateCoursesOptions(), | ||||
|             CoreCourses.invalidateCoursesByField('ids', this.courseIdsToInvalidate.join(',')), | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize sort properties. | ||||
|      */ | ||||
|     protected initializeSort(): void { | ||||
|         this.sort$ = formControlValue(this.sort); | ||||
|         this.sortOptions = Object.values(AddonBlockTimelineSort).map(value => ({ | ||||
|             value, | ||||
|             name: `addon.block_timeline.${value}`, | ||||
|         })); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize filter properties. | ||||
|      */ | ||||
|     protected initializeFilter(): void { | ||||
|         this.filter$ = formControlValue(this.filter); | ||||
|         this.statusFilterOptions = [ | ||||
|             { value: AddonBlockTimelineFilter.All, name: 'core.all' }, | ||||
|             { value: AddonBlockTimelineFilter.Overdue, name: 'addon.block_timeline.overdue' }, | ||||
|         ]; | ||||
|         this.dateFilterOptions = [ | ||||
|             AddonBlockTimelineFilter.Next7Days, | ||||
|             AddonBlockTimelineFilter.Next30Days, | ||||
|             AddonBlockTimelineFilter.Next3Months, | ||||
|             AddonBlockTimelineFilter.Next6Months, | ||||
|         ] | ||||
|             .map(value => ({ | ||||
|                 value, | ||||
|                 name: `addon.block_timeline.${value}`, | ||||
|             })); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize sections properties. | ||||
|      */ | ||||
|     protected initializeSections(): void { | ||||
|         const filtersRange: Record<AddonBlockTimelineFilter, AddonBlockTimelineDateRange> = { | ||||
|             all: { from: -14 }, | ||||
|             overdue: { from: -14, to: 1 }, | ||||
|             next7days: { from: 0, to: 7 }, | ||||
|             next30days: { from: 0, to: 30 }, | ||||
|             next3months: { from: 0, to: 90 }, | ||||
|             next6months: { from: 0, to: 180 }, | ||||
|         }; | ||||
|         const sortValue = this.sort.valueChanges as Observable<AddonBlockTimelineSort>; | ||||
|         const courses = sortValue.pipe( | ||||
|             distinctUntilChanged(), | ||||
|             map(async sort => { | ||||
|                 switch (sort) { | ||||
|                     case AddonBlockTimelineSort.ByDates: | ||||
|                         return []; | ||||
|                     case AddonBlockTimelineSort.ByCourses: | ||||
|                         return CoreCoursesHelper.getUserCoursesWithOptions(); | ||||
|                 } | ||||
|             }), | ||||
|             resolved(), | ||||
|             map(courses => { | ||||
|                 this.courseIdsToInvalidate = courses.map(course => course.id); | ||||
| 
 | ||||
|                 return courses; | ||||
|             }), | ||||
|         ); | ||||
| 
 | ||||
|         this.sections$ = combineLatest([this.filter$, sortValue, this.search$, courses]).pipe( | ||||
|             map(async ([filter, sort, search, courses]) => { | ||||
|                 const includeOverdue = filter === AddonBlockTimelineFilter.Overdue; | ||||
|                 const dateRange = filtersRange[filter]; | ||||
| 
 | ||||
|                 switch (sort) { | ||||
|                     case AddonBlockTimelineSort.ByDates: | ||||
|                         return this.getSectionsByDates(search, includeOverdue, dateRange); | ||||
|                     case AddonBlockTimelineSort.ByCourses: | ||||
|                         return this.getSectionsByCourse(search, includeOverdue, dateRange, courses); | ||||
|                 } | ||||
|             }), | ||||
|             resolved(), | ||||
|             catchError(error => { | ||||
|                 // An error ocurred in the function, log the error and just resolve the observable so the workflow continues.
 | ||||
|                 this.logger.error(error); | ||||
| 
 | ||||
|                 // Error getting data, fail.
 | ||||
|                 CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError, true); | ||||
| 
 | ||||
|                 return of([]); | ||||
|             }), | ||||
|             share(), | ||||
|             tap(() => (this.loaded = true)), | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get sections sorted by dates. | ||||
|      * | ||||
|      * @param search Search string. | ||||
|      * @param overdue Whether to filter overdue events or not. | ||||
|      * @param dateRange Date range to filter events by. | ||||
|      * @returns Sections. | ||||
|      */ | ||||
|     protected async getSectionsByDates( | ||||
|         search: string | null, | ||||
|         overdue: boolean, | ||||
|         dateRange: AddonBlockTimelineDateRange, | ||||
|     ): Promise<AddonBlockTimelineSection[]> { | ||||
|         const section = new AddonBlockTimelineSection(search, overdue, dateRange); | ||||
| 
 | ||||
|         await section.loadMore(); | ||||
| 
 | ||||
|         return section.data$.value.events.length > 0 ? [section] : []; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get sections sorted by courses. | ||||
|      * | ||||
|      * @param search Search string. | ||||
|      * @param overdue Whether to filter overdue events or not. | ||||
|      * @param dateRange Date range to filter events by. | ||||
|      * @param courses Courses. | ||||
|      * @returns Sections. | ||||
|      */ | ||||
|     protected async getSectionsByCourse( | ||||
|         search: string | null, | ||||
|         overdue: boolean, | ||||
|         dateRange: AddonBlockTimelineDateRange, | ||||
|         courses: CoreEnrolledCourseDataWithOptions[], | ||||
|     ): Promise<AddonBlockTimelineSection[]> { | ||||
|         // Do not filter courses by date because they can contain activities due.
 | ||||
|         const courseIds = courses.map(course => course.id); | ||||
|         const gracePeriod = await this.getCoursesGracePeriod(); | ||||
|         const courseEvents = await AddonBlockTimeline.getActionEventsByCourses(courseIds, search ?? ''); | ||||
| 
 | ||||
|         return courses | ||||
|             .filter( | ||||
|                 course => | ||||
|                     !course.hidden && | ||||
|                     !CoreCoursesHelper.isPastCourse(course, gracePeriod.after) && | ||||
|                     !CoreCoursesHelper.isFutureCourse(course, gracePeriod.after, gracePeriod.before) && | ||||
|                     courseEvents[course.id].events.length > 0, | ||||
|             ) | ||||
|             .map(course => new AddonBlockTimelineSection( | ||||
|                 search, | ||||
|                 overdue, | ||||
|                 dateRange, | ||||
|                 course, | ||||
|                 courseEvents[course.id].events, | ||||
|                 courseEvents[course.id].canLoadMore, | ||||
|             )) | ||||
|             .filter(section => section.data$.value.events.length > 0); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get courses grace period for the current site. | ||||
|      * | ||||
|      * @returns Courses grace period. | ||||
|      */ | ||||
|     protected async getCoursesGracePeriod(): Promise<{ before: number; after: number }> { | ||||
|         try { | ||||
|             const currentSite = CoreSites.getRequiredCurrentSite(); | ||||
| 
 | ||||
|             return { | ||||
|                 before: parseInt(await currentSite.getConfig('coursegraceperiodbefore'), 10), | ||||
|                 after: parseInt(await currentSite.getConfig('coursegraceperiodafter'), 10), | ||||
|             }; | ||||
|         } catch { | ||||
|             return { before: 0, after: 0 }; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export type AddonBlockTimelineCourse = CoreEnrolledCourseDataWithOptions & { | ||||
|     events?: AddonCalendarEvent[]; | ||||
|     canLoadMore?: number; | ||||
| }; | ||||
| /** | ||||
|  * Sort options. | ||||
|  */ | ||||
| export enum AddonBlockTimelineSort { | ||||
|     ByDates = 'sortbydates', | ||||
|     ByCourses = 'sortbycourses', | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Filter options. | ||||
|  */ | ||||
| export const enum AddonBlockTimelineFilter { | ||||
|     All = 'all', | ||||
|     Overdue = 'overdue', | ||||
|     Next7Days = 'next7days', | ||||
|     Next30Days = 'next30days', | ||||
|     Next3Months = 'next3months', | ||||
|     Next6Months = 'next6months', | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Select option. | ||||
|  */ | ||||
| export interface AddonBlockTimelineOption<Value> { | ||||
|     value: Value; | ||||
|     name: string; | ||||
| } | ||||
|  | ||||
| @ -107,6 +107,10 @@ export class AddonBlockTimelineProvider { | ||||
|         searchValue = '', | ||||
|         siteId?: string, | ||||
|     ): Promise<{[courseId: string]: { events: AddonCalendarEvent[]; canLoadMore?: number } }> { | ||||
|         if (courseIds.length === 0) { | ||||
|             return {}; | ||||
|         } | ||||
| 
 | ||||
|         const site = await CoreSites.getSite(siteId); | ||||
| 
 | ||||
|         const time = this.getDayStart(-14); // Check two weeks ago.
 | ||||
|  | ||||
							
								
								
									
										92
									
								
								src/addons/block/timeline/tests/behat/basic_usage.feature
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/addons/block/timeline/tests/behat/basic_usage.feature
									
									
									
									
									
										Normal 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 | ||||
| @ -529,9 +529,7 @@ class AddonCalendarMonthSlidesItemsManagerSource extends CoreSwipeSlidesDynamicI | ||||
|                 day.eventsFormated = day.eventsFormated || []; | ||||
|                 day.filteredEvents = day.filteredEvents || []; | ||||
|                 // Format online events.
 | ||||
|                 const onlineEventsFormatted = await Promise.all( | ||||
|                     day.events.map(async (event) => AddonCalendarHelper.formatEventData(event)), | ||||
|                 ); | ||||
|                 const onlineEventsFormatted = day.events.map((event) => AddonCalendarHelper.formatEventData(event)); | ||||
| 
 | ||||
|                 day.eventsFormated = day.eventsFormated.concat(onlineEventsFormatted); | ||||
| 
 | ||||
|  | ||||
| @ -165,7 +165,7 @@ export class AddonCalendarUpcomingEventsComponent implements OnInit, DoCheck, On | ||||
|     async fetchEvents(): Promise<void> { | ||||
|         // Don't pass courseId and categoryId, we'll filter them locally.
 | ||||
|         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.
 | ||||
|         this.events = this.mergeEvents(); | ||||
|         // Filter events by course.
 | ||||
|  | ||||
| @ -669,9 +669,7 @@ class AddonCalendarDaySlidesItemsManagerSource extends CoreSwipeSlidesDynamicIte | ||||
|         try { | ||||
|             // 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()); | ||||
|             preloadedDay.onlineEvents = await Promise.all( | ||||
|                 result.events.map((event) => AddonCalendarHelper.formatEventData(event)), | ||||
|             ); | ||||
|             preloadedDay.onlineEvents = result.events.map((event) => AddonCalendarHelper.formatEventData(event)); | ||||
|         } catch (error) { | ||||
|             // Allow navigating to non-cached days in offline (behave as if using emergency cache).
 | ||||
|             if (CoreNetwork.isOnline()) { | ||||
|  | ||||
| @ -198,7 +198,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { | ||||
|             // Get the event data.
 | ||||
|             if (this.eventId >= 0) { | ||||
|                 const event = await AddonCalendar.getEventById(this.eventId); | ||||
|                 this.event = await AddonCalendarHelper.formatEventData(event); | ||||
|                 this.event = AddonCalendarHelper.formatEventData(event); | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|  | ||||
| @ -64,7 +64,7 @@ export class AddonCalendarHelperProvider { | ||||
|      * @param eventType Type of the event. | ||||
|      * @return Event icon. | ||||
|      */ | ||||
|     getEventIcon(eventType: AddonCalendarEventType): string { | ||||
|     getEventIcon(eventType: AddonCalendarEventType | string): string { | ||||
|         if (this.eventTypeIcons.length == 0) { | ||||
|             CoreUtils.enumKeys(AddonCalendarEventType).forEach((name) => { | ||||
|                 const value = AddonCalendarEventType[name]; | ||||
| @ -164,9 +164,9 @@ export class AddonCalendarHelperProvider { | ||||
|      * | ||||
|      * @param event Event to format. | ||||
|      */ | ||||
|     async formatEventData( | ||||
|     formatEventData( | ||||
|         event: AddonCalendarEvent | AddonCalendarEventBase | AddonCalendarGetEventsEvent, | ||||
|     ): Promise<AddonCalendarEventToDisplay> { | ||||
|     ): AddonCalendarEventToDisplay { | ||||
| 
 | ||||
|         const eventFormatted: AddonCalendarEventToDisplay = { | ||||
|             ...event, | ||||
| @ -181,7 +181,7 @@ export class AddonCalendarHelperProvider { | ||||
|         }; | ||||
| 
 | ||||
|         if (event.modulename) { | ||||
|             eventFormatted.eventIcon = await CoreCourse.getModuleIconSrc(event.modulename); | ||||
|             eventFormatted.eventIcon = CoreCourse.getModuleIconSrc(event.modulename); | ||||
|             eventFormatted.moduleIcon = eventFormatted.eventIcon; | ||||
|             eventFormatted.iconTitle = CoreCourse.translateModuleName(event.modulename); | ||||
|         } | ||||
|  | ||||
| @ -796,7 +796,7 @@ export class AddonCalendarProvider { | ||||
|      * @param event The event to get its type. | ||||
|      * @return Event type. | ||||
|      */ | ||||
|     getEventType(event: { modulename?: string; eventtype: AddonCalendarEventType}): string { | ||||
|     getEventType(event: { modulename?: string; eventtype: AddonCalendarEventType | string }): string { | ||||
|         if (event.modulename) { | ||||
|             return 'course'; | ||||
|         } | ||||
| @ -1878,7 +1878,7 @@ export type AddonCalendarEventBase = { | ||||
|     activityname?: string; // Activityname.
 | ||||
|     activitystr?: string; // Activitystr.
 | ||||
|     instance?: number; // Instance.
 | ||||
|     eventtype: AddonCalendarEventType; // Eventtype.
 | ||||
|     eventtype: AddonCalendarEventType | string; // Eventtype.
 | ||||
|     timestart: number; // Timestart.
 | ||||
|     timeduration: number; // Timeduration.
 | ||||
|     timesort: number; // Timesort.
 | ||||
| @ -2284,7 +2284,7 @@ export type AddonCalendarEventToDisplay = Partial<AddonCalendarCalendarEvent> & | ||||
|     timestart: number; | ||||
|     timeduration: number; | ||||
|     eventcount: number; | ||||
|     eventtype: AddonCalendarEventType; | ||||
|     eventtype: AddonCalendarEventType | string; | ||||
|     courseid?: number; | ||||
|     offline?: boolean; | ||||
|     showDate?: boolean; // Calculated in the app. Whether date should be shown before this event.
 | ||||
|  | ||||
| @ -282,7 +282,7 @@ export type AddonCalendarEventDBRecord = { | ||||
|     id: number; | ||||
|     name: string; | ||||
|     description: string; | ||||
|     eventtype: AddonCalendarEventType; | ||||
|     eventtype: AddonCalendarEventType | string; | ||||
|     timestart: number; | ||||
|     timeduration: number; | ||||
|     categoryid?: number; | ||||
|  | ||||
| @ -46,7 +46,7 @@ export class AddonModLabelModuleHandlerService extends CoreModuleHandlerBase imp | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async getData(module: CoreCourseModuleData): Promise<CoreCourseModuleHandlerData> { | ||||
|     getData(module: CoreCourseModuleData): CoreCourseModuleHandlerData { | ||||
|         // Remove the description from the module so it isn't rendered twice.
 | ||||
|         const title = module.description || ''; | ||||
|         module.description = ''; | ||||
|  | ||||
| @ -104,13 +104,11 @@ export class AddonModResourceModuleHandlerService extends CoreModuleHandlerBase | ||||
|             // Ignore errors.
 | ||||
|         }); | ||||
| 
 | ||||
|         this.getIconSrc(module).then((icon) => { | ||||
|             handlerData.icon = icon; | ||||
| 
 | ||||
|             return; | ||||
|         }).catch(() => { | ||||
|         try { | ||||
|             handlerData.icon = this.getIconSrc(module); | ||||
|         } catch { | ||||
|             // Ignore errors.
 | ||||
|         }); | ||||
|         } | ||||
| 
 | ||||
|         return handlerData; | ||||
|     } | ||||
| @ -227,13 +225,13 @@ export class AddonModResourceModuleHandlerService extends CoreModuleHandlerBase | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async getIconSrc(module?: CoreCourseModuleData): Promise<string | undefined> { | ||||
|     getIconSrc(module?: CoreCourseModuleData): string | undefined { | ||||
|         if (!module) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (CoreSites.getCurrentSite()?.isVersionGreaterEqualThan('4.0')) { | ||||
|             return await CoreCourse.getModuleIconSrc(module.modname, module.modicon); | ||||
|             return CoreCourse.getModuleIconSrc(module.modname, module.modicon); | ||||
|         } | ||||
|         let mimetypeIcon = ''; | ||||
| 
 | ||||
| @ -251,7 +249,7 @@ export class AddonModResourceModuleHandlerService extends CoreModuleHandlerBase | ||||
|             mimetypeIcon = CoreMimetypeUtils.getFileIcon(file.filename || ''); | ||||
|         } | ||||
| 
 | ||||
|         return await CoreCourse.getModuleIconSrc(module.modname, module.modicon, mimetypeIcon); | ||||
|         return CoreCourse.getModuleIconSrc(module.modname, module.modicon, mimetypeIcon); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -55,7 +55,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async getData(module: CoreCourseModuleData): Promise<CoreCourseModuleHandlerData> { | ||||
|     getData(module: CoreCourseModuleData): CoreCourseModuleHandlerData { | ||||
| 
 | ||||
|         /** | ||||
|          * Open the URL. | ||||
| @ -80,7 +80,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple | ||||
|         }; | ||||
| 
 | ||||
|         const handlerData: CoreCourseModuleHandlerData = { | ||||
|             icon: await CoreCourse.getModuleIconSrc(module.modname, module.modicon), | ||||
|             icon: CoreCourse.getModuleIconSrc(module.modname, module.modicon), | ||||
|             title: module.name, | ||||
|             class: 'addon-mod_url-handler', | ||||
|             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) { | ||||
|                 return; | ||||
|             } | ||||
| @ -120,7 +120,7 @@ export class AddonModUrlModuleHandlerService extends CoreModuleHandlerBase imple | ||||
|                 const icon = AddonModUrl.guessIcon(module.contents[0].fileurl); | ||||
| 
 | ||||
|                 // 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; | ||||
|  | ||||
| @ -17,6 +17,7 @@ import { Translate } from '@singletons'; | ||||
| import { ModalOptions } from '@ionic/core'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { IonSelect } from '@ionic/angular'; | ||||
| import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that show a combo select button (combobox). | ||||
| @ -40,8 +41,15 @@ import { IonSelect } from '@ionic/angular'; | ||||
|     selector: 'core-combobox', | ||||
|     templateUrl: 'core-combobox.html', | ||||
|     styleUrls: ['combobox.scss'], | ||||
|     providers: [ | ||||
|         { | ||||
|             provide: NG_VALUE_ACCESSOR, | ||||
|             multi:true, | ||||
|             useExisting: CoreComboboxComponent, | ||||
|         }, | ||||
|     ], | ||||
| }) | ||||
| export class CoreComboboxComponent { | ||||
| export class CoreComboboxComponent implements ControlValueAccessor { | ||||
| 
 | ||||
|     @ViewChild(IonSelect) select!: IonSelect; | ||||
| 
 | ||||
| @ -58,6 +66,49 @@ export class CoreComboboxComponent { | ||||
| 
 | ||||
|     expanded = false; | ||||
| 
 | ||||
|     protected touched = false; | ||||
|     protected formOnChange?: (value: unknown) => void; | ||||
|     protected formOnTouched?: () => void; | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     writeValue(selection: string): void { | ||||
|         this.selection = selection; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     registerOnChange(onChange: (value: unknown) => void): void { | ||||
|         this.formOnChange = onChange; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     registerOnTouched(onTouched: () => void): void { | ||||
|         this.formOnTouched = onTouched; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     setDisabledState(disabled: boolean): void { | ||||
|         this.disabled = disabled; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Callback when the selected value changes. | ||||
|      * | ||||
|      * @param selection Selected value. | ||||
|      */ | ||||
|     onValueChanged(selection: unknown): void { | ||||
|         this.touch(); | ||||
|         this.onChange.emit(selection); | ||||
|         this.formOnChange?.(selection); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Shows combobox modal. | ||||
|      * | ||||
| @ -65,6 +116,8 @@ export class CoreComboboxComponent { | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async openSelect(event?: UIEvent): Promise<void> { | ||||
|         this.touch(); | ||||
| 
 | ||||
|         if (this.interface == 'modal') { | ||||
|             if (this.expanded || !this.modalOptions) { | ||||
|                 return; | ||||
| @ -79,11 +132,23 @@ export class CoreComboboxComponent { | ||||
|             this.expanded = false; | ||||
| 
 | ||||
|             if (data) { | ||||
|                 this.onChange.emit(data); | ||||
|                 this.onValueChanged(data); | ||||
|             } | ||||
|         } else if (this.select) { | ||||
|             this.select.open(event); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Mark as touched. | ||||
|      */ | ||||
|     protected touch(): void { | ||||
|         if (this.touched) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.touched = true; | ||||
|         this.formOnTouched?.(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -5,7 +5,7 @@ | ||||
|         <div class="select-icon-inner"></div> | ||||
|     </div> | ||||
| </ion-button> | ||||
| <ion-select *ngIf="interface != 'modal'" class="ion-text-start" [(ngModel)]="selection" (ngModelChange)="onChange.emit(selection)" | ||||
| <ion-select *ngIf="interface != 'modal'" class="ion-text-start" [(ngModel)]="selection" (ngModelChange)="onValueChanged(selection)" | ||||
|     [interface]="interface" [attr.aria-label]="label + ': ' + selection" [disabled]="disabled" [hidden]="!!icon"> | ||||
|     <ng-content></ng-content> | ||||
| </ion-select> | ||||
|  | ||||
| @ -18,7 +18,6 @@ import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreTextUtils } from '@services/utils/text'; | ||||
| import { CoreCourseBlock } from '../../course/services/course'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| import { Params } from '@angular/router'; | ||||
| import { ContextLevel } from '@/core/constants'; | ||||
| import { CoreNavigationOptions } from '@services/navigator'; | ||||
| @ -29,7 +28,7 @@ import { CoreNavigationOptions } from '@services/navigator'; | ||||
| @Component({ | ||||
|     template: '', | ||||
| }) | ||||
| export abstract class CoreBlockBaseComponent implements OnInit { | ||||
| export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent { | ||||
| 
 | ||||
|     @Input() title!: string; // The block title.
 | ||||
|     @Input() block!: CoreCourseBlock; // The block to render.
 | ||||
| @ -65,30 +64,12 @@ export abstract class CoreBlockBaseComponent implements OnInit { | ||||
|         await this.loadContent(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Refresh the data. | ||||
|      * | ||||
|      * @param refresher Refresher. | ||||
|      * @param done Function to call when done. | ||||
|      * @param showErrors If show errors to the user of hide them. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async doRefresh(refresher?: IonRefresher, done?: () => void, showErrors: boolean = false): Promise<void> { | ||||
|         if (this.loaded) { | ||||
|             return this.refreshContent(showErrors).finally(() => { | ||||
|                 refresher?.complete(); | ||||
|                 done && done(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Perform the refresh content function. | ||||
|      * | ||||
|      * @param showErrors Wether to show errors to the user or hide them. | ||||
|      * @return Resolved when done. | ||||
|      */ | ||||
|     protected async refreshContent(showErrors: boolean = false): Promise<void> { | ||||
|     protected async refreshContent(): Promise<void> { | ||||
|         // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
 | ||||
|         try { | ||||
|             await this.invalidateContent(); | ||||
| @ -97,13 +78,11 @@ export abstract class CoreBlockBaseComponent implements OnInit { | ||||
|             this.logger.error(ex); | ||||
|         } | ||||
| 
 | ||||
|         await this.loadContent(true, showErrors); | ||||
|         await this.loadContent(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Perform the invalidate content function. | ||||
|      * | ||||
|      * @return Resolved when done. | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async invalidateContent(): Promise<void> { | ||||
|         return; | ||||
| @ -111,16 +90,11 @@ export abstract class CoreBlockBaseComponent implements OnInit { | ||||
| 
 | ||||
|     /** | ||||
|      * Loads the component contents and shows the corresponding error. | ||||
|      * | ||||
|      * @param refresh Whether we're refreshing data. | ||||
|      * @param showErrors Wether to show errors to the user or hide them. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     protected async loadContent(refresh?: boolean, showErrors: boolean = false): Promise<void> { | ||||
|     protected async loadContent(): Promise<void> { | ||||
|         // Wrap the call in a try/catch so the workflow isn't interrupted if an error occurs.
 | ||||
|         try { | ||||
|             await this.fetchContent(refresh); | ||||
|             await this.fetchContent(); | ||||
|         } catch (error) { | ||||
|             // An error ocurred in the function, log the error and just resolve the promise so the workflow continues.
 | ||||
|             this.logger.error(error); | ||||
| @ -135,12 +109,22 @@ export abstract class CoreBlockBaseComponent implements OnInit { | ||||
|     /** | ||||
|      * Download the component contents. | ||||
|      * | ||||
|      * @param refresh Whether we're refreshing data. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     protected async fetchContent(refresh: boolean = false): Promise<void> { | ||||
|     protected async fetchContent(): Promise<void> { | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Interface for block components. | ||||
|  */ | ||||
| export interface ICoreBlockComponent { | ||||
| 
 | ||||
|     /** | ||||
|      * Perform the invalidate content function. | ||||
|      */ | ||||
|     invalidateContent(): Promise<void>; | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -17,8 +17,7 @@ import { CoreBlockDelegate } from '../../services/block-delegate'; | ||||
| import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { CoreCourseBlock } from '@/core/features/course/services/course'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| import type { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; | ||||
| import type { ICoreBlockComponent } from '@features/block/classes/base-block-component'; | ||||
| 
 | ||||
| /** | ||||
|  * Component to render a block. | ||||
| @ -30,14 +29,14 @@ import type { CoreBlockBaseComponent } from '@features/block/classes/base-block- | ||||
| }) | ||||
| export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { | ||||
| 
 | ||||
|     @ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent<CoreBlockBaseComponent>; | ||||
|     @ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent<ICoreBlockComponent>; | ||||
| 
 | ||||
|     @Input() block!: CoreCourseBlock; // The block to render.
 | ||||
|     @Input() contextLevel!: string; // The context where the block will be used.
 | ||||
|     @Input() instanceId!: number; // The instance ID associated with the context level.
 | ||||
|     @Input() extraData!: Record<string, unknown>; // Any extra data to be passed to the block.
 | ||||
| 
 | ||||
|     componentClass?: Type<unknown>; // The class of the component to render.
 | ||||
|     componentClass?: Type<ICoreBlockComponent>; // The class of the component to render.
 | ||||
|     data: Record<string, unknown> = {}; // Data to pass to the component.
 | ||||
|     class?: string; // CSS class to apply to the block.
 | ||||
|     loaded = false; | ||||
| @ -133,24 +132,6 @@ export class CoreBlockComponent implements OnInit, OnDestroy, DoCheck { | ||||
|         delete this.blockSubscription; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Refresh the data. | ||||
|      * | ||||
|      * @param refresher Refresher. Please pass this only if the refresher should finish when this function finishes. | ||||
|      * @param done Function to call when done. | ||||
|      * @param showErrors If show errors to the user of hide them. | ||||
|      * @return Promise resolved when done. | ||||
|      */ | ||||
|     async doRefresh( | ||||
|         refresher?: IonRefresher, | ||||
|         done?: () => void, | ||||
|         showErrors: boolean = false, | ||||
|     ): Promise<void> { | ||||
|         if (this.dynamicComponent) { | ||||
|             await this.dynamicComponent.callComponentMethod('doRefresh', refresher, done, showErrors); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Invalidate some data. | ||||
|      * | ||||
|  | ||||
| @ -22,7 +22,7 @@ import { Params } from '@angular/router'; | ||||
| import { makeSingleton } from '@singletons'; | ||||
| import { CoreBlockDefaultHandler } from './handlers/default-block'; | ||||
| import { CoreNavigationOptions } from '@services/navigator'; | ||||
| import type { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; | ||||
| import type { ICoreBlockComponent } from '@features/block/classes/base-block-component'; | ||||
| 
 | ||||
| /** | ||||
|  * Interface that all blocks must implement. | ||||
| @ -66,7 +66,7 @@ export interface CoreBlockHandlerData { | ||||
|      * The component to render the contents of the block. | ||||
|      * It's recommended to return the class of the component, but you can also return an instance of the component. | ||||
|      */ | ||||
|     component: Type<CoreBlockBaseComponent>; | ||||
|     component: Type<ICoreBlockComponent>; | ||||
| 
 | ||||
|     /** | ||||
|      * Data to pass to the component. All the properties in this object will be passed to the component as inputs. | ||||
|  | ||||
| @ -34,14 +34,14 @@ export class CoreModuleHandlerBase implements Partial<CoreCourseModuleHandler> { | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async getData( | ||||
|     getData( | ||||
|         module: CoreCourseModuleData, | ||||
|         courseId: 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
 | ||||
|     ): Promise<CoreCourseModuleHandlerData> { | ||||
|     ): Promise<CoreCourseModuleHandlerData> | CoreCourseModuleHandlerData { | ||||
|         return { | ||||
|             icon: await CoreCourse.getModuleIconSrc(module.modname, module.modicon), | ||||
|             icon: CoreCourse.getModuleIconSrc(module.modname, module.modicon), | ||||
|             title: module.name, | ||||
|             class: 'addon-mod_' + module.modname + '-handler', | ||||
|             showDownloadButton: true, | ||||
|  | ||||
| @ -783,7 +783,7 @@ export class CoreCourseProvider { | ||||
|      * @param modicon The mod icon string to use in case we are not using a core activity. | ||||
|      * @return The IMG src. | ||||
|      */ | ||||
|     async getModuleIconSrc(moduleName: string, modicon?: string, mimetypeIcon = ''): Promise<string> { | ||||
|     getModuleIconSrc(moduleName: string, modicon?: string, mimetypeIcon = ''): string { | ||||
|         if (mimetypeIcon) { | ||||
|             return mimetypeIcon; | ||||
|         } | ||||
|  | ||||
| @ -41,12 +41,12 @@ export class CoreCourseModuleDefaultHandler implements CoreCourseModuleHandler { | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     async getData( | ||||
|     getData( | ||||
|         module: CoreCourseModuleData, | ||||
|     ): Promise<CoreCourseModuleHandlerData> { | ||||
|     ): CoreCourseModuleHandlerData { | ||||
|         // Return the default data.
 | ||||
|         const defaultData: CoreCourseModuleHandlerData = { | ||||
|             icon: await CoreCourse.getModuleIconSrc(module.modname, module.modicon), | ||||
|             icon: CoreCourse.getModuleIconSrc(module.modname, module.modicon), | ||||
|             title: module.name, | ||||
|             class: 'core-course-default-handler core-course-module-' + module.modname + '-handler', | ||||
|             action: async (event: Event, module: CoreCourseModuleData, courseId: number, options?: CoreNavigationOptions) => { | ||||
|  | ||||
| @ -391,7 +391,7 @@ export class CoreCourseModuleDelegateService extends CoreDelegate<CoreCourseModu | ||||
|     async getModuleIconSrc(modname: string, modicon?: string, module?: CoreCourseModuleData): Promise<string> { | ||||
|         const icon = await this.executeFunctionOnEnabled<Promise<string>>(modname, 'getIconSrc', [module]); | ||||
| 
 | ||||
|         return icon || await CoreCourse.getModuleIconSrc(modname, modicon) || ''; | ||||
|         return icon || CoreCourse.getModuleIconSrc(modname, modicon) || ''; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -1005,6 +1005,10 @@ export class CoreCoursesProvider { | ||||
|      * @return Promise resolved when the data is invalidated. | ||||
|      */ | ||||
|     async invalidateCoursesByField(field: string = '', value: number | string = '', siteId?: string): Promise<void> { | ||||
|         if (typeof value === 'string' && value.length === 0) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         siteId = siteId || CoreSites.getCurrentSiteId(); | ||||
| 
 | ||||
|         const result = await this.fixCoursesByFieldParams(field, value, siteId); | ||||
|  | ||||
| @ -191,7 +191,7 @@ export class CoreGradesCoursePage implements AfterViewInit, OnDestroy { | ||||
|      */ | ||||
|     private async fetchGrades(): Promise<void> { | ||||
|         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.columns = formattedTable.columns; | ||||
|  | ||||
| @ -98,7 +98,7 @@ export class CoreGradesHelperProvider { | ||||
|      * @param tableRow JSON object representing row of grades table data. | ||||
|      * @return Formatted row object. | ||||
|      */ | ||||
|     protected async formatGradeRowForTable(tableRow: CoreGradesTableRow): Promise<CoreGradesFormattedTableRow> { | ||||
|     protected formatGradeRowForTable(tableRow: CoreGradesTableRow): CoreGradesFormattedTableRow { | ||||
|         const row: CoreGradesFormattedTableRow = {}; | ||||
|         for (let name in tableRow) { | ||||
|             const column: CoreGradesTableColumn = tableRow[name]; | ||||
| @ -116,7 +116,7 @@ export class CoreGradesHelperProvider { | ||||
|                 row.colspan = itemNameColumn.colspan; | ||||
|                 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('hidden') >= 0 ? ' hidden' : ''; | ||||
|                 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. | ||||
|      * @return Formatted HTML table. | ||||
|      */ | ||||
|     async formatGradesTable(table: CoreGradesTable): Promise<CoreGradesFormattedTable> { | ||||
|     formatGradesTable(table: CoreGradesTable): CoreGradesFormattedTable { | ||||
|         const maxDepth = table.maxdepth; | ||||
|         const formatted: CoreGradesFormattedTable = { | ||||
|             columns: [], | ||||
| @ -202,7 +202,7 @@ export class CoreGradesHelperProvider { | ||||
|             feedback: 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.
 | ||||
|         let normalRow = formatted.rows.find( | ||||
| @ -474,7 +474,7 @@ export class CoreGradesHelperProvider { | ||||
|         // Find href containing "/mod/xxx/xxx.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) { | ||||
|                 const matches = row.itemname.content.match(regex); | ||||
| 
 | ||||
| @ -486,7 +486,7 @@ export class CoreGradesHelperProvider { | ||||
|             } | ||||
| 
 | ||||
|             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. | ||||
|      * @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', '/'); | ||||
|         if (text.indexOf('/agg_mean') > -1) { | ||||
|             row.itemtype = 'agg_mean'; | ||||
| @ -621,7 +621,7 @@ export class CoreGradesHelperProvider { | ||||
|                 row.itemtype = 'mod'; | ||||
|                 row.itemmodule = module[1]; | ||||
|                 row.iconAlt = CoreCourse.translateModuleName(row.itemmodule) || ''; | ||||
|                 row.image = await CoreCourse.getModuleIconSrc( | ||||
|                 row.image = CoreCourse.getModuleIconSrc( | ||||
|                     module[1], | ||||
|                     CoreDomUtils.convertToElement(text).querySelector('img')?.getAttribute('src') ?? undefined, | ||||
|                 ); | ||||
|  | ||||
| @ -14,7 +14,7 @@ | ||||
| 
 | ||||
| import { Type } from '@angular/core'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import type { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; | ||||
| import type { ICoreBlockComponent } from '@features/block/classes/base-block-component'; | ||||
| import { CoreBlockPreRenderedComponent } from '@features/block/components/pre-rendered-block/pre-rendered-block'; | ||||
| import { CoreBlockDelegate, CoreBlockHandler, CoreBlockHandlerData } from '@features/block/services/block-delegate'; | ||||
| import { CoreCourseBlock } from '@features/course/services/course'; | ||||
| @ -52,7 +52,7 @@ export class CoreSitePluginsBlockHandler extends CoreSitePluginsBaseHandler impl | ||||
|         instanceId: number, | ||||
|     ): Promise<CoreBlockHandlerData> { | ||||
|         const className = this.handlerSchema.displaydata?.class || 'block_' + block.name; | ||||
|         let component: Type<CoreBlockBaseComponent> | undefined; | ||||
|         let component: Type<ICoreBlockComponent> | undefined; | ||||
| 
 | ||||
|         if (this.handlerSchema.displaydata?.type == 'title') { | ||||
|             component = CoreSitePluginsOnlyTitleBlockComponent; | ||||
|  | ||||
| @ -76,7 +76,7 @@ export class CoreSitePluginsModuleHandler extends CoreSitePluginsBaseHandler imp | ||||
|             module.description = ''; | ||||
| 
 | ||||
|             return { | ||||
|                 icon: await CoreCourse.getModuleIconSrc(module.modname, icon), | ||||
|                 icon: CoreCourse.getModuleIconSrc(module.modname, icon), | ||||
|                 title: title || '', | ||||
|                 a11yTitle: '', | ||||
|                 class: this.handlerSchema.displaydata?.class, | ||||
| @ -87,7 +87,7 @@ export class CoreSitePluginsModuleHandler extends CoreSitePluginsBaseHandler imp | ||||
|         const showDowloadButton = this.handlerSchema.downloadbutton; | ||||
|         const handlerData: CoreCourseModuleHandlerData = { | ||||
|             title: module.name, | ||||
|             icon: await CoreCourse.getModuleIconSrc(module.modname, icon), | ||||
|             icon: CoreCourse.getModuleIconSrc(module.modname, icon), | ||||
|             class: this.handlerSchema.displaydata?.class, | ||||
|             showDownloadButton: showDowloadButton !== undefined ? showDowloadButton : hasOffline, | ||||
|         }; | ||||
|  | ||||
| @ -81,12 +81,12 @@ export class CoreUtilsProvider { | ||||
|      * @param promises Promises. | ||||
|      * @return Promise resolved if all promises are resolved and rejected if at least 1 promise fails. | ||||
|      */ | ||||
|     async allPromises(promises: Promise<unknown>[]): Promise<void> { | ||||
|     async allPromises(promises: unknown[]): Promise<void> { | ||||
|         if (!promises || !promises.length) { | ||||
|             return Promise.resolve(); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const getPromiseError = async (promise): Promise<Error | void> => { | ||||
|         const getPromiseError = async (promise: unknown): Promise<Error | void> => { | ||||
|             try { | ||||
|                 await promise; | ||||
|             } catch (error) { | ||||
|  | ||||
							
								
								
									
										62
									
								
								src/core/utils/rxjs.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/core/utils/rxjs.ts
									
									
									
									
									
										Normal 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)); | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										89
									
								
								src/core/utils/tests/rxjs.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/core/utils/tests/rxjs.test.ts
									
									
									
									
									
										Normal 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']); | ||||
|     }); | ||||
| 
 | ||||
| }); | ||||
| @ -28,6 +28,8 @@ import { CoreCronDelegate } from '@services/cron'; | ||||
| import { CoreLoadingComponent } from '@components/loading/loading'; | ||||
| import { CoreComponentsRegistry } from '@singletons/components-registry'; | ||||
| import { CoreDom } from '@singletons/dom'; | ||||
| import { IonRefresher } from '@ionic/angular'; | ||||
| import { CoreCoursesDashboardPage } from '@features/courses/pages/dashboard/dashboard'; | ||||
| 
 | ||||
| /** | ||||
|  * Behat runtime servive with public API. | ||||
| @ -52,6 +54,7 @@ export class TestsBehatRuntime { | ||||
|             log: TestsBehatRuntime.log, | ||||
|             press: TestsBehatRuntime.press, | ||||
|             pressStandard: TestsBehatRuntime.pressStandard, | ||||
|             pullToRefresh: TestsBehatRuntime.pullToRefresh, | ||||
|             scrollTo: TestsBehatRuntime.scrollTo, | ||||
|             setField: TestsBehatRuntime.setField, | ||||
|             handleCustomURL: TestsBehatRuntime.handleCustomURL, | ||||
| @ -349,6 +352,39 @@ export class TestsBehatRuntime { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Trigger a pull to refresh gesture in the current page. | ||||
|      * | ||||
|      * @return OK if successful, or ERROR: followed by message | ||||
|      */ | ||||
|     static async pullToRefresh(): Promise<string> { | ||||
|         this.log('Action - pullToRefresh'); | ||||
| 
 | ||||
|         try { | ||||
|             // TODO We should generalize this to work with other pages. It's not possible to use
 | ||||
|             // an IonRefresher instance because it doesn't expose any methods to trigger refresh,
 | ||||
|             // so we'll have to find another way.
 | ||||
| 
 | ||||
|             const dashboard = this.getAngularInstance<CoreCoursesDashboardPage>( | ||||
|                 'page-core-courses-dashboard', | ||||
|                 'CoreCoursesDashboardPage', | ||||
|             ); | ||||
| 
 | ||||
|             if (!dashboard) { | ||||
|                 return 'ERROR: It\'s not possible to pull to refresh the current page ' | ||||
|                     + '(the dashboard page is the only one supported at the moment).'; | ||||
|             } | ||||
| 
 | ||||
|             await new Promise(resolve => { | ||||
|                 dashboard.refreshDashboard({ complete: resolve } as IonRefresher); | ||||
|             }); | ||||
| 
 | ||||
|             return 'OK'; | ||||
|         } catch (error) { | ||||
|             return 'ERROR: ' + error.message; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Gets the currently displayed page header. | ||||
|      * | ||||
| @ -403,7 +439,7 @@ export class TestsBehatRuntime { | ||||
|      * @param className Constructor class name | ||||
|      * @return Component instance | ||||
|      */ | ||||
|     static getAngularInstance(selector: string, className: string): unknown { | ||||
|     static getAngularInstance<T = unknown>(selector: string, className: string): T | null { | ||||
|         this.log('Action - Get Angular instance ' + selector + ', ' + className); | ||||
| 
 | ||||
|         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user