blob: e05d3d6214f61ec7ce9ff90d57f4648450137501 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './i18n_setup.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {dedupingMixin} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
/**
* @typedef {{
* BASIC: !Route,
* ADVANCED: !Route,
* ABOUT: !Route,
* }}
*/
export let MinimumRoutes;
/** Class for navigable routes. */
export class Route {
/** @param {string} path */
constructor(path) {
/** @type {string} */
this.path = path;
/** @type {?Route} */
this.parent = null;
/** @type {number} */
this.depth = 0;
/**
* @type {boolean} Whether this route corresponds to a navigable
* dialog. Those routes must belong to a "section".
*/
this.isNavigableDialog = false;
// Below are all legacy properties to provide compatibility with the old
// routing system.
/** @type {string} */
this.section = '';
}
/**
* Returns a new Route instance that's a child of this route.
* @param {string} path Extends this route's path if it doesn't contain a
* leading slash.
* @return {!Route}
*/
createChild(path) {
assert(path);
// |path| extends this route's path if it doesn't have a leading slash.
// If it does have a leading slash, it's just set as the new route's URL.
const newUrl = path[0] === '/' ? path : `${this.path}/${path}`;
const route = new Route(newUrl);
route.parent = this;
route.section = this.section;
route.depth = this.depth + 1;
return route;
}
/**
* Returns a new Route instance that's a child section of this route.
* TODO(tommycli): Remove once we've obsoleted the concept of sections.
* @param {string} path
* @param {string} section
* @return {!Route}
*/
createSection(path, section) {
const route = this.createChild(path);
route.section = section;
return route;
}
/**
* Returns the absolute path string for this Route, assuming this function
* has been called from within chrome://settings.
* @return {string}
*/
getAbsolutePath() {
return window.location.origin + this.path;
}
/**
* Returns true if this route matches or is an ancestor of the parameter.
* @param {!Route} route
* @return {boolean}
*/
contains(route) {
for (let r = route; r != null; r = r.parent) {
if (this === r) {
return true;
}
}
return false;
}
/**
* Returns true if this route is a subpage of a section.
* @return {boolean}
*/
isSubpage() {
return !this.isNavigableDialog && !!this.parent && !!this.section &&
this.parent.section === this.section;
}
}
/**
* Regular expression that captures the leading slash, the content and the
* trailing slash in three different groups.
* @type {!RegExp}
*/
const CANONICAL_PATH_REGEX = /(^\/)([\/-\w]+)(\/$)/;
/** @type {?Router} */
let routerInstance = null;
export class Router {
/** @return {!Router} The singleton instance. */
static getInstance() {
return assert(routerInstance);
}
/** @param {!Router} instance */
static setInstance(instance) {
assert(!routerInstance);
routerInstance = instance;
}
/** @param {!Router} instance */
static resetInstanceForTesting(instance) {
if (routerInstance) {
instance.routeObservers_ = routerInstance.routeObservers_;
}
routerInstance = instance;
}
/** @param {!MinimumRoutes} availableRoutes */
constructor(availableRoutes) {
/**
* List of available routes. This is populated taking into account current
* state (like guest mode).
* @private {!MinimumRoutes}
*/
this.routes_ = availableRoutes;
/**
* The current active route. This updated is only by settings.navigateTo
* or settings.initializeRouteFromUrl.
* @type {!Route}
*/
this.currentRoute = this.routes_.BASIC;
/**
* The current query parameters. This is updated only by
* settings.navigateTo or settings.initializeRouteFromUrl.
* @private {!URLSearchParams}
*/
this.currentQueryParameters_ = new URLSearchParams();
/** @private {boolean} */
this.wasLastRouteChangePopstate_ = false;
/** @private {boolean}*/
this.initializeRouteFromUrlCalled_ = false;
/** @private {!Set} */
this.routeObservers_ = new Set();
}
/** @param {Object} observer */
addObserver(observer) {
assert(!this.routeObservers_.has(observer));
this.routeObservers_.add(observer);
}
/** @param {Object} observer */
removeObserver(observer) {
assert(this.routeObservers_.delete(observer));
}
/** @return {Route} */
getRoute(routeName) {
return this.routes_[routeName];
}
/** @return {!Object} */
getRoutes() {
return this.routes_;
}
/**
* Helper function to set the current route and notify all observers.
* @param {!Route} route
* @param {!URLSearchParams} queryParameters
* @param {boolean} isPopstate
*/
setCurrentRoute(route, queryParameters, isPopstate) {
this.recordMetrics(route.path);
const oldRoute = this.currentRoute;
this.currentRoute = route;
this.currentQueryParameters_ = queryParameters;
this.wasLastRouteChangePopstate_ = isPopstate;
new Set(this.routeObservers_).forEach((observer) => {
observer.currentRouteChanged(this.currentRoute, oldRoute);
});
}
/** @return {!Route} */
getCurrentRoute() {
return this.currentRoute;
}
/** @return {!URLSearchParams} */
getQueryParameters() {
return new URLSearchParams(
this.currentQueryParameters_); // Defensive copy.
}
/** @return {boolean} */
lastRouteChangeWasPopstate() {
return this.wasLastRouteChangePopstate_;
}
/**
* @param {string} path
* @return {?Route} The matching canonical route, or null if none
* matches.
*/
getRouteForPath(path) {
// Allow trailing slash in paths.
const canonicalPath = path.replace(CANONICAL_PATH_REGEX, '$1$2');
// TODO(tommycli): Use Object.values once Closure compilation supports it.
const matchingKey =
Object.keys(this.routes_)
.find((key) => this.routes_[key].path === canonicalPath);
return matchingKey ? this.routes_[matchingKey] : 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.
* @param {!URLSearchParams} params
*/
updateRouteParams(params) {
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;
new Set(this.routeObservers_).forEach((observer) => {
observer.currentRouteChanged(this.currentRoute, this.currentRoute);
});
}
/**
* Navigates to a canonical route and pushes a new history entry.
* @param {!Route} route
* @param {URLSearchParams=} opt_dynamicParameters Navigations to the same
* URL parameters in a different order will still push to history.
* @param {boolean=} opt_removeSearch Whether to strip the 'search' URL
* parameter during navigation. Defaults to false.
*/
navigateTo(route, opt_dynamicParameters, opt_removeSearch) {
// The ADVANCED route only serves as a parent of subpages, and should not
// be possible to navigate to it directly.
if (route === this.routes_.ADVANCED) {
route = this.routes_.BASIC;
}
const params = opt_dynamicParameters || new URLSearchParams();
const removeSearch = !!opt_removeSearch;
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() {
const previousRoute = window.history.state &&
assert(this.getRouteForPath(
/** @type {string} */ (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() {
assert(!this.initializeRouteFromUrlCalled_);
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);
// Never allow direct navigation to ADVANCED.
if (route && route !== this.routes_.ADVANCED) {
this.currentRoute = route;
this.currentQueryParameters_ =
new URLSearchParams(window.location.search);
} else {
window.history.replaceState(undefined, '', this.routes_.BASIC.path);
}
}
/**
* Make a UMA note about visiting this URL path.
* @param {string} urlPath The url path (only).
*/
recordMetrics(urlPath) {
assert(!urlPath.startsWith('chrome://'));
assert(!urlPath.startsWith('settings'));
assert(urlPath.startsWith('/'));
assert(!urlPath.match(/\?/g));
const metricName = loadTimeData.valueExists('isOSSettings') &&
loadTimeData.getBoolean('isOSSettings') ?
'ChromeOS.Settings.PathVisited' :
'WebUI.Settings.PathVisited';
chrome.metricsPrivate.recordSparseHashable(metricName, urlPath);
}
resetRouteForTesting() {
this.initializeRouteFromUrlCalled_ = false;
this.wasLastRouteChangePopstate_ = false;
this.currentRoute = this.routes_.BASIC;
this.currentQueryParameters_ = new URLSearchParams();
}
}
/**
* @polymer
* @mixinFunction
*/
export const RouteObserverMixin = dedupingMixin(superClass => {
/**
* @polymer
* @mixinClass
*/
class RouteObserverMixin extends superClass {
/** @override */
connectedCallback() {
super.connectedCallback();
routerInstance.addObserver(this);
// Emulating Polymer data bindings, the observer is called when the
// element starts observing the route.
this.currentRouteChanged(routerInstance.currentRoute, undefined);
}
/** @override */
disconnectedCallback() {
super.disconnectedCallback();
routerInstance.removeObserver(this);
}
/**
* @param {!Route} newRoute
* @param {!Route=} opt_oldRoute
*/
currentRouteChanged(newRoute, opt_oldRoute) {
assertNotReached();
}
}
return /** @type {?} */ (RouteObserverMixin);
});
/** @interface */
export class RouteObserverMixinInterface {
/**
* @param {!Route} newRoute
* @param {!Route=} opt_oldRoute
*/
currentRouteChanged(newRoute, opt_oldRoute) {}
}