(core) support ?embed=true and &style=light for a clean embed experience

Summary:
This adds query parameters useful for tailoring the Grist experience, with an eye to embedding.

Setting `style=light` removes side and top bars, as a first pass at a focused view of a single document page (this would benefit from refining).

Setting `embed=true` has no significant effect just yet other than it restricts document access to viewer at most (this can be overridden by specifying `/m/default`).

Test Plan: added tests

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2585
This commit is contained in:
Paul Fitzpatrick 2020-08-14 12:40:39 -04:00
parent 48ca124f23
commit 20d8124f45
10 changed files with 415 additions and 4 deletions

View File

@ -0,0 +1,48 @@
/**
* Module to help deal with unsaved changes when closing a page.
*/
import {Disposable} from 'grainjs';
/**
* Create an UnsavedChanges object to indicate there are UnsavedChanges. Dispose it when this is
* no longer the case. The optional callback will be called to confirm there are indeed unsaved
* changes. If omitted, it is assumed that there are.
*/
export class UnsavedChange extends Disposable {
constructor(
// If given, saveChanges() will call it to save changes.
private _saveCB?: () => Promise<void>,
// If given, it may return false to indicate that actually nothing has changed.
private _haveChanges?: () => boolean,
) {
super();
unsavedChanges.add(this);
this.onDispose(() => unsavedChanges.delete(this));
}
public haveUnsavedChanges() { return !this._haveChanges || this._haveChanges(); }
public async save(): Promise<void> { return this._saveCB && this._saveCB(); }
}
export class UnsavedChangeSet {
private _changes = new Set<UnsavedChange>();
/**
* Check if there are any unsaved changes out there.
*/
public haveUnsavedChanges(): boolean {
return Array.from(this._changes).some((c) => c.haveUnsavedChanges());
}
/**
* Save any unsaved changes out there.
*/
public async saveChanges(): Promise<void> {
await Promise.all(Array.from(this._changes).map((c) => c.save()));
}
public add(unsaved: UnsavedChange) { this._changes.add(unsaved); }
public delete(unsaved: UnsavedChange) { this._changes.delete(unsaved); }
}
// Global set of UnsavedChanges, checked on page unload.
export const unsavedChanges = new UnsavedChangeSet();

140
app/client/lib/UrlState.ts Normal file
View File

@ -0,0 +1,140 @@
/**
* 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 <a> 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<IUrlState> {
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<void>;
}
/**
* Represents the state of a page in browser history, as encoded in window.location URL.
*/
export class UrlState<IUrlState> extends Disposable {
// Current state. This gets initialized in the constructor, and updated on navigation events.
public state = observable<IUrlState>(this._getState());
constructor(private _window: HistWindow, private _stateImpl: UrlStateSpec<IUrlState>) {
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, options: {replace?: boolean, avoidReload?: boolean} = {}) {
const prevState = this.state.get();
const newState = this._stateImpl.updateState(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().
*/
public makeUrl(urlState: IUrlState, use: UseCB = unwrap): string {
const fullState = this._stateImpl.updateState(use(this.state), urlState);
return this._stateImpl.encodeUrl(fullState, this._window.location);
}
/**
* Applies to an <a> 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): 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);
}
}
// 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 = <T>(obs: BaseObservable<T>) => T;
const unwrap: UseCB = (obs) => obs.get();

15
app/client/lib/log.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* Client-side debug logging.
* At the moment this simply logs to the browser console, but it's still useful to have dedicated
* methods to allow collecting them in the future, or silencing them in production or in mocha
* tests.
*/
export type LogMethod = (message: string, ...args: any[]) => void;
// tslint:disable:no-console
export const debug: LogMethod = console.debug.bind(console);
export const info: LogMethod = console.info.bind(console);
export const log: LogMethod = console.log.bind(console);
export const warn: LogMethod = console.warn.bind(console);
export const error: LogMethod = console.error.bind(console);

View File

@ -0,0 +1,160 @@
/**
* 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, useNewUI} from 'app/common/gristUrls';
import {Document} from 'app/common/UserAPI';
import isEmpty = require('lodash/isEmpty');
/**
* 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, specifically `doc` and `slug`.
export function docUrl(doc: Document): IGristUrlState {
return {doc: doc.urlId || doc.id, slug: getSlugIfNeeded(doc)};
}
// Returns the home page for the current org.
export function getMainOrgUrl(): string { return urlState().makeUrl({}); }
// Get url for the login page, which will then redirect to nextUrl (current page by default).
export function getLoginUrl(nextUrl: string = _getCurrentUrl()): string {
return _getLoginLogoutUrl('login', nextUrl);
}
// Get url for the logout page, which will then redirect to nextUrl (signed-out page by default).
export function getLogoutUrl(nextUrl: string = getSignedOutUrl()): string {
return _getLoginLogoutUrl('logout', nextUrl);
}
// Get url for the login page, which will then redirect to nextUrl (current page by default).
export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string {
return _getLoginLogoutUrl('signin', nextUrl);
}
// Get url for the reset password page.
export function getResetPwdUrl(): string {
const startUrl = new URL(window.location.href);
startUrl.pathname = '/resetPassword';
return startUrl.href;
}
// Returns the URL for the "you are signed out" page.
export function getSignedOutUrl(): string { return getMainOrgUrl() + "signed-out"; }
// Helper which returns the URL of the current page, except when it's the "/signed-out" page, in
// which case returns the org URL. This is a good URL to use for a post-login redirect.
function _getCurrentUrl(): string {
return window.location.pathname.endsWith("/signed-out") ? getMainOrgUrl() : window.location.href;
}
// Helper for getLoginUrl()/getLogoutUrl().
function _getLoginLogoutUrl(method: 'login'|'logout'|'signin', nextUrl: string): string {
const startUrl = new URL(window.location.href);
startUrl.pathname = '/' + method;
startUrl.searchParams.set('next', nextUrl);
return startUrl.href;
}
export type IDocPage = number | 'new' | 'code';
/**
* 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.billing || newState.welcome) ?
(prevState.org ? {org: prevState.org, newui: prevState.newui} : {}) :
prevState;
return {...keepState, ...newState};
}
/**
* 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 a billing page.
const billingReload = Boolean(prevState.billing) !== Boolean(newState.billing);
// Reload when changing 'newui' flag.
const newuiReload = useNewUI(prevState.newui) !== useNewUI(newState.newui);
// Reload when moving to/from a welcome page.
const welcomeReload = Boolean(prevState.welcome) !== Boolean(newState.welcome);
return Boolean(orgReload || billingReload || gristConfig.errPage || docReload || newuiReload || welcomeReload);
}
/**
* 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();
}
}
}

View File

@ -63,6 +63,7 @@ export function pagePanels(page: PageContents) {
cssMainPane( cssMainPane(
cssTopHeader( cssTopHeader(
testId('top-header'),
(left.hideOpener ? null : (left.hideOpener ? null :
cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen), cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen),
testId('left-opener'), testId('left-opener'),
@ -144,6 +145,9 @@ export const cssLeftPane = styled(cssVBox, `
display: none; display: none;
} }
} }
.interface-light & {
display: none;
}
`); `);
const cssMainPane = styled(cssVBox, ` const cssMainPane = styled(cssVBox, `
position: relative; position: relative;
@ -170,6 +174,9 @@ const cssRightPane = styled(cssVBox, `
display: none; display: none;
} }
} }
.interface-light & {
display: none;
}
`); `);
const cssTopHeader = styled('div', ` const cssTopHeader = styled('div', `
height: 48px; height: 48px;
@ -184,6 +191,10 @@ const cssTopHeader = styled('div', `
display: none; display: none;
} }
} }
.interface-light & {
display: none;
}
`); `);
const cssResizeFlexVHandle = styled(resizeFlexVHandle, ` const cssResizeFlexVHandle = styled(resizeFlexVHandle, `
--resize-handle-color: ${colors.mediumGrey}; --resize-handle-color: ${colors.mediumGrey};

View File

@ -6,6 +6,7 @@
* https://css-tricks.com/snippets/css/system-font-stack/ * https://css-tricks.com/snippets/css/system-font-stack/
* *
*/ */
import {urlState} from 'app/client/models/gristUrlState';
import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes'; import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes';
import {dom, makeTestId, styled, TestId} from 'grainjs'; import {dom, makeTestId, styled, TestId} from 'grainjs';
import values = require('lodash/values'); import values = require('lodash/values');
@ -164,4 +165,6 @@ export function attachCssRootVars(productFlavor: ProductFlavor, varsOnly: boolea
if (theme.bodyClassName) { if (theme.bodyClassName) {
document.body.classList.add(theme.bodyClassName); document.body.classList.add(theme.bodyClassName);
} }
const interfaceStyle = urlState().state.get().params?.style || 'full';
document.body.classList.add(`interface-${interfaceStyle}`);
} }

View File

@ -8,8 +8,9 @@ export type DocEntryTag = ''|'sample'|'invite'|'shared';
export const OpenDocMode = StringUnion( export const OpenDocMode = StringUnion(
'default', // open doc with user's maximal access level 'default', // open doc with user's maximal access level
'fork', // open doc limited to view access (if user has at least that level of access) 'view', // open doc limited to view access (if user has at least that level of access)
'view' // as for 'view', but suggest a fork on any attempt to edit 'fork', // as for 'view', but suggest a fork on any attempt to edit - the client will
// enable the editing UI experience and trigger a fork on any edit.
); );
export type OpenDocMode = typeof OpenDocMode.type; export type OpenDocMode = typeof OpenDocMode.type;

View File

@ -1,6 +1,6 @@
import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI'; import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI';
import {OpenDocMode} from 'app/common/DocListAPI'; import {OpenDocMode} from 'app/common/DocListAPI';
import {encodeQueryParams} from 'app/common/gutil'; import {encodeQueryParams, isAffirmative} from 'app/common/gutil';
import {localhostRegex} from 'app/common/LoginState'; import {localhostRegex} from 'app/common/LoginState';
import {Document} from 'app/common/UserAPI'; import {Document} from 'app/common/UserAPI';
import identity = require('lodash/identity'); import identity = require('lodash/identity');
@ -17,6 +17,10 @@ export type IHomePage = typeof HomePage.type;
export const WelcomePage = StringUnion('user', 'teams'); export const WelcomePage = StringUnion('user', 'teams');
export type WelcomePage = typeof WelcomePage.type; export type WelcomePage = typeof WelcomePage.type;
// Overall UI style. "full" is normal, "light" is a single page focused, panels hidden experience.
export const InterfaceStyle = StringUnion('light', 'full');
export type InterfaceStyle = typeof InterfaceStyle.type;
// Default subdomain for home api service if not otherwise specified. // Default subdomain for home api service if not otherwise specified.
export const DEFAULT_HOME_SUBDOMAIN = 'api'; export const DEFAULT_HOME_SUBDOMAIN = 'api';
@ -59,6 +63,8 @@ export interface IGristUrlState {
params?: { params?: {
billingPlan?: string; billingPlan?: string;
billingTask?: BillingTask; billingTask?: BillingTask;
embed?: boolean;
style?: InterfaceStyle;
}; };
hash?: HashLink; // if present, this specifies an individual row within a section of a page. hash?: HashLink; // if present, this specifies an individual row within a section of a page.
} }
@ -240,6 +246,16 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
if (sp.has('billingTask')) { if (sp.has('billingTask')) {
state.params!.billingTask = parseBillingTask(sp.get('billingTask')!); state.params!.billingTask = parseBillingTask(sp.get('billingTask')!);
} }
if (sp.has('style')) {
state.params!.style = parseInterfaceStyle(sp.get('style')!);
}
if (sp.has('embed')) {
const embed = state.params!.embed = isAffirmative(sp.get('embed'));
// Turn view mode on if no mode has been specified.
if (embed && !state.mode) { state.mode = 'view'; }
// Turn on light style if no style has been specified.
if (embed && !state.params!.style) { state.params!.style = 'light'; }
}
if (location.hash) { if (location.hash) {
const hash = location.hash; const hash = location.hash;
const hashParts = hash.split('.'); const hashParts = hash.split('.');
@ -301,6 +317,13 @@ function parseWelcomePage(p: string): WelcomePage {
return WelcomePage.guard(p) ? p : 'user'; return WelcomePage.guard(p) ? p : 'user';
} }
/**
* Read interface style and make sure it is either valid or left undefined.
*/
function parseInterfaceStyle(t: string): InterfaceStyle|undefined {
return InterfaceStyle.guard(t) ? t : undefined;
}
/** /**
* Parses the URL like "foo.bar.baz" into the pair {org: "foo", base: ".bar.baz"}. * Parses the URL like "foo.bar.baz" into the pair {org: "foo", base: ".bar.baz"}.
* Port is allowed and included into base. * Port is allowed and included into base.

View File

@ -780,3 +780,12 @@ export async function isLongerThan(promise: Promise<any>, timeoutMsec: number):
]); ]);
return isPending; return isPending;
} }
/**
* Returns true if the parameter, when rendered as a string, matches
* 1, on, or true (case insensitively). Useful for processing query
* parameters that may have been manually set.
*/
export function isAffirmative(parameter: any): boolean {
return ['1', 'on', 'true', 'yes'].includes(String(parameter).toLowerCase());
}

View File

@ -1,5 +1,6 @@
import {ApiError} from 'app/common/ApiError'; import {ApiError} from 'app/common/ApiError';
import {DEFAULT_HOME_SUBDOMAIN, parseSubdomain} from 'app/common/gristUrls'; import {DEFAULT_HOME_SUBDOMAIN, parseSubdomain} from 'app/common/gristUrls';
import * as gutil from 'app/common/gutil';
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager'; import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg'; import {RequestWithOrg} from 'app/server/lib/extractOrg';
@ -84,7 +85,7 @@ export function allowHost(req: Request, allowedHost: string|URL) {
} }
export function isParameterOn(parameter: any): boolean { export function isParameterOn(parameter: any): boolean {
return ['1', 'on', 'true'].includes(String(parameter).toLowerCase()); return gutil.isAffirmative(parameter);
} }
/** /**