From c39d6cc8a5f1bae38000e28c552a2415220292d8 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 15 Jun 2021 16:17:35 +0200 Subject: [PATCH] MOBILE-3320 course: Support nested navigation --- src/app/app-routing.module.ts | 27 ++++++++++++ src/app/tests/app-routing.module.test.ts | 43 +++++++++++++++++++ src/core/features/course/course.module.ts | 3 +- .../features/course/pages/index/index.page.ts | 12 +++++- .../services/handlers/default-format.ts | 6 ++- src/core/services/navigator.ts | 41 ++++++++++++++++-- 6 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 src/app/tests/app-routing.module.test.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 937bc5166..4d833851f 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -97,6 +97,33 @@ function buildConditionalUrlMatcher(pathOrMatcher: string | UrlMatcher, conditio }; } +export function buildRegExpUrlMatcher(regexp: RegExp): UrlMatcher { + return (segments: UrlSegment[]): UrlMatchResult | null => { + // Ignore empty paths. + if (segments.length === 0) { + return null; + } + + const path = segments.map(segment => segment.path).join('/'); + const match = regexp.exec(path)?.[0]; + + // Ignore paths that don't match the start of the url. + if (!match || !path.startsWith(match)) { + return null; + } + + // Consume segments that match. + const [consumed] = segments.slice(1).reduce(([consumed, path], segment) => path === match + ? [consumed, path] + :[ + consumed.concat(segment), + `${path}/${segment.path}`, + ], [[segments[0]] as UrlSegment[], segments[0].path]); + + return { consumed }; + }; +} + export type ModuleRoutes = { children: Routes; siblings: Routes }; export type ModuleRoutesConfig = Routes | Partial; diff --git a/src/app/tests/app-routing.module.test.ts b/src/app/tests/app-routing.module.test.ts new file mode 100644 index 000000000..843ccb3a5 --- /dev/null +++ b/src/app/tests/app-routing.module.test.ts @@ -0,0 +1,43 @@ +// (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 { Route } from '@angular/compiler/src/core'; +import { UrlSegment, UrlSegmentGroup } from '@angular/router'; + +import { mock } from '@/testing/utils'; + +import { buildRegExpUrlMatcher } from '../app-routing.module'; + +describe('Routing utils', () => { + + it('matches paths using a RegExp', () => { + const matcher = buildRegExpUrlMatcher(/foo(\/bar)*/); + const route = mock(); + const segmentGroup = mock(); + const toUrlSegment = (path: string) => new UrlSegment(path, {}); + const testMatcher = (path: string, consumedParts: string[] | null) => + expect(matcher(path.split('/').map(toUrlSegment), segmentGroup, route)) + .toEqual( + consumedParts + ? { consumed: consumedParts.map(toUrlSegment) } + : null, + ); + + testMatcher('baz/foo/bar', null); + testMatcher('foo', ['foo']); + testMatcher('foo/baz', ['foo']); + testMatcher('foo/bar/bar/baz', ['foo', 'bar', 'bar']); + }); + +}); diff --git a/src/core/features/course/course.module.ts b/src/core/features/course/course.module.ts index 78e11b453..3e56ecb0c 100644 --- a/src/core/features/course/course.module.ts +++ b/src/core/features/course/course.module.ts @@ -40,6 +40,7 @@ import { CoreCourseOptionsDelegateService } from './services/course-options-dele import { CoreCourseOfflineProvider } from './services/course-offline'; import { CoreCourseSyncProvider } from './services/sync'; import { COURSE_INDEX_PATH } from '@features/course/course-lazy.module'; +import { buildRegExpUrlMatcher } from '@/app/app-routing.module'; export const CORE_COURSE_SERVICES: Type[] = [ CoreCourseProvider, @@ -59,7 +60,7 @@ export const COURSE_CONTENTS_PATH = `${COURSE_PAGE_NAME}/${COURSE_INDEX_PATH}/${ const routes: Routes = [ { - path: COURSE_PAGE_NAME, + matcher: buildRegExpUrlMatcher(new RegExp(`^${COURSE_PAGE_NAME}(/deep)*`)), loadChildren: () => import('@features/course/course-lazy.module').then(m => m.CoreCourseLazyModule), }, ]; diff --git a/src/core/features/course/pages/index/index.page.ts b/src/core/features/course/pages/index/index.page.ts index 70a7eec37..cf9a9a892 100644 --- a/src/core/features/course/pages/index/index.page.ts +++ b/src/core/features/course/pages/index/index.page.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, ViewChild, OnDestroy, OnInit } from '@angular/core'; -import { Params } from '@angular/router'; +import { ActivatedRoute, Params } from '@angular/router'; import { CoreTabsOutletTab, CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet'; import { CoreCourseFormatDelegate } from '../../services/format-delegate'; @@ -54,7 +54,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { pageParams: {}, }; - constructor() { + constructor(private route: ActivatedRoute) { this.selectTabObserver = CoreEvents.on(CoreEvents.SELECT_COURSE_TAB, (data) => { if (!data.name) { // If needed, set sectionId and sectionNumber. They'll only be used if the content tabs hasn't been loaded yet. @@ -81,6 +81,11 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { * Component being initialized. */ async ngOnInit(): Promise { + // Increase route depth. + const path = CoreNavigator.getRouteFullPath(this.route.snapshot); + + CoreNavigator.increaseRouteDepth(path.replace(/(\/deep)+/, '')); + // Get params. this.course = CoreNavigator.getRouteParam('course'); this.firstTabName = CoreNavigator.getRouteParam('selectedTab'); @@ -180,6 +185,9 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { * Page destroyed. */ ngOnDestroy(): void { + const path = CoreNavigator.getRouteFullPath(this.route.snapshot); + + CoreNavigator.decreaseRouteDepth(path.replace(/(\/deep)+/, '')); this.selectTabObserver?.off(); } diff --git a/src/core/features/course/services/handlers/default-format.ts b/src/core/features/course/services/handlers/default-format.ts index 4b0258cb6..e2a8e1e46 100644 --- a/src/core/features/course/services/handlers/default-format.ts +++ b/src/core/features/course/services/handlers/default-format.ts @@ -177,7 +177,11 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { Object.assign(params, { course: course }); // Don't return the .push promise, we don't want to display a loading modal during the page transition. - CoreNavigator.navigateToSitePath(`course/${course.id}`, { params }); + const currentTab = CoreNavigator.getCurrentMainMenuTab(); + const routeDepth = CoreNavigator.getRouteDepth(`/main/${currentTab}/course/${course.id}`); + const deepPath = '/deep'.repeat(routeDepth); + + CoreNavigator.navigateToSitePath(`course${deepPath}/${course.id}`, { params }); } /** diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index 7d15cfa9e..a5cc68273 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; +import { ActivatedRoute, ActivatedRouteSnapshot, Params } from '@angular/router'; import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; @@ -71,6 +71,7 @@ export type CoreNavigatorCurrentRouteOptions = Partial<{ @Injectable({ providedIn: 'root' }) export class CoreNavigatorService { + protected routesDepth: Record = {}; protected storedParams: Record = {}; protected lastParamId = 0; @@ -397,6 +398,38 @@ export class CoreNavigatorService { return pageComponent || routeData ? null : route; } + /** + * Increase the number of times a route is repeated on the navigation stack. + * + * @param path Absolute route path. + */ + increaseRouteDepth(path: string): void { + this.routesDepth[path] = this.getRouteDepth(path) + 1; + } + + /** + * Decrease the number of times a route is repeated on the navigation stack. + * + * @param path Absolute route path. + */ + decreaseRouteDepth(path: string): void { + if (this.getRouteDepth(path) <= 1) { + delete this.routesDepth[path]; + } else { + this.routesDepth[path]--; + } + } + + /** + * Get the number of times a route is repeated on the navigation stack. + * + * @param path Absolute route path. + * @return Route depth. + */ + getRouteDepth(path: string): number { + return this.routesDepth[path] ?? 0; + } + /** * Navigate to a path within the main menu. * If the path belongs to a visible tab, that tab will be selected. @@ -493,16 +526,16 @@ export class CoreNavigatorService { /** * Get the full path of a certain route, including parent routes paths. * - * @param route Route. + * @param route Route snapshot. * @return Path. */ - getRouteFullPath(route: ActivatedRoute | null): string { + getRouteFullPath(route: ActivatedRouteSnapshot | null): string { if (!route) { return ''; } const parentPath = this.getRouteFullPath(route.parent); - const routePath = route.snapshot.url.join('/'); + const routePath = route.url.join('/'); if (!parentPath && !routePath) { return '';