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