feat: refactor useNotification (#35423)

* more refactor

* chore: motion support

* chore: tmp test

* test: Hooks

* chore: static function

* tmp of it

* all of it

* mv prefix

* chore: clean up

* chore: clean up

* more test case

* test: all base test

* test: all test case

* init

* refactor: rm notification.open instance related code

* follow up

* refactor: singlton

* test: notification test case

* refactor to destroy

* refactor: message base

* test: part test case

* test: more

* test: more

* test: all test

* chore: clean up

* docs: reorder

* chore: fix lint

* test: fix test case

* chore: add act

* chore: back

* chore: fix style

* test: notification test

* test: more and more

* test: fix more test

* test: index

* test: more & more

* test: fix placement

* test: fix coverage

* chore: clean up

* chore: bundle size

* fix: 17

* chore: more

* test: message

* test: more test

* fix: lint

* test: rm class in static

* chore: clean up

* test: coverage

* chore: fix lint
This commit is contained in:
二货机器人 2022-05-11 14:26:18 +08:00 committed by GitHub
parent d326765a6b
commit 2341a25d91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 2526 additions and 1935 deletions

View File

@ -123,22 +123,12 @@ export const globalConfig = () => ({
return suffixCls ? `${getGlobalPrefixCls()}-${suffixCls}` : getGlobalPrefixCls(); return suffixCls ? `${getGlobalPrefixCls()}-${suffixCls}` : getGlobalPrefixCls();
}, },
getIconPrefixCls: getGlobalIconPrefixCls, getIconPrefixCls: getGlobalIconPrefixCls,
getRootPrefixCls: (rootPrefixCls?: string, customizePrefixCls?: string) => { getRootPrefixCls: () => {
// Customize rootPrefixCls is first priority
if (rootPrefixCls) {
return rootPrefixCls;
}
// If Global prefixCls provided, use this // If Global prefixCls provided, use this
if (globalPrefixCls) { if (globalPrefixCls) {
return globalPrefixCls; return globalPrefixCls;
} }
// [Legacy] If customize prefixCls provided, we cut it to get the prefixCls
if (customizePrefixCls && customizePrefixCls.includes('-')) {
return customizePrefixCls.replace(/^(.*)-[^-]*$/, '$1');
}
// Fallback to default prefixCls // Fallback to default prefixCls
return getGlobalPrefixCls(); return getGlobalPrefixCls();
}, },

View File

@ -1,207 +0,0 @@
import { act } from 'react-dom/test-utils';
import { sleep } from '../../../tests/utils';
import message, { getInstance } from '..';
import ConfigProvider from '../../config-provider';
describe('message.config', () => {
// Mock for rc-util raf
window.requestAnimationFrame = callback => window.setTimeout(callback, 16);
window.cancelAnimationFrame = id => {
window.clearTimeout(id);
};
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllTimers();
});
afterEach(() => {
jest.useRealTimers();
act(() => {
message.destroy();
});
});
it('should be able to config top', () => {
message.config({
top: 100,
});
act(() => {
message.info('whatever');
});
expect(document.querySelectorAll('.ant-message')[0].style.top).toBe('100px');
});
it('should be able to config rtl', () => {
message.config({
rtl: true,
});
act(() => {
message.info('whatever');
});
expect(document.querySelectorAll('.ant-message-rtl').length).toBe(1);
});
it('should be able to config getContainer', () => {
message.config({
getContainer: () => {
const div = document.createElement('div');
div.className = 'custom-container';
document.body.appendChild(div);
return div;
},
});
act(() => {
message.info('whatever');
});
expect(document.querySelectorAll('.custom-container').length).toBe(1);
});
it('should be able to config maxCount', () => {
message.config({
maxCount: 5,
});
for (let i = 0; i < 10; i += 1) {
act(() => {
message.info('test');
});
}
act(() => {
message.info('last');
});
expect(document.querySelectorAll('.ant-message-notice').length).toBe(5);
expect(document.querySelectorAll('.ant-message-notice')[4].textContent).toBe('last');
act(() => {
jest.runAllTimers();
});
expect(getInstance().component.state.notices).toHaveLength(0);
});
it('should be able to config duration', async () => {
jest.useRealTimers();
message.config({
duration: 0.5,
});
act(() => {
message.info('last');
});
expect(getInstance().component.state.notices).toHaveLength(1);
await sleep(1000);
expect(getInstance().component.state.notices).toHaveLength(0);
message.config({
duration: 3,
});
});
it('customize prefix should auto get transition prefixCls', () => {
message.config({
prefixCls: 'light-message',
});
act(() => {
message.info('bamboo');
});
expect(getInstance().config).toEqual(
expect.objectContaining({
transitionName: 'light-move-up',
}),
);
message.config({
prefixCls: '',
});
});
it('should be able to global config rootPrefixCls', () => {
ConfigProvider.config({ prefixCls: 'prefix-test', iconPrefixCls: 'bamboo' });
act(() => {
message.info('last');
});
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
expect(document.querySelectorAll('.prefix-test-message-notice')).toHaveLength(1);
expect(document.querySelectorAll('.bamboo-info-circle')).toHaveLength(1);
ConfigProvider.config({ prefixCls: 'ant', iconPrefixCls: null });
});
it('should be able to config prefixCls', () => {
message.config({
prefixCls: 'prefix-test',
});
act(() => {
message.info('last');
});
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
expect(document.querySelectorAll('.prefix-test-notice')).toHaveLength(1);
message.config({
prefixCls: '', // can be set to empty, ant default value is set in ConfigProvider
});
});
it('should be able to config transitionName', () => {
message.config({
transitionName: '',
});
act(() => {
message.info('last');
});
expect(document.querySelectorAll('.ant-move-up-enter')).toHaveLength(0);
message.config({
transitionName: 'ant-move-up',
});
});
it('should be able to config getContainer, although messageInstance already exists', () => {
function createContainer() {
const container = document.createElement('div');
document.body.appendChild(container);
return [
container,
() => {
document.body.removeChild(container);
},
];
}
const [container1, removeContainer1] = createContainer();
const [container2, removeContainer2] = createContainer();
expect(container1.querySelector('.ant-message-notice')).toBeFalsy();
expect(container2.querySelector('.ant-message-notice')).toBeFalsy();
message.config({
getContainer: () => container1,
});
const messageText1 = 'mounted in container1';
act(() => {
message.info(messageText1);
});
expect(container1.querySelector('.ant-message-notice').textContent).toEqual(messageText1);
message.config({
getContainer: () => container2,
});
const messageText2 = 'mounted in container2';
act(() => {
message.info(messageText2);
});
expect(container2.querySelector('.ant-message-notice').textContent).toEqual(messageText2);
removeContainer1();
removeContainer2();
});
});

View File

@ -0,0 +1,212 @@
import { act } from '../../../tests/utils';
import message, { actWrapper } from '..';
import ConfigProvider from '../../config-provider';
import { awaitPromise, triggerMotionEnd } from './util';
describe('message.config', () => {
beforeAll(() => {
actWrapper(act);
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(async () => {
// Clean up
message.destroy();
await triggerMotionEnd();
jest.useRealTimers();
await awaitPromise();
});
it('should be able to config top', async () => {
message.config({
top: 100,
});
message.info('whatever');
await awaitPromise();
expect(document.querySelector('.ant-message')).toHaveStyle({
top: '100px',
});
});
it('should be able to config rtl', async () => {
message.config({
rtl: true,
});
message.info('whatever');
await awaitPromise();
expect(document.querySelector('.ant-message-rtl')).toBeTruthy();
});
it('should be able to config getContainer', async () => {
const div = document.createElement('div');
div.className = 'custom-container';
document.body.appendChild(div);
message.config({
getContainer: () => div,
});
message.info('whatever');
await awaitPromise();
expect(div.querySelector('.ant-message')).toBeTruthy();
message.config({
getContainer: null,
});
document.body.removeChild(div);
});
it('should be able to config maxCount', async () => {
message.config({
maxCount: 5,
});
for (let i = 0; i < 10; i += 1) {
message.info('test');
}
message.info('last');
await awaitPromise();
const noticeWithoutLeaving = Array.from(
document.querySelectorAll('.ant-message-notice'),
).filter(ele => !ele.classList.contains('ant-message-move-up-leave'));
expect(noticeWithoutLeaving).toHaveLength(5);
expect(noticeWithoutLeaving[4].textContent).toEqual('last');
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
message.config({
maxCount: null,
});
});
it('should be able to config duration', async () => {
message.config({
duration: 0.5,
});
message.info('last');
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
act(() => {
jest.advanceTimersByTime(100);
});
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
message.config({
duration: undefined,
});
});
it('customize prefix should auto get transition prefixCls', async () => {
message.config({
prefixCls: 'light-message',
});
message.info('bamboo');
await awaitPromise();
expect(document.querySelector('.light-message-move-up')).toBeTruthy();
message.config({
prefixCls: null,
});
});
it('should be able to global config rootPrefixCls', async () => {
ConfigProvider.config({ prefixCls: 'prefix-test', iconPrefixCls: 'bamboo' });
message.info('last');
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
expect(document.querySelectorAll('.prefix-test-message-notice')).toHaveLength(1);
expect(document.querySelectorAll('.bamboo-info-circle')).toHaveLength(1);
ConfigProvider.config({ prefixCls: 'ant', iconPrefixCls: null! });
});
it('should be able to config prefixCls', async () => {
message.config({
prefixCls: 'prefix-test',
});
message.info('last');
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
expect(document.querySelectorAll('.prefix-test-notice')).toHaveLength(1);
message.config({
prefixCls: '', // can be set to empty, ant default value is set in ConfigProvider
});
});
it('should be able to config transitionName', async () => {
message.config({
transitionName: '',
});
message.info('last');
await awaitPromise();
expect(document.querySelector('.ant-message-notice')).toBeTruthy();
expect(document.querySelectorAll('.ant-move-up-enter')).toHaveLength(0);
message.config({
transitionName: undefined,
});
});
it('should be able to config getContainer, although messageInstance already exists', async () => {
function createContainer(): [HTMLElement, VoidFunction] {
const container = document.createElement('div');
document.body.appendChild(container);
return [
container,
() => {
document.body.removeChild(container);
},
];
}
const [container1, removeContainer1] = createContainer();
const [container2, removeContainer2] = createContainer();
expect(container1.querySelector('.ant-message-notice')).toBeFalsy();
expect(container2.querySelector('.ant-message-notice')).toBeFalsy();
message.config({
getContainer: () => container1,
});
const messageText1 = 'mounted in container1';
message.info(messageText1);
await awaitPromise();
expect(container1.querySelector('.ant-message-notice')!.textContent).toEqual(messageText1);
// Config will directly change container
message.config({
getContainer: () => container2,
});
const messageText2 = 'mounted in container2';
message.info(messageText2);
expect(container2.querySelectorAll('.ant-message-notice')[1]!.textContent).toEqual(
messageText2,
);
removeContainer1();
removeContainer2();
});
});

View File

@ -1,21 +1,18 @@
/* eslint-disable jsx-a11y/control-has-associated-label */ /* eslint-disable jsx-a11y/control-has-associated-label */
import React from 'react'; import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import message, { getInstance } from '..'; import message from '..';
import ConfigProvider from '../../config-provider'; import ConfigProvider from '../../config-provider';
import { render, fireEvent } from '../../../tests/utils';
import { triggerMotionEnd } from './util';
describe('message.hooks', () => { describe('message.hooks', () => {
beforeAll(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
}); });
afterAll(() => {
jest.useRealTimers();
});
afterEach(() => { afterEach(() => {
message.destroy(); jest.useRealTimers();
}); });
it('should work', () => { it('should work', () => {
@ -46,10 +43,11 @@ describe('message.hooks', () => {
); );
}; };
const wrapper = mount(<Demo />); const { container } = render(<Demo />);
wrapper.find('button').simulate('click'); fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(1);
expect(document.querySelector('.hook-test-result').innerHTML).toEqual('bamboo'); expect(document.querySelectorAll('.my-test-message-notice')).toHaveLength(1);
expect(document.querySelector('.hook-test-result')!.textContent).toEqual('bamboo');
}); });
it('should work with success', () => { it('should work with success', () => {
@ -80,16 +78,15 @@ describe('message.hooks', () => {
); );
}; };
const wrapper = mount(<Demo />); const { container } = render(<Demo />);
wrapper.find('button').simulate('click'); fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(1);
expect(document.querySelectorAll('.anticon-check-circle').length).toBe(1); expect(document.querySelectorAll('.my-test-message-notice')).toHaveLength(1);
expect(document.querySelector('.hook-test-result').innerHTML).toEqual('bamboo'); expect(document.querySelectorAll('.anticon-check-circle')).toHaveLength(1);
expect(document.querySelector('.hook-test-result')!.textContent).toEqual('bamboo');
}); });
it('should work with onClose', done => { it('should work with onClose', done => {
// if not use real timer, done won't be called
jest.useRealTimers();
const Demo = () => { const Demo = () => {
const [api, holder] = message.useMessage(); const [api, holder] = message.useMessage();
return ( return (
@ -111,14 +108,13 @@ describe('message.hooks', () => {
); );
}; };
const wrapper = mount(<Demo />); const { container } = render(<Demo />);
wrapper.find('button').simulate('click'); fireEvent.click(container.querySelector('button')!);
jest.useFakeTimers();
triggerMotionEnd();
}); });
it('should work with close promise', done => { it('should work with close promise', done => {
// if not use real timer, done won't be called
jest.useRealTimers();
const Demo = () => { const Demo = () => {
const [api, holder] = message.useMessage(); const [api, holder] = message.useMessage();
return ( return (
@ -141,13 +137,14 @@ describe('message.hooks', () => {
); );
}; };
const wrapper = mount(<Demo />); const { container } = render(<Demo />);
wrapper.find('button').simulate('click'); fireEvent.click(container.querySelector('button')!);
jest.useFakeTimers();
triggerMotionEnd();
}); });
it('should work with hide', () => { it('should work with hide', async () => {
let hide; let hide: VoidFunction;
const Demo = () => { const Demo = () => {
const [api, holder] = message.useMessage(); const [api, holder] = message.useMessage();
return ( return (
@ -166,48 +163,50 @@ describe('message.hooks', () => {
); );
}; };
const wrapper = mount(<Demo />); const { container } = render(<Demo />);
wrapper.find('button').simulate('click'); fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-message-notice')).toHaveLength(1);
act(() => { act(() => {
jest.runAllTimers(); hide!();
}); });
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(1); await triggerMotionEnd('.my-test-message-move-up-leave');
act(() => { expect(document.querySelectorAll('.my-test-message-notice')).toHaveLength(0);
hide();
jest.runAllTimers();
});
expect(getInstance().component.state.notices).toHaveLength(0);
}); });
it('should be same hook', () => { it('should be same hook', () => {
let count = 0; let cacheAPI: any;
const Demo = () => { const Demo = () => {
const [, forceUpdate] = React.useState({}); const [, forceUpdate] = React.useState({});
const [api] = message.useMessage(); const [api] = message.useMessage();
React.useEffect(() => { React.useEffect(() => {
count += 1; if (!cacheAPI) {
expect(count).toEqual(1); cacheAPI = api;
forceUpdate(); } else {
expect(cacheAPI).toBe(api);
}
forceUpdate({});
}, [api]); }, [api]);
return null; return null;
}; };
mount(<Demo />); render(<Demo />);
}); });
it("should use ConfigProvider's getPopupContainer as message container", () => { it("should use ConfigProvider's getPopupContainer as message container", () => {
const containerId = 'container'; const containerId = 'container';
const getPopupContainer = () => { const div = document.createElement('div');
const div = document.createElement('div'); div.id = containerId;
div.id = containerId; document.body.appendChild(div);
document.body.appendChild(div);
return div; const getPopupContainer = () => div;
};
const Demo = () => { const Demo = () => {
const [api, holder] = message.useMessage(); const [api, holder] = message.useMessage();
return ( return (
@ -226,13 +225,39 @@ describe('message.hooks', () => {
); );
}; };
const wrapper = mount(<Demo />); const { container } = render(<Demo />);
fireEvent.click(container.querySelector('button')!);
wrapper.find('button').simulate('click'); expect(div.querySelectorAll('.my-test-message-notice')).toHaveLength(1);
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(1); expect(div.querySelectorAll('.anticon-check-circle')).toHaveLength(1);
expect(document.querySelectorAll('.anticon-check-circle').length).toBe(1); expect(div.querySelector('.hook-content')!.textContent).toEqual('happy');
expect(document.querySelector('.hook-content').innerHTML).toEqual('happy'); expect(document.querySelectorAll(`#${containerId}`)).toHaveLength(1);
expect(document.querySelectorAll(`#${containerId}`).length).toBe(1); });
expect(wrapper.find(`#${containerId}`).children.length).toBe(1);
it('warning if user call update in render', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const Demo = () => {
const [api, holder] = message.useMessage();
const calledRef = React.useRef(false);
if (!calledRef.current) {
api.info({
content: <div className="bamboo" />,
});
calledRef.current = true;
}
return holder;
};
render(<Demo />);
expect(document.querySelector('.bamboo')).toBeFalsy();
expect(errorSpy).toHaveBeenCalledWith(
'Warning: [antd: Message] You are calling notice in render which will break in React 18 concurrent mode. Please trigger in effect instead.',
);
errorSpy.mockRestore();
}); });
}); });

View File

@ -0,0 +1,59 @@
import message, { actWrapper, actDestroy } from '..';
import { act } from '../../../tests/utils';
import { awaitPromise, triggerMotionEnd } from './util';
describe('call close immediately', () => {
beforeAll(() => {
actWrapper(act);
});
beforeEach(() => {
actDestroy();
jest.useFakeTimers();
});
afterEach(async () => {
// Clean up
message.destroy();
await triggerMotionEnd();
act(() => {
jest.runAllTimers();
});
jest.useRealTimers();
await awaitPromise();
});
it('open', async () => {
const closeFn = message.open({
content: '',
});
closeFn();
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
// Created close
const closeFn2 = message.open({
content: 'showed',
});
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
closeFn2();
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
});
it('info', async () => {
const closeFn = message.info('Message1', 0);
closeFn();
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
});
});

View File

@ -1,255 +0,0 @@
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { SmileOutlined } from '@ant-design/icons';
import message, { getInstance } from '..';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
describe('message', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
act(() => {
message.destroy();
});
});
it('should be able to hide manually', async () => {
let hide1;
let hide2;
act(() => {
hide1 = message.info('whatever', 0);
});
act(() => {
hide2 = message.info('whatever', 0);
});
expect(document.querySelectorAll('.ant-message-notice').length).toBe(2);
hide1();
act(() => {
jest.runAllTimers();
});
expect(getInstance().component.state.notices).toHaveLength(1);
hide2();
act(() => {
jest.runAllTimers();
});
expect(getInstance().component.state.notices).toHaveLength(0);
});
it('should be able to remove manually with a unique key', () => {
const key1 = 'key1';
const key2 = 'key2';
act(() => {
message.info({ content: 'Message1', key: 'key1', duration: 0 });
});
act(() => {
message.info({ content: 'Message2', key: 'key2', duration: 0 });
});
expect(document.querySelectorAll('.ant-message-notice').length).toBe(2);
act(() => {
message.destroy(key1);
jest.runAllTimers();
});
expect(getInstance().component.state.notices).toHaveLength(1);
act(() => {
message.destroy(key2);
jest.runAllTimers();
});
expect(getInstance().component.state.notices).toHaveLength(0);
});
it('should be able to destroy globally', () => {
act(() => {
message.info('whatever', 0);
});
act(() => {
message.info('whatever', 0);
});
expect(document.querySelectorAll('.ant-message').length).toBe(1);
expect(document.querySelectorAll('.ant-message-notice').length).toBe(2);
act(() => {
message.destroy();
jest.runAllTimers();
});
expect(document.querySelectorAll('.ant-message').length).toBe(0);
expect(document.querySelectorAll('.ant-message-notice').length).toBe(0);
});
it('should not need to use duration argument when using the onClose arguments', () => {
message.info('whatever', () => {});
});
it('should have the default duration when using the onClose arguments', done => {
jest.useRealTimers();
const defaultDuration = 3;
const now = Date.now();
message.info('whatever', () => {
// calculate the approximately duration value
const aboutDuration = parseInt((Date.now() - now) / 1000, 10);
expect(aboutDuration).toBe(defaultDuration);
done();
});
});
it('trigger onClick method', () => {
const onClick = jest.fn();
class Test extends React.Component {
componentDidMount() {
message.info({
onClick,
duration: 0,
content: 'message info',
});
}
render() {
return <div>test message onClick method</div>;
}
}
mount(<Test />);
expect(document.querySelectorAll('.ant-message-notice').length).toBe(1);
document.querySelectorAll('.ant-message-notice')[0].click();
expect(onClick).toHaveBeenCalled();
});
it('should be called like promise', done => {
jest.useRealTimers();
const defaultDuration = 3;
const now = Date.now();
message.info('whatever').then(() => {
// calculate the approximately duration value
const aboutDuration = parseInt((Date.now() - now) / 1000, 10);
expect(aboutDuration).toBe(defaultDuration);
done();
});
});
// https://github.com/ant-design/ant-design/issues/8201
it('should hide message correctly', () => {
let hide;
class Test extends React.Component {
componentDidMount() {
act(() => {
hide = message.loading('Action in progress..', 0);
});
}
render() {
return <div>test</div>;
}
}
mount(<Test />);
expect(document.querySelectorAll('.ant-message-notice').length).toBe(1);
act(() => {
hide();
jest.runAllTimers();
});
expect(getInstance().component.state.notices).toHaveLength(0);
});
it('should allow custom icon', () => {
act(() => {
message.open({ content: 'Message', icon: <SmileOutlined /> });
});
expect(document.querySelectorAll('.anticon-smile').length).toBe(1);
});
it('should have no icon', () => {
message.open({ content: 'Message', icon: <span /> });
expect(document.querySelectorAll('.ant-message-notice .anticon').length).toBe(0);
});
it('should have no icon when not pass icon props', () => {
message.open({ content: 'Message' });
expect(document.querySelectorAll('.ant-message-notice .anticon').length).toBe(0);
});
// https://github.com/ant-design/ant-design/issues/8201
it('should destroy messages correctly', () => {
class Test extends React.Component {
componentDidMount() {
message.loading('Action in progress1..', 0);
message.loading('Action in progress2..', 0);
setTimeout(() => message.destroy(), 1000);
}
render() {
return <div>test</div>;
}
}
mount(<Test />);
expect(document.querySelectorAll('.ant-message-notice').length).toBe(2);
jest.runAllTimers();
expect(document.querySelectorAll('.ant-message-notice').length).toBe(0);
});
it('should support update message content with a unique key', () => {
const key = 'updatable';
class Test extends React.Component {
componentDidMount() {
message.loading({ content: 'Loading...', key });
// Testing that content of the message should be updated.
setTimeout(() => message.success({ content: 'Loaded', key }), 1000);
setTimeout(() => message.destroy(), 3000);
}
render() {
return <div>test</div>;
}
}
mount(<Test />);
expect(document.querySelectorAll('.ant-message-notice').length).toBe(1);
jest.advanceTimersByTime(1500);
expect(document.querySelectorAll('.ant-message-notice').length).toBe(1);
jest.runAllTimers();
expect(document.querySelectorAll('.ant-message-notice').length).toBe(0);
});
it('update message content with a unique key and cancel manually', () => {
const key = 'updatable';
class Test extends React.Component {
componentDidMount() {
let hideLoading;
act(() => {
hideLoading = message.loading({ content: 'Loading...', key, duration: 0 });
});
// Testing that content of the message should be cancel manually.
setTimeout(() => {
act(() => {
hideLoading();
});
}, 1000);
}
render() {
return <div>test</div>;
}
}
mount(<Test />);
expect(document.querySelectorAll('.ant-message-notice').length).toBe(1);
jest.advanceTimersByTime(1500);
expect(getInstance().component.state.notices).toHaveLength(0);
});
it('should not throw error when pass null', () => {
message.error(null);
});
});

View File

@ -0,0 +1,237 @@
import React from 'react';
import { SmileOutlined } from '@ant-design/icons';
import message, { actWrapper } from '..';
import { act, fireEvent, sleep } from '../../../tests/utils';
import { awaitPromise, triggerMotionEnd } from './util';
describe('message', () => {
beforeAll(() => {
actWrapper(act);
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(async () => {
// Clean up
message.destroy();
await triggerMotionEnd();
act(() => {
jest.runAllTimers();
});
jest.useRealTimers();
await awaitPromise();
});
it('should be able to hide manually', async () => {
const hide1 = message.info('whatever', 0);
const hide2 = message.info('whatever', 0);
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(2);
hide1();
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
hide2();
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
});
it('should be able to remove manually with a unique key', async () => {
const key1 = 'key1';
const key2 = 'key2';
message.info({ content: 'Message1', key: 'key1', duration: 0 });
message.info({ content: 'Message2', key: 'key2', duration: 0 });
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(2);
message.destroy(key1);
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
message.destroy(key2);
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
});
it('should be able to destroy globally', async () => {
message.info('whatever', 0);
message.info('whatever', 0);
await awaitPromise();
expect(document.querySelectorAll('.ant-message')).toHaveLength(1);
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(2);
message.destroy();
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message')).toHaveLength(0);
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
});
it('should not need to use duration argument when using the onClose arguments', async () => {
const onClose = jest.fn();
const close = message.info('whatever', onClose);
await awaitPromise();
close();
await triggerMotionEnd();
expect(onClose).toHaveBeenCalled();
});
it('should have the default duration when using the onClose arguments', async () => {
const onClose = jest.fn();
message.info('whatever', onClose);
await awaitPromise();
act(() => {
jest.advanceTimersByTime(2500);
});
expect(document.querySelector('.ant-message-move-up-leave')).toBeFalsy();
act(() => {
jest.advanceTimersByTime(1000);
});
expect(document.querySelector('.ant-message-move-up-leave')).toBeTruthy();
});
it('trigger onClick method', async () => {
const onClick = jest.fn();
message.info({
onClick,
duration: 0,
content: 'message info',
});
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
fireEvent.click(document.querySelector('.ant-message-notice')!);
expect(onClick).toHaveBeenCalled();
});
it('should be called like promise', async () => {
const onClose = jest.fn();
message.info('whatever').then(onClose);
await awaitPromise();
act(() => {
jest.advanceTimersByTime(2500);
});
expect(onClose).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(1000);
});
await sleep(); // Wait to let event loop run
expect(onClose).toHaveBeenCalled();
});
// https://github.com/ant-design/ant-design/issues/8201
it('should hide message correctly', async () => {
const hide = message.loading('Action in progress..', 0);
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
hide!();
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
});
it('should allow custom icon', async () => {
message.open({ content: 'Message', icon: <SmileOutlined /> });
await awaitPromise();
expect(document.querySelector('.anticon-smile')).toBeTruthy();
});
it('should have no icon', async () => {
message.open({ content: 'Message', icon: <span /> });
await awaitPromise();
expect(document.querySelector('.ant-message-notice .anticon')).toBeFalsy();
});
it('should have no icon when not pass icon props', async () => {
message.open({ content: 'Message' });
await awaitPromise();
expect(document.querySelector('.ant-message-notice .anticon')).toBeFalsy();
});
// https://github.com/ant-design/ant-design/issues/8201
it('should destroy messages correctly', async () => {
message.loading('Action in progress1..', 0);
message.loading('Action in progress2..', 0);
setTimeout(() => message.destroy(), 1000);
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(2);
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
});
it('should support update message content with a unique key', async () => {
const key = 'updatable';
message.loading({ content: 'Loading...', key });
// Testing that content of the message should be updated.
setTimeout(() => message.success({ content: 'Loaded', key }), 1000);
setTimeout(() => message.destroy(), 3000);
await awaitPromise();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
act(() => {
jest.advanceTimersByTime(1500);
});
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
expect(document.querySelector('.ant-message-move-up-leave')).toBeFalsy();
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(0);
});
it('update message content with a unique key and cancel manually', async () => {
const key = 'updatable';
const hideLoading = message.loading({ content: 'Loading...', key, duration: 0 });
await awaitPromise();
setTimeout(() => {
act(() => {
hideLoading();
});
}, 1000);
expect(document.querySelectorAll('.ant-message-notice')).toHaveLength(1);
act(() => {
jest.advanceTimersByTime(1500);
});
expect(document.querySelectorAll('.ant-message-move-up-leave')).toHaveLength(1);
});
it('should not throw error when pass null', async () => {
message.error(null);
await awaitPromise();
});
});

View File

@ -1,31 +1,56 @@
import message from '..'; import message, { actWrapper } from '..';
import { act } from '../../../tests/utils';
import { awaitPromise, triggerMotionEnd } from './util';
describe('message.typescript', () => { describe('message.typescript', () => {
it('promise without auguments', () => { beforeAll(() => {
actWrapper(act);
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(async () => {
// Clean up
message.destroy();
await triggerMotionEnd();
jest.useRealTimers();
await awaitPromise();
});
it('promise without arguments', async () => {
message.success('yes!!!', 0); message.success('yes!!!', 0);
await Promise.resolve();
}); });
it('promise with one augument', done => { it('promise with one arguments', async () => {
message.success('yes!!!').then(filled => { const filled = jest.fn();
expect(filled).toBe(true);
done(); message.success('yes!!!').then(filled);
});
await triggerMotionEnd();
expect(filled).toHaveBeenCalledWith(true);
}); });
it('promise two auguments', done => { it('promise two arguments', async () => {
message.success('yes!!!').then( const filled = jest.fn();
filled => { const rejected = jest.fn();
expect(filled).toBe(true);
done(); message.success('yes!!!').then(filled, rejected);
},
rejected => { await triggerMotionEnd();
expect(rejected).toBe(false);
}, expect(filled).toHaveBeenCalledWith(true);
); expect(rejected).not.toHaveBeenCalled();
}); });
it('hide', () => { it('hide', async () => {
const hide = message.loading('doing...'); const hide = message.loading('doing...');
await Promise.resolve();
hide(); hide();
}); });
}); });

View File

@ -0,0 +1,25 @@
import { act, fireEvent } from '../../../tests/utils';
export async function awaitPromise() {
for (let i = 0; i < 10; i += 1) {
// eslint-disable-next-line no-await-in-loop
await Promise.resolve();
}
}
export async function triggerMotionEnd(selector: string = '.ant-message-move-up-leave') {
await awaitPromise();
// Flush css motion state update
for (let i = 0; i < 5; i += 1) {
act(() => {
jest.runAllTimers();
});
}
document.querySelectorAll(selector).forEach(ele => {
fireEvent.animationEnd(ele);
});
await awaitPromise();
}

View File

@ -1,17 +1,17 @@
--- ---
order: 10 order: -1
title: title:
zh-CN: 通过 Hooks 获取上下文4.5.0+ zh-CN: Hooks 调用(推荐
en-US: Get context with hooks (4.5.0+) en-US: Hooks usage (recommended)
--- ---
## zh-CN ## zh-CN
通过 `message.useMessage` 创建支持读取 context 的 `contextHolder` 通过 `message.useMessage` 创建支持读取 context 的 `contextHolder`请注意,我们推荐通过顶层注册的方式代替 `message` 静态方法,因为静态方法无法消费上下文,因而 ConfigProvider 的数据也不会生效。
## en-US ## en-US
Use `message.useMessage` to get `contextHolder` with context accessible issue. Use `message.useMessage` to get `contextHolder` with context accessible issue. Please note that, we recommend to use top level registration instead of `message` static method, because static method cannot consume context, and ConfigProvider data will not work.
```jsx ```jsx
import { message, Button } from 'antd'; import { message, Button } from 'antd';

View File

@ -1,90 +0,0 @@
import * as React from 'react';
import useRCNotification from 'rc-notification/lib/useNotification';
import type {
NotificationInstance as RCNotificationInstance,
NoticeContent as RCNoticeContent,
HolderReadyCallback as RCHolderReadyCallback,
} from 'rc-notification/lib/Notification';
import type { ConfigConsumerProps } from '../../config-provider';
import { ConfigConsumer } from '../../config-provider';
import type { MessageInstance, ArgsProps, ThenableArgument } from '..';
import { attachTypeApi, getKeyThenIncreaseKey, typeList } from '..';
export default function createUseMessage(
getRcNotificationInstance: (
args: ArgsProps,
callback: (info: { prefixCls: string; instance: RCNotificationInstance }) => void,
) => void,
getRCNoticeProps: (args: ArgsProps, prefixCls: string) => RCNoticeContent,
) {
const useMessage = (): [MessageInstance, React.ReactElement] => {
// We can only get content by render
let getPrefixCls: ConfigConsumerProps['getPrefixCls'];
let getPopupContainer: ConfigConsumerProps['getPopupContainer'];
// We create a proxy to handle delay created instance
let innerInstance: RCNotificationInstance | null = null;
const proxy = {
add: (noticeProps: RCNoticeContent, holderCallback?: RCHolderReadyCallback) => {
innerInstance?.component.add(noticeProps, holderCallback);
},
} as any;
const [hookNotify, holder] = useRCNotification(proxy);
function notify(args: ArgsProps) {
const { prefixCls: customizePrefixCls } = args;
const mergedPrefixCls = getPrefixCls('message', customizePrefixCls);
const rootPrefixCls = getPrefixCls();
const target = args.key || getKeyThenIncreaseKey();
const closePromise = new Promise(resolve => {
const callback = () => {
if (typeof args.onClose === 'function') {
args.onClose();
}
return resolve(true);
};
getRcNotificationInstance(
{
...args,
prefixCls: mergedPrefixCls,
rootPrefixCls,
getPopupContainer,
},
({ prefixCls, instance }) => {
innerInstance = instance;
hookNotify(getRCNoticeProps({ ...args, key: target, onClose: callback }, prefixCls));
},
);
});
const result: any = () => {
if (innerInstance) {
innerInstance.removeNotice(target);
}
};
result.then = (filled: ThenableArgument, rejected: ThenableArgument) =>
closePromise.then(filled, rejected);
result.promise = closePromise;
return result;
}
// Fill functions
const hookApiRef = React.useRef<any>({});
hookApiRef.current.open = notify;
typeList.forEach(type => attachTypeApi(hookApiRef.current, type));
return [
hookApiRef.current,
<ConfigConsumer key="holder">
{(context: ConfigConsumerProps) => {
({ getPrefixCls, getPopupContainer } = context);
return holder;
}}
</ConfigConsumer>,
];
};
return useMessage;
}

View File

@ -1,277 +1,356 @@
import * as React from 'react'; import * as React from 'react';
import classNames from 'classnames'; import { render } from 'rc-util/lib/React/render';
import RCNotification from 'rc-notification'; import useMessage, { useInternalMessage } from './useMessage';
import type { import type {
NotificationInstance as RCNotificationInstance, ArgsProps,
NoticeContent, MessageInstance,
} from 'rc-notification/lib/Notification'; ConfigOptions,
import LoadingOutlined from '@ant-design/icons/LoadingOutlined'; NoticeType,
import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled'; TypeOpen,
import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'; MessageType,
import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'; } from './interface';
import InfoCircleFilled from '@ant-design/icons/InfoCircleFilled';
import createUseMessage from './hooks/useMessage';
import ConfigProvider, { globalConfig } from '../config-provider'; import ConfigProvider, { globalConfig } from '../config-provider';
import { wrapPromiseFn } from './util';
let messageInstance: RCNotificationInstance | null; export { ArgsProps };
let defaultDuration = 3;
let defaultTop: number;
let key = 1;
let localPrefixCls = '';
let transitionName = 'move-up';
let hasTransitionName = false;
let getContainer: () => HTMLElement;
let maxCount: number;
let rtl = false;
export function getKeyThenIncreaseKey() { const methods: NoticeType[] = ['success', 'info', 'warning', 'error', 'loading'];
return key++;
let message: GlobalMessage | null = null;
let act: (callback: VoidFunction) => Promise<void> | void = (callback: VoidFunction) => callback();
interface GlobalMessage {
fragment: DocumentFragment;
instance?: MessageInstance | null;
sync?: VoidFunction;
} }
export interface ConfigOptions { interface OpenTask {
top?: number; type: 'open';
duration?: number; config: ArgsProps;
prefixCls?: string; resolve: VoidFunction;
getContainer?: () => HTMLElement; setCloseFn: (closeFn: VoidFunction) => void;
transitionName?: string; skipped?: boolean;
maxCount?: number;
rtl?: boolean;
} }
function setMessageConfig(options: ConfigOptions) { interface TypeTask {
if (options.top !== undefined) { type: NoticeType;
defaultTop = options.top; args: Parameters<TypeOpen>;
messageInstance = null; // delete messageInstance for new defaultTop resolve: VoidFunction;
} setCloseFn: (closeFn: VoidFunction) => void;
if (options.duration !== undefined) { skipped?: boolean;
defaultDuration = options.duration;
}
if (options.prefixCls !== undefined) {
localPrefixCls = options.prefixCls;
}
if (options.getContainer !== undefined) {
getContainer = options.getContainer;
messageInstance = null; // delete messageInstance for new getContainer
}
if (options.transitionName !== undefined) {
transitionName = options.transitionName;
messageInstance = null; // delete messageInstance for new transitionName
hasTransitionName = true;
}
if (options.maxCount !== undefined) {
maxCount = options.maxCount;
messageInstance = null;
}
if (options.rtl !== undefined) {
rtl = options.rtl;
}
} }
function getRCNotificationInstance( type Task =
args: ArgsProps, | OpenTask
callback: (info: { | TypeTask
prefixCls: string; | {
rootPrefixCls: string; type: 'destroy';
iconPrefixCls: string; key: React.Key;
instance: RCNotificationInstance; skipped?: boolean;
}) => void, };
) {
const { prefixCls: customizePrefixCls, getPopupContainer: getContextPopupContainer } = args; let taskQueue: Task[] = [];
const { getPrefixCls, getRootPrefixCls, getIconPrefixCls } = globalConfig();
const prefixCls = getPrefixCls('message', customizePrefixCls || localPrefixCls); let defaultGlobalConfig: ConfigOptions = {};
const rootPrefixCls = getRootPrefixCls(args.rootPrefixCls, prefixCls);
const iconPrefixCls = getIconPrefixCls(); function getGlobalContext() {
const {
prefixCls: globalPrefixCls,
getContainer: globalGetContainer,
rtl,
maxCount,
top,
} = defaultGlobalConfig;
const mergedPrefixCls = globalPrefixCls ?? globalConfig().getPrefixCls('message');
const mergedContainer = globalGetContainer?.() || document.body;
return {
prefixCls: mergedPrefixCls,
container: mergedContainer,
rtl,
maxCount,
top,
};
}
interface GlobalHolderRef {
instance: MessageInstance;
sync: () => void;
}
const GlobalHolder = React.forwardRef<GlobalHolderRef, {}>((_, ref) => {
const [prefixCls, setPrefixCls] = React.useState<string>();
const [container, setContainer] = React.useState<HTMLElement>();
const [maxCount, setMaxCount] = React.useState<number | undefined>();
const [rtl, setRTL] = React.useState<boolean | undefined>();
const [top, setTop] = React.useState<number | undefined>();
const [api, holder] = useInternalMessage({
prefixCls,
getContainer: () => container!,
maxCount,
rtl,
top,
});
const global = globalConfig();
const rootPrefixCls = global.getRootPrefixCls();
const rootIconPrefixCls = global.getIconPrefixCls();
const sync = () => {
const {
prefixCls: nextGlobalPrefixCls,
container: nextGlobalContainer,
maxCount: nextGlobalMaxCount,
rtl: nextGlobalRTL,
top: nextTop,
} = getGlobalContext();
setPrefixCls(nextGlobalPrefixCls);
setContainer(nextGlobalContainer);
setMaxCount(nextGlobalMaxCount);
setRTL(nextGlobalRTL);
setTop(nextTop);
};
React.useEffect(sync, []);
React.useImperativeHandle(ref, () => {
const instance: any = { ...api };
Object.keys(instance).forEach(method => {
instance[method] = (...args: any[]) => {
sync();
return (api as any)[method](...args);
};
});
return {
instance,
sync,
};
});
return (
<ConfigProvider prefixCls={rootPrefixCls} iconPrefixCls={rootIconPrefixCls}>
{holder}
</ConfigProvider>
);
});
function flushNotice() {
if (!message) {
const holderFragment = document.createDocumentFragment();
const newMessage: GlobalMessage = {
fragment: holderFragment,
};
message = newMessage;
// Delay render to avoid sync issue
act(() => {
render(
<GlobalHolder
ref={node => {
const { instance, sync } = node || {};
// React 18 test env will throw if call immediately in ref
Promise.resolve().then(() => {
if (!newMessage.instance && instance) {
newMessage.instance = instance;
newMessage.sync = sync;
flushNotice();
}
});
}}
/>,
holderFragment,
);
});
if (messageInstance) {
callback({ prefixCls, rootPrefixCls, iconPrefixCls, instance: messageInstance });
return; return;
} }
const instanceConfig = { // Notification not ready
prefixCls, if (message && !message.instance) {
transitionName: hasTransitionName ? transitionName : `${rootPrefixCls}-${transitionName}`, return;
style: { top: defaultTop }, // 覆盖原来的样式 }
getContainer: getContainer || getContextPopupContainer,
maxCount,
};
RCNotification.newInstance(instanceConfig, (instance: any) => { // >>> Execute task
if (messageInstance) { taskQueue.forEach(task => {
callback({ prefixCls, rootPrefixCls, iconPrefixCls, instance: messageInstance }); const { type, skipped } = task;
return;
}
messageInstance = instance;
if (process.env.NODE_ENV === 'test') { // Only `skipped` when user call notice but cancel it immediately
(messageInstance as any).config = instanceConfig; // and instance not ready
} if (!skipped) {
switch (type) {
case 'open': {
act(() => {
const closeFn = message!.instance!.open({
...defaultGlobalConfig,
...task.config,
});
callback({ prefixCls, rootPrefixCls, iconPrefixCls, instance }); closeFn?.then(task.resolve);
}); task.setCloseFn(closeFn);
} });
break;
}
export interface ThenableArgument { case 'destroy':
(val: any): void; act(() => {
} message?.instance!.destroy(task.key);
});
break;
export interface MessageType extends PromiseLike<any> { // Other type open
(): void; default: {
} act(() => {
const closeFn = message!.instance![type](...task.args);
const typeToIcon = { closeFn?.then(task.resolve);
info: InfoCircleFilled, task.setCloseFn(closeFn);
success: CheckCircleFilled, });
error: CloseCircleFilled, }
warning: ExclamationCircleFilled,
loading: LoadingOutlined,
};
export type NoticeType = keyof typeof typeToIcon;
export const typeList = Object.keys(typeToIcon) as NoticeType[];
export interface ArgsProps {
content: any;
duration?: number;
type?: NoticeType;
prefixCls?: string;
rootPrefixCls?: string;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
onClose?: () => void;
icon?: React.ReactNode;
key?: string | number;
style?: React.CSSProperties;
className?: string;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
}
function getRCNoticeProps(
args: ArgsProps,
prefixCls: string,
iconPrefixCls?: string,
): NoticeContent {
const duration = args.duration !== undefined ? args.duration : defaultDuration;
const IconComponent = typeToIcon[args.type!];
const messageClass = classNames(`${prefixCls}-custom-content`, {
[`${prefixCls}-${args.type}`]: args.type,
[`${prefixCls}-rtl`]: rtl === true,
});
return {
key: args.key,
duration,
style: args.style || {},
className: args.className,
content: (
<ConfigProvider iconPrefixCls={iconPrefixCls}>
<div className={messageClass}>
{args.icon || (IconComponent && <IconComponent />)}
<span>{args.content}</span>
</div>
</ConfigProvider>
),
onClose: args.onClose,
onClick: args.onClick,
};
}
function notice(args: ArgsProps): MessageType {
const target = args.key || getKeyThenIncreaseKey();
const closePromise = new Promise(resolve => {
const callback = () => {
if (typeof args.onClose === 'function') {
args.onClose();
} }
return resolve(true);
};
getRCNotificationInstance(args, ({ prefixCls, iconPrefixCls, instance }) => {
instance.notice(
getRCNoticeProps({ ...args, key: target, onClose: callback }, prefixCls, iconPrefixCls),
);
});
});
const result: any = () => {
if (messageInstance) {
messageInstance.removeNotice(target);
} }
});
// Clean up
taskQueue = [];
}
// ==============================================================================
// == Export ==
// ==============================================================================
type MethodType = typeof methods[number];
function setMessageGlobalConfig(config: ConfigOptions) {
defaultGlobalConfig = {
...defaultGlobalConfig,
...config,
}; };
result.then = (filled: ThenableArgument, rejected: ThenableArgument) =>
closePromise.then(filled, rejected); // Trigger sync for it
result.promise = closePromise; act(() => {
message?.sync?.();
});
}
function open(config: ArgsProps): MessageType {
const result = wrapPromiseFn(resolve => {
let closeFn: VoidFunction;
const task: OpenTask = {
type: 'open',
config,
resolve,
setCloseFn: fn => {
closeFn = fn;
},
};
taskQueue.push(task);
return () => {
if (closeFn) {
act(() => {
closeFn();
});
} else {
task.skipped = true;
}
};
});
flushNotice();
return result; return result;
} }
type ConfigContent = React.ReactNode; function typeOpen(type: NoticeType, args: Parameters<TypeOpen>): MessageType {
type ConfigDuration = number | (() => void); const result = wrapPromiseFn(resolve => {
type JointContent = ConfigContent | ArgsProps; let closeFn: VoidFunction;
export type ConfigOnClose = () => void;
function isArgsProps(content: JointContent): content is ArgsProps { const task: TypeTask = {
return ( type,
Object.prototype.toString.call(content) === '[object Object]' && args,
!!(content as ArgsProps).content resolve,
); setCloseFn: fn => {
closeFn = fn;
},
};
taskQueue.push(task);
return () => {
if (closeFn) {
act(() => {
closeFn();
});
} else {
task.skipped = true;
}
};
});
flushNotice();
return result;
} }
const api: any = { function destroy(key: React.Key) {
open: notice, taskQueue.push({
config: setMessageConfig, type: 'destroy',
destroy(messageKey?: React.Key) { key,
if (messageInstance) { });
if (messageKey) { flushNotice();
const { removeNotice } = messageInstance; }
removeNotice(messageKey);
} else { const baseStaticMethods: {
const { destroy } = messageInstance; open: (config: ArgsProps) => MessageType;
destroy(); destroy: (key?: React.Key) => void;
messageInstance = null; config: any;
} useMessage: typeof useMessage;
} } = {
}, open,
destroy,
config: setMessageGlobalConfig,
useMessage,
}; };
export function attachTypeApi(originalApi: MessageApi, type: NoticeType) { const staticMethods: typeof baseStaticMethods & Record<MethodType, TypeOpen> =
originalApi[type] = ( baseStaticMethods as any;
content: JointContent,
duration?: ConfigDuration,
onClose?: ConfigOnClose,
) => {
if (isArgsProps(content)) {
return originalApi.open({ ...content, type });
}
if (typeof duration === 'function') { methods.forEach(type => {
onClose = duration; staticMethods[type] = (...args: Parameters<TypeOpen>) => typeOpen(type, args);
duration = undefined; });
}
return originalApi.open({ content, duration, type, onClose }); // ==============================================================================
// == Test ==
// ==============================================================================
const noop = () => {};
/** @private Only Work in test env */
// eslint-disable-next-line import/no-mutable-exports
export let actWrapper: (wrapper: any) => void = noop;
if (process.env.NODE_ENV === 'test') {
actWrapper = wrapper => {
act = wrapper;
}; };
} }
typeList.forEach(type => attachTypeApi(api, type)); /** @private Only Work in test env */
// eslint-disable-next-line import/no-mutable-exports
export let actDestroy = noop;
api.warn = api.warning; if (process.env.NODE_ENV === 'test') {
api.useMessage = createUseMessage(getRCNotificationInstance, getRCNoticeProps); actDestroy = () => {
message = null;
export interface MessageInstance { };
info(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
success(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
error(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
warning(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
loading(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
open(args: ArgsProps): MessageType;
} }
export interface MessageApi extends MessageInstance { export default staticMethods;
warn(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
config(options: ConfigOptions): void;
destroy(messageKey?: React.Key): void;
useMessage(): [MessageInstance, React.ReactElement];
}
/** @private test Only function. Not work on production */
export const getInstance = () => (process.env.NODE_ENV === 'test' ? messageInstance : null);
export default api as MessageApi;

View File

@ -0,0 +1,47 @@
import type * as React from 'react';
export type NoticeType = 'info' | 'success' | 'error' | 'warning' | 'loading';
export interface ConfigOptions {
top?: number;
duration?: number;
prefixCls?: string;
getContainer?: () => HTMLElement;
transitionName?: string;
maxCount?: number;
rtl?: boolean;
}
export interface ArgsProps {
content: React.ReactNode;
duration?: number;
type?: NoticeType;
onClose?: () => void;
icon?: React.ReactNode;
key?: string | number;
style?: React.CSSProperties;
className?: string;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export type JointContent = React.ReactNode | ArgsProps;
export interface MessageType extends PromiseLike<boolean> {
(): void;
}
export type TypeOpen = (
content: JointContent,
duration?: number | VoidFunction, // Also can use onClose directly
onClose?: VoidFunction,
) => MessageType;
export interface MessageInstance {
info: TypeOpen;
success: TypeOpen;
error: TypeOpen;
warning: TypeOpen;
loading: TypeOpen;
open(args: ArgsProps): MessageType;
destroy(key?: React.Key): void;
}

View File

@ -51,9 +51,46 @@
font-size: @font-size-lg; font-size: @font-size-lg;
} }
&-notice.@{ant-prefix}-move-up-leave.@{ant-prefix}-move-up-leave-active { &-move-up {
animation-fill-mode: forwards;
}
&-move-up-appear,
&-move-up-enter {
animation-name: MessageMoveIn;
animation-duration: 0.3s;
animation-play-state: paused;
animation-timing-function: @ease-out-circ;
}
&-move-up-appear&-move-up-appear-active,
&-move-up-enter&-move-up-enter-active {
animation-play-state: running;
}
&-move-up-leave {
animation-name: MessageMoveOut; animation-name: MessageMoveOut;
animation-duration: 0.3s; animation-duration: 0.3s;
animation-play-state: paused;
animation-timing-function: @ease-out-circ;
}
&-move-up-leave&-move-up-leave-active {
animation-play-state: running;
}
}
@keyframes MessageMoveIn {
0% {
padding: 0;
transform: translateY(-100%);
opacity: 0;
}
100% {
padding: 8px;
transform: translateY(0);
opacity: 1;
} }
} }

View File

@ -0,0 +1,228 @@
import * as React from 'react';
import { useNotification as useRcNotification } from 'rc-notification/lib';
import type { NotificationAPI } from 'rc-notification/lib';
import classNames from 'classnames';
import LoadingOutlined from '@ant-design/icons/LoadingOutlined';
import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled';
import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled';
import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled';
import InfoCircleFilled from '@ant-design/icons/InfoCircleFilled';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import { ConfigContext } from '../config-provider';
import type {
MessageInstance,
ArgsProps,
MessageType,
ConfigOptions,
NoticeType,
TypeOpen,
} from './interface';
import { getMotion, wrapPromiseFn } from './util';
import devWarning from '../_util/devWarning';
const TypeIcon = {
info: <InfoCircleFilled />,
success: <CheckCircleFilled />,
error: <CloseCircleFilled />,
warning: <ExclamationCircleFilled />,
loading: <LoadingOutlined />,
};
const DEFAULT_OFFSET = 8;
const DEFAULT_DURATION = 3;
// ==============================================================================
// == Holder ==
// ==============================================================================
type HolderProps = ConfigOptions & {
onAllRemoved?: VoidFunction;
};
interface HolderRef extends NotificationAPI {
prefixCls: string;
}
const Holder = React.forwardRef<HolderRef, HolderProps>((props, ref) => {
const {
top,
prefixCls: staticPrefixCls,
getContainer: staticGetContainer,
maxCount,
rtl,
transitionName,
onAllRemoved,
} = props;
const { getPrefixCls, getPopupContainer } = React.useContext(ConfigContext);
const prefixCls = staticPrefixCls || getPrefixCls('message');
// =============================== Style ===============================
const getStyle = () => ({
left: '50%',
transform: 'translateX(-50%)',
top: top ?? DEFAULT_OFFSET,
});
const getClassName = () => (rtl ? `${prefixCls}-rtl` : '');
// ============================== Motion ===============================
const getNotificationMotion = () => getMotion(prefixCls, transitionName);
// ============================ Close Icon =============================
const mergedCloseIcon = (
<span className={`${prefixCls}-close-x`}>
<CloseOutlined className={`${prefixCls}-close-icon`} />
</span>
);
// ============================== Origin ===============================
const [api, holder] = useRcNotification({
prefixCls,
style: getStyle,
className: getClassName,
motion: getNotificationMotion,
closable: false,
closeIcon: mergedCloseIcon,
duration: DEFAULT_DURATION,
getContainer: () => staticGetContainer?.() || getPopupContainer?.() || document.body,
maxCount,
onAllRemoved,
});
// ================================ Ref ================================
React.useImperativeHandle(ref, () => ({
...api,
prefixCls,
}));
return holder;
});
// ==============================================================================
// == Hook ==
// ==============================================================================
let keyIndex = 0;
export function useInternalMessage(
notificationConfig?: HolderProps,
): [MessageInstance, React.ReactElement] {
const holderRef = React.useRef<HolderRef>(null);
// ================================ API ================================
const wrapAPI = React.useMemo<MessageInstance>(() => {
// Wrap with notification content
// >>> close
const close = (key: React.Key) => {
holderRef.current?.close(key);
};
// >>> Open
const open = (config: ArgsProps): MessageType => {
if (!holderRef.current) {
devWarning(
false,
'Message',
'You are calling notice in render which will break in React 18 concurrent mode. Please trigger in effect instead.',
);
const fakeResult: any = () => {};
fakeResult.then = () => {};
return fakeResult;
}
const { open: originOpen, prefixCls } = holderRef.current;
const noticePrefixCls = `${prefixCls}-notice`;
const { content, icon, type, key, className, onClose, ...restConfig } = config;
let mergedKey: React.Key = key!;
if (mergedKey === undefined || mergedKey === null) {
keyIndex += 1;
mergedKey = `antd-message-${keyIndex}`;
}
return wrapPromiseFn(resolve => {
originOpen({
...restConfig,
key: mergedKey,
content: (
<div className={classNames(`${prefixCls}-custom-content`, `${prefixCls}-${type}`)}>
{icon || TypeIcon[type!]}
<span>{content}</span>
</div>
),
placement: 'top',
className: classNames(type && `${noticePrefixCls}-${type}`, className),
onClose: () => {
onClose?.();
resolve();
},
});
// Return close function
return () => {
close(mergedKey);
};
});
};
// >>> destroy
const destroy = (key?: React.Key) => {
if (key !== undefined) {
close(key);
} else {
holderRef.current?.destroy();
}
};
const clone = {
open,
destroy,
} as MessageInstance;
const keys: NoticeType[] = ['info', 'success', 'warning', 'error', 'loading'];
keys.forEach(type => {
const typeOpen: TypeOpen = (jointContent, duration, onClose) => {
let config: ArgsProps;
if (jointContent && typeof jointContent === 'object' && 'content' in jointContent) {
config = jointContent;
} else {
config = {
content: jointContent,
};
}
// Params
let mergedDuration: number | undefined;
let mergedOnClose: VoidFunction | undefined;
if (typeof duration === 'function') {
mergedOnClose = duration;
} else {
mergedDuration = duration;
mergedOnClose = onClose;
}
const mergedConfig = {
onClose: mergedOnClose,
duration: mergedDuration,
...config,
type,
};
return open(mergedConfig);
};
clone[type] = typeOpen;
});
return clone;
}, []);
// ============================== Return ===============================
return [wrapAPI, <Holder key="holder" {...notificationConfig} ref={holderRef} />];
}
export default function useMessage(notificationConfig?: ConfigOptions) {
return useInternalMessage(notificationConfig);
}

View File

@ -0,0 +1,28 @@
import type { CSSMotionProps } from 'rc-motion';
export function getMotion(prefixCls: string, transitionName?: string): CSSMotionProps {
return {
motionName: transitionName ?? `${prefixCls}-move-up`,
};
}
/** Wrap message open with promise like function */
export function wrapPromiseFn(openFn: (resolve: VoidFunction) => VoidFunction) {
let closeFn: VoidFunction;
const closePromise = new Promise<boolean>(resolve => {
closeFn = openFn(() => {
resolve(true);
});
});
const result: any = () => {
closeFn?.();
};
result.then = (filled: VoidFunction, rejected: VoidFunction) =>
closePromise.then(filled, rejected);
result.promise = closePromise;
return result;
}

View File

@ -1,56 +0,0 @@
import { act } from 'react-dom/test-utils';
import notification, { getInstance } from '..';
import { sleep } from '../../../tests/utils';
describe('notification.config', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterAll(() => {
notification.destroy();
});
it('should be able to config maxCount', async () => {
notification.config({
maxCount: 5,
duration: 0.5,
});
for (let i = 0; i < 10; i += 1) {
act(() => {
notification.open({
message: 'Notification message',
key: i,
});
});
}
act(() => {
notification.open({
message: 'Notification last',
key: '11',
});
});
await act(async () => {
await Promise.resolve();
});
expect(document.querySelectorAll('.ant-notification-notice').length).toBe(5);
expect(document.querySelectorAll('.ant-notification-notice')[4].textContent).toBe(
'Notification last',
);
act(() => {
jest.runAllTimers();
});
await act(async () => {
await sleep(500);
});
expect((await getInstance('ant-notification-topRight')).component.state.notices).toHaveLength(
0,
);
});
});

View File

@ -0,0 +1,87 @@
import notification, { actWrapper } from '..';
import { act } from '../../../tests/utils';
import { awaitPromise, triggerMotionEnd } from './util';
describe('notification.config', () => {
beforeAll(() => {
actWrapper(act);
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(async () => {
// Clean up
notification.destroy();
await triggerMotionEnd();
notification.config({
prefixCls: null,
getContainer: null,
});
jest.useRealTimers();
await awaitPromise();
});
it('should be able to config maxCount', async () => {
notification.config({
maxCount: 5,
duration: 0.5,
});
for (let i = 0; i < 10; i += 1) {
notification.open({
message: 'Notification message',
key: i,
duration: 999,
});
// eslint-disable-next-line no-await-in-loop
await awaitPromise();
act(() => {
// One frame is 16ms
jest.advanceTimersByTime(100);
});
// eslint-disable-next-line no-await-in-loop
await triggerMotionEnd(false);
const count = document.querySelectorAll('.ant-notification-notice').length;
expect(count).toBeLessThanOrEqual(5);
}
act(() => {
notification.open({
message: 'Notification last',
key: '11',
duration: 999,
});
});
act(() => {
// One frame is 16ms
jest.advanceTimersByTime(100);
});
await triggerMotionEnd(false);
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(5);
expect(document.querySelectorAll('.ant-notification-notice')[4].textContent).toBe(
'Notification last',
);
act(() => {
jest.runAllTimers();
});
act(() => {
jest.runAllTimers();
});
await triggerMotionEnd(false);
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(0);
});
});

View File

@ -3,18 +3,15 @@ import React from 'react';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import notification from '..'; import notification from '..';
import ConfigProvider from '../../config-provider'; import ConfigProvider from '../../config-provider';
import { render, fireEvent } from '../../../tests/utils';
describe('notification.hooks', () => { describe('notification.hooks', () => {
beforeAll(() => { beforeEach(() => {
jest.useFakeTimers(); jest.useFakeTimers();
}); });
afterAll(() => {
jest.useRealTimers();
});
afterEach(() => { afterEach(() => {
notification.destroy(); jest.useRealTimers();
}); });
it('should work', () => { it('should work', () => {
@ -30,6 +27,7 @@ describe('notification.hooks', () => {
type="button" type="button"
onClick={() => { onClick={() => {
api.open({ api.open({
message: null,
description: ( description: (
<Context.Consumer> <Context.Consumer>
{name => <span className="hook-test-result">{name}</span>} {name => <span className="hook-test-result">{name}</span>}
@ -45,10 +43,12 @@ describe('notification.hooks', () => {
); );
}; };
const wrapper = mount(<Demo />); const { container } = render(<Demo />);
wrapper.find('button').simulate('click');
expect(document.querySelectorAll('.my-test-notification-notice').length).toBe(1); fireEvent.click(container.querySelector('button')!);
expect(document.querySelector('.hook-test-result').innerHTML).toEqual('bamboo');
expect(document.querySelectorAll('.my-test-notification-notice')).toHaveLength(1);
expect(document.querySelector('.hook-test-result')!.textContent).toEqual('bamboo');
}); });
it('should work with success', () => { it('should work with success', () => {
@ -64,6 +64,7 @@ describe('notification.hooks', () => {
type="button" type="button"
onClick={() => { onClick={() => {
api.success({ api.success({
message: null,
description: ( description: (
<Context.Consumer> <Context.Consumer>
{name => <span className="hook-test-result">{name}</span>} {name => <span className="hook-test-result">{name}</span>}
@ -79,11 +80,12 @@ describe('notification.hooks', () => {
); );
}; };
const wrapper = mount(<Demo />); const { container } = render(<Demo />);
wrapper.find('button').simulate('click'); fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-notification-notice').length).toBe(1);
expect(document.querySelectorAll('.anticon-check-circle').length).toBe(1); expect(document.querySelectorAll('.my-test-notification-notice')).toHaveLength(1);
expect(document.querySelector('.hook-test-result').innerHTML).toEqual('bamboo'); expect(document.querySelectorAll('.anticon-check-circle')).toHaveLength(1);
expect(document.querySelector('.hook-test-result')!.textContent).toEqual('bamboo');
}); });
it('should be same hook', () => { it('should be same hook', () => {
@ -96,7 +98,7 @@ describe('notification.hooks', () => {
React.useEffect(() => { React.useEffect(() => {
count += 1; count += 1;
expect(count).toEqual(1); expect(count).toEqual(1);
forceUpdate(); forceUpdate({});
}, [api]); }, [api]);
return null; return null;
@ -104,4 +106,53 @@ describe('notification.hooks', () => {
mount(<Demo />); mount(<Demo />);
}); });
describe('not break in effect', () => {
it('basic', () => {
const Demo = () => {
const [api, holder] = notification.useNotification();
React.useEffect(() => {
api.info({
message: null,
description: <div className="bamboo" />,
});
}, []);
return holder;
};
render(<Demo />);
expect(document.querySelector('.bamboo')).toBeTruthy();
});
it('warning if user call update in render', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const Demo = () => {
const [api, holder] = notification.useNotification();
const calledRef = React.useRef(false);
if (!calledRef.current) {
api.info({
message: null,
description: <div className="bamboo" />,
});
calledRef.current = true;
}
return holder;
};
render(<Demo />);
expect(document.querySelector('.bamboo')).toBeFalsy();
expect(errorSpy).toHaveBeenCalledWith(
'Warning: [antd: Notification] You are calling notice in render which will break in React 18 concurrent mode. Please trigger in effect instead.',
);
errorSpy.mockRestore();
});
});
}); });

View File

@ -1,333 +0,0 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { UserOutlined } from '@ant-design/icons';
import notification, { getInstance } from '..';
import ConfigProvider from '../../config-provider';
import { sleep } from '../../../tests/utils';
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
describe('notification', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllTimers();
});
afterEach(() => {
jest.runAllTimers();
jest.useRealTimers();
act(() => {
notification.destroy();
});
});
it('not duplicate create holder', async () => {
for (let i = 0; i < 5; i += 1) {
act(() => {
notification.open({
message: 'Notification Title',
duration: 0,
prefixCls: 'additional-holder',
});
});
}
await sleep();
const count = document.querySelectorAll('.additional-holder').length;
expect(count).toEqual(1);
});
it('should be able to hide manually', async () => {
act(() => {
notification.open({
message: 'Notification Title 1',
duration: 0,
key: '1',
});
jest.runAllTimers();
});
act(() => {
jest.runAllTimers();
});
act(() => {
notification.open({
message: 'Notification Title 2',
duration: 0,
key: '2',
});
jest.runAllTimers();
});
act(() => {
jest.runAllTimers();
});
await act(async () => {
await Promise.resolve();
});
expect(document.querySelectorAll('.ant-notification-notice').length).toBe(2);
act(() => {
notification.close('1');
jest.runAllTimers();
});
await act(async () => {
await Promise.resolve();
});
expect((await getInstance('ant-notification-topRight')).component.state.notices).toHaveLength(
1,
);
act(() => {
notification.close('2');
jest.runAllTimers();
});
await act(async () => {
await Promise.resolve();
});
expect((await getInstance('ant-notification-topRight')).component.state.notices).toHaveLength(
0,
);
});
it('should be able to destroy globally', async () => {
act(() => {
notification.open({
message: 'Notification Title',
duration: 0,
});
});
act(() => {
notification.open({
message: 'Notification Title',
duration: 0,
});
});
await act(async () => {
await Promise.resolve();
});
expect(document.querySelectorAll('.ant-notification').length).toBe(1);
expect(document.querySelectorAll('.ant-notification-notice').length).toBe(2);
act(() => {
notification.destroy();
});
await act(async () => {
await Promise.resolve();
});
expect(document.querySelectorAll('.ant-notification').length).toBe(0);
expect(document.querySelectorAll('.ant-notification-notice').length).toBe(0);
});
it('should be able to destroy after config', () => {
act(() => {
notification.config({
bottom: 100,
});
});
act(() => {
notification.destroy();
});
});
it('should be able to config rtl', () => {
act(() => {
notification.config({
rtl: true,
});
});
act(() => {
notification.open({
message: 'whatever',
});
});
expect(document.querySelectorAll('.ant-notification-rtl').length).toBe(1);
});
it('should be able to global config rootPrefixCls', () => {
ConfigProvider.config({ prefixCls: 'prefix-test', iconPrefixCls: 'bamboo' });
act(() => {
notification.success({ message: 'Notification Title', duration: 0 });
});
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(0);
expect(document.querySelectorAll('.prefix-test-notification-notice')).toHaveLength(1);
expect(document.querySelectorAll('.bamboo-check-circle')).toHaveLength(1);
ConfigProvider.config({ prefixCls: 'ant', iconPrefixCls: null });
});
it('should be able to config prefixCls', () => {
notification.config({
prefixCls: 'prefix-test',
});
act(() => {
notification.open({
message: 'Notification Title',
duration: 0,
});
});
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(0);
expect(document.querySelectorAll('.prefix-test-notice')).toHaveLength(1);
notification.config({
prefixCls: '',
});
});
it('should be able to open with icon', async () => {
const iconPrefix = '.ant-notification-notice-icon';
const openNotificationWithIcon = async type => {
act(() => {
notification[type]({
message: 'Notification Title',
duration: 0,
description: 'This is the content of the notification.',
});
jest.runAllTimers();
});
};
const list = ['success', 'info', 'warning', 'error'];
const promises = list.map(type => openNotificationWithIcon(type));
await act(async () => {
await Promise.all(promises);
});
list.forEach(type => {
expect(document.querySelectorAll(`${iconPrefix}-${type}`).length).toBe(1);
});
});
it('should be able to add parent class for different notification types', async () => {
const openNotificationWithIcon = async type => {
act(() => {
notification[type]({
message: 'Notification Title',
duration: 0,
description: 'This is the content of the notification.',
});
jest.runAllTimers();
});
};
const list = ['success', 'info', 'warning', 'error'];
const promises = list.map(type => openNotificationWithIcon(type));
await act(async () => {
await Promise.all(promises);
});
list.forEach(type => {
expect(document.querySelectorAll(`.ant-notification-notice-${type}`).length).toBe(1);
});
});
it('trigger onClick', () => {
act(() => {
notification.open({
message: 'Notification Title',
duration: 0,
});
});
expect(document.querySelectorAll('.ant-notification').length).toBe(1);
});
it('support closeIcon', () => {
act(() => {
notification.open({
message: 'Notification Title',
duration: 0,
closeIcon: <span className="test-customize-icon" />,
});
});
expect(document.querySelectorAll('.test-customize-icon').length).toBe(1);
});
it('support config closeIcon', () => {
notification.config({
closeIcon: <span className="test-customize-icon" />,
});
act(() => {
notification.open({
message: 'Notification Title',
duration: 0,
closeIcon: <span className="test-customize-icon" />,
});
});
expect(document.querySelectorAll('.test-customize-icon').length).toBe(1);
});
it('closeIcon should be update', async () => {
const openNotificationWithCloseIcon = async type => {
act(() => {
notification.open({
message: 'Notification Title',
closeIcon: <span className={`test-customize-icon-${type}`} />,
});
jest.runAllTimers();
});
};
const list = ['1', '2'];
const promises = list.map(type => openNotificationWithCloseIcon(type));
await act(async () => {
await Promise.all(promises);
});
list.forEach(type => {
expect(document.querySelectorAll(`.test-customize-icon-${type}`).length).toBe(1);
});
});
it('support config duration', () => {
notification.config({
duration: 0,
});
act(() => {
notification.open({
message: 'whatever',
});
});
expect(document.querySelectorAll('.ant-notification').length).toBe(1);
});
it('support icon', () => {
act(() => {
notification.open({
message: 'Notification Title',
duration: 0,
icon: <UserOutlined />,
});
});
expect(document.querySelectorAll('.anticon-user').length).toBe(1);
});
});

View File

@ -0,0 +1,292 @@
import React from 'react';
import { UserOutlined } from '@ant-design/icons';
import notification, { actWrapper } from '..';
import ConfigProvider from '../../config-provider';
import { act, fireEvent } from '../../../tests/utils';
import { awaitPromise, triggerMotionEnd } from './util';
describe('notification', () => {
beforeAll(() => {
actWrapper(act);
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(async () => {
// Clean up
notification.destroy();
await triggerMotionEnd();
notification.config({
prefixCls: null,
getContainer: null,
});
jest.useRealTimers();
await awaitPromise();
});
it('not duplicate create holder', async () => {
notification.config({
prefixCls: 'additional-holder',
});
for (let i = 0; i < 5; i += 1) {
notification.open({
message: 'Notification Title',
duration: 0,
});
}
await awaitPromise();
act(() => {
jest.runAllTimers();
});
expect(document.querySelectorAll('.additional-holder')).toHaveLength(1);
});
it('should be able to hide manually', async () => {
notification.open({
message: 'Notification Title 1',
duration: 0,
key: '1',
});
await awaitPromise();
notification.open({
message: 'Notification Title 2',
duration: 0,
key: '2',
});
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(2);
// Close 1
notification.destroy('1');
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(1);
// Close 2
notification.destroy('2');
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(0);
});
it('should be able to destroy globally', async () => {
notification.open({
message: 'Notification Title 1',
duration: 0,
});
await awaitPromise();
notification.open({
message: 'Notification Title 2',
duration: 0,
});
expect(document.querySelectorAll('.ant-notification')).toHaveLength(1);
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(2);
notification.destroy();
await triggerMotionEnd();
expect(document.querySelectorAll('.ant-notification')).toHaveLength(0);
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(0);
});
it('should be able to destroy after config', () => {
notification.config({
bottom: 100,
});
notification.destroy();
});
it('should be able to config rtl', async () => {
notification.config({
rtl: true,
});
notification.open({
message: 'whatever',
});
await awaitPromise();
expect(document.querySelectorAll('.ant-notification-rtl')).toHaveLength(1);
});
it('should be able to global config rootPrefixCls', async () => {
ConfigProvider.config({ prefixCls: 'prefix-test', iconPrefixCls: 'bamboo' });
notification.success({ message: 'Notification Title', duration: 0 });
await awaitPromise();
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(0);
expect(document.querySelectorAll('.prefix-test-notification-notice')).toHaveLength(1);
expect(document.querySelectorAll('.bamboo-check-circle')).toHaveLength(1);
ConfigProvider.config({ prefixCls: 'ant', iconPrefixCls: null! });
});
it('should be able to config prefixCls', async () => {
notification.config({
prefixCls: 'prefix-test',
});
notification.open({
message: 'Notification Title',
duration: 0,
});
await awaitPromise();
expect(document.querySelectorAll('.ant-notification-notice')).toHaveLength(0);
expect(document.querySelectorAll('.prefix-test-notice')).toHaveLength(1);
notification.config({
prefixCls: null,
});
});
it('should be able to open with icon', async () => {
const iconPrefix = '.ant-notification-notice-icon';
const list = ['success', 'info', 'warning', 'error'] as const;
list.forEach(type => {
notification[type]({
message: 'Notification Title',
duration: 0,
description: 'This is the content of the notification.',
});
});
await awaitPromise();
list.forEach(type => {
expect(document.querySelectorAll(`${iconPrefix}-${type}`)).toHaveLength(1);
});
});
it('should be able to add parent class for different notification types', async () => {
const list = ['success', 'info', 'warning', 'error'] as const;
list.forEach(type => {
notification[type]({
message: 'Notification Title',
duration: 0,
description: 'This is the content of the notification.',
});
});
await awaitPromise();
list.forEach(type => {
expect(document.querySelectorAll(`.ant-notification-notice-${type}`)).toHaveLength(1);
});
});
it('trigger onClick', async () => {
const onClick = jest.fn();
notification.open({
message: 'Notification Title',
duration: 0,
onClick,
});
await awaitPromise();
expect(document.querySelectorAll('.ant-notification')).toHaveLength(1);
fireEvent.click(document.querySelector('.ant-notification-notice')!);
expect(onClick).toHaveBeenCalled();
});
it('support closeIcon', async () => {
notification.open({
message: 'Notification Title',
duration: 0,
closeIcon: <span className="test-customize-icon" />,
});
await awaitPromise();
expect(document.querySelectorAll('.test-customize-icon')).toHaveLength(1);
});
it('support config closeIcon', async () => {
notification.config({
closeIcon: <span className="test-customize-icon" />,
});
// Global Icon
notification.open({
message: 'Notification Title',
duration: 0,
});
await awaitPromise();
expect(document.querySelector('.test-customize-icon')).toBeTruthy();
// Notice Icon
notification.open({
message: 'Notification Title',
duration: 0,
closeIcon: <span className="replace-icon" />,
});
expect(document.querySelector('.replace-icon')).toBeTruthy();
notification.config({
closeIcon: null,
});
});
it('closeIcon should be update', async () => {
const list = ['1', '2'];
list.forEach(type => {
notification.open({
message: 'Notification Title',
closeIcon: <span className={`test-customize-icon-${type}`} />,
duration: 0,
});
});
await awaitPromise();
list.forEach(type => {
expect(document.querySelector(`.test-customize-icon-${type}`)).toBeTruthy();
});
});
it('support config duration', async () => {
notification.config({
duration: 0,
});
notification.open({
message: 'whatever',
});
await awaitPromise();
expect(document.querySelector('.ant-notification')).toBeTruthy();
});
it('support icon', async () => {
notification.open({
message: 'Notification Title',
duration: 0,
icon: <UserOutlined />,
});
await awaitPromise();
expect(document.querySelector('.anticon-user')).toBeTruthy();
});
});

View File

@ -1,250 +0,0 @@
import { act } from 'react-dom/test-utils';
import notification from '..';
describe('Notification.placement', () => {
afterEach(() => notification.destroy());
function $$(className) {
return document.body.querySelectorAll(className);
}
function getStyle(el, prop) {
const style = window.getComputedStyle ? window.getComputedStyle(el) : el.currentStyle;
// If a css property's value is `auto`, it will return an empty string.
return prop ? style[prop] : style;
}
function open(args) {
notification.open({
message: 'Notification Title',
description: 'This is the content of the notification.',
...args,
});
}
function config(args) {
notification.config({
...args,
});
act(() => {
open();
});
}
describe('placement', () => {
it('can be configured per notification using the `open` method', () => {
const defaultTop = '24px';
const defaultBottom = '24px';
let style;
// top
act(() => {
open({
placement: 'top',
top: 50,
});
});
style = getStyle($$('.ant-notification-top')[0]);
expect(style.top).toBe('50px');
expect(style.left).toBe('50%');
expect(style.transform).toBe('translateX(-50%)');
expect(style.right).toBe('');
expect(style.bottom).toBe('');
act(() => {
open({
placement: 'top',
});
});
expect($$('.ant-notification-top').length).toBe(1);
// topLeft
act(() => {
open({
placement: 'topLeft',
top: 50,
});
});
style = getStyle($$('.ant-notification-topLeft')[0]);
expect(style.top).toBe('50px');
expect(style.left).toBe('0px');
expect(style.bottom).toBe('');
act(() => {
open({
placement: 'topLeft',
});
});
expect($$('.ant-notification-topLeft').length).toBe(1);
// topRight
act(() => {
open({
placement: 'topRight',
});
});
style = getStyle($$('.ant-notification-topRight')[0]);
expect(style.top).toBe(defaultTop);
expect(style.right).toBe('0px');
expect(style.bottom).toBe('');
act(() => {
open({
placement: 'topRight',
});
});
expect($$('.ant-notification-topRight').length).toBe(1);
// bottom
act(() => {
open({
placement: 'bottom',
bottom: 100,
});
});
style = getStyle($$('.ant-notification-bottom')[0]);
expect(style.top).toBe('');
expect(style.left).toBe('50%');
expect(style.transform).toBe('translateX(-50%)');
expect(style.right).toBe('');
expect(style.bottom).toBe('100px');
act(() => {
open({
placement: 'bottom',
});
});
expect($$('.ant-notification-bottom').length).toBe(1);
// bottomRight
act(() => {
open({
placement: 'bottomRight',
bottom: 100,
});
});
style = getStyle($$('.ant-notification-bottomRight')[0]);
expect(style.top).toBe('');
expect(style.right).toBe('0px');
expect(style.bottom).toBe('100px');
act(() => {
open({
placement: 'bottomRight',
});
});
expect($$('.ant-notification-bottomRight').length).toBe(1);
// bottomLeft
act(() => {
open({
placement: 'bottomLeft',
});
});
style = getStyle($$('.ant-notification-bottomLeft')[0]);
expect(style.top).toBe('');
expect(style.left).toBe('0px');
expect(style.bottom).toBe(defaultBottom);
act(() => {
open({
placement: 'bottomLeft',
});
});
expect($$('.ant-notification-bottomLeft').length).toBe(1);
});
it('can be configured globally using the `config` method', () => {
let style;
// topLeft
config({
placement: 'topLeft',
top: 50,
bottom: 50,
});
style = getStyle($$('.ant-notification-topLeft')[0]);
expect(style.top).toBe('50px');
expect(style.left).toBe('0px');
expect(style.bottom).toBe('');
// topRight
config({
placement: 'topRight',
top: 100,
bottom: 50,
});
style = getStyle($$('.ant-notification-topRight')[0]);
expect(style.top).toBe('100px');
expect(style.right).toBe('0px');
expect(style.bottom).toBe('');
// bottomRight
config({
placement: 'bottomRight',
top: 50,
bottom: 100,
});
style = getStyle($$('.ant-notification-bottomRight')[0]);
expect(style.top).toBe('');
expect(style.right).toBe('0px');
expect(style.bottom).toBe('100px');
// bottomLeft
config({
placement: 'bottomLeft',
top: 100,
bottom: 50,
});
style = getStyle($$('.ant-notification-bottomLeft')[0]);
expect(style.top).toBe('');
expect(style.left).toBe('0px');
expect(style.bottom).toBe('50px');
});
});
describe('mountNode', () => {
const $container = document.createElement('div');
beforeEach(() => {
document.body.appendChild($container);
});
afterEach(() => {
$container.remove();
});
it('can be configured per notification using the `open` method', () => {
act(() => {
open({
getContainer: () => $container,
});
});
expect($container.querySelector('.ant-notification')).not.toBe(null);
notification.destroy();
setTimeout(() => {
// Upcoming notifications still use their default mountNode and not $container
act(() => {
open();
});
expect($container.querySelector('.ant-notification')).toBe(null);
});
});
it('can be configured globally using the `config` method', () => {
config({
getContainer: () => $container,
});
expect($container.querySelector('.ant-notification')).not.toBe(null);
notification.destroy();
setTimeout(() => {
// Upcoming notifications are mounted in $container
act(() => {
open();
});
expect($container.querySelector('.ant-notification')).not.toBe(null);
});
});
});
});

View File

@ -0,0 +1,167 @@
import notification, { actWrapper } from '..';
import { act, fireEvent } from '../../../tests/utils';
import type { ArgsProps, GlobalConfigProps } from '../interface';
import { awaitPromise, triggerMotionEnd } from './util';
describe('Notification.placement', () => {
function open(args?: Partial<ArgsProps>) {
notification.open({
message: 'Notification Title',
description: 'This is the content of the notification.',
...args,
});
}
function config(args: Partial<GlobalConfigProps>) {
notification.config({
...args,
});
act(() => {
open();
});
}
beforeAll(() => {
actWrapper(act);
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(async () => {
// Clean up
notification.destroy();
await triggerMotionEnd();
notification.config({
prefixCls: null,
getContainer: null,
});
jest.useRealTimers();
await awaitPromise();
});
describe('placement', () => {
it('can be configured globally using the `config` method', async () => {
// topLeft
config({
placement: 'topLeft',
top: 50,
bottom: 50,
});
await awaitPromise();
expect(document.querySelector('.ant-notification-topLeft')).toHaveStyle({
top: '50px',
left: '0px',
bottom: '',
});
// topRight
config({
placement: 'topRight',
top: 100,
bottom: 50,
});
expect(document.querySelector('.ant-notification-topRight')).toHaveStyle({
top: '100px',
right: '0px',
bottom: '',
});
// bottomRight
config({
placement: 'bottomRight',
top: 50,
bottom: 100,
});
expect(document.querySelector('.ant-notification-bottomRight')).toHaveStyle({
top: '',
right: '0px',
bottom: '100px',
});
// bottomLeft
config({
placement: 'bottomLeft',
top: 100,
bottom: 50,
});
expect(document.querySelector('.ant-notification-bottomLeft')).toHaveStyle({
top: '',
left: '0px',
bottom: '50px',
});
// top
config({
placement: 'top',
top: 50,
bottom: 60,
});
await awaitPromise();
expect(document.querySelector('.ant-notification-top')).toHaveStyle({
top: '50px',
left: '50%',
bottom: '',
});
// bottom
config({
placement: 'bottom',
top: 50,
bottom: 60,
});
await awaitPromise();
expect(document.querySelector('.ant-notification-bottom')).toHaveStyle({
top: '',
left: '50%',
bottom: '60px',
});
});
});
describe('mountNode', () => {
const $container = document.createElement('div');
beforeEach(() => {
document.body.appendChild($container);
});
afterEach(() => {
$container.remove();
});
it('can be configured globally using the `config` method', async () => {
config({
getContainer: () => $container,
});
await awaitPromise();
expect($container.querySelector('.ant-notification')).toBeTruthy();
notification.destroy();
// Leave motion
act(() => {
jest.runAllTimers();
});
document.querySelectorAll('.ant-notification-notice').forEach(ele => {
fireEvent.animationEnd(ele);
});
expect($container.querySelector('.ant-notification')).toBeFalsy();
// Upcoming notifications are mounted in $container
act(() => {
open();
});
expect($container.querySelector('.ant-notification')).toBeTruthy();
});
});
});

View File

@ -0,0 +1,30 @@
import { act, fireEvent } from '../../../tests/utils';
export async function awaitPromise() {
for (let i = 0; i < 10; i += 1) {
// eslint-disable-next-line no-await-in-loop
await Promise.resolve();
}
}
export async function triggerMotionEnd(runAllTimers: boolean = true) {
await awaitPromise();
if (runAllTimers) {
// Flush css motion state update
for (let i = 0; i < 5; i += 1) {
act(() => {
jest.runAllTimers();
});
}
}
// document.querySelectorAll('.ant-notification-fade-leave').forEach(ele => {
// fireEvent.animationEnd(ele);
// });
document.querySelectorAll('[role="alert"]').forEach(ele => {
fireEvent.animationEnd(ele.parentNode?.parentNode!);
});
await awaitPromise();
}

View File

@ -1,17 +1,17 @@
--- ---
order: 8 order: -1
title: title:
zh-CN: 通过 Hooks 获取上下文 zh-CN: Hooks 调用(推荐)
en-US: Get context with hooks en-US: Hooks usage (recommended)
--- ---
## zh-CN ## zh-CN
通过 `notification.useNotification` 创建支持读取 context 的 `contextHolder` 通过 `notification.useNotification` 创建支持读取 context 的 `contextHolder`请注意,我们推荐通过顶层注册的方式代替 `message` 静态方法,因为静态方法无法消费上下文,因而 ConfigProvider 的数据也不会生效。
## en-US ## en-US
Use `notification.useNotification` to get `contextHolder` with context accessible issue. Use `notification.useNotification` to get `contextHolder` with context accessible issue. Please note that, we recommend to use top level registration instead of `notification` static method, because static method cannot consume context, and ConfigProvider data will not work.
```jsx ```jsx
import { Button, notification, Divider, Space } from 'antd'; import { Button, notification, Divider, Space } from 'antd';

View File

@ -14,7 +14,7 @@ title:
To customize the style or font of the close button. To customize the style or font of the close button.
```jsx ```jsx
import { Button, notification } from 'antd'; import { Button, notification, Space } from 'antd';
const close = () => { const close = () => {
console.log( console.log(
@ -25,9 +25,14 @@ const close = () => {
const openNotification = () => { const openNotification = () => {
const key = `open${Date.now()}`; const key = `open${Date.now()}`;
const btn = ( const btn = (
<Button type="primary" size="small" onClick={() => notification.close(key)}> <Space>
Confirm <Button type="link" size="small" onClick={() => notification.destroy()}>
</Button> Destroy All
</Button>
<Button type="primary" size="small" onClick={() => notification.destroy(key)}>
Confirm
</Button>
</Space>
); );
notification.open({ notification.open({
message: 'Notification Title', message: 'Notification Title',

View File

@ -1,74 +0,0 @@
import * as React from 'react';
import useRCNotification from 'rc-notification/lib/useNotification';
import type {
NotificationInstance as RCNotificationInstance,
NoticeContent as RCNoticeContent,
HolderReadyCallback as RCHolderReadyCallback,
} from 'rc-notification/lib/Notification';
import type { ConfigConsumerProps } from '../../config-provider';
import { ConfigConsumer } from '../../config-provider';
import type { NotificationInstance, ArgsProps } from '..';
export default function createUseNotification(
getNotificationInstance: (
args: ArgsProps,
callback: (info: { prefixCls: string; instance: RCNotificationInstance }) => void,
) => void,
getRCNoticeProps: (args: ArgsProps, prefixCls: string) => RCNoticeContent,
) {
const useNotification = (): [NotificationInstance, React.ReactElement] => {
// We can only get content by render
let getPrefixCls: ConfigConsumerProps['getPrefixCls'];
// We create a proxy to handle delay created instance
let innerInstance: RCNotificationInstance | null = null;
const proxy = {
add: (noticeProps: RCNoticeContent, holderCallback?: RCHolderReadyCallback) => {
innerInstance?.component.add(noticeProps, holderCallback);
},
} as any;
const [hookNotify, holder] = useRCNotification(proxy);
function notify(args: ArgsProps) {
const { prefixCls: customizePrefixCls } = args;
const mergedPrefixCls = getPrefixCls('notification', customizePrefixCls);
getNotificationInstance(
{
...args,
prefixCls: mergedPrefixCls,
},
({ prefixCls, instance }) => {
innerInstance = instance;
hookNotify(getRCNoticeProps(args, prefixCls));
},
);
}
// Fill functions
const hookApiRef = React.useRef<any>({});
hookApiRef.current.open = notify;
['success', 'info', 'warning', 'error'].forEach(type => {
hookApiRef.current[type] = (args: ArgsProps) =>
hookApiRef.current.open({
...args,
type,
});
});
return [
hookApiRef.current,
<ConfigConsumer key="holder">
{(context: ConfigConsumerProps) => {
({ getPrefixCls } = context);
return holder;
}}
</ConfigConsumer>,
];
};
return useNotification;
}

View File

@ -24,8 +24,7 @@ To display a notification message at any of the four corners of the viewport. Ty
- `notification.warning(config)` - `notification.warning(config)`
- `notification.warn(config)` - `notification.warn(config)`
- `notification.open(config)` - `notification.open(config)`
- `notification.close(key: String)` - `notification.destroy(key?: String)`
- `notification.destroy()`
The properties of config are as follows: The properties of config are as follows:

View File

@ -1,345 +1,258 @@
import * as React from 'react'; import * as React from 'react';
import Notification from 'rc-notification'; import { render } from 'rc-util/lib/React/render';
import type { NotificationInstance as RCNotificationInstance } from 'rc-notification/lib/Notification'; import useNotification, { useInternalNotification } from './useNotification';
import CloseOutlined from '@ant-design/icons/CloseOutlined'; import type { ArgsProps, NotificationInstance, GlobalConfigProps } from './interface';
import classNames from 'classnames';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import CloseCircleOutlined from '@ant-design/icons/CloseCircleOutlined';
import ExclamationCircleOutlined from '@ant-design/icons/ExclamationCircleOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import createUseNotification from './hooks/useNotification';
import ConfigProvider, { globalConfig } from '../config-provider'; import ConfigProvider, { globalConfig } from '../config-provider';
export type NotificationPlacement = let notification: GlobalNotification | null = null;
| 'top'
| 'topLeft'
| 'topRight'
| 'bottom'
| 'bottomLeft'
| 'bottomRight';
export type IconType = 'success' | 'info' | 'error' | 'warning'; let act: (callback: VoidFunction) => Promise<void> | void = (callback: VoidFunction) => callback();
const notificationInstance: { interface GlobalNotification {
[key: string]: Promise<RCNotificationInstance>; fragment: DocumentFragment;
} = {}; instance?: NotificationInstance | null;
let defaultDuration = 4.5; sync?: VoidFunction;
let defaultTop = 24;
let defaultBottom = 24;
let defaultPrefixCls = '';
let defaultPlacement: NotificationPlacement = 'topRight';
let defaultGetContainer: () => HTMLElement;
let defaultCloseIcon: React.ReactNode;
let rtl = false;
let maxCount: number;
export interface ConfigProps {
top?: number;
bottom?: number;
duration?: number;
prefixCls?: string;
placement?: NotificationPlacement;
getContainer?: () => HTMLElement;
closeIcon?: React.ReactNode;
rtl?: boolean;
maxCount?: number;
} }
function setNotificationConfig(options: ConfigProps) { type Task =
const { duration, placement, bottom, top, getContainer, closeIcon, prefixCls } = options; | {
if (prefixCls !== undefined) { type: 'open';
defaultPrefixCls = prefixCls; config: ArgsProps;
} }
if (duration !== undefined) { | {
defaultDuration = duration; type: 'destroy';
} key: React.Key;
if (placement !== undefined) { };
defaultPlacement = placement;
} else if (options.rtl) {
defaultPlacement = 'topLeft';
}
if (bottom !== undefined) {
defaultBottom = bottom;
}
if (top !== undefined) {
defaultTop = top;
}
if (getContainer !== undefined) {
defaultGetContainer = getContainer;
}
if (closeIcon !== undefined) {
defaultCloseIcon = closeIcon;
}
if (options.rtl !== undefined) {
rtl = options.rtl;
}
if (options.maxCount !== undefined) {
maxCount = options.maxCount;
}
}
function getPlacementStyle( let taskQueue: Task[] = [];
placement: NotificationPlacement,
top: number = defaultTop,
bottom: number = defaultBottom,
) {
let style;
switch (placement) {
case 'top':
style = {
left: '50%',
transform: 'translateX(-50%)',
right: 'auto',
top,
bottom: 'auto',
};
break;
case 'topLeft':
style = {
left: 0,
top,
bottom: 'auto',
};
break;
case 'topRight':
style = {
right: 0,
top,
bottom: 'auto',
};
break;
case 'bottom':
style = {
left: '50%',
transform: 'translateX(-50%)',
right: 'auto',
top: 'auto',
bottom,
};
break;
case 'bottomLeft':
style = {
left: 0,
top: 'auto',
bottom,
};
break;
default:
style = {
right: 0,
top: 'auto',
bottom,
};
break;
}
return style;
}
function getNotificationInstance( let defaultGlobalConfig: GlobalConfigProps = {};
args: ArgsProps,
callback: (info: { function getGlobalContext() {
prefixCls: string;
iconPrefixCls: string;
instance: RCNotificationInstance;
}) => void,
) {
const { const {
placement = defaultPlacement, prefixCls: globalPrefixCls,
getContainer: globalGetContainer,
rtl,
maxCount,
top, top,
bottom, bottom,
getContainer = defaultGetContainer, } = defaultGlobalConfig;
prefixCls: customizePrefixCls, const mergedPrefixCls = globalPrefixCls ?? globalConfig().getPrefixCls('notification');
} = args; const mergedContainer = globalGetContainer?.() || document.body;
const { getPrefixCls, getIconPrefixCls } = globalConfig();
const prefixCls = getPrefixCls('notification', customizePrefixCls || defaultPrefixCls);
const iconPrefixCls = getIconPrefixCls();
const cacheKey = `${prefixCls}-${placement}`; return {
const cacheInstance = notificationInstance[cacheKey]; prefixCls: mergedPrefixCls,
container: mergedContainer,
rtl,
maxCount,
top,
bottom,
};
}
if (cacheInstance) { interface GlobalHolderRef {
Promise.resolve(cacheInstance).then(instance => { instance: NotificationInstance;
callback({ prefixCls: `${prefixCls}-notice`, iconPrefixCls, instance }); sync: () => void;
}
const GlobalHolder = React.forwardRef<GlobalHolderRef, {}>((_, ref) => {
const [prefixCls, setPrefixCls] = React.useState<string>();
const [container, setContainer] = React.useState<HTMLElement>();
const [maxCount, setMaxCount] = React.useState<number | undefined>();
const [rtl, setRTL] = React.useState<boolean | undefined>();
const [top, setTop] = React.useState<number | undefined>();
const [bottom, setBottom] = React.useState<number | undefined>();
const [api, holder] = useInternalNotification({
prefixCls,
getContainer: () => container!,
maxCount,
rtl,
top,
bottom,
});
const global = globalConfig();
const rootPrefixCls = global.getRootPrefixCls();
const rootIconPrefixCls = global.getIconPrefixCls();
const sync = () => {
const {
prefixCls: nextGlobalPrefixCls,
container: nextGlobalContainer,
maxCount: nextGlobalMaxCount,
rtl: nextGlobalRTL,
top: nextTop,
bottom: nextBottom,
} = getGlobalContext();
setPrefixCls(nextGlobalPrefixCls);
setContainer(nextGlobalContainer);
setMaxCount(nextGlobalMaxCount);
setRTL(nextGlobalRTL);
setTop(nextTop);
setBottom(nextBottom);
};
React.useEffect(sync, []);
React.useImperativeHandle(ref, () => {
const instance: any = { ...api };
Object.keys(instance).forEach(method => {
instance[method] = (...args: any[]) => {
sync();
return (api as any)[method](...args);
};
});
return {
instance,
sync,
};
});
return (
<ConfigProvider prefixCls={rootPrefixCls} iconPrefixCls={rootIconPrefixCls}>
{holder}
</ConfigProvider>
);
});
function flushNotice() {
if (!notification) {
const holderFragment = document.createDocumentFragment();
const newNotification: GlobalNotification = {
fragment: holderFragment,
};
notification = newNotification;
// Delay render to avoid sync issue
act(() => {
render(
<GlobalHolder
ref={node => {
const { instance, sync } = node || {};
Promise.resolve().then(() => {
if (!newNotification.instance && instance) {
newNotification.instance = instance;
newNotification.sync = sync;
flushNotice();
}
});
}}
/>,
holderFragment,
);
}); });
return; return;
} }
const notificationClass = classNames(`${prefixCls}-${placement}`, { // Notification not ready
[`${prefixCls}-rtl`]: rtl === true, if (notification && !notification.instance) {
}); return;
notificationInstance[cacheKey] = new Promise(resolve => {
Notification.newInstance(
{
prefixCls,
className: notificationClass,
style: getPlacementStyle(placement, top, bottom),
getContainer,
maxCount,
},
notification => {
resolve(notification);
callback({
prefixCls: `${prefixCls}-notice`,
iconPrefixCls,
instance: notification,
});
},
);
});
}
const typeToIcon = {
success: CheckCircleOutlined,
info: InfoCircleOutlined,
error: CloseCircleOutlined,
warning: ExclamationCircleOutlined,
};
export interface ArgsProps {
message: React.ReactNode;
description?: React.ReactNode;
btn?: React.ReactNode;
key?: string;
onClose?: () => void;
duration?: number | null;
icon?: React.ReactNode;
placement?: NotificationPlacement;
maxCount?: number;
style?: React.CSSProperties;
prefixCls?: string;
className?: string;
readonly type?: IconType;
onClick?: () => void;
top?: number;
bottom?: number;
getContainer?: () => HTMLElement;
closeIcon?: React.ReactNode;
}
function getRCNoticeProps(args: ArgsProps, prefixCls: string, iconPrefixCls?: string) {
const {
duration: durationArg,
icon,
type,
description,
message,
btn,
onClose,
onClick,
key,
style,
className,
closeIcon = defaultCloseIcon,
} = args;
const duration = durationArg === undefined ? defaultDuration : durationArg;
let iconNode: React.ReactNode = null;
if (icon) {
iconNode = <span className={`${prefixCls}-icon`}>{args.icon}</span>;
} else if (type) {
iconNode = React.createElement(typeToIcon[type] || null, {
className: `${prefixCls}-icon ${prefixCls}-icon-${type}`,
});
} }
const closeIconToRender = ( // >>> Execute task
<span className={`${prefixCls}-close-x`}> taskQueue.forEach(task => {
{closeIcon || <CloseOutlined className={`${prefixCls}-close-icon`} />} // eslint-disable-next-line default-case
</span> switch (task.type) {
); case 'open': {
act(() => {
notification!.instance!.open({
...defaultGlobalConfig,
...task.config,
});
});
break;
}
const autoMarginTag = case 'destroy':
!description && iconNode ? ( act(() => {
<span className={`${prefixCls}-message-single-line-auto-margin`} /> notification?.instance!.destroy(task.key);
) : null; });
break;
}
});
return { // Clean up
content: ( taskQueue = [];
<ConfigProvider iconPrefixCls={iconPrefixCls}>
<div className={iconNode ? `${prefixCls}-with-icon` : ''} role="alert">
{iconNode}
<div className={`${prefixCls}-message`}>
{autoMarginTag}
{message}
</div>
<div className={`${prefixCls}-description`}>{description}</div>
{btn ? <span className={`${prefixCls}-btn`}>{btn}</span> : null}
</div>
</ConfigProvider>
),
duration,
closable: true,
closeIcon: closeIconToRender,
onClose,
onClick,
key,
style: style || {},
className: classNames(className, {
[`${prefixCls}-${type}`]: !!type,
}),
};
} }
function notice(args: ArgsProps) { // ==============================================================================
getNotificationInstance(args, ({ prefixCls, iconPrefixCls, instance }) => { // == Export ==
instance.notice(getRCNoticeProps(args, prefixCls, iconPrefixCls)); // ==============================================================================
const methods = ['success', 'info', 'warning', 'error'] as const;
type MethodType = typeof methods[number];
function setNotificationGlobalConfig(config: GlobalConfigProps) {
defaultGlobalConfig = {
...defaultGlobalConfig,
...config,
};
// Trigger sync for it
act(() => {
notification?.sync?.();
}); });
} }
const api: any = { function open(config: ArgsProps) {
open: notice, taskQueue.push({
close(key: string) { type: 'open',
Object.keys(notificationInstance).forEach(cacheKey => config,
Promise.resolve(notificationInstance[cacheKey]).then(instance => { });
instance.removeNotice(key); flushNotice();
}), }
);
}, function destroy(key: React.Key) {
config: setNotificationConfig, taskQueue.push({
destroy() { type: 'destroy',
Object.keys(notificationInstance).forEach(cacheKey => { key,
Promise.resolve(notificationInstance[cacheKey]).then(instance => { });
instance.destroy(); flushNotice();
}); }
delete notificationInstance[cacheKey]; // lgtm[js/missing-await]
}); const baseStaticMethods: {
}, open: (config: ArgsProps) => void;
destroy: (key?: React.Key) => void;
config: any;
useNotification: typeof useNotification;
} = {
open,
destroy,
config: setNotificationGlobalConfig,
useNotification,
}; };
['success', 'info', 'warning', 'error'].forEach(type => { const staticMethods: typeof baseStaticMethods & Record<MethodType, (config: ArgsProps) => void> =
api[type] = (args: ArgsProps) => baseStaticMethods as any;
api.open({
...args, methods.forEach(type => {
staticMethods[type] = config =>
open({
...config,
type, type,
}); });
}); });
api.warn = api.warning; // ==============================================================================
api.useNotification = createUseNotification(getNotificationInstance, getRCNoticeProps); // == Test ==
// ==============================================================================
const noop = () => {};
export interface NotificationInstance { /** @private Only Work in test env */
success(args: ArgsProps): void; // eslint-disable-next-line import/no-mutable-exports
error(args: ArgsProps): void; export let actWrapper: (wrapper: any) => void = noop;
info(args: ArgsProps): void;
warning(args: ArgsProps): void; if (process.env.NODE_ENV === 'test') {
open(args: ArgsProps): void; actWrapper = wrapper => {
act = wrapper;
};
} }
export interface NotificationApi extends NotificationInstance { export default staticMethods;
warn(args: ArgsProps): void;
close(key: string): void;
config(options: ConfigProps): void;
destroy(): void;
// Hooks
useNotification: () => [NotificationInstance, React.ReactElement];
}
/** @private test Only function. Not work on production */
export const getInstance = async (cacheKey: string) =>
process.env.NODE_ENV === 'test' ? notificationInstance[cacheKey] : null;
export default api as NotificationApi;

View File

@ -25,8 +25,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/Jxm5nw61w/Notification.svg
- `notification.warning(config)` - `notification.warning(config)`
- `notification.warn(config)` - `notification.warn(config)`
- `notification.open(config)` - `notification.open(config)`
- `notification.close(key: String)` - `notification.destroy(key?: String)`
- `notification.destroy()`
config 参数如下: config 参数如下:

View File

@ -0,0 +1,57 @@
import type * as React from 'react';
export type NotificationPlacement =
| 'top'
| 'topLeft'
| 'topRight'
| 'bottom'
| 'bottomLeft'
| 'bottomRight';
export type IconType = 'success' | 'info' | 'error' | 'warning';
export interface ArgsProps {
message: React.ReactNode;
description?: React.ReactNode;
btn?: React.ReactNode;
key?: React.Key;
onClose?: () => void;
duration?: number | null;
icon?: React.ReactNode;
placement?: NotificationPlacement;
style?: React.CSSProperties;
className?: string;
readonly type?: IconType;
onClick?: () => void;
closeIcon?: React.ReactNode;
}
export interface NotificationInstance {
success(args: ArgsProps): void;
error(args: ArgsProps): void;
info(args: ArgsProps): void;
warning(args: ArgsProps): void;
open(args: ArgsProps): void;
destroy(key?: React.Key): void;
}
export interface GlobalConfigProps {
top?: number;
bottom?: number;
duration?: number;
prefixCls?: string;
getContainer?: () => HTMLElement;
placement?: NotificationPlacement;
closeIcon?: React.ReactNode;
rtl?: boolean;
maxCount?: number;
}
export interface NotificationConfig {
top?: number;
bottom?: number;
prefixCls?: string;
getContainer?: () => HTMLElement;
maxCount?: number;
rtl?: boolean;
}

View File

@ -0,0 +1,192 @@
import * as React from 'react';
import { useNotification as useRcNotification } from 'rc-notification/lib';
import type { NotificationAPI } from 'rc-notification/lib';
import classNames from 'classnames';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
import CloseCircleOutlined from '@ant-design/icons/CloseCircleOutlined';
import ExclamationCircleOutlined from '@ant-design/icons/ExclamationCircleOutlined';
import InfoCircleOutlined from '@ant-design/icons/InfoCircleOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import { ConfigContext } from '../config-provider';
import type {
NotificationInstance,
ArgsProps,
NotificationPlacement,
NotificationConfig,
} from './interface';
import { getPlacementStyle, getMotion } from './util';
import devWarning from '../_util/devWarning';
const typeToIcon = {
success: CheckCircleOutlined,
info: InfoCircleOutlined,
error: CloseCircleOutlined,
warning: ExclamationCircleOutlined,
};
const DEFAULT_OFFSET = 24;
const DEFAULT_DURATION = 4.5;
// ==============================================================================
// == Holder ==
// ==============================================================================
type HolderProps = NotificationConfig & {
onAllRemoved?: VoidFunction;
};
interface HolderRef extends NotificationAPI {
prefixCls: string;
}
const Holder = React.forwardRef<HolderRef, HolderProps>((props, ref) => {
const {
top,
bottom,
prefixCls: staticPrefixCls,
getContainer: staticGetContainer,
maxCount,
rtl,
onAllRemoved,
} = props;
const { getPrefixCls, getPopupContainer } = React.useContext(ConfigContext);
const prefixCls = staticPrefixCls || getPrefixCls('notification');
// =============================== Style ===============================
const getStyle = (placement: NotificationPlacement) =>
getPlacementStyle(placement, top ?? DEFAULT_OFFSET, bottom ?? DEFAULT_OFFSET);
const getClassName = () => (rtl ? `${prefixCls}-rtl` : '');
// ============================== Motion ===============================
const getNotificationMotion = () => getMotion(prefixCls);
// ============================ Close Icon =============================
const mergedCloseIcon = (
<span className={`${prefixCls}-close-x`}>
<CloseOutlined className={`${prefixCls}-close-icon`} />
</span>
);
// ============================== Origin ===============================
const [api, holder] = useRcNotification({
prefixCls,
style: getStyle,
className: getClassName,
motion: getNotificationMotion,
closable: true,
closeIcon: mergedCloseIcon,
duration: DEFAULT_DURATION,
getContainer: () => staticGetContainer?.() || getPopupContainer?.() || document.body,
maxCount,
onAllRemoved,
});
// ================================ Ref ================================
React.useImperativeHandle(ref, () => ({
...api,
prefixCls,
}));
return holder;
});
// ==============================================================================
// == Hook ==
// ==============================================================================
export function useInternalNotification(
notificationConfig?: HolderProps,
): [NotificationInstance, React.ReactElement] {
const holderRef = React.useRef<HolderRef>(null);
// ================================ API ================================
const wrapAPI = React.useMemo<NotificationInstance>(() => {
// Wrap with notification content
// >>> Open
const open = (config: ArgsProps) => {
if (!holderRef.current) {
devWarning(
false,
'Notification',
'You are calling notice in render which will break in React 18 concurrent mode. Please trigger in effect instead.',
);
return;
}
const { open: originOpen, prefixCls } = holderRef.current;
const noticePrefixCls = `${prefixCls}-notice`;
const {
message,
description,
icon,
type,
placement = 'topRight',
btn,
className,
...restConfig
} = config;
let iconNode: React.ReactNode = null;
if (icon) {
iconNode = <span className={`${noticePrefixCls}-icon`}>{icon}</span>;
} else if (type) {
iconNode = React.createElement(typeToIcon[type] || null, {
className: classNames(`${noticePrefixCls}-icon`, `${noticePrefixCls}-icon-${type}`),
});
}
return originOpen({
...restConfig,
content: (
<div
className={classNames({
[`${noticePrefixCls}-with-icon`]: iconNode,
})}
role="alert"
>
{iconNode}
<div className={`${noticePrefixCls}-message`}>{message}</div>
<div className={`${noticePrefixCls}-description`}>{description}</div>
{btn && <div className={`${noticePrefixCls}-btn`}>{btn}</div>}
</div>
),
placement,
className: classNames(type && `${noticePrefixCls}-${type}`, className),
});
};
// >>> destroy
const destroy = (key?: React.Key) => {
if (key !== undefined) {
holderRef.current?.close(key);
} else {
holderRef.current?.destroy();
}
};
const clone = {
open,
destroy,
} as NotificationInstance;
const keys = ['success', 'info', 'warning', 'error'] as const;
keys.forEach(type => {
clone[type] = config =>
open({
...config,
type,
});
});
return clone;
}, []);
// ============================== Return ===============================
return [wrapAPI, <Holder key="holder" {...notificationConfig} ref={holderRef} />];
}
export default function useNotification(notificationConfig?: NotificationConfig) {
return useInternalNotification(notificationConfig);
}

View File

@ -0,0 +1,68 @@
import type * as React from 'react';
import type { CSSMotionProps } from 'rc-motion';
import type { NotificationPlacement } from './interface';
export function getPlacementStyle(placement: NotificationPlacement, top: number, bottom: number) {
let style: React.CSSProperties;
switch (placement) {
case 'top':
style = {
left: '50%',
transform: 'translateX(-50%)',
right: 'auto',
top,
bottom: 'auto',
};
break;
case 'topLeft':
style = {
left: 0,
top,
bottom: 'auto',
};
break;
case 'topRight':
style = {
right: 0,
top,
bottom: 'auto',
};
break;
case 'bottom':
style = {
left: '50%',
transform: 'translateX(-50%)',
right: 'auto',
top: 'auto',
bottom,
};
break;
case 'bottomLeft':
style = {
left: 0,
top: 'auto',
bottom,
};
break;
default:
style = {
right: 0,
top: 'auto',
bottom,
};
break;
}
return style;
}
export function getMotion(prefixCls: string): CSSMotionProps {
return {
motionName: `${prefixCls}-fade`,
};
}

View File

@ -138,7 +138,7 @@
"rc-mentions": "~1.8.0", "rc-mentions": "~1.8.0",
"rc-menu": "~9.6.0", "rc-menu": "~9.6.0",
"rc-motion": "^2.5.1", "rc-motion": "^2.5.1",
"rc-notification": "~4.6.0", "rc-notification": "~5.0.0-alpha.8",
"rc-pagination": "~3.1.9", "rc-pagination": "~3.1.9",
"rc-picker": "~2.6.4", "rc-picker": "~2.6.4",
"rc-progress": "~3.2.1", "rc-progress": "~3.2.1",
@ -157,7 +157,7 @@
"rc-tree-select": "~5.3.0", "rc-tree-select": "~5.3.0",
"rc-trigger": "^5.2.10", "rc-trigger": "^5.2.10",
"rc-upload": "~4.3.0", "rc-upload": "~4.3.0",
"rc-util": "^5.20.0", "rc-util": "^5.21.3",
"scroll-into-view-if-needed": "^2.2.25", "scroll-into-view-if-needed": "^2.2.25",
"shallowequal": "^1.1.0" "shallowequal": "^1.1.0"
}, },
@ -306,7 +306,7 @@
"bundlesize": [ "bundlesize": [
{ {
"path": "./dist/antd.min.js", "path": "./dist/antd.min.js",
"maxSize": "356 kB" "maxSize": "358 kB"
}, },
{ {
"path": "./dist/antd.min.css", "path": "./dist/antd.min.css",

View File

@ -327,7 +327,7 @@ class Footer extends React.Component<WrappedComponentProps & { location: any }>
intl: { messages }, intl: { messages },
} = this.props; } = this.props;
message.loading({ message.loading({
content: messages['app.footer.primary-color-changing'], content: messages['app.footer.primary-color-changing'] as string,
key: 'change-primary-color', key: 'change-primary-color',
}); });
const changeColor = () => { const changeColor = () => {
@ -337,7 +337,7 @@ class Footer extends React.Component<WrappedComponentProps & { location: any }>
}) })
.then(() => { .then(() => {
message.success({ message.success({
content: messages['app.footer.primary-color-changed'], content: messages['app.footer.primary-color-changed'] as string,
key: 'change-primary-color', key: 'change-primary-color',
}); });
this.setState({ color }); this.setState({ color });

View File

@ -1,6 +1,10 @@
# V5 breaking change 记录 # V5 breaking change 记录
- getPopupContainer: 所有的 getPopupContainer 都需要保证返回的是唯一的 div。React 18 concurrent 下会反复调用该方法。
- Dropdown - Dropdown
- 魔改包裹元素样式移除,请使用 Space 组件 - 魔改包裹元素样式移除,请使用 Space 组件
- DropdownButton 的 prefixCls 改为 `dropdown` - DropdownButton 的 prefixCls 改为 `dropdown`
- Upload List 结构变化 - Upload List 结构变化
- Notification
- 静态方法不在允许在 `open` 中动态设置 `prefixCls` `maxCount` `top` `bottom` `getContainer`Notification 静态方法现在将只有一个实例。如果需要不同配置,请使用 `useNotification`
- close 改名为 destroy 和 message 保持一致