/** * Generic support of observable state represented by the current page's URL. The state is * initialized on first use, and updated on navigation events, such as Back/Forward button clicks, * and on calls to pushUrl(). * * Application-specific module should instantiate UrlState with the desired way to encode state in * URLs. Other code may then use the UrlState object exposed by that app-specific module. * * UrlState also provides functions to navigate: makeUrl(), pushUrl(), and setLinkUrl(). The * preferred option is to use actual links for navigation, creating them like so: * * import {urlState} from '...appUrlState'; * dom('a', urlState().setLinkUrl({org: 'foo'})) * dom('a', urlState().setLinkUrl({docPage: pageId})) * * These will set actual hrefs (e.g. allowing links to be opened in a new tab), and also will * intercept clicks and update history (using pushUrl()) without reloading the page. */ import * as log from 'app/client/lib/log'; import {BaseObservable, Disposable, dom, DomElementMethod, observable} from 'grainjs'; export interface UrlStateSpec { encodeUrl(state: IUrlState, baseLocation: Location | URL): string; decodeUrl(location: Location | URL): IUrlState; updateState(prevState: IUrlState, newState: IUrlState): IUrlState; // If present, the return value is checked by pushUrl() to decide if we can stay on the page or // need to load the new URL. The new URL is always loaded if origin changes. needPageLoad(prevState: IUrlState, newState: IUrlState): boolean; // Give the implementation a chance to complete outstanding work, e.g. if there is unsaved // data in the page state that would get destroyed. delayPushUrl(prevState: IUrlState, newState: IUrlState): Promise; } export type UpdateFunc = (prevState: IUrlState) => IUrlState; /** * Represents the state of a page in browser history, as encoded in window.location URL. */ export class UrlState extends Disposable { // Current state. This gets initialized in the constructor, and updated on navigation events. public state = observable(this._getState()); constructor(private _window: HistWindow, private _stateImpl: UrlStateSpec) { super(); // Create a hook for navigation. It's exposed on the window for overriding in tests. if (!_window._urlStateLoadPage) { _window._urlStateLoadPage = (href) => { _window.location.href = href; }; } // On navigation events, update our current state, including the observables. this.autoDispose(dom.onElem(this._window, 'popstate', (ev) => this.loadState())); } /** * Creates a new history entry (navigable with Back/Forward buttons), encoding the given state * in the URL. This is similar to navigating to a new URL, but does not reload the page. */ public async pushUrl(urlState: IUrlState|UpdateFunc, options: {replace?: boolean, avoidReload?: boolean} = {}) { const prevState = this.state.get(); const newState = this._mergeState(prevState, urlState); const newUrl = this._stateImpl.encodeUrl(newState, this._window.location); // Don't create a new history entry if nothing changed as it would only be annoying. if (newUrl === this._window.location.href) { return; } const oldOrigin = this._window.location.origin; const newOrigin = new URL(newUrl).origin; // We can only pushState() without reloading the page if going to a same-origin URL. const samePage = (oldOrigin === newOrigin && (options.avoidReload || !this._stateImpl.needPageLoad(prevState, newState))); if (samePage) { await this._stateImpl.delayPushUrl(prevState, newState); if (options.replace) { this._window.history.replaceState(null, '', newUrl); } else { this._window.history.pushState(null, '', newUrl); } // pushState/replaceState above do not trigger 'popstate' event, so we call loadState() manually. this.loadState(); } else { this._window._urlStateLoadPage!(newUrl); } } /** * Creates a URL (e.g. to use in a link's href) encoding the given state. The `use` argument * allows for this to be used in a computed, and is used by setLinkUrl() and setHref(). * * If urlState is an object (such as IGristUrlState), it gets merged with previous state * according to rules (in gristUrlState's updateState). Alternatively, it can be a function that * takes previous state and returns the new one (without mutating the previous state). */ public makeUrl(urlState: IUrlState|UpdateFunc, use: UseCB = unwrap): string { const fullState = this._mergeState(use(this.state), urlState); return this._stateImpl.encodeUrl(fullState, this._window.location); } /** * Sets href on a dom element, e.g. dom('a', setHref({...})). * This is similar to {href: makeUrl(urlState)}, but the destination URL will reflect the * current url state (e.g. due to switching pages). */ public setHref(urlState: IUrlState|UpdateFunc): DomElementMethod { return dom.attr('href', (use) => this.makeUrl(urlState, use)); } /** * Applies to an element to create a smart link, e.g. dom('a', setLinkUrl({ws: wsId})). It * both sets the href (e.g. to allow the link to be opened to a new tab), AND intercepts plain * clicks on it to "follow" the link without reloading the page. */ public setLinkUrl(urlState: IUrlState|UpdateFunc): DomElementMethod[] { return [ dom.attr('href', (use) => this.makeUrl(urlState, use)), dom.on('click', (ev) => { // Only override plain-vanilla clicks. if (ev.shiftKey || ev.metaKey || ev.ctrlKey || ev.altKey) { return; } ev.preventDefault(); return this.pushUrl(urlState); }), ]; } /** * Reset the state from the current URL. This shouldn't normally need to get called. It's called * automatically when needed. It's also used by tests. */ public loadState() { log.debug(`loadState ${this._window.location.href}`); this.state.set(this._getState()); } private _getState(): IUrlState { return this._stateImpl.decodeUrl(this._window.location); } private _mergeState(prevState: IUrlState, newState: IUrlState|UpdateFunc): IUrlState { return (typeof newState === 'object') ? this._stateImpl.updateState(prevState, newState) : newState(prevState); } } // This is what we expect from the global Window object. Tests may override with a mock. export interface HistWindow extends EventTarget { history: History; location: Location; // This is a hook we create, to allow stubbing or overriding in tests. _urlStateLoadPage?: (href: string) => void; } // The type of a 'use' callback as used in a computed(). It's what makes a computed subscribe to // its dependencies. The unwrap() helper allows using a dependency without any subscribing. type UseCB = (obs: BaseObservable) => T; const unwrap: UseCB = (obs) => obs.get();