mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
48ca124f23
commit
20d8124f45
48
app/client/components/UnsavedChanges.ts
Normal file
48
app/client/components/UnsavedChanges.ts
Normal 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
140
app/client/lib/UrlState.ts
Normal 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
15
app/client/lib/log.ts
Normal 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);
|
160
app/client/models/gristUrlState.ts
Normal file
160
app/client/models/gristUrlState.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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};
|
||||||
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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());
|
||||||
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user