ant-design/components/modal/__tests__/confirm.test.tsx
Peter Gassner 25ea3a7202
fix: Allow users to handle promise rejections in ActionButton's onOk callback (#40018)
* fix: Allow users to handle promise rejections in onOk callback

Components that rely on ActionButton swallow rejected promises. This makes it impossible for userland code to handle them.

* polish: Fix linting problems

* polish: Return rejected promise instead of throwing

* refact: Remove test for unhandled promise rejection

This test breaks when run in parallel by Jest. At the moment we have no way of changing the way Jest works and it prohibits us from testing for "unhandledRejection" events.

See: https://github.com/ant-design/ant-design/pull/40018#issuecomment-1373590259

* test: hack for rejection

Co-authored-by: 二货机器人 <smith3816@gmail.com>
2023-01-08 00:27:33 +08:00

777 lines
22 KiB
TypeScript

import { SmileOutlined } from '@ant-design/icons';
import CSSMotion from 'rc-motion';
import { genCSSMotion } from 'rc-motion/lib/CSSMotion';
import KeyCode from 'rc-util/lib/KeyCode';
import { resetWarned } from 'rc-util/lib/warning';
import * as React from 'react';
import TestUtils from 'react-dom/test-utils';
import type { ModalFuncProps } from '..';
import Modal from '..';
import { waitFakeTimer, act } from '../../../tests/utils';
import ConfigProvider from '../../config-provider';
import type { ModalFunc } from '../confirm';
import destroyFns from '../destroyFns';
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const { confirm } = Modal;
jest.mock('rc-motion');
(global as any).injectPromise = false;
(global as any).rejectPromise = null;
jest.mock('../../_util/ActionButton', () => {
const ActionButton = jest.requireActual('../../_util/ActionButton').default;
return (props: any) => {
const { actionFn } = props;
let mockActionFn: any = actionFn;
if (actionFn && (global as any).injectPromise) {
mockActionFn = (...args: any) => {
let ret = actionFn(...args);
if (ret.then) {
let resolveFn: any;
let rejectFn: any;
ret = ret.then(
(v: any) => {
resolveFn?.(v);
},
(e: any) => {
rejectFn?.(e)?.catch((err: Error) => {
(global as any).rejectPromise = err;
});
},
);
ret.then = (resolve: any, reject: any) => {
resolveFn = resolve;
rejectFn = reject;
};
}
return ret;
};
}
return <ActionButton {...props} actionFn={mockActionFn} />;
};
});
describe('Modal.confirm triggers callbacks correctly', () => {
// Inject CSSMotion to replace with No transition support
const MockCSSMotion = genCSSMotion(false);
Object.keys(MockCSSMotion).forEach((key) => {
(CSSMotion as any)[key] = (MockCSSMotion as any)[key];
});
// // Mock for rc-util raf
// window.requestAnimationFrame = callback => {
// const ret = window.setTimeout(callback, 16);
// return ret;
// };
// window.cancelAnimationFrame = id => {
// window.clearTimeout(id);
// };
// jest.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
// const id = window.setTimeout(callback);
// console.log('Mock Raf:', id);
// return id;
// });
// jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => window.clearTimeout(id));
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
/* eslint-disable no-console */
// Hack error to remove act warning
const originError = console.error;
console.error = (...args) => {
const errorStr = String(args[0]);
if (errorStr.includes('was not wrapped in act(...)')) {
return;
}
originError(...args);
};
/* eslint-enable */
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
(global as any).injectPromise = false;
(global as any).rejectPromise = null;
});
afterEach(async () => {
errorSpy.mockReset();
Modal.destroyAll();
await waitFakeTimer();
document.body.innerHTML = '';
jest.clearAllTimers();
});
afterAll(() => {
jest.useRealTimers();
errorSpy.mockRestore();
});
function $$(className: string) {
return document.body.querySelectorAll<HTMLElement>(className);
}
async function open(args?: ModalFuncProps) {
confirm({
title: 'Want to delete these items?',
content: 'some descriptions',
...args,
});
await waitFakeTimer();
}
it('should not render title when title not defined', async () => {
confirm({
content: 'some descriptions',
});
await waitFakeTimer();
expect(document.querySelector('.ant-modal-confirm-title')).toBe(null);
});
it('trigger onCancel once when click on cancel button', async () => {
const onCancel = jest.fn();
const onOk = jest.fn();
await open({
onCancel,
onOk,
});
$$('.ant-btn')[0].click();
expect(onCancel.mock.calls.length).toBe(1);
expect(onOk.mock.calls.length).toBe(0);
});
it('trigger onOk once when click on ok button', async () => {
const onCancel = jest.fn();
const onOk = jest.fn();
await open({
onCancel,
onOk,
});
$$('.ant-btn-primary')[0].click();
expect(onCancel.mock.calls.length).toBe(0);
expect(onOk.mock.calls.length).toBe(1);
});
it('should allow Modal.confirm without onCancel been set', async () => {
await open();
// Third Modal
$$('.ant-btn')[0].click();
expect(errorSpy).not.toHaveBeenCalled();
});
it('should allow Modal.confirm without onOk been set', async () => {
await open();
// Fourth Modal
$$('.ant-btn-primary')[0].click();
expect(errorSpy).not.toHaveBeenCalled();
});
it('should close confirm modal when press ESC', async () => {
const onCancel = jest.fn();
Modal.confirm({
title: 'title',
content: 'content',
onCancel,
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-confirm`)).toHaveLength(1);
TestUtils.Simulate.keyDown($$('.ant-modal')[0], {
keyCode: KeyCode.ESC,
});
await waitFakeTimer(0);
expect($$(`.ant-modal-confirm-confirm`)).toHaveLength(0);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('should not hide confirm when onOk return Promise.resolve', async () => {
await open({
onOk: () => Promise.resolve(''),
});
$$('.ant-btn-primary')[0].click();
expect($$('.ant-modal-confirm')).toHaveLength(1);
});
it('should emit error when onOk return Promise.reject', async () => {
(global as any).injectPromise = true;
const error = new Error('something wrong');
await open({
onOk: () => Promise.reject(error),
});
$$('.ant-btn-primary')[0].click();
// wait promise
await waitFakeTimer();
expect((global as any).rejectPromise instanceof Error).toBeTruthy();
});
it('shows animation when close', async () => {
await open();
expect($$('.ant-modal-confirm')).toHaveLength(1);
await waitFakeTimer();
$$('.ant-btn')[0].click();
await waitFakeTimer();
expect($$('.ant-modal-confirm')).toHaveLength(0);
});
it('ok only', async () => {
await open({ okCancel: false });
expect($$('.ant-btn')).toHaveLength(1);
expect($$('.ant-btn')[0].innerHTML).toContain('OK');
});
it('allows extra props on buttons', async () => {
await open({
okButtonProps: { disabled: true },
cancelButtonProps: { 'data-test': 'baz' } as ModalFuncProps['cancelButtonProps'],
});
expect($$('.ant-btn')).toHaveLength(2);
expect(($$('.ant-btn')[0].attributes as any)['data-test'].value).toBe('baz');
expect(($$('.ant-btn')[1] as HTMLButtonElement).disabled).toBe(true);
});
describe('should close modals when click confirm button', () => {
(['info', 'success', 'warning', 'error'] as const).forEach((type) => {
it(type, async () => {
Modal[type]?.({ title: 'title', content: 'content' });
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
$$('.ant-btn')[0].click();
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(0);
});
});
});
it('should close confirm modal when click cancel button', async () => {
const onCancel = jest.fn();
Modal.confirm({
// test legacy visible
visible: true,
title: 'title',
content: 'content',
onCancel,
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-confirm`)).toHaveLength(1);
$$('.ant-btn')[0].click();
await waitFakeTimer();
expect($$(`.ant-modal-confirm-confirm`)).toHaveLength(0);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('should close confirm modal when click close button', async () => {
const onCancel = jest.fn();
Modal.confirm({
title: 'title',
content: 'content',
closable: true,
closeIcon: 'X',
onCancel,
});
await waitFakeTimer();
expect($$(`.ant-modal-close`)).toHaveLength(1);
$$('.ant-btn')[0].click();
await waitFakeTimer();
expect($$(`.ant-modal-close`)).toHaveLength(0);
expect(onCancel).toHaveBeenCalledTimes(1);
});
describe('should not close modals when click confirm button when onOk has argument', () => {
(['confirm', 'info', 'success', 'warning', 'error'] as const).forEach((type) => {
it(type, async () => {
Modal[type]?.({
title: 'title',
content: 'content',
onOk: (_) => null, // eslint-disable-line no-unused-vars
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
$$('.ant-btn-primary')[0].click();
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
});
});
});
describe('could be update by new config', () => {
(['info', 'success', 'warning', 'error'] as const).forEach((type) => {
it(type, async () => {
const instance = Modal[type]?.({
title: 'title',
content: 'content',
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
expect($$('.ant-modal-confirm-title')[0].innerHTML).toBe('title');
expect($$('.ant-modal-confirm-content')[0].innerHTML).toBe('content');
instance.update({
title: 'new title',
content: 'new content',
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
expect($$('.ant-modal-confirm-title')[0].innerHTML).toBe('new title');
expect($$('.ant-modal-confirm-content')[0].innerHTML).toBe('new content');
instance.destroy();
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(0);
});
});
});
describe('could be update by call function', () => {
(['info', 'success', 'warning', 'error'] as const).forEach((type) => {
it(type, async () => {
const instance = Modal[type]?.({
title: 'title',
okButtonProps: { loading: true, style: { color: 'red' } },
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
expect($$('.ant-modal-confirm-title')[0].innerHTML).toBe('title');
expect($$('.ant-modal-confirm-btns .ant-btn-primary')[0].classList).toContain(
'ant-btn-loading',
);
expect($$('.ant-modal-confirm-btns .ant-btn-primary')[0].style.color).toBe('red');
instance.update((prevConfig) => ({
...prevConfig,
okButtonProps: {
...prevConfig.okButtonProps,
loading: false,
},
}));
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
expect($$('.ant-modal-confirm-title')[0].innerHTML).toBe('title');
expect($$('.ant-modal-confirm-btns .ant-btn-primary')[0].classList).not.toContain(
'ant-btn-loading',
);
expect($$('.ant-modal-confirm-btns .ant-btn-primary')[0].style.color).toBe('red');
instance.destroy();
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(0);
});
});
});
describe('could be destroy', () => {
(['info', 'success', 'warning', 'error'] as const).forEach((type) => {
it(type, async () => {
const instance = Modal[type]?.({
title: 'title',
content: 'content',
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
instance.destroy();
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(0);
});
});
});
it('could be Modal.destroyAll', async () => {
// Show
(['info', 'success', 'warning', 'error'] as const).forEach((type) => {
Modal[type]?.({
title: 'title',
content: 'content',
});
});
await waitFakeTimer();
['info', 'success', 'warning', 'error'].forEach((type) => {
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
});
// Destroy
Modal.destroyAll();
await waitFakeTimer();
['info', 'success', 'warning', 'error'].forEach((type) => {
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(0);
});
});
it('prefixCls', async () => {
await open({ prefixCls: 'custom-modal' });
expect($$('.custom-modal-mask')).toHaveLength(1);
expect($$('.custom-modal-wrap')).toHaveLength(1);
expect($$('.custom-modal-confirm')).toHaveLength(1);
expect($$('.custom-modal-confirm-body-wrapper')).toHaveLength(1);
});
it('should be Modal.confirm without mask', async () => {
await open({ mask: false });
expect($$('.ant-modal-mask')).toHaveLength(0);
});
it('destroyFns should reduce when instance.destroy', async () => {
Modal.destroyAll(); // clear destroyFns
await waitFakeTimer();
const instances: ReturnType<ModalFunc>[] = [];
(['info', 'success', 'warning', 'error'] as const).forEach((type) => {
const instance = Modal[type]?.({
title: 'title',
content: 'content',
});
// Render modal
act(() => {
jest.runAllTimers();
});
instances.push(instance);
});
const { length } = instances;
instances.forEach((instance, index) => {
expect(destroyFns.length).toBe(length - index);
act(() => {
instance.destroy();
jest.runAllTimers();
});
expect(destroyFns.length).toBe(length - index - 1);
});
});
it('should warning when pass a string as icon props', async () => {
const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
confirm({
content: 'some descriptions',
icon: 'ab',
});
await waitFakeTimer();
expect(warnSpy).not.toHaveBeenCalled();
confirm({
content: 'some descriptions',
icon: 'question',
});
await waitFakeTimer();
expect(warnSpy).toHaveBeenCalledWith(
`Warning: [antd: Modal] \`icon\` is using ReactNode instead of string naming in v4. Please check \`question\` at https://ant.design/components/icon`,
);
warnSpy.mockRestore();
});
it('icon can be null to hide icon', async () => {
jest.useFakeTimers();
confirm({
title: 'some title',
content: 'some descriptions',
icon: null,
});
await waitFakeTimer();
// We check icon is not exist in the body
expect(document.querySelector('.ant-modal-confirm-body')!.children).toHaveLength(2);
expect(
document.querySelector('.ant-modal-confirm-body')!.querySelector('.anticon'),
).toBeFalsy();
jest.useRealTimers();
});
it('ok button should trigger onOk once when click it many times quickly', async () => {
const onOk = jest.fn();
await open({ onOk });
$$('.ant-btn-primary')[0].click();
$$('.ant-btn-primary')[0].click();
expect(onOk).toHaveBeenCalledTimes(1);
});
// https://github.com/ant-design/ant-design/issues/23358
it('ok button should trigger onOk multiple times when onOk has close argument', async () => {
const onOk = jest.fn();
await open({
onOk(close?: any) {
onOk();
// @ts-ignore
(() => {})(close); // do nothing
},
});
$$('.ant-btn-primary')[0].click();
$$('.ant-btn-primary')[0].click();
$$('.ant-btn-primary')[0].click();
expect(onOk).toHaveBeenCalledTimes(3);
});
it('should be able to global config rootPrefixCls', async () => {
ConfigProvider.config({ prefixCls: 'my', iconPrefixCls: 'bamboo' });
confirm({ title: 'title', icon: <SmileOutlined /> });
await waitFakeTimer();
expect(document.querySelectorAll('.ant-btn').length).toBe(0);
expect(document.querySelectorAll('.my-btn').length).toBe(2);
expect(document.querySelectorAll('.bamboo-smile').length).toBe(1);
expect(document.querySelectorAll('.my-modal-confirm').length).toBe(1);
ConfigProvider.config({ prefixCls: 'ant', iconPrefixCls: undefined });
});
it('should be able to config rootPrefixCls', async () => {
resetWarned();
Modal.config({
rootPrefixCls: 'my',
});
expect(errorSpy).toHaveBeenCalledWith(
'Warning: [antd: Modal] Modal.config is deprecated. Please use ConfigProvider.config instead.',
);
confirm({
title: 'title',
});
await waitFakeTimer();
expect(document.querySelectorAll('.ant-btn').length).toBe(0);
expect(document.querySelectorAll('.my-btn').length).toBe(2);
expect(document.querySelectorAll('.my-modal-confirm').length).toBe(1);
Modal.config({
rootPrefixCls: 'your',
});
confirm({
title: 'title',
});
await waitFakeTimer();
expect(document.querySelectorAll('.ant-btn').length).toBe(0);
expect(document.querySelectorAll('.my-btn').length).toBe(2);
expect(document.querySelectorAll('.my-modal-confirm').length).toBe(1);
expect(document.querySelectorAll('.your-btn').length).toBe(2);
expect(document.querySelectorAll('.your-modal-confirm').length).toBe(1);
Modal.config({
rootPrefixCls: '',
});
});
it('trigger afterClose once when click on cancel button', async () => {
const afterClose = jest.fn();
await open({
afterClose,
});
// first Modal
$$('.ant-btn')[0].click();
expect(afterClose).not.toHaveBeenCalled();
await waitFakeTimer(500);
expect(afterClose).toHaveBeenCalled();
});
it('trigger afterClose once when click on ok button', async () => {
const afterClose = jest.fn();
await open({
afterClose,
});
// second Modal
$$('.ant-btn-primary')[0].click();
expect(afterClose).not.toHaveBeenCalled();
await waitFakeTimer(500);
expect(afterClose).toHaveBeenCalled();
});
it('bodyStyle', async () => {
await open({ bodyStyle: { width: 500 } });
const { width } = $$('.ant-modal-body')[0].style;
expect(width).toBe('500px');
});
describe('the callback close should be a method when onCancel has a close parameter', () => {
(['confirm', 'info', 'success', 'warning', 'error'] as const).forEach((type) => {
it(`click the close icon to trigger ${type} onCancel`, async () => {
const mock = jest.fn();
Modal[type]?.({
closable: true,
onCancel: (close) => mock(close),
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
$$('.ant-modal-close')[0].click();
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(0);
expect(mock).toHaveBeenCalledWith(expect.any(Function));
});
});
(['confirm', 'info', 'success', 'warning', 'error'] as const).forEach((type) => {
it(`press ESC to trigger ${type} onCancel`, async () => {
const mock = jest.fn();
Modal[type]?.({
keyboard: true,
onCancel: (close) => mock(close),
});
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
TestUtils.Simulate.keyDown($$('.ant-modal')[0], {
keyCode: KeyCode.ESC,
});
await waitFakeTimer(0);
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(0);
expect(mock).toHaveBeenCalledWith(expect.any(Function));
});
});
(['confirm', 'info', 'success', 'warning', 'error'] as const).forEach((type) => {
it(`click the mask to trigger ${type} onCancel`, async () => {
const mock = jest.fn();
Modal[type]?.({
maskClosable: true,
onCancel: (close) => mock(close),
});
await waitFakeTimer();
expect($$('.ant-modal-mask')).toHaveLength(1);
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
$$('.ant-modal-wrap')[0].click();
await waitFakeTimer();
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(0);
expect(mock).toHaveBeenCalledWith(expect.any(Function));
});
});
});
it('confirm modal click Cancel button close callback is a function', async () => {
const mock = jest.fn();
Modal.confirm({
onCancel: (close) => mock(close),
});
await waitFakeTimer();
$$('.ant-modal-confirm-btns > .ant-btn')[0].click();
await waitFakeTimer();
expect(mock).toHaveBeenCalledWith(expect.any(Function));
});
it('close can close modal when onCancel has a close parameter', async () => {
Modal.confirm({
onCancel: (close) => close(),
});
await waitFakeTimer();
expect($$('.ant-modal-confirm-confirm')).toHaveLength(1);
$$('.ant-modal-confirm-btns > .ant-btn')[0].click();
await waitFakeTimer();
expect($$('.ant-modal-confirm-confirm')).toHaveLength(0);
});
// https://github.com/ant-design/ant-design/issues/37461
it('Update should closable', async () => {
resetWarned();
jest.useFakeTimers();
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const modal = Modal.confirm({});
modal.update({
visible: true,
});
await waitFakeTimer();
expect($$('.ant-modal-confirm-confirm')).toHaveLength(1);
$$('.ant-modal-confirm-btns > .ant-btn')[0].click();
await waitFakeTimer();
expect($$('.ant-modal-confirm-confirm')).toHaveLength(0);
jest.useRealTimers();
errSpy.mockRestore();
});
it('null of Footer', async () => {
Modal.confirm({
footer: null,
});
await waitFakeTimer();
expect($$('.ant-modal-confirm-btns')).toHaveLength(0);
});
it('Update Footer', async () => {
Modal.confirm({
footer: (
<div>
<button className="custom-modal-footer" type="button">
Custom Modal Footer
</button>
</div>
),
});
await waitFakeTimer();
expect($$('.custom-modal-footer')).toHaveLength(1);
});
});