/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ import { env } from 'consul-ui/env'; const OPTIONAL = {}; if (env('CONSUL_PARTITIONS_ENABLED')) { OPTIONAL.partition = /^_([a-zA-Z0-9]([a-zA-Z0-9-]{0,62}[a-zA-Z0-9])?)$/; } if (env('CONSUL_NSPACES_ENABLED')) { OPTIONAL.nspace = /^~([a-zA-Z0-9]([a-zA-Z0-9-]{0,62}[a-zA-Z0-9])?)$/; } OPTIONAL.peer = /^:([a-zA-Z0-9]([a-zA-Z0-9-]{0,62}[a-zA-Z0-9])?)$/; const trailingSlashRe = /\/$/; // see below re: ember double slashes // const moreThan1SlashRe = /\/{2,}/g; const _uuid = function () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; return (c === 'x' ? r : (r & 3) | 8).toString(16); }); }; // let popstateFired = false; /** * Register a callback to be invoked whenever the browser history changes, * including using forward and back buttons. */ const route = function (e) { const path = e.state.path; const url = this.getURLForTransition(path); // Ignore initial page load popstate event in Chrome // if (!popstateFired) { // popstateFired = true; // if (url === this._previousURL) { // return; // } // } if (url === this._previousURL) { if (path === this._previousPath) { return; } this._previousPath = e.state.path; // async this.container.lookup('route:application').refresh(); } if (typeof this.callback === 'function') { // TODO: Can we use `settled` or similar to make this `route` method async? // not async this.callback(url); } // used for webkit workaround this._previousURL = url; this._previousPath = e.state.path; }; export default class FSMWithOptionalLocation { // extend FSMLocation implementation = 'fsm-with-optional'; baseURL = ''; /** * Set from router:main._setupLocation (-internals/routing/lib/system/router) * Will be pre-pended to path upon state change */ rootURL = '/'; /** * Path is the 'application path' i.e. the path/URL with no root/base URLs * but potentially with optional parameters (these are remove when getURL is called) */ path = '/'; /** * Sneaky undocumented property used in ember's main router used to skip any * setup of location from the main router. We currently don't need this but * document it here incase we ever do. */ cancelRouterSetup = false; /** * Used to store our 'optional' segments should we have any */ optional = {}; static create() { return new this(...arguments); } constructor(owner, doc, env) { this.container = Object.entries(owner)[0][1]; // add the route/state change handler this.route = route.bind(this); this.doc = typeof doc === 'undefined' ? this.container.lookup('service:-document') : doc; this.env = typeof env === 'undefined' ? this.container.lookup('service:env') : env; const base = this.doc.querySelector('base[href]'); if (base !== null) { this.baseURL = base.getAttribute('href'); } } /** * @internal * Called from router:main._setupLocation (-internals/routing/lib/system/router) * Used to set state on first call to setURL */ initState() { this.location = this.location || this.doc.defaultView.location; this.machine = this.machine || this.doc.defaultView.history; this.doc.defaultView.addEventListener('popstate', this.route); const state = this.machine.state; const url = this.getURL(); const href = this.formatURL(url); if (state && state.path === href) { // preserve existing state // used for webkit workaround, since there will be no initial popstate event this._previousPath = href; this._previousURL = url; } else { this.dispatch('replace', href); } } getURLFrom(url) { // remove trailing slashes if they exist url = url || this.location.pathname; this.rootURL = this.rootURL.replace(trailingSlashRe, ''); this.baseURL = this.baseURL.replace(trailingSlashRe, ''); // remove baseURL and rootURL from start of path return url .replace(new RegExp(`^${this.baseURL}(?=/|$)`), '') .replace(new RegExp(`^${this.rootURL}(?=/|$)`), ''); // ember default locations remove double slashes here e.g. '//' // .replace(moreThan1SlashRe, '/'); // remove extra slashes } getURLForTransition(url) { this.optional = {}; url = this.getURLFrom(url) .split('/') .filter((item, i) => { if (i < 3) { let found = false; Object.entries(OPTIONAL).reduce((prev, [key, re]) => { const res = re.exec(item); if (res !== null) { prev[key] = { value: item, match: res[1], }; found = true; } return prev; }, this.optional); return !found; } return true; }) .join('/'); return url; } optionalParams() { let optional = this.optional || {}; return ['partition', 'nspace', 'peer'].reduce((prev, item) => { let value = ''; if (typeof optional[item] !== 'undefined') { value = optional[item].match; } prev[item] = value; return prev; }, {}); } // public entrypoints for app hrefs/URLs // visit and transitionTo can't be async/await as they return promise-like // non-promises that get re-wrapped by the addition of async/await visit() { return this.transitionTo(...arguments); } /** * Turns a routeName into a full URL string for anchor hrefs etc. */ hrefTo(routeName, params, _hash) { // copy to always work with a new hash even when helper persists hash const hash = { ..._hash }; if (typeof hash.dc !== 'undefined') { delete hash.dc; } if (typeof hash.nspace !== 'undefined') { hash.nspace = `~${hash.nspace}`; } if (typeof hash.partition !== 'undefined') { hash.partition = `_${hash.partition}`; } if (typeof hash.peer !== 'undefined') { hash.peer = `:${hash.peer}`; } if (typeof this.router === 'undefined') { this.router = this.container.lookup('router:main'); } let withOptional = true; switch (true) { case routeName === 'settings': case routeName.startsWith('docs.'): withOptional = false; break; } if (this.router.currentRouteName.startsWith('docs.')) { // If we are in docs, then add a default dc as there won't be one in the // URL params.unshift(env('CONSUL_DATACENTER_PRIMARY')); if (routeName.startsWith('dc')) { // if its an app URL replace it with debugging instead of linking return `console://${routeName} <= ${JSON.stringify(params)}`; } } const router = this.router._routerMicrolib; let url; try { url = router.generate(routeName, ...params, { queryParams: {}, }); } catch (e) { // if the previous generation throws due to params not being available // its probably due to the view wanting to re-render even though we are // leaving the view and the router has already moved the state to old // state so try again with the old state to avoid errors params = Object.values(router.oldState.params).reduce((prev, item) => { return prev.concat(Object.keys(item).length > 0 ? item : []); }, []); url = router.generate(routeName, ...params); } return this.formatURL(url, hash, withOptional); } /** * Takes a full browser URL including rootURL and optional (a full href) and * performs an ember transition/refresh and browser location update using that */ transitionTo(url) { if (this.router.currentRouteName.startsWith('docs') && url.startsWith('console://')) { console.info(`location.transitionTo: ${url.substr(10)}`); return true; } const previousOptional = Object.entries(this.optionalParams()); const transitionURL = this.getURLForTransition(url); if (this._previousURL === transitionURL) { // probably an optional parameter change as the Ember URLs are the same // whereas the entire URL is different this.dispatch('push', url); return Promise.resolve(); // this.setURL(url); } else { const currentOptional = this.optionalParams(); if (previousOptional.some(([key, value]) => currentOptional[key] !== value)) { // an optional parameter change and a normal param change as the Ember // URLs are different and we know the optional params changed // TODO: Consider changing the above previousURL === transitionURL to // use the same 'check the optionalParams' approach this.dispatch('push', url); } // use ember to transition, which will eventually come around to use location.setURL return this.container.lookup('router:main').transitionTo(transitionURL); } } // // Ember location interface /** * Returns the current `location.pathname` without `rootURL` or `baseURL` */ getURL() { const search = this.location.search || ''; let hash = ''; if (typeof this.location.hash !== 'undefined') { hash = this.location.hash.substr(0); } const url = this.getURLForTransition(this.location.pathname); return `${url}${search}${hash}`; } formatURL(url, optional, withOptional = true) { if (url !== '') { // remove trailing slashes if they exists this.rootURL = this.rootURL.replace(trailingSlashRe, ''); this.baseURL = this.baseURL.replace(trailingSlashRe, ''); } else if (this.baseURL[0] === '/' && this.rootURL[0] === '/') { // if baseURL and rootURL both start with a slash // ... remove trailing slash from baseURL if it exists this.baseURL = this.baseURL.replace(trailingSlashRe, ''); } if (withOptional) { const temp = url.split('/'); optional = { ...this.optional, ...(optional || {}), }; optional = Object.values(optional) .filter((item) => Boolean(item)) .map((item) => item.value || item, []); temp.splice(...[1, 0].concat(optional)); url = temp.join('/'); } return `${this.baseURL}${this.rootURL}${url}`; } /** * Change URL takes an ember application URL */ changeURL(type, path) { this.path = path; const state = this.machine.state; path = this.formatURL(path); if (!state || state.path !== path) { this.dispatch(type, path); } } setURL(path) { // this.optional = {}; this.changeURL('push', path); } replaceURL(path) { this.changeURL('replace', path); } onUpdateURL(callback) { this.callback = callback; } // /** * Dispatch takes a full actual browser URL with all the rootURL and optional * params if they exist */ dispatch(event, path) { const state = { path: path, uuid: _uuid(), }; this.machine[`${event}State`](state, null, path); // popstate listeners only run from a browser action not when a state change // is called directly, so manually call the popstate listener. // https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event#the_history_stack this.route({ state: state }); } willDestroy() { this.doc.defaultView.removeEventListener('popstate', this.route); } }