chromium/chrome/browser/resources/ash/settings/router.ts

// 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 {isRevampWayfindingEnabled} from './common/load_time_booleans.js';
import {RouteObserverMixinInterface} from './common/route_observer_mixin.js';
import {createRoutes, OsSettingsRoutes, 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 obsolete paths.
    if (isRevampWayfindingEnabled()) {
      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 Advanced section.
 */
export function isAdvancedRoute(route: Route|null): boolean {
  if (!route) {
    return false;
  }
  return routes.ADVANCED.contains(route);
}

/**
 * @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 this route exists under the About page
 */
export function isAboutRoute(route: Route|null): boolean {
  if (!route) {
    return false;
  }
  return routes.ABOUT.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;
}