diff --git a/app/client/components/UnsavedChanges.ts b/app/client/components/UnsavedChanges.ts new file mode 100644 index 00000000..5d0c3177 --- /dev/null +++ b/app/client/components/UnsavedChanges.ts @@ -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, + // 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 { return this._saveCB && this._saveCB(); } +} + +export class UnsavedChangeSet { + private _changes = new Set(); + + /** + * 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 { + 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(); diff --git a/app/client/lib/UrlState.ts b/app/client/lib/UrlState.ts new file mode 100644 index 00000000..faddb088 --- /dev/null +++ b/app/client/lib/UrlState.ts @@ -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 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; +} + +/** + * 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, 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 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 = (obs: BaseObservable) => T; +const unwrap: UseCB = (obs) => obs.get(); diff --git a/app/client/lib/log.ts b/app/client/lib/log.ts new file mode 100644 index 00000000..4aa6418f --- /dev/null +++ b/app/client/lib/log.ts @@ -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); diff --git a/app/client/models/gristUrlState.ts b/app/client/models/gristUrlState.ts new file mode 100644 index 00000000..d3c18be2 --- /dev/null +++ b/app/client/models/gristUrlState.ts @@ -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: + * / + * /ws// + * /doc/[/p/] + * + * where depends on whether subdomains are in use, i.e. one of: + * .getgrist.com + * localhost:8080/o/ + * + * 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 { + return _urlState || (_urlState = new UrlState(window, new UrlStateImpl(window as any))); +} +let _urlState: UrlState|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}) {} + + /** + * The actual serialization of a url state into a URL. The URL has the form + * / + * /ws// + * /doc/[/p/] + * /doc/[/m/fork][/p/] + * + * where depends on whether subdomains are in use, e.g. + * .getgrist.com + * localhost:8080/o/ + */ + 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 { + if (newState.docPage !== prevState.docPage) { + return unsavedChanges.saveChanges(); + } + } +} diff --git a/app/client/ui/PagePanels.ts b/app/client/ui/PagePanels.ts index b45bef8d..191be2a4 100644 --- a/app/client/ui/PagePanels.ts +++ b/app/client/ui/PagePanels.ts @@ -63,6 +63,7 @@ export function pagePanels(page: PageContents) { cssMainPane( cssTopHeader( + testId('top-header'), (left.hideOpener ? null : cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen), testId('left-opener'), @@ -144,6 +145,9 @@ export const cssLeftPane = styled(cssVBox, ` display: none; } } + .interface-light & { + display: none; + } `); const cssMainPane = styled(cssVBox, ` position: relative; @@ -170,6 +174,9 @@ const cssRightPane = styled(cssVBox, ` display: none; } } + .interface-light & { + display: none; + } `); const cssTopHeader = styled('div', ` height: 48px; @@ -184,6 +191,10 @@ const cssTopHeader = styled('div', ` display: none; } } + + .interface-light & { + display: none; + } `); const cssResizeFlexVHandle = styled(resizeFlexVHandle, ` --resize-handle-color: ${colors.mediumGrey}; diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index f5f73b36..f3303d68 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -6,6 +6,7 @@ * 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 {dom, makeTestId, styled, TestId} from 'grainjs'; import values = require('lodash/values'); @@ -164,4 +165,6 @@ export function attachCssRootVars(productFlavor: ProductFlavor, varsOnly: boolea if (theme.bodyClassName) { document.body.classList.add(theme.bodyClassName); } + const interfaceStyle = urlState().state.get().params?.style || 'full'; + document.body.classList.add(`interface-${interfaceStyle}`); } diff --git a/app/common/DocListAPI.ts b/app/common/DocListAPI.ts index 3fb0638d..2d5c2816 100644 --- a/app/common/DocListAPI.ts +++ b/app/common/DocListAPI.ts @@ -8,8 +8,9 @@ export type DocEntryTag = ''|'sample'|'invite'|'shared'; export const OpenDocMode = StringUnion( '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' // as for 'view', but suggest a fork on any attempt to edit + 'view', // open doc limited to view access (if user has at least that level of access) + '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; diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 760c7b0a..5aa7ce09 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -1,6 +1,6 @@ import {BillingPage, BillingSubPage, BillingTask} from 'app/common/BillingAPI'; 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 {Document} from 'app/common/UserAPI'; import identity = require('lodash/identity'); @@ -17,6 +17,10 @@ export type IHomePage = typeof HomePage.type; export const WelcomePage = StringUnion('user', 'teams'); 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. export const DEFAULT_HOME_SUBDOMAIN = 'api'; @@ -59,6 +63,8 @@ export interface IGristUrlState { params?: { billingPlan?: string; billingTask?: BillingTask; + embed?: boolean; + style?: InterfaceStyle; }; hash?: HashLink; // if present, this specifies an individual row within a section of a page. } @@ -240,6 +246,16 @@ export function decodeUrl(gristConfig: Partial, location: Locat if (sp.has('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) { const hash = location.hash; const hashParts = hash.split('.'); @@ -301,6 +317,13 @@ function parseWelcomePage(p: string): WelcomePage { 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"}. * Port is allowed and included into base. diff --git a/app/common/gutil.ts b/app/common/gutil.ts index 089d132a..22bef03f 100644 --- a/app/common/gutil.ts +++ b/app/common/gutil.ts @@ -780,3 +780,12 @@ export async function isLongerThan(promise: Promise, timeoutMsec: number): ]); 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()); +} diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index 319521b0..79cb2e80 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -1,5 +1,6 @@ import {ApiError} from 'app/common/ApiError'; 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 {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer'; 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 { - return ['1', 'on', 'true'].includes(String(parameter).toLowerCase()); + return gutil.isAffirmative(parameter); } /**