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 14, 2024
1 parent 8027223 commit 43666f4
Show file tree
Hide file tree
Showing 20 changed files with 562 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/useDialog @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 './useDialog';
7 changes: 7 additions & 0 deletions src/hooks/useDialog/DialogContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react';

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

export const DialogContext = React.createContext<DialogContextType>({
openDialog: () => Promise.resolve({success: false}),
});
84 changes: 84 additions & 0 deletions src/hooks/useDialog/DialogProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react';

import {getUniqId} from '@gravity-ui/uikit';
import omit from 'lodash/omit';

import {DialogContext} from './DialogContext';
import type {DialogRendererProps, DialogResult} from './types';

type DialogProviderProps = {
children: React.ReactNode | React.ReactNode[];
};

export const DialogProvider = ({children}: DialogProviderProps) => {
const [dialogs, setDialogs] = React.useState<Record<number, React.ReactNode>>({});
const dialogsRef: React.MutableRefObject<Record<number, React.ReactNode>> =
React.useRef(dialogs);

if (dialogsRef.current !== dialogs) {
dialogsRef.current = dialogs;
}

const contextValue = React.useMemo(
() => ({
openDialog: <ResultType extends unknown>(
renderDialog: ({
onSuccess,
asyncOnSuccess,
onCancel,
}: DialogRendererProps<ResultType>) => React.ReactNode,
) =>
new Promise<{success: boolean; value?: ResultType}>((resolve) => {
const key = getUniqId();

const handleClose = (result: DialogResult<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,
});

requestAnimationFrame(() => {
setDialogs({
...dialogsRef.current,
[key]: dialog,
});
});
}),
}),
[],
);

return (
<DialogContext.Provider value={contextValue}>
{children}
{Object.entries(dialogs).map(([key, dialog]) => (
<React.Fragment key={key}>{dialog}</React.Fragment>
))}
</DialogContext.Provider>
);
};
109 changes: 109 additions & 0 deletions src/hooks/useDialog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<!--GITHUB_BLOCK-->

# useDialog

<!--/GITHUB_BLOCK-->

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

### Provider

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

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

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

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

## Examples

#### Base

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

const {openDialog} = useDialog();

const handleOpenNoteEditor = useCallback(async () => {
const result = await openDialog<string>(({onSuccess, onCancel}) => (
<Dialog open onClose={onCancel} size="s">
<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 {useDialog} from '@gravity-ui/components';

const {openDialog} = useDialog();

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}) => <Dialog open onClose={onCancel} size="s">
<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 43666f4

Please sign in to comment.