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:
committed by
Facebook GitHub Bot
parent
9c4deb3501
commit
f74029699f
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ test('Correct top level API exposed', () => {
|
|||||||
"DataSource",
|
"DataSource",
|
||||||
"DataTable",
|
"DataTable",
|
||||||
"DetailSidebar",
|
"DetailSidebar",
|
||||||
|
"Dialog",
|
||||||
"ElementsInspector",
|
"ElementsInspector",
|
||||||
"Layout",
|
"Layout",
|
||||||
"MarkerTimeline",
|
"MarkerTimeline",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
132
desktop/flipper-plugin/src/ui/Dialog.tsx
Normal file
132
desktop/flipper-plugin/src/ui/Dialog.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}),
|
};
|
||||||
div,
|
render(handler(unmount), div);
|
||||||
);
|
return unmount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user