diff --git a/src/core/classes/page-items-list-manager.ts b/src/core/classes/page-items-list-manager.ts new file mode 100644 index 000000000..0984b31e8 --- /dev/null +++ b/src/core/classes/page-items-list-manager.ts @@ -0,0 +1,202 @@ +// (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 { ActivatedRouteSnapshot, Params } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Helper class to manage the state and routing of a list of items in a page, for example on pages using a split view. + */ +export abstract class CorePageItemsListManager { + + protected itemsList: Item[] | null = null; + protected itemsMap: Record | null = null; + protected selectedItem: Item | null = null; + protected pageComponent: unknown; + protected splitView?: CoreSplitViewComponent; + protected splitViewOutletSubscription?: Subscription; + + constructor(pageComponent: unknown) { + this.pageComponent = pageComponent; + } + + get items(): Item[] { + return this.itemsList || []; + } + + get loaded(): boolean { + return this.itemsMap !== null; + } + + get empty(): boolean { + return this.itemsList === null || this.itemsList.length === 0; + } + + /** + * Process page started operations. + */ + async start(): Promise { + // Calculate current selected item. + const route = CoreNavigator.instance.getCurrentRoute({ pageComponent: this.pageComponent }); + if (route !== null && route.firstChild) { + this.updateSelectedItem(route.firstChild.snapshot); + } + + // Select default item if none is selected on a non-mobile layout. + if (!CoreScreen.instance.isMobile && this.selectedItem === null) { + const defaultItem = this.getDefaultItem(); + + if (defaultItem) { + this.select(defaultItem); + } + } + + // Log activity. + await CoreUtils.instance.ignoreErrors(this.logActivity()); + } + + /** + * Process page destroyed operations. + */ + destroy(): void { + this.splitViewOutletSubscription?.unsubscribe(); + } + + /** + * Watch a split view outlet to keep track of the selected item. + * + * @param splitView Split view component. + */ + watchSplitViewOutlet(splitView: CoreSplitViewComponent): void { + this.splitView = splitView; + this.splitViewOutletSubscription = splitView.outletRouteObservable.subscribe(route => this.updateSelectedItem(route)); + + this.updateSelectedItem(splitView.outletRoute); + } + + // @todo Implement watchResize. + + /** + * Check whether the given item is selected or not. + * + * @param item Item. + * @return Whether the given item is selected. + */ + isSelected(item: Item): boolean { + return this.selectedItem === item; + } + + /** + * Select an item. + * + * @param item Item. + */ + async select(item: Item): Promise { + // Get current route in the page. + const route = CoreNavigator.instance.getCurrentRoute({ pageComponent: this.pageComponent }); + + if (route === null) { + return; + } + + // If this item is already selected, do nothing. + const itemPath = this.getItemPath(item); + + if (route.firstChild?.routeConfig?.path === itemPath) { + return; + } + + // Navigate to item. + const path = route.firstChild ? `../${itemPath}` : itemPath; + const params = this.getItemQueryParams(item); + + await CoreNavigator.instance.navigate(path, { params }); + } + + /** + * Set the list of items. + * + * @param items Items. + */ + setItems(items: Item[]): void { + this.itemsList = items.slice(0); + this.itemsMap = items.reduce((map, item) => { + map[this.getItemPath(item)] = item; + + return map; + }, {}); + + this.updateSelectedItem(this.splitView?.outletRoute); + } + + /** + * Log activity when the page starts. + */ + protected async logActivity(): Promise { + // + } + + /** + * Update the selected item given the current route. + * + * @param route Current route. + */ + protected updateSelectedItem(route?: ActivatedRouteSnapshot | null): void { + const selectedItemPath = route ? this.getSelectedItemPath(route) : null; + + this.selectedItem = selectedItemPath + ? this.itemsMap?.[selectedItemPath] ?? null + : null; + } + + /** + * Get the item that should be selected by default. + */ + protected getDefaultItem(): Item | null { + return this.itemsList?.[0] || null; + } + + /** + * Get the query parameters to use when navigating to an item page. + * + * @param item Item. + * @return Query parameters to use when navigating to the item page. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getItemQueryParams(item: Item): Params { + return {}; + } + + /** + * Get the path to use when navigating to an item page. + * + * @param item Item. + * @return Path to use when navigating to the item page. + */ + protected abstract getItemPath(item: Item): string; + + /** + * Get the path of the selected item given the current route. + * + * @param route Current route. + * @return Path of the selected item in the given route. + */ + protected abstract getSelectedItemPath(route: ActivatedRouteSnapshot): string | null; + +} diff --git a/src/core/components/split-view/split-view.ts b/src/core/components/split-view/split-view.ts index c9ab27d97..101719d7d 100644 --- a/src/core/components/split-view/split-view.ts +++ b/src/core/components/split-view/split-view.ts @@ -13,9 +13,10 @@ // limitations under the License. import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; import { IonRouterOutlet } from '@ionic/angular'; import { CoreScreen } from '@services/screen'; -import { Subscription } from 'rxjs'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; enum CoreSplitViewMode { MenuOnly = 'menu-only', // Hides content. @@ -35,18 +36,33 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy { @Input() placeholderText = 'core.emptysplit'; isNested = false; + private outletRouteSubject: BehaviorSubject = new BehaviorSubject(null); private subscriptions?: Subscription[]; constructor(private element: ElementRef) {} + get outletRoute(): ActivatedRouteSnapshot | null { + return this.outletRouteSubject.value; + } + + get outletRouteObservable(): Observable { + return this.outletRouteSubject.asObservable(); + } + /** * @inheritdoc */ ngAfterViewInit(): void { this.isNested = !!this.element.nativeElement.parentElement?.closest('core-split-view'); this.subscriptions = [ - this.outlet.activateEvents.subscribe(() => this.updateClasses()), - this.outlet.deactivateEvents.subscribe(() => this.updateClasses()), + this.outlet.activateEvents.subscribe(() => { + this.updateClasses(); + this.outletRouteSubject.next(this.outlet.activatedRoute.snapshot); + }), + this.outlet.deactivateEvents.subscribe(() => { + this.updateClasses(); + this.outletRouteSubject.next(null); + }), CoreScreen.instance.layoutObservable.subscribe(() => this.updateClasses()), ]; diff --git a/src/core/features/settings/pages/index/index.html b/src/core/features/settings/pages/index/index.html index dc2d95829..2552bd392 100644 --- a/src/core/features/settings/pages/index/index.html +++ b/src/core/features/settings/pages/index/index.html @@ -11,11 +11,11 @@ {{ 'core.settings.' + section.name | translate }} diff --git a/src/core/features/settings/pages/index/index.ts b/src/core/features/settings/pages/index/index.ts index 0d40b0c11..d373ad9a2 100644 --- a/src/core/features/settings/pages/index/index.ts +++ b/src/core/features/settings/pages/index/index.ts @@ -12,83 +12,57 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { Subscription } from 'rxjs'; - -import { CoreNavigator } from '@services/navigator'; -import { CoreScreen } from '@services/screen'; +import { AfterViewInit, Component, OnDestroy, ViewChild } from '@angular/core'; import { CoreSettingsConstants, CoreSettingsSection } from '@features/settings/constants'; +import { CorePageItemsListManager } from '@classes/page-items-list-manager'; +import { ActivatedRouteSnapshot } from '@angular/router'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; @Component({ selector: 'page-core-settings-index', templateUrl: 'index.html', }) -export class CoreSettingsIndexPage implements OnInit, OnDestroy { +export class CoreSettingsIndexPage implements AfterViewInit, OnDestroy { - sections = CoreSettingsConstants.SECTIONS; - activeSection?: string; - layoutSubscription?: Subscription; + sections: CoreSettingsSectionsManager = new CoreSettingsSectionsManager(CoreSettingsIndexPage); + + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; /** * @inheritdoc */ - ngOnInit(): void { - this.layoutSubscription = CoreScreen.instance.layoutObservable.subscribe(() => this.updateActiveSection()); - } - - /** - * @inheritdoc - */ - ionViewWillEnter(): void { - this.updateActiveSection(); + ngAfterViewInit(): void { + this.sections.setItems(CoreSettingsConstants.SECTIONS); + this.sections.watchSplitViewOutlet(this.splitView); + this.sections.start(); } /** * @inheritdoc */ ngOnDestroy(): void { - this.layoutSubscription?.unsubscribe(); - } - - /** - * Open a section page. - * - * @param section Section to open. - */ - openSection(section: CoreSettingsSection): void { - const path = this.activeSection ? `../${section.path}` : section.path; - - CoreNavigator.instance.navigate(path); - - this.updateActiveSection(section.name); - } - - /** - * Update active section. - * - * @param activeSection Active section. - */ - private updateActiveSection(activeSection?: string): void { - if (CoreScreen.instance.isMobile) { - delete this.activeSection; - - return; - } - - this.activeSection = activeSection ?? this.guessActiveSection(); - } - - /** - * Guess active section looking at the current route. - * - * @return Active section. - */ - private guessActiveSection(): string | undefined { - const activeSection = this.sections.find( - section => CoreNavigator.instance.isCurrent(`**/settings/${section.path}`), - ); - - return activeSection?.name; + this.sections.destroy(); + } + +} + +/** + * Helper class to manage sections. + */ +class CoreSettingsSectionsManager extends CorePageItemsListManager { + + /** + * @inheritdoc + */ + protected getItemPath(section: CoreSettingsSection): string { + return section.path; + } + + /** + * @inheritdoc + */ + protected getSelectedItemPath(route: ActivatedRouteSnapshot): string | null { + return route.parent?.routeConfig?.path ?? null; } } diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index 1789848fa..473807261 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -50,6 +50,14 @@ export type CoreNavigationOptions = { reset?: boolean; }; +/** + * Options for CoreNavigatorService#getCurrentRoute method. + */ +type GetCurrentRouteOptions = Partial<{ + parentRoute: ActivatedRoute; + pageComponent: unknown; +}>; + /** * Service to provide some helper functions regarding navigation. */ @@ -310,13 +318,26 @@ export class CoreNavigatorService { /** * Get current activated route. * - * @param route Parent route. + * @param options + * - parent: Parent route, if this isn't provided the current active route will be used. + * - pageComponent: Page component of the route to find, if this isn't provided the deepest route in the hierarchy + * will be returned. * @return Current activated route. */ - protected getCurrentRoute(route?: ActivatedRoute): ActivatedRoute { - route = route ?? Router.instance.routerState.root; + getCurrentRoute(): ActivatedRoute; + getCurrentRoute(options: GetCurrentRouteOptions): ActivatedRoute | null; + getCurrentRoute({ parentRoute, pageComponent }: GetCurrentRouteOptions = {}): ActivatedRoute | null { + parentRoute = parentRoute ?? Router.instance.routerState.root; - return route.firstChild ? this.getCurrentRoute(route.firstChild) : route; + if (pageComponent && parentRoute.component === pageComponent) { + return parentRoute; + } + + if (parentRoute.firstChild) { + return this.getCurrentRoute({ parentRoute: parentRoute.firstChild, pageComponent }); + } + + return pageComponent ? null : parentRoute; } /**