374 lines
11 KiB
JavaScript
374 lines
11 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
}
|