From 0619c4cd6c3479c621701585ca267895e5c768f2 Mon Sep 17 00:00:00 2001 From: kseniyakuzina Date: Wed, 13 Mar 2024 08:19:27 +0300 Subject: [PATCH] feat(usePromiseDialog): add usePromiseDialog hook and Confirm component --- src/components/Confirm/Confirm.scss | 11 ++ src/components/Confirm/Confirm.tsx | 57 +++++++++ src/components/Confirm/README.md | 54 +++++++++ .../Confirm/__stories__/Confirm.stories.tsx | 38 ++++++ src/components/Confirm/index.ts | 1 + src/components/index.ts | 1 + src/hooks/index.ts | 1 + .../usePromiseDialog/PromiseDialogContext.ts | 7 ++ .../PromiseDialogProvider.tsx | 85 ++++++++++++++ src/hooks/usePromiseDialog/README.md | 110 ++++++++++++++++++ .../__stories__/NoteEditor.tsx | 45 +++++++ .../UsePromiseDialogDemo.classname.ts | 3 + .../__stories__/UsePromiseDialogDemo.scss | 10 ++ .../__stories__/UsePromiseDialogDemo.tsx | 92 +++++++++++++++ .../UsePromiseDialogStories.stories.tsx | 11 ++ src/hooks/usePromiseDialog/index.ts | 2 + src/hooks/usePromiseDialog/types.ts | 25 ++++ .../usePromiseDialog/usePromiseDialog.ts | 7 ++ 18 files changed, 560 insertions(+) create mode 100644 src/components/Confirm/Confirm.scss create mode 100644 src/components/Confirm/Confirm.tsx create mode 100644 src/components/Confirm/README.md create mode 100644 src/components/Confirm/__stories__/Confirm.stories.tsx create mode 100644 src/components/Confirm/index.ts create mode 100644 src/hooks/usePromiseDialog/PromiseDialogContext.ts create mode 100644 src/hooks/usePromiseDialog/PromiseDialogProvider.tsx create mode 100644 src/hooks/usePromiseDialog/README.md create mode 100644 src/hooks/usePromiseDialog/__stories__/NoteEditor.tsx create mode 100644 src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.classname.ts create mode 100644 src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.scss create mode 100644 src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.tsx create mode 100644 src/hooks/usePromiseDialog/__stories__/UsePromiseDialogStories.stories.tsx create mode 100644 src/hooks/usePromiseDialog/index.ts create mode 100644 src/hooks/usePromiseDialog/types.ts create mode 100644 src/hooks/usePromiseDialog/usePromiseDialog.ts diff --git a/src/components/Confirm/Confirm.scss b/src/components/Confirm/Confirm.scss new file mode 100644 index 0000000000..cb17bc34eb --- /dev/null +++ b/src/components/Confirm/Confirm.scss @@ -0,0 +1,11 @@ +@use '../../../styles/mixins.scss'; +@use '../../components/variables'; + +$block: '.#{variables.$ns}confirm'; + +#{$block} { + &__message { + margin: 0; + @include mixins.text-body-2; + } +} diff --git a/src/components/Confirm/Confirm.tsx b/src/components/Confirm/Confirm.tsx new file mode 100644 index 0000000000..b68180d444 --- /dev/null +++ b/src/components/Confirm/Confirm.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import type {ButtonProps, DialogProps} from '..'; +import {Dialog} from '..'; +import {block} from '../utils/cn'; + +import './Confirm.scss'; + +const b = block('confirm'); + +export type ConfirmProps = { + title?: string; + message?: string; + content?: React.ReactNode; + confirmButtonText?: string; + cancelButtonText?: string; + onConfirm: () => void; + onCancel: () => void; + confirmButtonProps?: ButtonProps; + cancelButtonProps?: ButtonProps; + onClose?: DialogProps['onClose']; +} & Omit; + +export const Confirm = ({ + title, + message = '', + content, + confirmButtonText, + cancelButtonText, + onConfirm, + onCancel, + confirmButtonProps, + cancelButtonProps, + onClose, + ...dialogProps +}: ConfirmProps) => { + return ( + + + + {content} + {message && !content &&

{message}

} +
+ +
+ ); +}; diff --git a/src/components/Confirm/README.md b/src/components/Confirm/README.md new file mode 100644 index 0000000000..04834bfb4a --- /dev/null +++ b/src/components/Confirm/README.md @@ -0,0 +1,54 @@ + + +# Confirm + + + +```tsx +import {Confirm} from '@gravity-ui/uikit'; +``` + +`Confirm` is a utility component, which renders confirmation dialogs + +## Properties + +| Name | Description | Type | Required | +| :----------------- | :------------------------------------------------------------- | :-----------: | :------: | +| title | The confirm dialog title | `string` | Yes | +| cancelButtonText | The cancel button text | `string` | Yes | +| confirmButtonText | The ok button text | `string` | Yes | +| message | The confirmation message (used if the content is not provided) | `string` | | +| content | The confirmation custom content | `ReactNode` | | +| confirmButtonProps | The ok button props | `ButtonProps` | | +| cancelButtonProps | The cancel buttonProps | `ButtonProps` | | + +And other Dialog props + +## Usage + +```tsx +import {Confirm} from '@gravity-ui/uikit'; + +const [open, setOpen] = React.useState(false); + +return ( + + + { + alert('Confirmed'); + setOpen(false); + }} + onCancel={() => setOpen(false)} + cancelButtonText="No" + confirmButtonText="Yes" + open={open} + aria-labelledby="app-confirmation-dialog-title" + /> + +); +``` diff --git a/src/components/Confirm/__stories__/Confirm.stories.tsx b/src/components/Confirm/__stories__/Confirm.stories.tsx new file mode 100644 index 0000000000..92215b086c --- /dev/null +++ b/src/components/Confirm/__stories__/Confirm.stories.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {Button} from '../../Button'; +import {Confirm} from '../Confirm'; +import type {ConfirmProps} from '../Confirm'; + +export default { + title: 'Components/Feedback/Confirm', + component: Confirm, +} as Meta; + +const DefaultTemplate: StoryFn = (args) => { + const [open, setOpen] = React.useState(false); + + return ( + + + { + alert('Confirmed'); + setOpen(false); + }} + onCancel={() => setOpen(false)} + cancelButtonText="No" + confirmButtonText="Yes" + open={open} + aria-labelledby="app-confirmation-dialog-title" + /> + + ); +}; +export const Default = DefaultTemplate.bind({}); diff --git a/src/components/Confirm/index.ts b/src/components/Confirm/index.ts new file mode 100644 index 0000000000..d8e1c97b8c --- /dev/null +++ b/src/components/Confirm/index.ts @@ -0,0 +1 @@ +export * from './Confirm'; diff --git a/src/components/index.ts b/src/components/index.ts index 893ecc47ad..2db50e2d4c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,6 +14,7 @@ export * from './ClipboardButton'; export * from './ClipboardIcon'; export * from './CopyToClipboard'; export * from './Dialog'; +export * from './Confirm'; export * from './Disclosure'; export * from './DropdownMenu'; export * from './Hotkey'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9a98690749..4e6927d8ae 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -14,3 +14,4 @@ export * from './useTimeout'; export * from './useViewportSize'; export * from './useVirtualElementRef'; export * from './useUniqId'; +export * from './usePromiseDialog'; diff --git a/src/hooks/usePromiseDialog/PromiseDialogContext.ts b/src/hooks/usePromiseDialog/PromiseDialogContext.ts new file mode 100644 index 0000000000..cf2bc718de --- /dev/null +++ b/src/hooks/usePromiseDialog/PromiseDialogContext.ts @@ -0,0 +1,7 @@ +import React from 'react'; + +import type {PromiseDialogContextType} from './types'; + +export const PromiseDialogContext = React.createContext({ + openDialog: () => Promise.resolve({success: false}), +}); diff --git a/src/hooks/usePromiseDialog/PromiseDialogProvider.tsx b/src/hooks/usePromiseDialog/PromiseDialogProvider.tsx new file mode 100644 index 0000000000..59de0215ae --- /dev/null +++ b/src/hooks/usePromiseDialog/PromiseDialogProvider.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import omit from 'lodash/omit'; + +import {PromiseDialogContext} from './PromiseDialogContext'; +import type {DialogRendererProps, PromiseDialogResult} from './types'; + +type PromiseDialogProviderProps = { + children: React.ReactNode | React.ReactNode[]; +}; + +export const PromiseDialogProvider = ({children}: PromiseDialogProviderProps) => { + const [dialogs, setDialogs] = React.useState>([]); + const dialogsRef: React.MutableRefObject> = + React.useRef(dialogs); + + React.useEffect(() => { + dialogsRef.current = dialogs; + }, [dialogs]); + + const contextValue = React.useMemo( + () => ({ + openDialog: ( + renderDialog: ({ + onSuccess, + asyncOnSuccess, + onCancel, + key, + }: DialogRendererProps) => React.ReactNode, + ) => + new Promise<{success: boolean; value?: ResultType}>((resolve) => { + const currentKeys = Object.keys(dialogsRef.current); + + const key = parseInt(currentKeys[currentKeys.length - 1] || '0', 10) + 1; + + const handleClose = (result: PromiseDialogResult) => { + resolve(result); + + setDialogs(omit(dialogsRef.current, key)); + }; + + const handleSuccess = (value: ResultType) => { + handleClose({success: true, value}); + }; + + const handleSuccessPromise = ( + getValue: Promise, + onError: (error: unknown) => void, + ) => { + getValue + .then((value) => { + handleClose({success: true, value}); + }) + .catch(onError); + }; + + const handleCancel = () => { + handleClose({success: false}); + }; + + const dialog = renderDialog({ + onSuccess: handleSuccess, + asyncOnSuccess: handleSuccessPromise, + onCancel: handleCancel, + key, + }); + + requestAnimationFrame(() => { + setDialogs({ + ...dialogsRef.current, + [key]: dialog, + }); + }); + }), + }), + [], + ); + + return ( + + {children} + {Object.values(dialogs)} + + ); +}; diff --git a/src/hooks/usePromiseDialog/README.md b/src/hooks/usePromiseDialog/README.md new file mode 100644 index 0000000000..3f006aae9d --- /dev/null +++ b/src/hooks/usePromiseDialog/README.md @@ -0,0 +1,110 @@ + + +# usePromiseDialog + + + +```tsx +import {usePromiseDialog} from '@gravity-ui/uikit'; +``` + +### Provider + +Before usage, you should wrap your components with `PromiseDialogProvider` + +```tsx +import {PromiseDialogProvider} from '@gravity-ui/uikit'; + +{children}; +``` + +`usePromiseDialog` is a utility hook, which allows you to open a dialog without adding a Dialog component to your code and controlling it's state + +## Returns + +Returns an object with `openDialog` property. `openDialog` is a generic function, which accepts a dialog content renderer and the dialog props. The content renderer provides an object +`{ onSuccess, asyncOnSuccess, onCancel, key }`. + +- You should call `onSuccess` with the result of calling the dialog (in this case the promise result will be `{ success: true, value }`); +- `asyncOnSuccess` with the promise which resolves the result (the promise dialog result will be `{ success: true, value }`, but the dialog won't resolve and close in case of an error occurred in the promise, passed with the arguments) +- `onCancel` if you want to cancel the action (in this case the promise result will be `{ success: false }`). +- `key` is the unique dialog key. You should use it as the React key for your dialog. + +```ts +function openDialog( + renderContent: ({ + onSuccess, + asyncOnSuccess, + onCancel, + key, + }: { + onSuccess: (value: ResultType) => void; + asyncOnSuccess: (getValue: Promise, onError: (error: unknown) => void) => void; + onCancel: () => void; + key: number; + }) => React.ReactNode | React.ReactNode[], + dialogProps?: Partial, +): Promise<{ + success: boolean; + value?: ResultType; +}>; +``` + +## Examples + +#### Base + +```tsx +import {Dialog, usePromiseDialog} from '@gravity-ui/uikit'; + +const {openDialog} = usePromiseDialog(); + +const handleOpenNoteEditor = useCallback(async () => { + const result = await openDialog(({onSuccess, onCancel, key}) => ( + + + + )); + + if (result.success) { + const note = result.value; + alert(`Your note is ${note}`); + } else { + alert('You cancelled creating the note'); + } +}, [openDialog]); +``` + +#### Don't close dialog on error + +```tsx +import {Dialog, usePromiseDialog} from '@gravity-ui/uikit'; + +const {openDialog} = usePromiseDialog(); + +const handleOpenNoteEditor = useCallback(async () => { + const handleSave = (note: string) => { + return new Promise((resolve, reject) => { + if (note) { + resolve(note); + } else { + alert('Enter the note'); + reject(); + } + }); + }; + + const result = await openDialog( + ({asyncOnSuccess, onCancel, key}) => + asyncOnSuccess(handleSave(note), console.error)} onCancel={onCancel} /> + + ); + + if (result.success) { + const note = result.value; + alert(`Your note is ${note}`); + } else { + alert('You cancelled creating the note'); + } +}, [openDialog]); +``` diff --git a/src/hooks/usePromiseDialog/__stories__/NoteEditor.tsx b/src/hooks/usePromiseDialog/__stories__/NoteEditor.tsx new file mode 100644 index 0000000000..f24f8b2b38 --- /dev/null +++ b/src/hooks/usePromiseDialog/__stories__/NoteEditor.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import {Button, Dialog, TextInput} from '../../../components'; + +export type NoteEditorProps = { + onSave: (value: string) => void; + onValidateAndSave: (note: string) => void; + onCancel: () => void; +}; + +export const NoteEditor = ({onSave, onValidateAndSave, onCancel}: NoteEditorProps) => { + const [note, setNote] = React.useState(''); + + const handleApply = React.useCallback(() => { + onSave(note); + }, [note, onSave]); + + const handleValidateAndApply = React.useCallback(() => { + onValidateAndSave(note); + }, [note, onValidateAndSave]); + + return ( + + + + + + ( + + {buttonCancel} + {buttonApply} + + + )} + /> + + ); +}; diff --git a/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.classname.ts b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.classname.ts new file mode 100644 index 0000000000..929506606d --- /dev/null +++ b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.classname.ts @@ -0,0 +1,3 @@ +import {block} from '../../../components/utils/cn'; + +export const cnUsePromiseDialogDemo = block('promise-dialog-demo'); diff --git a/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.scss b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.scss new file mode 100644 index 0000000000..000a2f4023 --- /dev/null +++ b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.scss @@ -0,0 +1,10 @@ +@import '../../../components/variables'; + +$block: '.#{$ns}promise-dialog-demo'; + +#{$block} { + &__buttons { + display: flex; + gap: 16px; + } +} diff --git a/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.tsx b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.tsx new file mode 100644 index 0000000000..edc321e7f3 --- /dev/null +++ b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.tsx @@ -0,0 +1,92 @@ +import React from 'react'; + +import {PromiseDialogProvider, usePromiseDialog} from '..'; +import {Button, Confirm, Dialog} from '../../../components'; + +import {NoteEditor} from './NoteEditor'; +import {cnUsePromiseDialogDemo} from './UsePromiseDialogDemo.classname'; + +import './UsePromiseDialogDemo.scss'; + +const AddNoteButton = () => { + const {openDialog} = usePromiseDialog(); + + const handleOpenNoteEditor = React.useCallback(async () => { + const handleValidateAndSave = (note: string) => { + return new Promise((resolve, reject) => { + if (note) { + resolve(note); + } else { + alert('Enter the note'); + reject(); + } + }); + }; + + const result = await openDialog(({onSuccess, asyncOnSuccess, onCancel, key}) => ( + + + asyncOnSuccess(handleValidateAndSave(note), console.error) + } + onCancel={onCancel} + /> + + )); + + if (result.success) { + const note = result.value; + alert(`Your note is ${note}`); + } else { + alert('You cancelled creating the note'); + } + }, [openDialog]); + + return ( + + ); +}; + +const RemoveNoteButton = () => { + const {openDialog} = usePromiseDialog(); + + const handleRemoveNote = React.useCallback(async () => { + const result = await openDialog(({onSuccess, onCancel, key}) => ( + + )); + + if (result.success) { + alert('The note was removed'); + } else { + alert('You cancelled removing the note'); + } + }, [openDialog]); + + return ( + + ); +}; + +export const UsePromiseDialogDemo = () => { + return ( + +
+ + +
+
+ ); +}; diff --git a/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogStories.stories.tsx b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogStories.stories.tsx new file mode 100644 index 0000000000..fe06fc358a --- /dev/null +++ b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogStories.stories.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {UsePromiseDialogDemo} from './UsePromiseDialogDemo'; + +export default { + title: 'Hooks/usePromiseDialog', +} as Meta; + +export const Showcase: StoryFn = () => ; diff --git a/src/hooks/usePromiseDialog/index.ts b/src/hooks/usePromiseDialog/index.ts new file mode 100644 index 0000000000..c9dc547408 --- /dev/null +++ b/src/hooks/usePromiseDialog/index.ts @@ -0,0 +1,2 @@ +export {usePromiseDialog} from './usePromiseDialog'; +export {PromiseDialogProvider} from './PromiseDialogProvider'; diff --git a/src/hooks/usePromiseDialog/types.ts b/src/hooks/usePromiseDialog/types.ts new file mode 100644 index 0000000000..dd69ae0444 --- /dev/null +++ b/src/hooks/usePromiseDialog/types.ts @@ -0,0 +1,25 @@ +import type React from 'react'; + +export type PromiseDialogResult = { + success: boolean; + value?: ResultType; +}; + +export type DialogRendererProps = { + onSuccess: (value: ResultType) => void; + asyncOnSuccess: (getValue: Promise, onError: (error: unknown) => void) => void; + onCancel: () => void; + key: number; +}; + +export type PromiseDialogRenderer = ({ + onSuccess, + asyncOnSuccess, + onCancel, +}: DialogRendererProps) => React.ReactNode; + +export type PromiseDialogContextType = { + openDialog: ( + renderDialog: PromiseDialogRenderer, + ) => Promise>; +}; diff --git a/src/hooks/usePromiseDialog/usePromiseDialog.ts b/src/hooks/usePromiseDialog/usePromiseDialog.ts new file mode 100644 index 0000000000..3c5f3873d0 --- /dev/null +++ b/src/hooks/usePromiseDialog/usePromiseDialog.ts @@ -0,0 +1,7 @@ +import React from 'react'; + +import {PromiseDialogContext} from './PromiseDialogContext'; + +export const usePromiseDialog = () => { + return React.useContext(PromiseDialogContext); +};