gristlabs_grist-core/app/client/models/gristUrlState.ts

244 lines
9.2 KiB
TypeScript
Raw Normal View History

/**
* This module provides a urlState() function returning a singleton UrlState, which represents
* Grist application state as encoded into a URL, and navigation functions.
*
* For example, the current org is available as a value or as an observable:
*
* urlState().state.get().org
* computed((use) => use(urlState().state).org);
*
* Creating a link which has an href but changes state without reloading page is possible with:
*
* dom('a', urlState().setLinkUrl({ws: 10}), "...")
*
* Grist URLs have the form:
* <org-base>/
* <org-base>/ws/<ws>/
* <org-base>/doc/<doc>[/p/<docPage>]
*
* where <org-base> depends on whether subdomains are in use, i.e. one of:
* <org>.getgrist.com
* localhost:8080/o/<org>
*
* Note that the form of URLs depends on the settings in window.gristConfig object.
*/
import {unsavedChanges} from 'app/client/components/UnsavedChanges';
import {UrlState} from 'app/client/lib/UrlState';
import {decodeUrl, encodeUrl, getSlugIfNeeded, GristLoadConfig, IGristUrlState,
parseFirstUrlPart} from 'app/common/gristUrls';
(core) move more tests to grist-core Summary: * Tie build and run-time docker base images to a consistent version (buster) * Extend the test login system activated by GRIST_TEST_LOGIN to ease porting tests that currently rely on cognito (many) * Make org resets work in absence of billing endpoints * When in-memory session caches are used, add missing invalidation steps * Pass org information through sign-ups/sign-ins more carefully * For CORS, explicitly trust GRIST_HOST origin when set * Move some fixtures and tests to core, focussing on tests that cover existing failures or are in the set of tests run on deployments * Retain regular `test` target to run the test suite directly, without docker * Add a `test:smoke` target to run a single simple test without `GRIST_TEST_LOGIN` activated * Add a `test:docker` target to run the tests against a grist-core docker image - since tests rely on certain fixture teams/docs, added `TEST_SUPPORT_API_KEY` and `TEST_ADD_SAMPLES` flags to ease porting The tests ported were `nbrowser` tests: `ActionLog.ts` (the first test I tend to port to anything, out of habit), `Fork.ts` (exercises a lot of doc creation paths), `HomeIntro.ts` (a lot of DocMenu exercise), and `DuplicateDocument.ts` (covers a feature known to be failing prior to this diff, the CORS tweak resolves it). Test Plan: Manually tested via `buildtools/build_core.sh`. In follow up, I want to add running the `test:docker` target in grist-core's workflows. In jenkins, only the smoke test is run. There'd be an argument for running all tests, but they include particularly slow tests, and are duplicates of tests already run (in different configuration admittedly), so I'd like to try first just using them in grist-core to gate updates to any packaged version of Grist (the docker image currently). Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3176
2021-12-10 22:42:54 +00:00
import {addOrgToPath} from 'app/common/urlUtils';
import {Document} from 'app/common/UserAPI';
import isEmpty = require('lodash/isEmpty');
import isEqual = require('lodash/isEqual');
import {CellValue} from "app/plugin/GristData";
/**
* Returns a singleton UrlState object, initializing it on first use.
*/
export function urlState(): UrlState<IGristUrlState> {
return _urlState || (_urlState = new UrlState(window, new UrlStateImpl(window as any)));
}
let _urlState: UrlState<IGristUrlState>|undefined;
/**
* Returns url parameters appropriate for the specified document.
*
* In addition to setting `doc` and `slug`, it sets additional parameters
* from `params` if any are supplied.
*/
export function docUrl(doc: Document, params: {org?: string} = {}): IGristUrlState {
const state: IGristUrlState = {
doc: doc.urlId || doc.id,
slug: getSlugIfNeeded(doc),
};
// TODO: Get non-sample documents with `org` set to fully work (a few tests fail).
if (params.org) {
state.org = params.org;
}
return state;
}
// Returns the home page for the current org.
export function getMainOrgUrl(): string { return urlState().makeUrl({}); }
// When on a document URL, returns the URL with just the doc ID, omitting other bits (like page).
export function getCurrentDocUrl(): string { return urlState().makeUrl({docPage: undefined}); }
// Get url for the login page, which will then redirect to `nextUrl` (current page by default).
export function getLoginUrl(nextUrl: string | null = _getCurrentUrl()): string {
return _getLoginLogoutUrl('login', nextUrl);
}
// Get url for the signup page, which will then redirect to `nextUrl` (current page by default).
export function getSignupUrl(nextUrl: string = _getCurrentUrl()): string {
return _getLoginLogoutUrl('signup', nextUrl);
}
// Get url for the logout page.
export function getLogoutUrl(): string {
return _getLoginLogoutUrl('logout');
}
// Get url for the signin page, which will then redirect to `nextUrl` (current page by default).
export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string {
return _getLoginLogoutUrl('signin', nextUrl);
}
export function getWelcomeHomeUrl() {
return _buildUrl('welcome/home').href;
}
// Returns the relative URL (i.e. path) of the current page, except when it's the
// "/signed-out" page, in which case it returns the home page ("/").
// This is a good URL to use for a post-login redirect.
function _getCurrentUrl(): string {
const {hash, pathname, search} = new URL(window.location.href);
if (pathname.endsWith('/signed-out')) { return '/'; }
return parseFirstUrlPart('o', pathname).path + search + hash;
}
// Returns the URL for the given login page, with 'next' param optionally set.
function _getLoginLogoutUrl(page: 'login'|'logout'|'signin'|'signup', nextUrl?: string | null): string {
const startUrl = _buildUrl(page);
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
return startUrl.href;
}
function _buildUrl(page?: string): URL {
const startUrl = new URL(window.location.href);
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + (page ?? '');
startUrl.search = '';
startUrl.hash = '';
return startUrl;
}
/**
* Implements the interface expected by UrlState. It is only exported for the sake of tests; the
* only public interface is the urlState() accessor.
*/
export class UrlStateImpl {
constructor(private _window: {gristConfig?: Partial<GristLoadConfig>}) {}
/**
* The actual serialization of a url state into a URL. The URL has the form
* <org-base>/
* <org-base>/ws/<ws>/
* <org-base>/doc/<doc>[/p/<docPage>]
* <org-base>/doc/<doc>[/m/fork][/p/<docPage>]
*
* where <org-base> depends on whether subdomains are in use, e.g.
* <org>.getgrist.com
* localhost:8080/o/<org>
*/
public encodeUrl(state: IGristUrlState, baseLocation: Location | URL): string {
const gristConfig = this._window.gristConfig || {};
return encodeUrl(gristConfig, state, baseLocation);
}
/**
* Parse a URL location into an IGristUrlState object. See encodeUrl() documentation.
*/
public decodeUrl(location: Location | URL): IGristUrlState {
const gristConfig = this._window.gristConfig || {};
return decodeUrl(gristConfig, location);
}
/**
* Updates existing state with new state, with attention to Grist-specific meanings.
* E.g. setting 'docPage' will reuse previous 'doc', but setting 'org' or 'ws' will ignore it.
*/
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) ||
newState.account || newState.billing || newState.activation || newState.welcome) ?
(prevState.org ? {org: prevState.org} : {}) :
prevState;
return {...keepState, ...newState};
}
/**
* The account page, billing pages, and doc-specific pages for now require a page load.
* TODO: Make it so doc pages do NOT require a page load, since we are actually serving the same
* single-page app for home and for docs, and should only need a reload triggered if it's
* a matter of DocWorker requiring a different version (e.g. /v/OTHER/doc/...).
*/
public needPageLoad(prevState: IGristUrlState, newState: IGristUrlState): boolean {
const gristConfig = this._window.gristConfig || {};
const orgReload = prevState.org !== newState.org;
// Reload when moving to/from a document or between doc and non-doc.
const docReload = prevState.doc !== newState.doc;
// Reload when moving to/from the account page.
const accountReload = Boolean(prevState.account) !== Boolean(newState.account);
// Reload when moving to/from a billing page.
const billingReload = Boolean(prevState.billing) !== Boolean(newState.billing);
// Reload when moving to/from an activation page.
const activationReload = Boolean(prevState.activation) !== Boolean(newState.activation);
// Reload when moving to/from a welcome page.
const welcomeReload = Boolean(prevState.welcome) !== Boolean(newState.welcome);
// Reload when link keys change, which changes what the user can access
const linkKeysReload = !isEqual(prevState.params?.linkParameters, newState.params?.linkParameters);
// Reload when moving to/from the Grist sign-up page.
const signupReload = [prevState.login, newState.login].includes('signup')
&& prevState.login !== newState.login;
return Boolean(orgReload || accountReload || billingReload || activationReload
|| gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload);
}
/**
* Complete outstanding work before changes that would destroy page state, e.g. if there are
* edits to be saved.
*/
public async delayPushUrl(prevState: IGristUrlState, newState: IGristUrlState): Promise<void> {
if (newState.docPage !== prevState.docPage) {
return unsavedChanges.saveChanges();
}
}
}
/**
* Given value like `foo bar baz`, constructs URL by checking if `baz` is a valid URL and,
* if not, prepending `http://`.
*/
export function constructUrl(value: CellValue): string {
if (typeof value !== 'string') {
return '';
}
const url = value.slice(value.lastIndexOf(' ') + 1);
try {
// Try to construct a valid URL
return (new URL(url)).toString();
} catch (e) {
// Not a valid URL, so try to prefix it with http
return 'http://' + url;
}
}
/**
* If urlValue contains a URL to the current document that can be navigated to without a page reload,
* returns a parsed IGristUrlState that can be passed to urlState().pushState() to do that navigation.
* Otherwise, returns null.
*/
export function sameDocumentUrlState(urlValue: CellValue): IGristUrlState | null {
const urlString = constructUrl(urlValue);
let url: URL;
try {
url = new URL(urlString);
} catch {
return null;
}
const oldOrigin = window.location.origin;
const newOrigin = url.origin;
if (oldOrigin !== newOrigin) {
return null;
}
const urlStateImpl = new UrlStateImpl(window as any);
const result = urlStateImpl.decodeUrl(url);
if (urlStateImpl.needPageLoad(urlState().state.get(), result)) {
return null;
} else {
return result;
}
}