Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(usePromiseDialog): add usePromiseDialog hook #1331

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 '../../../styles/mixins.scss';
@use '../../components/variables';

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

#{$block} {
&__message {
margin: 0;
@include mixins.text-body-2;
}
}
57 changes: 57 additions & 0 deletions src/components/Confirm/Confirm.tsx
Original file line number Diff line number Diff line change
@@ -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<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/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 (
<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 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<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 @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './useTimeout';
export * from './useViewportSize';
export * from './useVirtualElementRef';
export * from './useUniqId';
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>
);
};
110 changes: 110 additions & 0 deletions src/hooks/usePromiseDialog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<!--GITHUB_BLOCK-->

# usePromiseDialog

<!--/GITHUB_BLOCK-->

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

### Provider

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

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

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

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

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
Loading