Skip to content

Commit

Permalink
feat(usePromiseDialog): add usePromiseDialog hook and Confirm component
Browse files Browse the repository at this point in the history
  • Loading branch information
kseniyakuzina committed Mar 13, 2024
1 parent 3db3bbf commit 398296f
Show file tree
Hide file tree
Showing 20 changed files with 568 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
/src/components/StoreBadge @NikitaCG
/src/components/Stories @darkgenius
/src/components/AdaptiveTabs @artemipanchuk
/src/components/Confirm @kseniya57
/src/hooks/usePromiseDialog @kseniya57
11 changes: 11 additions & 0 deletions src/components/Confirm/Confirm.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@use '@gravity-ui/uikit/styles/mixins';
@use '../../components/variables';

$block: '.#{variables.$ns}confirm';

#{$block} {
&__message {
margin: 0;
@include mixins.text-body-2;
}
}
58 changes: 58 additions & 0 deletions src/components/Confirm/Confirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';

import type {ButtonProps, DialogProps} from '@gravity-ui/uikit';
import {Dialog} from '@gravity-ui/uikit';

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<DialogProps, 'children' | 'onClose'>;

export const Confirm = ({
title,
message = '',
content,
confirmButtonText,
cancelButtonText,
onConfirm,
onCancel,
confirmButtonProps,
cancelButtonProps,
onClose,
...dialogProps
}: ConfirmProps) => {
return (
<Dialog {...dialogProps} onClose={onClose ?? onCancel}>
<Dialog.Header caption={title} />
<Dialog.Body>
{content}
{message && !content && <p className={b('message')}>{message}</p>}
</Dialog.Body>
<Dialog.Footer
preset="default"
showError={false}
listenKeyEnter
textButtonApply={confirmButtonText}
textButtonCancel={cancelButtonText}
onClickButtonApply={onConfirm}
onClickButtonCancel={onCancel}
propsButtonCancel={cancelButtonProps}
propsButtonApply={confirmButtonProps}
/>
</Dialog>
);
};
54 changes: 54 additions & 0 deletions src/components/Confirm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!--GITHUB_BLOCK-->

# Confirm

<!--/GITHUB_BLOCK-->

```tsx
import {Confirm} from '@gravity-ui/components';
```

`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/components';

const [open, setOpen] = React.useState(false);

return (
<React.Fragment>
<Button view="normal" onClick={() => setOpen(true)}>
Show confirm
</Button>
<Confirm
{...args}
title="Do you want to confirm?"
onConfirm={() => {
alert('Confirmed');
setOpen(false);
}}
onCancel={() => setOpen(false)}
cancelButtonText="No"
confirmButtonText="Yes"
open={open}
aria-labelledby="app-confirmation-dialog-title"
/>
</React.Fragment>
);
```
38 changes: 38 additions & 0 deletions src/components/Confirm/__stories__/Confirm.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';

import {Button} from '@gravity-ui/uikit';
import type {Meta, StoryFn} from '@storybook/react';

import {Confirm} from '../Confirm';
import type {ConfirmProps} from '../Confirm';

export default {
title: 'Components/Confirm',
component: Confirm,
} as Meta<ConfirmProps>;

const DefaultTemplate: StoryFn<ConfirmProps> = (args) => {
const [open, setOpen] = React.useState(false);

return (
<React.Fragment>
<Button view="normal" onClick={() => setOpen(true)}>
Show confirm
</Button>
<Confirm
{...args}
title="Do you want to confirm?"
onConfirm={() => {
alert('Confirmed');
setOpen(false);
}}
onCancel={() => setOpen(false)}
cancelButtonText="No"
confirmButtonText="Yes"
open={open}
aria-labelledby="app-confirmation-dialog-title"
/>
</React.Fragment>
);
};
export const Default = DefaultTemplate.bind({});
1 change: 1 addition & 0 deletions src/components/Confirm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './Confirm';
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './SharePopover';
export * from './StoreBadge';
export * from './Stories';
export * from './StoriesGroup';
export * from './Confirm';
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './usePromiseDialog';
7 changes: 7 additions & 0 deletions src/hooks/usePromiseDialog/PromiseDialogContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';

import type {PromiseDialogContextType} from './types';

export const PromiseDialogContext = React.createContext<PromiseDialogContextType>({
openDialog: () => Promise.resolve({success: false}),
});
85 changes: 85 additions & 0 deletions src/hooks/usePromiseDialog/PromiseDialogProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<number, React.ReactNode>>([]);
const dialogsRef: React.MutableRefObject<Record<number, React.ReactNode>> =
React.useRef(dialogs);

React.useEffect(() => {
dialogsRef.current = dialogs;
}, [dialogs]);

const contextValue = React.useMemo(
() => ({
openDialog: <ResultType extends unknown>(
renderDialog: ({
onSuccess,
asyncOnSuccess,
onCancel,
key,
}: DialogRendererProps<ResultType>) => 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<ResultType>) => {
resolve(result);

setDialogs(omit(dialogsRef.current, key));
};

const handleSuccess = (value: ResultType) => {
handleClose({success: true, value});
};

const handleSuccessPromise = (
getValue: Promise<ResultType>,
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 (
<PromiseDialogContext.Provider value={contextValue}>
{children}
{Object.values(dialogs)}
</PromiseDialogContext.Provider>
);
};
112 changes: 112 additions & 0 deletions src/hooks/usePromiseDialog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<!--GITHUB_BLOCK-->

# usePromiseDialog

<!--/GITHUB_BLOCK-->

```tsx
import {usePromiseDialog} from '@gravity-ui/components';
```

### Provider

Before usage, you should wrap your components with `PromiseDialogProvider`

```tsx
import {PromiseDialogProvider} from '@gravity-ui/components';

<PromiseDialogProvider>{children}</PromiseDialogProvider>;
```

`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<ResultRype extends unknown>(
renderContent: ({
onSuccess,
asyncOnSuccess,
onCancel,
key,
}: {
onSuccess: (value: ResultType) => void;
asyncOnSuccess: (getValue: Promise<ResultRype>, onError: (error: unknown) => void) => void;
onCancel: () => void;
key: number;
}) => React.ReactNode | React.ReactNode[],
dialogProps?: Partial<DialogProps>,
): Promise<{
success: boolean;
value?: ResultType;
}>;
```

## Examples

#### Base

```tsx
import {Dialog} from '@gravity-ui/uikit';
import {usePromiseDialog} from '@gravity-ui/components';

const {openDialog} = usePromiseDialog();

const handleOpenNoteEditor = useCallback(async () => {
const result = await openDialog<string>(({onSuccess, onCancel, key}) => (
<Dialog open onClose={onCancel} size="s" key={key}>
<NoteEditor onSave={onSuccess} onCancel={onCancel} />
</Dialog>
));

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} from '@gravity-ui/uikit';
import {usePromiseDialog} from '@gravity-ui/components';

const {openDialog} = usePromiseDialog();

const handleOpenNoteEditor = useCallback(async () => {
const handleSave = (note: string) => {
return new Promise<string>((resolve, reject) => {
if (note) {
resolve(note);
} else {
alert('Enter the note');
reject();
}
});
};

const result = await openDialog<string>(
({asyncOnSuccess, onCancel, key}) => <Dialog open onClose={onCancel} size="s" key={key}>
<NoteEditor onSave{(note: string) => asyncOnSuccess(handleSave(note), console.error)} onCancel={onCancel} />
</Dialog>
);

if (result.success) {
const note = result.value;
alert(`Your note is ${note}`);
} else {
alert('You cancelled creating the note');
}
}, [openDialog]);
```
Loading

0 comments on commit 398296f

Please sign in to comment.