gristlabs_grist-core/app/client/ui2018/modals.ts
Paul Fitzpatrick 3b3ae87ade (core) implement a safe mode for opening documents with rule problems
Summary:
Adds an "enter safe mode" option and explanation in modal that appears when a document fails to load, if user is owner. If "enter safe mode" is selected, document is reloaded on server in a special mode. Currently, the only difference is that if the acl rules fail to load, they are replaced with a fallback that grants full access to owners and no access to anyone else. An extra tag is shown to mark the document as safe mode, with an "x" for cancelling safe mode.

There are other ways a document could fail to load than just acl rules, so this is just a start.

Test Plan: added test

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2686
2020-12-14 13:04:13 -05:00

304 lines
9.8 KiB
TypeScript

import {FocusLayer} from 'app/client/lib/FocusLayer';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import {reportError} from 'app/client/models/errors';
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {Computed, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
export interface IModalControl {
close(): void;
focus(): void;
}
export interface IModalOptions {
noEscapeKey?: boolean; // If set, escape key does not close the dialog
noClickAway?: boolean; // If set, clicking into background does not close dialog.
}
// A custom error type to signal to the modal that it should stay open, but not report any error
// (presumably because the error was already reported).
export class StayOpen extends Error {
}
export type ModalWidth =
'normal' | // Normal dialog, from 428px to 480px in width.
'fixed-wide'; // Fixed 600px width.
/**
* A simple modal. Shows up in the middle of the screen with a tinted backdrop.
* Created with the given body content and width.
*
* Closed via clicking anywhere outside the modal. May also be closed by
* calling ctl.close().
*
* The createFn callback may tie the disposal of temporary objects to its `owner` argument.
*
* Example usage:
* modal((ctl, owner) => [
* cssModalTitle(`Pin doc`),
* cssModalBody('Are you sure you want to pin doc?')
* cssModalButtons(
* primary('Yes', dom.on('click', () => { onClick(true); ctl.close(); })),
* secondary('Cancel', dom.on('click', () => { onClick(false); ctl.close(); }))
* )
* ])
*/
export function modal(createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg,
options: IModalOptions = {}): void {
function close() {
if (modalDom.isConnected) {
document.body.removeChild(modalDom);
// Ensure we run the disposers for the DOM contained in the modal.
dom.domDispose(modalDom);
}
}
const modalDom = cssModalBacker(
dom.create((owner) => {
// Pause mousetrap keyboard shortcuts while the modal is shown. Without this, arrow keys
// will navigate in a grid underneath the modal, and Enter may open a cell there.
Mousetrap.setPaused(true);
owner.onDispose(() => Mousetrap.setPaused(false));
const focus = () => dialog.focus();
const dialog = cssModalDialog(
createFn({ close, focus }, owner),
dom.on('click', (ev) => ev.stopPropagation()),
options.noEscapeKey ? null : dom.onKeyDown({ Escape: close }),
testId('modal-dialog')
);
FocusLayer.create(owner, {
defaultFocusElem: dialog,
allowFocus: (elem) => (elem !== document.body),
});
return dialog;
}),
options.noClickAway ? null : dom.on('click', close),
);
document.body.appendChild(modalDom);
}
export interface ISaveModalOptions {
title: DomElementArg; // Normally just a string.
body: DomElementArg; // Content of the dialog.
saveLabel?: DomElementArg; // Normally just a string; defaults to "Save".
saveDisabled?: Observable<boolean>; // Optional observable for when to disable Save button.
saveFunc: () => Promise<unknown>; // Called on Save; dialog closes when promise is fulfilled.
hideCancel?: boolean; // If set, hide the Cancel button
width?: ModalWidth; // Set a width style for the dialog.
modalArgs?: DomElementArg; // Extra args to apply to the outer cssModalDialog element.
extraButtons?: DomContents; // More buttons!
}
/**
* Creates a modal dialog with a title, body, and Save/Cancel buttons. The provided createFunc()
* is called immediately to get the dialog's contents and options (see ISaveModalOptions for
* details). For example:
*
* saveModal((ctl, owner) => {
* const myObs = Computed.create(owner, ...);
* return {
* title: 'My Dialog',
* body: dom('div', 'Hello', dom.text(myObs)),
* saveDisabled: Computed.create(owner, (use) => !use(myObs)),
* saveFunc: () => server.ping(),
* modalArgs: {style: 'background-color: blue'},
* };
* });
*
* On Save, the dialog calls saveFunc(), disables the Save button, and stays open until saveFunc()
* is resolved. It then closes on success, or reports the error and stays open on rejection. To
* stay open without reporting an error (if one is already reported), throw StayOpen exception.
*
* The dialog interprets Enter/Escape keys as if the Save/Cancel buttons were clicked.
*
* Note that it's possible to close the dialog via Cancel while saveFunc() is pending. That's
* probably desirable, but keep in mind that the dialog may be disposed before saveFunc() returns.
*
* Error handling examples:
* 1. saveFunc: doSomething
* (Most common) If doSomething fails, the error is reported and the dialog stays open.
* 2. saveFunc: () => doSomething().catch(reportError)
* If doSomething fails, the error is reported but the dialog closes anyway.
* 3. saveFunc: () => doSomething().catch((e) => { alert("BOOM"); throw new StayOpen(); })
* If doSomething fails, an alert is shown, and the dialog stays open.
*/
export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) => ISaveModalOptions) {
return modal((ctl, owner) => {
const options = createFunc(ctl, owner);
const isSaving = Observable.create(owner, false);
const isSaveDisabled = Computed.create(owner, (use) =>
use(isSaving) || (options.saveDisabled ? use(options.saveDisabled) : false));
// We mark isSaving() observable to disable the save button while saveFunc() is pending.
// (I decided against a waitWithObsSet() helper for this, since it's too specific to this case
// when saveFunc() is prevented from being called multiple times in parallel.)
async function save() {
isSaving.set(true);
try {
await options.saveFunc();
ctl.close(); // Close on success.
} catch (err) {
// Report errors. If saveFunc() reports its own error and wants the dialog to stay open,
// it should throw StayOpen().
if (!(err instanceof StayOpen)) {
reportError(err);
}
isSaving.set(false);
}
}
return [
cssModalTitle(options.title, testId('modal-title')),
cssModalBody(options.body),
cssModalButtons(
bigPrimaryButton(options.saveLabel || 'Save',
dom.boolAttr('disabled', isSaveDisabled),
dom.on('click', save),
testId('modal-confirm'),
),
options.extraButtons,
options.hideCancel ? null : bigBasicButton('Cancel',
dom.on('click', () => ctl.close()),
testId('modal-cancel'),
),
),
dom.onKeyDown({ Enter: () => isSaveDisabled.get() || save() }),
options.width && cssModalWidth(options.width),
options.modalArgs,
];
});
}
/**
* Builds a simple confirm modal with 'Enter' bound to the confirm action.
*
* See saveModal() for error handling notes that here apply to the onConfirm callback.
*/
export function confirmModal(
title: string,
btnText: string,
onConfirm: () => Promise<void>,
explanation?: Element|string,
{hideCancel, extraButtons}: {hideCancel?: boolean, extraButtons?: DomContents} = {},
): void {
return saveModal((ctl, owner): ISaveModalOptions => ({
title,
body: explanation || null,
saveLabel: btnText,
saveFunc: onConfirm,
hideCancel,
width: 'normal',
extraButtons,
}));
}
/**
* Builds a simple spinner modal. The modal gets removed when `promise` resolves.
*/
export async function spinnerModal<T>(
title: string,
promise: Promise<T>): Promise<T> {
modal((ctl, owner) => {
// `finally` is missing from es2016, below is a work-around.
const close = () => ctl.close();
promise.then(close, close);
return [
cssModalSpinner.cls(''),
cssModalTitle(title),
cssSpinner(loadingSpinner()),
testId('modal-spinner'),
];
}, {
noClickAway: true,
noEscapeKey: true,
});
return await promise;
}
/**
* Apply this to a modal as
* modal(() => [cssModalBody(...), cssModalWidth('normal')])
* or
* saveModal(() => {..., width: 'normal'})
*/
export function cssModalWidth(style: ModalWidth) {
return cssModalDialog.cls('-' + style);
}
/* CSS styled components */
const cssModalDialog = styled('div', `
background-color: white;
min-width: 428px;
color: black;
margin: auto;
border-radius: 3px;
box-shadow: 0 2px 18px 0 rgba(31,37,50,0.31), 0 0 1px 0 rgba(76,86,103,0.24);
padding: 40px 64px;
outline: none;
&-normal {
max-width: 480px;
}
&-fixed-wide {
width: 600px;
}
`);
export const cssModalTitle = styled('div', `
font-size: ${vars.xxxlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
color: ${colors.dark};
margin: 0 0 16px 0;
line-height: 32px;
`);
export const cssModalBody = styled('div', `
margin: 16px 0;
`);
export const cssModalButtons = styled('div', `
margin: 40px 0 0 0;
& > button,
& > .${cssButton.className} {
margin: 0 8px 0 0;
}
`);
// For centering, we use 'margin: auto' on the flex item instead of 'justify-content: center' on
// the flex container, to ensure the full item can be scrolled in case of overflow.
// See https://stackoverflow.com/a/33455342/328565
const cssModalBacker = styled('div', `
position: fixed;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 999;
background-color: ${colors.backdrop};
overflow-y: auto;
`);
const cssSpinner = styled('div', `
display: flex;
align-items: center;
height: 80px;
margin: auto;
`);
const cssModalSpinner = styled('div', `
display: flex;
flex-direction: column;
`);