Introduce Dialog abstraction

Summary: Introduce convenience abstractions to make it easier to manage dialogs imperatively, by promisyfying common dialog abstractions.

Reviewed By: jknoxville, nikoant

Differential Revision: D29790462

fbshipit-source-id: c092c15cf569ec353b9c1042f25cd67e6c76db01
This commit is contained in:
Michel Weststrate
2021-07-22 04:16:01 -07:00
committed by Facebook GitHub Bot
parent 9c4deb3501
commit f74029699f
6 changed files with 179 additions and 48 deletions

View File

@@ -17,9 +17,7 @@ import {Group, SUPPORTED_GROUPS} from './reducers/supportForm';
import {Store} from './reducers/index'; import {Store} from './reducers/index';
import {importDataToStore} from './utils/exportData'; import {importDataToStore} from './utils/exportData';
import {selectPlugin} from './reducers/connections'; import {selectPlugin} from './reducers/connections';
import {Layout, renderReactRoot} from 'flipper-plugin'; import {Dialog} from 'flipper-plugin';
import React, {useState} from 'react';
import {Alert, Input, Modal} from 'antd';
import {handleOpenPluginDeeplink} from './dispatcher/handleOpenPluginDeeplink'; import {handleOpenPluginDeeplink} from './dispatcher/handleOpenPluginDeeplink';
const UNKNOWN = 'Unknown deeplink'; const UNKNOWN = 'Unknown deeplink';
@@ -118,40 +116,13 @@ export const uriComponents = (url: string): Array<string> => {
}; };
export function openDeeplinkDialog(store: Store) { export function openDeeplinkDialog(store: Store) {
renderReactRoot((hide) => <TestDeeplinkDialog store={store} onHide={hide} />); Dialog.prompt({
} title: 'Open deeplink',
message: 'Enter a deeplink:',
function TestDeeplinkDialog({ defaultValue: 'flipper://',
store, onConfirm: async (deeplink) => {
onHide, await handleDeeplink(store, deeplink);
}: { return deeplink;
store: Store; },
onHide: () => void; });
}) {
const [query, setQuery] = useState('flipper://');
const [error, setError] = useState('');
return (
<Modal
title="Open deeplink..."
visible
onOk={() => {
handleDeeplink(store, query)
.then(onHide)
.catch((e) => {
setError(`Failed to handle deeplink '${query}': ${e}`);
});
}}
onCancel={onHide}>
<Layout.Container gap>
<>Enter a deeplink to test it:</>
<Input
value={query}
onChange={(v) => {
setQuery(v.target.value);
}}
/>
{error && <Alert type="error" message={error} />}
</Layout.Container>
</Modal>
);
} }

View File

@@ -36,6 +36,7 @@ test('Correct top level API exposed', () => {
"DataSource", "DataSource",
"DataTable", "DataTable",
"DetailSidebar", "DetailSidebar",
"Dialog",
"ElementsInspector", "ElementsInspector",
"Layout", "Layout",
"MarkerTimeline", "MarkerTimeline",

View File

@@ -112,7 +112,7 @@ export {
} from './ui/data-inspector/DataDescription'; } from './ui/data-inspector/DataDescription';
export {MarkerTimeline} from './ui/MarkerTimeline'; export {MarkerTimeline} from './ui/MarkerTimeline';
export {DataInspector} from './ui/data-inspector/DataInspector'; export {DataInspector} from './ui/data-inspector/DataInspector';
export {Dialog} from './ui/Dialog';
export { export {
ElementsInspector, ElementsInspector,
Element as ElementsInspectorElement, Element as ElementsInspectorElement,

View File

@@ -0,0 +1,132 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {Alert, Input, Modal, Typography} from 'antd';
import {Atom, createState, useValue} from '../state/atom';
import React from 'react';
import {renderReactRoot} from '../utils/renderReactRoot';
import {Layout} from './Layout';
type DialogResult<T> = Promise<false | T> & {close: () => void};
type BaseDialogOptions = {
title: string;
okText?: string;
cancelText?: string;
};
export const Dialog = {
show<T>(
opts: BaseDialogOptions & {
children: React.ReactNode;
onConfirm: () => Promise<T>;
},
): DialogResult<T> {
let cancel: () => void;
return Object.assign(
new Promise<false | T>((resolve) => {
renderReactRoot((hide) => {
const submissionError = createState<string>('');
cancel = () => {
hide();
resolve(false);
};
return (
<Modal
title={opts.title}
visible
okText={opts.okText}
cancelText={opts.cancelText}
onOk={async () => {
try {
const value = await opts.onConfirm();
hide();
resolve(value);
} catch (e) {
submissionError.set(e.toString());
}
}}
onCancel={cancel}
width={400}>
<Layout.Container gap>
{opts.children}
<SubmissionError submissionError={submissionError} />
</Layout.Container>
</Modal>
);
});
}),
{
close() {
cancel();
},
},
);
},
confirm({
message,
...rest
}: {
message: string | React.ReactElement;
} & BaseDialogOptions): DialogResult<true> {
return Dialog.show<true>({
...rest,
children: message,
onConfirm: async () => true,
});
},
prompt({
message,
defaultValue,
onConfirm,
...rest
}: BaseDialogOptions & {
message: string | React.ReactElement;
defaultValue?: string;
onConfirm?: (value: string) => Promise<string>;
}): DialogResult<string> {
const inputValue = createState(defaultValue ?? '');
return Dialog.show<string>({
...rest,
children: (
<>
<Typography.Text>{message}</Typography.Text>
<PromptInput inputValue={inputValue} />
</>
),
onConfirm: async () => {
const value = inputValue.get();
if (onConfirm) {
return await onConfirm(value);
}
return value;
},
});
},
};
function PromptInput({inputValue}: {inputValue: Atom<string>}) {
const currentValue = useValue(inputValue);
return (
<Input
value={currentValue}
onChange={(e) => {
inputValue.set(e.target.value);
}}
/>
);
}
function SubmissionError({submissionError}: {submissionError: Atom<string>}) {
const currentError = useValue(submissionError);
return currentError ? <Alert type="error" message={currentError} /> : null;
}

View File

@@ -16,13 +16,12 @@ import {render, unmountComponentAtNode} from 'react-dom';
*/ */
export function renderReactRoot( export function renderReactRoot(
handler: (unmount: () => void) => React.ReactElement, handler: (unmount: () => void) => React.ReactElement,
): void { ): () => void {
const div = document.body.appendChild(document.createElement('div')); const div = document.body.appendChild(document.createElement('div'));
render( const unmount = () => {
handler(() => { unmountComponentAtNode(div);
unmountComponentAtNode(div); div.remove();
div.remove(); };
}), render(handler(unmount), div);
div, return unmount;
);
} }

View File

@@ -895,6 +895,34 @@ Properties:
* `type`: `default` or `dropdown. Defines the styling of the component. By default shows a list, but alternatively the items can be displayed in a drop down * `type`: `default` or `dropdown. Defines the styling of the component. By default shows a list, but alternatively the items can be displayed in a drop down
* `scrollable`: By default the data list will take all available space and scroll if items aren't otherwise visible. By setting `scrollable={false}` the list will only take its natural size * `scrollable`: By default the data list will take all available space and scroll if items aren't otherwise visible. By setting `scrollable={false}` the list will only take its natural size
### Dialog
The `Dialog` namespace provides a set of utility to prompt the user with feedback of input. Rather than spawning dialogs by hand, the benefit of the `Dialog` utilities is that they all return promises capture the results.
The promises returned by `Dialog` will resolve to `false` if the user intentionally closed the dialog (typically by using cancel / escape / clicking the close button).
The promises returend by `Dialog` utilities will expose a `close()` method that can be used to programmatically close a dialog. In which case the pending promise will resolve to `false` as well.
General properties accepted by the `Dialog` utility:
* `title` - Overrides the title of the dialog, defaults to empty.
* `width` - Overrides the default width (400) for dialogs. Number in pixels.
* `okText` - Overrides the caption of the OK button
* `cancelText` - Overrides the caption of the Cancel button
Available utilities
* `Dialog.confirm(options): Promise<boolean>`. Show a confirmation dialog to the user. Options:
* `message`: Description of what the user is confirming.
* `Dialog.prompt(options): Promise<string | false>`. Prompt the user for some input. Options:
* `message`: Text accompanying the input
* `defaultValue`
* `onConfirm(value) => Promise<string>`. Can be used to transform the inputted value before resolving the prompt promise. If the handler throws, this will be shown as validation error in the dialog.
* `Dialog.show<T>(options): Promise<T | false`. Low level building block to build dialogs. Options:
* `children`: React Element to render as children of the dialog.
* `onConfirm: () => Promise<T>`. Handler to handle the OK button, which should produce the value the `Dialog.show` call will resolve to.
### NUX ### NUX
An element that can be used to provide a New User eXperience: Hints that give a one time introduction to new features to the current user. An element that can be used to provide a New User eXperience: Hints that give a one time introduction to new features to the current user.