diff --git a/desktop/app/src/deeplink.tsx b/desktop/app/src/deeplink.tsx index 16e4fac2f..6f742bd64 100644 --- a/desktop/app/src/deeplink.tsx +++ b/desktop/app/src/deeplink.tsx @@ -17,9 +17,7 @@ import {Group, SUPPORTED_GROUPS} from './reducers/supportForm'; import {Store} from './reducers/index'; import {importDataToStore} from './utils/exportData'; import {selectPlugin} from './reducers/connections'; -import {Layout, renderReactRoot} from 'flipper-plugin'; -import React, {useState} from 'react'; -import {Alert, Input, Modal} from 'antd'; +import {Dialog} from 'flipper-plugin'; import {handleOpenPluginDeeplink} from './dispatcher/handleOpenPluginDeeplink'; const UNKNOWN = 'Unknown deeplink'; @@ -118,40 +116,13 @@ export const uriComponents = (url: string): Array => { }; export function openDeeplinkDialog(store: Store) { - renderReactRoot((hide) => ); -} - -function TestDeeplinkDialog({ - store, - onHide, -}: { - store: Store; - onHide: () => void; -}) { - const [query, setQuery] = useState('flipper://'); - const [error, setError] = useState(''); - return ( - { - handleDeeplink(store, query) - .then(onHide) - .catch((e) => { - setError(`Failed to handle deeplink '${query}': ${e}`); - }); - }} - onCancel={onHide}> - - <>Enter a deeplink to test it: - { - setQuery(v.target.value); - }} - /> - {error && } - - - ); + Dialog.prompt({ + title: 'Open deeplink', + message: 'Enter a deeplink:', + defaultValue: 'flipper://', + onConfirm: async (deeplink) => { + await handleDeeplink(store, deeplink); + return deeplink; + }, + }); } diff --git a/desktop/flipper-plugin/src/__tests__/api.node.tsx b/desktop/flipper-plugin/src/__tests__/api.node.tsx index 9a1e7eb94..f4fa490e3 100644 --- a/desktop/flipper-plugin/src/__tests__/api.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/api.node.tsx @@ -36,6 +36,7 @@ test('Correct top level API exposed', () => { "DataSource", "DataTable", "DetailSidebar", + "Dialog", "ElementsInspector", "Layout", "MarkerTimeline", diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index 7fd1b0561..604f4cd38 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -112,7 +112,7 @@ export { } from './ui/data-inspector/DataDescription'; export {MarkerTimeline} from './ui/MarkerTimeline'; export {DataInspector} from './ui/data-inspector/DataInspector'; - +export {Dialog} from './ui/Dialog'; export { ElementsInspector, Element as ElementsInspectorElement, diff --git a/desktop/flipper-plugin/src/ui/Dialog.tsx b/desktop/flipper-plugin/src/ui/Dialog.tsx new file mode 100644 index 000000000..d6fc1907b --- /dev/null +++ b/desktop/flipper-plugin/src/ui/Dialog.tsx @@ -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 = Promise & {close: () => void}; + +type BaseDialogOptions = { + title: string; + okText?: string; + cancelText?: string; +}; + +export const Dialog = { + show( + opts: BaseDialogOptions & { + children: React.ReactNode; + onConfirm: () => Promise; + }, + ): DialogResult { + let cancel: () => void; + + return Object.assign( + new Promise((resolve) => { + renderReactRoot((hide) => { + const submissionError = createState(''); + cancel = () => { + hide(); + resolve(false); + }; + return ( + { + try { + const value = await opts.onConfirm(); + hide(); + resolve(value); + } catch (e) { + submissionError.set(e.toString()); + } + }} + onCancel={cancel} + width={400}> + + {opts.children} + + + + ); + }); + }), + { + close() { + cancel(); + }, + }, + ); + }, + + confirm({ + message, + ...rest + }: { + message: string | React.ReactElement; + } & BaseDialogOptions): DialogResult { + return Dialog.show({ + ...rest, + children: message, + onConfirm: async () => true, + }); + }, + + prompt({ + message, + defaultValue, + onConfirm, + ...rest + }: BaseDialogOptions & { + message: string | React.ReactElement; + defaultValue?: string; + onConfirm?: (value: string) => Promise; + }): DialogResult { + const inputValue = createState(defaultValue ?? ''); + return Dialog.show({ + ...rest, + children: ( + <> + {message} + + + ), + onConfirm: async () => { + const value = inputValue.get(); + if (onConfirm) { + return await onConfirm(value); + } + return value; + }, + }); + }, +}; + +function PromptInput({inputValue}: {inputValue: Atom}) { + const currentValue = useValue(inputValue); + return ( + { + inputValue.set(e.target.value); + }} + /> + ); +} + +function SubmissionError({submissionError}: {submissionError: Atom}) { + const currentError = useValue(submissionError); + return currentError ? : null; +} diff --git a/desktop/flipper-plugin/src/utils/renderReactRoot.tsx b/desktop/flipper-plugin/src/utils/renderReactRoot.tsx index 24b813ce8..26a77afd2 100644 --- a/desktop/flipper-plugin/src/utils/renderReactRoot.tsx +++ b/desktop/flipper-plugin/src/utils/renderReactRoot.tsx @@ -16,13 +16,12 @@ import {render, unmountComponentAtNode} from 'react-dom'; */ export function renderReactRoot( handler: (unmount: () => void) => React.ReactElement, -): void { +): () => void { const div = document.body.appendChild(document.createElement('div')); - render( - handler(() => { - unmountComponentAtNode(div); - div.remove(); - }), - div, - ); + const unmount = () => { + unmountComponentAtNode(div); + div.remove(); + }; + render(handler(unmount), div); + return unmount; } diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index cca46bc13..271cdb5dc 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -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 * `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`. Show a confirmation dialog to the user. Options: + * `message`: Description of what the user is confirming. +* `Dialog.prompt(options): Promise`. Prompt the user for some input. Options: + * `message`: Text accompanying the input + * `defaultValue` + * `onConfirm(value) => Promise`. 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(options): Promise Promise`. Handler to handle the OK button, which should produce the value the `Dialog.show` call will resolve to. + + ### 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.