From 780862a40f01928b6023c99e914fa17e400b2139 Mon Sep 17 00:00:00 2001 From: kseniyakuzina Date: Fri, 16 Feb 2024 09:45:59 +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 | 58 +++++++++ .../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 | 91 ++++++++++++++ src/hooks/usePromiseDialog/README.md | 113 ++++++++++++++++++ .../__stories__/NoteEditor.tsx | 45 +++++++ .../UsePromiseDialogDemo.classname.ts | 3 + .../__stories__/UsePromiseDialogDemo.scss | 10 ++ .../__stories__/UsePromiseDialogDemo.tsx | 91 ++++++++++++++ .../UsePromiseDialogStories.stories.tsx | 11 ++ src/hooks/usePromiseDialog/index.ts | 2 + src/hooks/usePromiseDialog/types.ts | 24 ++++ .../usePromiseDialog/usePromiseDialog.ts | 7 ++ 18 files changed, 571 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..1afb1c51fe --- /dev/null +++ b/src/components/Confirm/README.md @@ -0,0 +1,58 @@ + + +# Confirm + + + +```tsx +import {Confirm} from '@gravity-ui/uikit'; +``` + +`Confirm` is a utility component, which renders confirmatuion 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 + +## Returns + +Returns an object with `openConfirm` property. `openConfirm` is a function, which accepts the confirm dialog props object, which can override the default useConfirm arguments (use when you have two almost the same confirmation dialogs in one component) + +## 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 04407d597d..60e3c4c63e 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..495ee6cc5d --- /dev/null +++ b/src/hooks/usePromiseDialog/PromiseDialogProvider.tsx @@ -0,0 +1,91 @@ +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[]; + onError: (error: unknown) => void; +}; + +export const PromiseDialogProvider = ({children, onError}: 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, + // onClose, + }: 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) => { + setTimeout(() => { + setDialogs(omit(dialogsRef.current, key)); + }, 100); + + resolve(result); + }; + + const handleSuccess = (value: ResultType) => { + handleClose({success: true, value}); + }; + + const handleSuccessPromise = (getValue: Promise) => { + getValue + .then((value) => { + handleClose({success: true, value}); + }) + .catch(onError); + }; + + const handleCancel = () => { + handleClose({success: false}); + }; + + // const handleComponentClose = ( + // event: MouseEvent | KeyboardEvent, + // reason: 'outsideClick' | 'escapeKeyDown' | 'closeButtonClick', + // ) => { + // onClose?.(event, reason); + // handleCancel(); + // }; + + const dialog = renderDialog({ + onSuccess: handleSuccess, + asyncOnSuccess: handleSuccessPromise, + onCancel: handleCancel, + // onClose: handleComponentClose, + }); + + setDialogs({ + ...dialogsRef.current, + [key]: dialog, + }); + }), + }), + [onError], + ); + + 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..5aafc6a281 --- /dev/null +++ b/src/hooks/usePromiseDialog/README.md @@ -0,0 +1,113 @@ + + +# 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}; +``` + +Properties: + +| Name | Description | Type | Default | +| :------ | :------------------------------------------------- | :------------------------: | :-----: | +| onError | The error handler (used when asyncOnSuccess fails) | `(error: unknown) => void` | | + +`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 }`. + +- 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 }`). + +```ts +function openDialog( + renderContent: ({ + onSuccess, + asyncOnSuccess, + onCancel, + }: { + onSuccess: (value: ResultType) => void; + asyncOnSuccess: (getValue: Promise) => void; + onCancel: () => void; + }) => 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}) => ( + + + + )); + + 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}) => + asyncOnSuccess(handleSave(note))} 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..a8208c3e19 --- /dev/null +++ b/src/hooks/usePromiseDialog/__stories__/UsePromiseDialogDemo.tsx @@ -0,0 +1,91 @@ +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}) => ( + + + asyncOnSuccess(handleValidateAndSave(note)) + } + 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}) => ( + + )); + + 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..5dbf960922 --- /dev/null +++ b/src/hooks/usePromiseDialog/types.ts @@ -0,0 +1,24 @@ +import type React from 'react'; + +export type PromiseDialogResult = { + success: boolean; + value?: ResultType; +}; + +export type DialogRendererProps = { + onSuccess: (value: ResultType) => void; + asyncOnSuccess: (getValue: Promise) => void; + onCancel: () => void; +}; + +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); +};