feat: Modal hooks confirm function support await (#43470)

* docs: move demo to useModal

* docs: reorder

* chore: update ts def

* test: test case

* chore: fix compile
This commit is contained in:
二货爱吃白萝卜 2023-07-11 09:58:25 +08:00 committed by GitHub
parent 46db582197
commit b072d3a02c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 147 additions and 20 deletions

View File

@ -14,6 +14,11 @@ export interface ActionButtonProps {
emitEvent?: boolean; emitEvent?: boolean;
quitOnNullishReturnValue?: boolean; quitOnNullishReturnValue?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
/**
* Do not throw if is await mode
*/
isSilent?: () => boolean;
} }
function isThenable<T extends any>(thing?: PromiseLike<T>): boolean { function isThenable<T extends any>(thing?: PromiseLike<T>): boolean {
@ -29,6 +34,7 @@ const ActionButton: React.FC<ActionButtonProps> = (props) => {
close, close,
autoFocus, autoFocus,
emitEvent, emitEvent,
isSilent,
quitOnNullishReturnValue, quitOnNullishReturnValue,
actionFn, actionFn,
} = props; } = props;
@ -70,6 +76,12 @@ const ActionButton: React.FC<ActionButtonProps> = (props) => {
// See: https://github.com/ant-design/ant-design/issues/6183 // See: https://github.com/ant-design/ant-design/issues/6183
setLoading(false, true); setLoading(false, true);
clickedRef.current = false; clickedRef.current = false;
// Do not throw if is `await` mode
if (isSilent?.()) {
return;
}
return Promise.reject(e); return Promise.reject(e);
}, },
); );

View File

@ -16,6 +16,12 @@ import type { ModalFuncProps, ModalLocale } from './interface';
interface ConfirmDialogProps extends ModalFuncProps { interface ConfirmDialogProps extends ModalFuncProps {
afterClose?: () => void; afterClose?: () => void;
close?: (...args: any[]) => void; close?: (...args: any[]) => void;
/**
* `close` prop support `...args` that pass to the developer
* that we can not break this.
* Provider `onClose` for internal usage
*/
onConfirm?: (confirmed: boolean) => void;
autoFocusButton?: null | 'ok' | 'cancel'; autoFocusButton?: null | 'ok' | 'cancel';
rootPrefixCls: string; rootPrefixCls: string;
iconPrefixCls?: string; iconPrefixCls?: string;
@ -23,6 +29,11 @@ interface ConfirmDialogProps extends ModalFuncProps {
/** @private Internal Usage. Do not override this */ /** @private Internal Usage. Do not override this */
locale?: ModalLocale; locale?: ModalLocale;
/**
* Do not throw if is await mode
*/
isSilent?: () => boolean;
} }
export function ConfirmContent( export function ConfirmContent(
@ -35,6 +46,8 @@ export function ConfirmContent(
onCancel, onCancel,
onOk, onOk,
close, close,
onConfirm,
isSilent,
okText, okText,
okButtonProps, okButtonProps,
cancelText, cancelText,
@ -89,8 +102,12 @@ export function ConfirmContent(
const cancelButton = mergedOkCancel && ( const cancelButton = mergedOkCancel && (
<ActionButton <ActionButton
isSilent={isSilent}
actionFn={onCancel} actionFn={onCancel}
close={close} close={(...args: any[]) => {
close?.(...args);
onConfirm?.(false);
}}
autoFocus={autoFocusButton === 'cancel'} autoFocus={autoFocusButton === 'cancel'}
buttonProps={cancelButtonProps} buttonProps={cancelButtonProps}
prefixCls={`${rootPrefixCls}-btn`} prefixCls={`${rootPrefixCls}-btn`}
@ -112,9 +129,13 @@ export function ConfirmContent(
<div className={`${confirmPrefixCls}-btns`}> <div className={`${confirmPrefixCls}-btns`}>
{cancelButton} {cancelButton}
<ActionButton <ActionButton
isSilent={isSilent}
type={okType} type={okType}
actionFn={onOk} actionFn={onOk}
close={close} close={(...args: any[]) => {
close?.(...args);
onConfirm?.(true);
}}
autoFocus={autoFocusButton === 'ok'} autoFocus={autoFocusButton === 'ok'}
buttonProps={okButtonProps} buttonProps={okButtonProps}
prefixCls={`${rootPrefixCls}-btn`} prefixCls={`${rootPrefixCls}-btn`}

View File

@ -367,4 +367,48 @@ describe('Modal.hook', () => {
expect(afterClose).toHaveBeenCalledTimes(1); expect(afterClose).toHaveBeenCalledTimes(1);
}); });
it('support await', async () => {
jest.useFakeTimers();
let notReady = true;
let lastResult: boolean | null = null;
const Demo = () => {
const [modal, contextHolder] = Modal.useModal();
React.useEffect(() => {
(async () => {
lastResult = await modal.confirm({
content: <Input />,
onOk: async () => {
if (notReady) {
notReady = false;
return Promise.reject();
}
},
});
})();
}, []);
return contextHolder;
};
render(<Demo />);
// Wait for modal show
await waitFakeTimer();
// First time click should not close
fireEvent.click(document.querySelector('.ant-btn-primary')!);
await waitFakeTimer();
expect(lastResult).toBeFalsy();
// Second time click to close
fireEvent.click(document.querySelector('.ant-btn-primary')!);
await waitFakeTimer();
expect(lastResult).toBeTruthy();
jest.useRealTimers();
});
}); });

View File

@ -1,7 +1,7 @@
## zh-CN ## zh-CN
通过 `Modal.useModal` 创建支持读取 context 的 `contextHolder` 通过 `Modal.useModal` 创建支持读取 context 的 `contextHolder`其中仅有 hooks 方法支持 Promise `await` 操作。
## en-US ## en-US
Use `Modal.useModal` to get `contextHolder` with context accessible issue. Use `Modal.useModal` to get `contextHolder` with context accessible issue. Only hooks method support Promise `await` operation.

View File

@ -22,8 +22,9 @@ const App: React.FC = () => {
<ReachableContext.Provider value="Light"> <ReachableContext.Provider value="Light">
<Space> <Space>
<Button <Button
onClick={() => { onClick={async () => {
modal.confirm(config); const confirmed = await modal.confirm(config);
console.log('Confirmed: ', confirmed);
}} }}
> >
Confirm Confirm
@ -36,14 +37,14 @@ const App: React.FC = () => {
Warning Warning
</Button> </Button>
<Button <Button
onClick={() => { onClick={async () => {
modal.info(config); modal.info(config);
}} }}
> >
Info Info
</Button> </Button>
<Button <Button
onClick={() => { onClick={async () => {
modal.error(config); modal.error(config);
}} }}
> >

View File

@ -22,16 +22,16 @@ Additionally, if you need show a simple confirmation dialog, you can use [`App.u
<code src="./demo/basic.tsx">Basic</code> <code src="./demo/basic.tsx">Basic</code>
<code src="./demo/async.tsx">Asynchronously close</code> <code src="./demo/async.tsx">Asynchronously close</code>
<code src="./demo/footer.tsx">Customized Footer</code> <code src="./demo/footer.tsx">Customized Footer</code>
<code src="./demo/confirm.tsx">Confirmation modal dialog</code> <code src="./demo/hooks.tsx">Use hooks to get context</code>
<code src="./demo/locale.tsx">Internationalization</code> <code src="./demo/locale.tsx">Internationalization</code>
<code src="./demo/manual.tsx">Manual to update destroy</code> <code src="./demo/manual.tsx">Manual to update destroy</code>
<code src="./demo/position.tsx">To customize the position of modal</code> <code src="./demo/position.tsx">To customize the position of modal</code>
<code src="./demo/dark.tsx" debug>Dark Bg</code> <code src="./demo/dark.tsx" debug>Dark Bg</code>
<code src="./demo/button-props.tsx">Customize footer buttons props</code> <code src="./demo/button-props.tsx">Customize footer buttons props</code>
<code src="./demo/hooks.tsx">Use hooks to get context</code>
<code src="./demo/modal-render.tsx">Custom modal content render</code> <code src="./demo/modal-render.tsx">Custom modal content render</code>
<code src="./demo/width.tsx">To customize the width of modal</code> <code src="./demo/width.tsx">To customize the width of modal</code>
<code src="./demo/static-info.tsx">Static Method</code> <code src="./demo/static-info.tsx">Static Method</code>
<code src="./demo/confirm.tsx">Static confirmation</code>
<code src="./demo/confirm-router.tsx">destroy confirmation modal dialog</code> <code src="./demo/confirm-router.tsx">destroy confirmation modal dialog</code>
<code src="./demo/render-panel.tsx" debug>\_InternalPanelDoNotUseOrYouWillBeFired</code> <code src="./demo/render-panel.tsx" debug>\_InternalPanelDoNotUseOrYouWillBeFired</code>
<code src="./demo/custom-mouse-position.tsx" debug>Control modal's animation origin position</code> <code src="./demo/custom-mouse-position.tsx" debug>Control modal's animation origin position</code>
@ -167,6 +167,17 @@ React.useEffect(() => {
return <div>{contextHolder}</div>; return <div>{contextHolder}</div>;
``` ```
`modal.confirm` return method:
- `destroy`: Destroy current modal
- `update`: Update current modal
- `then`: (Hooks only) Promise chain call, support `await` operation
```tsx
// Return `true` when click `onOk` and `false` when click `onCancel`
const confirmed = await modal.confirm({ ... });
```
## Design Token ## Design Token
<ComponentTokenTable component="Modal"></ComponentTokenTable> <ComponentTokenTable component="Modal"></ComponentTokenTable>

View File

@ -23,16 +23,16 @@ demo:
<code src="./demo/basic.tsx">基本</code> <code src="./demo/basic.tsx">基本</code>
<code src="./demo/async.tsx">异步关闭</code> <code src="./demo/async.tsx">异步关闭</code>
<code src="./demo/footer.tsx">自定义页脚</code> <code src="./demo/footer.tsx">自定义页脚</code>
<code src="./demo/confirm.tsx">确认对话框</code> <code src="./demo/hooks.tsx">使用 hooks 获得上下文</code>
<code src="./demo/locale.tsx">国际化</code> <code src="./demo/locale.tsx">国际化</code>
<code src="./demo/manual.tsx">手动更新和移除</code> <code src="./demo/manual.tsx">手动更新和移除</code>
<code src="./demo/position.tsx">自定义位置</code> <code src="./demo/position.tsx">自定义位置</code>
<code src="./demo/dark.tsx" debug>暗背景</code> <code src="./demo/dark.tsx" debug>暗背景</code>
<code src="./demo/button-props.tsx">自定义页脚按钮属性</code> <code src="./demo/button-props.tsx">自定义页脚按钮属性</code>
<code src="./demo/hooks.tsx">使用 hooks 获得上下文</code>
<code src="./demo/modal-render.tsx">自定义渲染对话框</code> <code src="./demo/modal-render.tsx">自定义渲染对话框</code>
<code src="./demo/width.tsx">自定义模态的宽度</code> <code src="./demo/width.tsx">自定义模态的宽度</code>
<code src="./demo/static-info.tsx">静态方法</code> <code src="./demo/static-info.tsx">静态方法</code>
<code src="./demo/confirm.tsx">静态确认对话框</code>
<code src="./demo/confirm-router.tsx">销毁确认对话框</code> <code src="./demo/confirm-router.tsx">销毁确认对话框</code>
<code src="./demo/render-panel.tsx" debug>\_InternalPanelDoNotUseOrYouWillBeFired</code> <code src="./demo/render-panel.tsx" debug>\_InternalPanelDoNotUseOrYouWillBeFired</code>
<code src="./demo/custom-mouse-position.tsx" debug>控制弹框动画原点</code> <code src="./demo/custom-mouse-position.tsx" debug>控制弹框动画原点</code>
@ -168,6 +168,17 @@ React.useEffect(() => {
return <div>{contextHolder}</div>; return <div>{contextHolder}</div>;
``` ```
`modal.confirm` 返回方法:
- `destroy`:销毁当前窗口
- `update`:更新当前窗口
- `then`Promise 链式调用,支持 `await` 操作。该方法为 Hooks 仅有
```tsx
//点击 `onOk` 时返回 `true`,点击 `onCancel` 时返回 `false`
const confirmed = await modal.confirm({ ... });
```
## Design Token ## Design Token
<ComponentTokenTable component="Modal"></ComponentTokenTable> <ComponentTokenTable component="Modal"></ComponentTokenTable>

View File

@ -8,6 +8,11 @@ import type { ModalFuncProps } from '../interface';
export interface HookModalProps { export interface HookModalProps {
afterClose: () => void; afterClose: () => void;
config: ModalFuncProps; config: ModalFuncProps;
onConfirm?: (confirmed: boolean) => void;
/**
* Do not throw if is await mode
*/
isSilent?: () => boolean;
} }
export interface HookModalRef { export interface HookModalRef {
@ -16,7 +21,7 @@ export interface HookModalRef {
} }
const HookModal: React.ForwardRefRenderFunction<HookModalRef, HookModalProps> = ( const HookModal: React.ForwardRefRenderFunction<HookModalRef, HookModalProps> = (
{ afterClose: hookAfterClose, config }, { afterClose: hookAfterClose, config, ...restProps },
ref, ref,
) => { ) => {
const [open, setOpen] = React.useState(true); const [open, setOpen] = React.useState(true);
@ -66,6 +71,7 @@ const HookModal: React.ForwardRefRenderFunction<HookModalRef, HookModalProps> =
} }
direction={innerConfig.direction || direction} direction={innerConfig.direction || direction}
cancelText={innerConfig.cancelText || contextLocale?.cancelText} cancelText={innerConfig.cancelText || contextLocale?.cancelText}
{...restProps}
/> />
); );
}; };

View File

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import usePatchElement from '../../_util/hooks/usePatchElement'; import usePatchElement from '../../_util/hooks/usePatchElement';
import type { ModalStaticFunctions } from '../confirm'; import type { ModalFunc, ModalStaticFunctions } from '../confirm';
import { withConfirm, withError, withInfo, withSuccess, withWarn } from '../confirm'; import { withConfirm, withError, withInfo, withSuccess, withWarn } from '../confirm';
import destroyFns from '../destroyFns'; import destroyFns from '../destroyFns';
import type { ModalFuncProps } from '../interface'; import type { ModalFuncProps } from '../interface';
@ -13,6 +13,13 @@ interface ElementsHolderRef {
patchElement: ReturnType<typeof usePatchElement>[1]; patchElement: ReturnType<typeof usePatchElement>[1];
} }
// Add `then` field for `ModalFunc` return instance.
export type ModalFuncWithPromise = (...args: Parameters<ModalFunc>) => ReturnType<ModalFunc> & {
then<T>(resolve: (confirmed: boolean) => T, reject: VoidFunction): Promise<T>;
};
export type HookAPI = Omit<Record<keyof ModalStaticFunctions, ModalFuncWithPromise>, 'warn'>;
const ElementsHolder = React.memo( const ElementsHolder = React.memo(
React.forwardRef<ElementsHolderRef>((_props, ref) => { React.forwardRef<ElementsHolderRef>((_props, ref) => {
const [elements, patchElement] = usePatchElement(); const [elements, patchElement] = usePatchElement();
@ -28,10 +35,7 @@ const ElementsHolder = React.memo(
}), }),
); );
function useModal(): readonly [ function useModal(): readonly [instance: HookAPI, contextHolder: React.ReactElement] {
instance: Omit<ModalStaticFunctions, 'warn'>,
contextHolder: React.ReactElement,
] {
const holderRef = React.useRef<ElementsHolderRef>(null); const holderRef = React.useRef<ElementsHolderRef>(null);
// ========================== Effect ========================== // ========================== Effect ==========================
@ -56,6 +60,13 @@ function useModal(): readonly [
const modalRef = React.createRef<HookModalRef>(); const modalRef = React.createRef<HookModalRef>();
// Proxy to promise with `onClose`
let resolvePromise: (confirmed: boolean) => void;
const promise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
let silent = false;
let closeFunc: Function | undefined; let closeFunc: Function | undefined;
const modal = ( const modal = (
<HookModal <HookModal
@ -65,6 +76,10 @@ function useModal(): readonly [
afterClose={() => { afterClose={() => {
closeFunc?.(); closeFunc?.();
}} }}
isSilent={() => silent}
onConfirm={(confirmed) => {
resolvePromise(confirmed);
}}
/> />
); );
@ -74,7 +89,7 @@ function useModal(): readonly [
destroyFns.push(closeFunc); destroyFns.push(closeFunc);
} }
return { const instance: ReturnType<ModalFuncWithPromise> = {
destroy: () => { destroy: () => {
function destroyAction() { function destroyAction() {
modalRef.current?.destroy(); modalRef.current?.destroy();
@ -97,12 +112,18 @@ function useModal(): readonly [
setActionQueue((prev) => [...prev, updateAction]); setActionQueue((prev) => [...prev, updateAction]);
} }
}, },
then: (resolve) => {
silent = true;
return promise.then(resolve);
},
}; };
return instance;
}, },
[], [],
); );
const fns = React.useMemo<Omit<ModalStaticFunctions, 'warn'>>( const fns = React.useMemo<HookAPI>(
() => ({ () => ({
info: getConfirmFunc(withInfo), info: getConfirmFunc(withInfo),
success: getConfirmFunc(withSuccess), success: getConfirmFunc(withSuccess),