mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Make ProfileDialog wait consistently for work to finish before closing.
Summary: - Added functionality to modal.ts to allow pending work to delay the closing of the dialog. Test Plan: Added a test case that tickled a failure previously. Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3071
This commit is contained in:
parent
9d1cc89dc9
commit
16eb158673
@ -899,7 +899,8 @@ GridView.prototype.buildDom = function() {
|
|||||||
dom.on('contextmenu', ev => {
|
dom.on('contextmenu', ev => {
|
||||||
// This is a little hack to position the menu the same way as with a click
|
// This is a little hack to position the menu the same way as with a click
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.currentTarget.querySelector('.g-column-menu-btn').click();
|
const btn = ev.currentTarget.querySelector('.g-column-menu-btn');
|
||||||
|
if (btn) { btn.click(); }
|
||||||
}),
|
}),
|
||||||
dom('div.g-column-label',
|
dom('div.g-column-label',
|
||||||
kf.editableLabel(field.displayLabel, isEditingLabel, renameCommands),
|
kf.editableLabel(field.displayLabel, isEditingLabel, renameCommands),
|
||||||
|
@ -27,7 +27,6 @@ function showProfileContent(ctl: IModalControl, owner: MultiHolder, appModel: Ap
|
|||||||
const isNameValid = Computed.create(owner, nameEdit, (use, val) => checkName(val));
|
const isNameValid = Computed.create(owner, nameEdit, (use, val) => checkName(val));
|
||||||
|
|
||||||
let needsReload = false;
|
let needsReload = false;
|
||||||
let closeBtn: Element;
|
|
||||||
|
|
||||||
async function fetchApiKey() { apiKey.set(await appModel.api.fetchApiKey()); }
|
async function fetchApiKey() { apiKey.set(await appModel.api.fetchApiKey()); }
|
||||||
async function createApiKey() { apiKey.set(await appModel.api.createApiKey()); }
|
async function createApiKey() { apiKey.set(await appModel.api.createApiKey()); }
|
||||||
@ -46,16 +45,17 @@ function showProfileContent(ctl: IModalControl, owner: MultiHolder, appModel: Ap
|
|||||||
async function updateUserName(val: string) {
|
async function updateUserName(val: string) {
|
||||||
const user = userObs.get();
|
const user = userObs.get();
|
||||||
if (user && val && val !== user.name) {
|
if (user && val && val !== user.name) {
|
||||||
closeBtn.toggleAttribute('disabled', true);
|
|
||||||
try {
|
|
||||||
await appModel.api.updateUserName(val);
|
await appModel.api.updateUserName(val);
|
||||||
await fetchAll();
|
await fetchAll();
|
||||||
needsReload = true;
|
needsReload = true;
|
||||||
} finally {
|
|
||||||
closeBtn.toggleAttribute('disabled', false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
owner.onDispose(() => {
|
||||||
|
if (needsReload) {
|
||||||
|
appModel.topAppModel.initialize();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
cssModalTitle('User Profile'),
|
cssModalTitle('User Profile'),
|
||||||
@ -70,7 +70,7 @@ function showProfileContent(ctl: IModalControl, owner: MultiHolder, appModel: Ap
|
|||||||
transientInput(
|
transientInput(
|
||||||
{
|
{
|
||||||
initialValue: user.name,
|
initialValue: user.name,
|
||||||
save: (val) => isNameValid.get() && updateUserName(val),
|
save: ctl.doWork(async (val) => isNameValid.get() && updateUserName(val)),
|
||||||
close: () => { isEditingName.set(false); nameEdit.set(''); },
|
close: () => { isEditingName.set(false); nameEdit.set(''); },
|
||||||
},
|
},
|
||||||
dom.on('input', (ev, el) => nameEdit.set(el.value)),
|
dom.on('input', (ev, el) => nameEdit.set(el.value)),
|
||||||
@ -105,21 +105,17 @@ function showProfileContent(ctl: IModalControl, owner: MultiHolder, appModel: Ap
|
|||||||
cssDataRow(cssSubHeader('API Key'), cssContent(
|
cssDataRow(cssSubHeader('API Key'), cssContent(
|
||||||
dom.create(ApiKey, {
|
dom.create(ApiKey, {
|
||||||
apiKey,
|
apiKey,
|
||||||
onCreate: createApiKey,
|
onCreate: ctl.doWork(createApiKey),
|
||||||
onDelete: deleteApiKey,
|
onDelete: ctl.doWork(deleteApiKey),
|
||||||
anonymous: false,
|
anonymous: false,
|
||||||
})
|
})
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
)),
|
)),
|
||||||
cssModalButtons(
|
cssModalButtons(
|
||||||
closeBtn = bigPrimaryButton('Close',
|
bigPrimaryButton('Close',
|
||||||
dom.on('click', () => {
|
dom.boolAttr('disabled', ctl.workInProgress),
|
||||||
if (needsReload) {
|
dom.on('click', () => ctl.close()),
|
||||||
appModel.topAppModel.initialize();
|
|
||||||
}
|
|
||||||
ctl.close();
|
|
||||||
}),
|
|
||||||
testId('modal-confirm')
|
testId('modal-confirm')
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -4,16 +4,120 @@ import {reportError} from 'app/client/models/errors';
|
|||||||
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
|
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
|
||||||
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||||
import {Computed, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
|
import {waitGrainObs} from 'app/common/gutil';
|
||||||
|
import {Computed, Disposable, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
|
||||||
|
|
||||||
|
// IModalControl is passed into the function creating the body of the modal.
|
||||||
export interface IModalControl {
|
export interface IModalControl {
|
||||||
close(): void;
|
// Observable for whether there is work in progress that's delaying the closing of the modal. It
|
||||||
|
// is useful for disabling a Save or Close button.
|
||||||
|
workInProgress: Computed<boolean>;
|
||||||
|
|
||||||
|
// Focus the modal dialog.
|
||||||
focus(): void;
|
focus(): void;
|
||||||
|
|
||||||
|
// Request to close, without waiting. It's possible for closing to get prevented.
|
||||||
|
close(): void;
|
||||||
|
|
||||||
|
// Returns true if closed, false if closing was prevented.
|
||||||
|
closeAndWait(): Promise<boolean>;
|
||||||
|
|
||||||
|
// Prevents closing, if close has been called ans is pending. No-op otherwise.
|
||||||
|
preventClose(): void;
|
||||||
|
|
||||||
|
// Wraps the passed-in function, so that closing is delayed while the function is running. If
|
||||||
|
// {close: true} is passed in, then requests closing of the modal when the function is done.
|
||||||
|
//
|
||||||
|
// With catchErrors set, errors are caught and reported, and prevent the dialog from closing.
|
||||||
|
// Otherwise, only StayOpen exception prevents closing; other errors will be propagated.
|
||||||
|
doWork<Args extends any[]>(
|
||||||
|
func: (...args: Args) => Promise<unknown>,
|
||||||
|
options?: {close?: boolean, catchErrors?: boolean},
|
||||||
|
): (...args: Args) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModalControl extends Disposable implements IModalControl {
|
||||||
|
private _inProgress = Observable.create<number>(this, 0);
|
||||||
|
private _workInProgress = Computed.create(this, this._inProgress, (use, n) => (n > 0));
|
||||||
|
private _closePromise: Promise<boolean>|undefined;
|
||||||
|
private _shouldClose = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _doClose: () => void,
|
||||||
|
private _doFocus: () => void,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public focus() {
|
||||||
|
this._doFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public close(): void {
|
||||||
|
this.closeAndWait().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async closeAndWait(): Promise<boolean> {
|
||||||
|
return this._closePromise || (this._closePromise = this._doCloseAndWait());
|
||||||
|
}
|
||||||
|
|
||||||
|
public preventClose(): void {
|
||||||
|
this._shouldClose = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get workInProgress() {
|
||||||
|
return this._workInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public doWork<Args extends any[]>(
|
||||||
|
func: (...args: Args) => Promise<unknown>,
|
||||||
|
options: {close?: boolean, catchErrors?: boolean} = {},
|
||||||
|
): (...args: Args) => Promise<void> {
|
||||||
|
return async (...args) => {
|
||||||
|
this._inProgress.set(this._inProgress.get() + 1);
|
||||||
|
const closePromise = options.close ? this.closeAndWait() : null;
|
||||||
|
try {
|
||||||
|
await func(...args);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StayOpen) {
|
||||||
|
this.preventClose();
|
||||||
|
} else if (options.catchErrors) {
|
||||||
|
reportError(err);
|
||||||
|
this.preventClose();
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._inProgress.set(this._inProgress.get() - 1);
|
||||||
|
if (closePromise) {
|
||||||
|
await closePromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doCloseAndWait(): Promise<boolean> {
|
||||||
|
this._shouldClose = true;
|
||||||
|
try {
|
||||||
|
// Since some modals expect an immediate close; avoid an await when no work is pending.
|
||||||
|
if (this.workInProgress.get()) {
|
||||||
|
await waitGrainObs(this.workInProgress, wip => !wip);
|
||||||
|
}
|
||||||
|
if (this._shouldClose) { this._doClose(); }
|
||||||
|
return this._shouldClose;
|
||||||
|
} finally {
|
||||||
|
this._closePromise = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IModalOptions {
|
export interface IModalOptions {
|
||||||
noEscapeKey?: boolean; // If set, escape key does not close the dialog
|
noEscapeKey?: boolean; // If set, escape key does not close the dialog
|
||||||
noClickAway?: boolean; // If set, clicking into background does not close dialog.
|
noClickAway?: boolean; // If set, clicking into background does not close dialog.
|
||||||
|
|
||||||
|
// If given, call and wait for this before closing the dialog. If it returns false, don't close.
|
||||||
|
// Error also prevents closing, and is reported as an unexpected error.
|
||||||
|
beforeClose?: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A custom error type to signal to the modal that it should stay open, but not report any error
|
// A custom error type to signal to the modal that it should stay open, but not report any error
|
||||||
@ -44,15 +148,18 @@ export type ModalWidth =
|
|||||||
* )
|
* )
|
||||||
* ])
|
* ])
|
||||||
*/
|
*/
|
||||||
export function modal(createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg,
|
export function modal(
|
||||||
options: IModalOptions = {}): void {
|
createFn: (ctl: IModalControl, owner: MultiHolder) => DomElementArg,
|
||||||
function close() {
|
options: IModalOptions = {}
|
||||||
if (modalDom.isConnected) {
|
): void {
|
||||||
|
|
||||||
|
function doClose() {
|
||||||
|
if (!modalDom.isConnected) { return; }
|
||||||
document.body.removeChild(modalDom);
|
document.body.removeChild(modalDom);
|
||||||
// Ensure we run the disposers for the DOM contained in the modal.
|
// Ensure we run the disposers for the DOM contained in the modal.
|
||||||
dom.domDispose(modalDom);
|
dom.domDispose(modalDom);
|
||||||
}
|
}
|
||||||
}
|
let close = doClose;
|
||||||
|
|
||||||
const modalDom = cssModalBacker(
|
const modalDom = cssModalBacker(
|
||||||
dom.create((owner) => {
|
dom.create((owner) => {
|
||||||
@ -62,8 +169,11 @@ export function modal(createFn: (ctl: IModalControl, owner: MultiHolder) => DomE
|
|||||||
owner.onDispose(() => Mousetrap.setPaused(false));
|
owner.onDispose(() => Mousetrap.setPaused(false));
|
||||||
|
|
||||||
const focus = () => dialog.focus();
|
const focus = () => dialog.focus();
|
||||||
|
const ctl = ModalControl.create(owner, doClose, focus);
|
||||||
|
close = () => ctl.close();
|
||||||
|
|
||||||
const dialog = cssModalDialog(
|
const dialog = cssModalDialog(
|
||||||
createFn({ close, focus }, owner),
|
createFn(ctl, owner),
|
||||||
dom.on('click', (ev) => ev.stopPropagation()),
|
dom.on('click', (ev) => ev.stopPropagation()),
|
||||||
options.noEscapeKey ? null : dom.onKeyDown({ Escape: close }),
|
options.noEscapeKey ? null : dom.onKeyDown({ Escape: close }),
|
||||||
testId('modal-dialog')
|
testId('modal-dialog')
|
||||||
@ -74,7 +184,7 @@ export function modal(createFn: (ctl: IModalControl, owner: MultiHolder) => DomE
|
|||||||
});
|
});
|
||||||
return dialog;
|
return dialog;
|
||||||
}),
|
}),
|
||||||
options.noClickAway ? null : dom.on('click', close),
|
options.noClickAway ? null : dom.on('click', () => close()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
@ -130,27 +240,10 @@ export function saveModal(createFunc: (ctl: IModalControl, owner: MultiHolder) =
|
|||||||
return modal((ctl, owner) => {
|
return modal((ctl, owner) => {
|
||||||
const options = createFunc(ctl, owner);
|
const options = createFunc(ctl, owner);
|
||||||
|
|
||||||
const isSaving = Observable.create(owner, false);
|
|
||||||
const isSaveDisabled = Computed.create(owner, (use) =>
|
const isSaveDisabled = Computed.create(owner, (use) =>
|
||||||
use(isSaving) || (options.saveDisabled ? use(options.saveDisabled) : false));
|
use(ctl.workInProgress) || (options.saveDisabled ? use(options.saveDisabled) : false));
|
||||||
|
|
||||||
// We mark isSaving() observable to disable the save button while saveFunc() is pending.
|
const save = ctl.doWork(options.saveFunc, {close: true, catchErrors: true});
|
||||||
// (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 [
|
return [
|
||||||
cssModalTitle(options.title, testId('modal-title')),
|
cssModalTitle(options.title, testId('modal-title')),
|
||||||
|
Loading…
Reference in New Issue
Block a user