| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {assert} from 'chrome://resources/js/assert.js'; |
| import {loadTimeData} from 'chrome://resources/js/load_time_data.js'; |
| |
| import {castExists} from './assert_extras.js'; |
| import type {RouteObserverMixinInterface} from './common/route_observer_mixin.js'; |
| import type {OsSettingsRoutes} from './os_settings_routes.js'; |
| import {createRoutes, PATH_REDIRECTS, Route} from './os_settings_routes.js'; |
| |
| export {Route}; |
| |
| /** |
| * Regular expression that captures the leading slash, the content and the |
| * trailing slash in three different groups. |
| */ |
| const CANONICAL_PATH_REGEX = /(^\/)([\/-\w]+)(\/$)/; |
| |
| /** The Router singleton instance. */ |
| let routerInstance: Router|null = null; |
| |
| /** |
| * Represents the available set of routes for the Router singleton. This is |
| * exported as a convenience to avoid always calling the more verbose way |
| * `Router.getInstance().routes`. |
| * |
| * When the Router singleton is updated (i.e. from tests), this export should |
| * also be updated to reflect the new set of routes. |
| */ |
| export let routes: OsSettingsRoutes; |
| |
| export class Router { |
| static getInstance(): Router { |
| assert(routerInstance, 'Router instance has not been set yet.'); |
| return routerInstance; |
| } |
| |
| static setInstance(instance: Router): void { |
| assert(routerInstance === null, 'Router instance has already been set.'); |
| routerInstance = instance; |
| routes = instance.routes; |
| } |
| |
| static resetInstanceForTesting(instance: Router): void { |
| if (routerInstance) { |
| instance.routeObservers_ = routerInstance.routeObservers_; |
| } |
| routerInstance = instance; |
| routes = instance.routes; |
| } |
| |
| private currentRoute_: Route; |
| private currentQueryParameters_: URLSearchParams; |
| private initializeRouteFromUrlCalled_: boolean; |
| private routes_: OsSettingsRoutes; |
| private routeObservers_: Set<RouteObserverMixinInterface>; |
| private lastRouteChangeWasPopstate_: boolean; |
| |
| constructor(availableRoutes: OsSettingsRoutes) { |
| /** |
| * List of available routes. This is populated taking into account current |
| * state (like guest mode). |
| */ |
| this.routes_ = availableRoutes; |
| |
| /** |
| * The current active route. This updated is only by navigateTo() or |
| * initializeRouteFromUrl(). |
| */ |
| this.currentRoute_ = this.routes_.BASIC; |
| |
| /** |
| * The current query parameters. This is updated only by |
| * settings.navigateTo or settings.initializeRouteFromUrl. |
| */ |
| this.currentQueryParameters_ = new URLSearchParams(); |
| |
| this.lastRouteChangeWasPopstate_ = false; |
| |
| this.initializeRouteFromUrlCalled_ = false; |
| |
| this.routeObservers_ = new Set<RouteObserverMixinInterface>(); |
| } |
| |
| // Convenience helper to index this.routes_ via bracket notation |
| // e.g. this.routesMap_[routeName] // => Route |
| private get routesMap_(): Record<string, Route> { |
| return this.routes_ as unknown as Record<string, Route>; |
| } |
| |
| get routes(): OsSettingsRoutes { |
| return this.routes_; |
| } |
| |
| get currentRoute(): Route { |
| return this.currentRoute_; |
| } |
| |
| addObserver(observer: RouteObserverMixinInterface): void { |
| assert(!this.routeObservers_.has(observer)); |
| this.routeObservers_.add(observer); |
| } |
| |
| removeObserver(observer: RouteObserverMixinInterface): void { |
| assert(this.routeObservers_.delete(observer)); |
| } |
| |
| getRoute(route: string): Route { |
| return this.routesMap_[route]; |
| } |
| |
| /** |
| * Helper function to set the current route and notify all observers. |
| */ |
| setCurrentRoute( |
| route: Route, queryParameters: URLSearchParams, |
| isPopstate: boolean): void { |
| this.recordMetrics_(route.path); |
| |
| const prevRoute = this.currentRoute_; |
| this.currentRoute_ = route; |
| this.currentQueryParameters_ = queryParameters; |
| this.lastRouteChangeWasPopstate_ = isPopstate; |
| this.routeObservers_.forEach((observer) => { |
| observer.currentRouteChanged(this.currentRoute_, prevRoute); |
| }); |
| |
| this.updateTitle_(); |
| } |
| |
| /** |
| * Updates the page title to reflect the current route. |
| */ |
| private updateTitle_(): void { |
| if (this.currentRoute_.title) { |
| document.title = loadTimeData.getStringF( |
| 'settingsAltPageTitle', this.currentRoute_.title); |
| } else if ( |
| this.currentRoute_.isNavigableDialog && |
| this.currentRoute_.parent?.title) { |
| document.title = loadTimeData.getStringF( |
| 'settingsAltPageTitle', this.currentRoute_.parent.title); |
| } else if ( |
| !this.currentRoute_.isSubpage() && |
| !this.routes_.ABOUT.contains(this.currentRoute_)) { |
| document.title = loadTimeData.getString('settings'); |
| } |
| } |
| |
| getQueryParameters(): URLSearchParams { |
| return new URLSearchParams( |
| this.currentQueryParameters_); // Defensive copy. |
| } |
| |
| lastRouteChangeWasPopstate(): boolean { |
| return this.lastRouteChangeWasPopstate_; |
| } |
| |
| /** |
| * @return a Route matching the |path| containing a leading "/", |
| * or null if none matched. |
| */ |
| getRouteForPath(path: string): Route|null { |
| assert(path[0] === '/', 'Path must contain a leading slash.'); |
| |
| // Remove any trailing slash. |
| let canonicalPath = path.replace(CANONICAL_PATH_REGEX, '$1$2'); |
| |
| // Handle redirects for paths (e.g. deprecated paths). |
| canonicalPath = PATH_REDIRECTS[canonicalPath] || canonicalPath; |
| |
| const matchingRoute = Object.values(this.routes_).find(route => { |
| return route.path === canonicalPath && isNavigableRoute(route); |
| }); |
| return matchingRoute || null; |
| } |
| |
| /** |
| * Updates the URL parameters of the current route via exchanging the |
| * window history state. This changes the Settings route path, but doesn't |
| * change the route itself, hence does not push a new route history entry. |
| * Notifies routeChangedObservers. |
| */ |
| updateUrlParams(params: URLSearchParams): void { |
| let url = this.currentRoute_.path; |
| const queryString = params.toString(); |
| if (queryString) { |
| url += '?' + queryString; |
| } |
| window.history.replaceState(window.history.state, '', url); |
| |
| // We can't call |setCurrentRoute()| for the following, as it would also |
| // update |oldRoute| and |currentRoute|, which should not happen when |
| // only the URL parameters are updated. |
| this.currentQueryParameters_ = params; |
| this.routeObservers_.forEach((observer) => { |
| observer.currentRouteChanged(this.currentRoute_, this.currentRoute_); |
| }); |
| } |
| |
| /** |
| * Navigates to a canonical route and pushes a new history entry. |
| * @param dynamicParameters Navigations to the same URL parameters in a |
| * different order will still push to history. |
| * @param removeSearch Whether to strip the 'search' URL parameter during |
| * navigation. Defaults to false. |
| */ |
| navigateTo( |
| route: Route, dynamicParameters?: URLSearchParams, |
| removeSearch?: boolean): void { |
| if (!isNavigableRoute(route)) { |
| route = this.routes_.BASIC; |
| } |
| |
| const params = dynamicParameters || new URLSearchParams(); |
| |
| const oldSearchParam = this.getQueryParameters().get('search') || ''; |
| const newSearchParam = params.get('search') || ''; |
| |
| if (!removeSearch && oldSearchParam && !newSearchParam) { |
| params.append('search', oldSearchParam); |
| } |
| |
| let url = route.path; |
| const queryString = params.toString(); |
| if (queryString) { |
| url += '?' + queryString; |
| } |
| |
| // History serializes the state, so we don't push the actual route object. |
| window.history.pushState(this.currentRoute_.path, '', url); |
| this.setCurrentRoute(route, params, false); |
| } |
| |
| /** |
| * Navigates to the previous route if it has an equal or lesser depth. |
| * If there is no previous route in history meeting those requirements, |
| * this navigates to the immediate parent. This will never exit Settings. |
| */ |
| navigateToPreviousRoute(): void { |
| let previousRoute = null; |
| if (window.history.state) { |
| previousRoute = castExists(this.getRouteForPath(window.history.state)); |
| } |
| |
| if (previousRoute && previousRoute.depth <= this.currentRoute_.depth) { |
| window.history.back(); |
| } else { |
| this.navigateTo(this.currentRoute_.parent || this.routes_.BASIC); |
| } |
| } |
| |
| /** |
| * Initialize the route and query params from the URL. |
| */ |
| initializeRouteFromUrl(): void { |
| assert( |
| !this.initializeRouteFromUrlCalled_, |
| 'initializeRouteFromUrl() can only be called once.'); |
| this.initializeRouteFromUrlCalled_ = true; |
| |
| const route = this.getRouteForPath(window.location.pathname); |
| |
| // Record all correct paths entered on the settings page, and |
| // as all incorrect paths are routed to the main settings page, |
| // record all incorrect paths as hitting the main settings page. |
| this.recordMetrics_(route ? route.path : this.routes_.BASIC.path); |
| |
| if (route && isNavigableRoute(route)) { |
| this.currentRoute_ = route; |
| this.currentQueryParameters_ = |
| new URLSearchParams(window.location.search); |
| } else { |
| window.history.replaceState(undefined, '', this.routes_.BASIC.path); |
| } |
| |
| this.updateTitle_(); |
| } |
| |
| resetRouteForTesting(): void { |
| this.initializeRouteFromUrlCalled_ = false; |
| this.lastRouteChangeWasPopstate_ = false; |
| this.currentRoute_ = this.routes_.BASIC; |
| this.currentQueryParameters_ = new URLSearchParams(); |
| } |
| |
| /** |
| * Make a UMA note about visiting this URL path. |
| */ |
| private recordMetrics_(urlPath: string): void { |
| assert(!urlPath.startsWith('chrome://')); |
| assert(!urlPath.startsWith('os-settings')); |
| assert(urlPath.startsWith('/')); |
| assert(!urlPath.match(/\?/g)); // query params should not be included |
| |
| const METRIC_NAME = 'ChromeOS.Settings.PathVisited'; |
| chrome.metricsPrivate.recordSparseValueWithPersistentHash( |
| METRIC_NAME, urlPath); |
| } |
| } |
| |
| /** |
| * Creates a Router instance and returns it. Use `Router.setInstance()` with |
| * this instance as an argument to set the singleton instance. |
| * |
| * Can be used from tests to re-create a Router with a new set of routes. |
| */ |
| export function createRouter(): Router { |
| return new Router(createRoutes()); |
| } |
| |
| Router.setInstance(createRouter()); |
| |
| window.addEventListener('popstate', () => { |
| // On pop state, do not push the state onto the window.history again. |
| const router = Router.getInstance(); |
| router.setCurrentRoute( |
| router.getRouteForPath(window.location.pathname) || routes.BASIC, |
| new URLSearchParams(window.location.search), true); |
| }); |
| |
| /** |
| * @returns true if this route exists under the Basic section (not advanced |
| * section). |
| */ |
| export function isBasicRoute(route: Route|null): boolean { |
| if (!route) { |
| return false; |
| } |
| return routes.BASIC.contains(route); |
| } |
| |
| /** |
| * @returns true if |route| is able to be directly navigated to (ie. there |
| * is a dedicated page or subpage that exists for the given route). |
| */ |
| export function isNavigableRoute(route: Route|null): boolean { |
| if (!route) { |
| return false; |
| } |
| // The ADVANCED route is not navigable. It only serves as a parent to group |
| // child routes. |
| return route !== routes.ADVANCED; |
| } |