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();
},
getIconPrefixCls: getGlobalIconPrefixCls,
getRootPrefixCls: (rootPrefixCls?: string, customizePrefixCls?: string) => {
// Customize rootPrefixCls is first priority
if (rootPrefixCls) {
return rootPrefixCls;
}
getRootPrefixCls: () => {
// If Global prefixCls provided, use this
if (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
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 */
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import message, { getInstance } from '..';
import message from '..';
import ConfigProvider from '../../config-provider';
import { render, fireEvent } from '../../../tests/utils';
import { triggerMotionEnd } from './util';
describe('message.hooks', () => {
beforeAll(() => {
beforeEach(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
afterEach(() => {
message.destroy();
jest.useRealTimers();
});
it('should work', () => {
@ -46,10 +43,11 @@ describe('message.hooks', () => {
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(1);
expect(document.querySelector('.hook-test-result').innerHTML).toEqual('bamboo');
const { container } = render(<Demo />);
fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-message-notice')).toHaveLength(1);
expect(document.querySelector('.hook-test-result')!.textContent).toEqual('bamboo');
});
it('should work with success', () => {
@ -80,16 +78,15 @@ describe('message.hooks', () => {
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(1);
expect(document.querySelectorAll('.anticon-check-circle').length).toBe(1);
expect(document.querySelector('.hook-test-result').innerHTML).toEqual('bamboo');
const { container } = render(<Demo />);
fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-message-notice')).toHaveLength(1);
expect(document.querySelectorAll('.anticon-check-circle')).toHaveLength(1);
expect(document.querySelector('.hook-test-result')!.textContent).toEqual('bamboo');
});
it('should work with onClose', done => {
// if not use real timer, done won't be called
jest.useRealTimers();
const Demo = () => {
const [api, holder] = message.useMessage();
return (
@ -111,14 +108,13 @@ describe('message.hooks', () => {
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
jest.useFakeTimers();
const { container } = render(<Demo />);
fireEvent.click(container.querySelector('button')!);
triggerMotionEnd();
});
it('should work with close promise', done => {
// if not use real timer, done won't be called
jest.useRealTimers();
const Demo = () => {
const [api, holder] = message.useMessage();
return (
@ -141,13 +137,14 @@ describe('message.hooks', () => {
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
jest.useFakeTimers();
const { container } = render(<Demo />);
fireEvent.click(container.querySelector('button')!);
triggerMotionEnd();
});
it('should work with hide', () => {
let hide;
it('should work with hide', async () => {
let hide: VoidFunction;
const Demo = () => {
const [api, holder] = message.useMessage();
return (
@ -166,48 +163,50 @@ describe('message.hooks', () => {
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
const { container } = render(<Demo />);
fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-message-notice')).toHaveLength(1);
act(() => {
jest.runAllTimers();
hide!();
});
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(1);
await triggerMotionEnd('.my-test-message-move-up-leave');
act(() => {
hide();
jest.runAllTimers();
});
expect(getInstance().component.state.notices).toHaveLength(0);
expect(document.querySelectorAll('.my-test-message-notice')).toHaveLength(0);
});
it('should be same hook', () => {
let count = 0;
let cacheAPI: any;
const Demo = () => {
const [, forceUpdate] = React.useState({});
const [api] = message.useMessage();
React.useEffect(() => {
count += 1;
expect(count).toEqual(1);
forceUpdate();
if (!cacheAPI) {
cacheAPI = api;
} else {
expect(cacheAPI).toBe(api);
}
forceUpdate({});
}, [api]);
return null;
};
mount(<Demo />);
render(<Demo />);
});
it("should use ConfigProvider's getPopupContainer as message container", () => {
const containerId = 'container';
const getPopupContainer = () => {
const div = document.createElement('div');
div.id = containerId;
document.body.appendChild(div);
return div;
};
const getPopupContainer = () => div;
const Demo = () => {
const [api, holder] = message.useMessage();
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(document.querySelectorAll('.my-test-message-notice').length).toBe(1);
expect(document.querySelectorAll('.anticon-check-circle').length).toBe(1);
expect(document.querySelector('.hook-content').innerHTML).toEqual('happy');
expect(document.querySelectorAll(`#${containerId}`).length).toBe(1);
expect(wrapper.find(`#${containerId}`).children.length).toBe(1);
expect(div.querySelectorAll('.my-test-message-notice')).toHaveLength(1);
expect(div.querySelectorAll('.anticon-check-circle')).toHaveLength(1);
expect(div.querySelector('.hook-content')!.textContent).toEqual('happy');
expect(document.querySelectorAll(`#${containerId}`)).toHaveLength(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', () => {
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);
await Promise.resolve();
});
it('promise with one augument', done => {
message.success('yes!!!').then(filled => {
expect(filled).toBe(true);
done();
});
it('promise with one arguments', async () => {
const filled = jest.fn();
message.success('yes!!!').then(filled);
await triggerMotionEnd();
expect(filled).toHaveBeenCalledWith(true);
});
it('promise two auguments', done => {
message.success('yes!!!').then(
filled => {
expect(filled).toBe(true);
done();
},
rejected => {
expect(rejected).toBe(false);
},
);
it('promise two arguments', async () => {
const filled = jest.fn();
const rejected = jest.fn();
message.success('yes!!!').then(filled, rejected);
await triggerMotionEnd();
expect(filled).toHaveBeenCalledWith(true);
expect(rejected).not.toHaveBeenCalled();
});
it('hide', () => {
it('hide', async () => {
const hide = message.loading('doing...');
await Promise.resolve();
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:
zh-CN: 通过 Hooks 获取上下文4.5.0+
en-US: Get context with hooks (4.5.0+)
zh-CN: Hooks 调用(推荐
en-US: Hooks usage (recommended)
---
## zh-CN
通过 `message.useMessage` 创建支持读取 context 的 `contextHolder`
通过 `message.useMessage` 创建支持读取 context 的 `contextHolder`请注意,我们推荐通过顶层注册的方式代替 `message` 静态方法,因为静态方法无法消费上下文,因而 ConfigProvider 的数据也不会生效。
## 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
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 classNames from 'classnames';
import RCNotification from 'rc-notification';
import { render } from 'rc-util/lib/React/render';
import useMessage, { useInternalMessage } from './useMessage';
import type {
NotificationInstance as RCNotificationInstance,
NoticeContent,
} from 'rc-notification/lib/Notification';
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 createUseMessage from './hooks/useMessage';
ArgsProps,
MessageInstance,
ConfigOptions,
NoticeType,
TypeOpen,
MessageType,
} from './interface';
import ConfigProvider, { globalConfig } from '../config-provider';
import { wrapPromiseFn } from './util';
let messageInstance: RCNotificationInstance | null;
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 { ArgsProps };
export function getKeyThenIncreaseKey() {
return key++;
const methods: NoticeType[] = ['success', 'info', 'warning', 'error', 'loading'];
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 {
top?: number;
duration?: number;
prefixCls?: string;
getContainer?: () => HTMLElement;
transitionName?: string;
maxCount?: number;
rtl?: boolean;
interface OpenTask {
type: 'open';
config: ArgsProps;
resolve: VoidFunction;
setCloseFn: (closeFn: VoidFunction) => void;
skipped?: boolean;
}
function setMessageConfig(options: ConfigOptions) {
if (options.top !== undefined) {
defaultTop = options.top;
messageInstance = null; // delete messageInstance for new defaultTop
}
if (options.duration !== undefined) {
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;
}
interface TypeTask {
type: NoticeType;
args: Parameters<TypeOpen>;
resolve: VoidFunction;
setCloseFn: (closeFn: VoidFunction) => void;
skipped?: boolean;
}
function getRCNotificationInstance(
args: ArgsProps,
callback: (info: {
prefixCls: string;
rootPrefixCls: string;
iconPrefixCls: string;
instance: RCNotificationInstance;
}) => void,
) {
const { prefixCls: customizePrefixCls, getPopupContainer: getContextPopupContainer } = args;
const { getPrefixCls, getRootPrefixCls, getIconPrefixCls } = globalConfig();
const prefixCls = getPrefixCls('message', customizePrefixCls || localPrefixCls);
const rootPrefixCls = getRootPrefixCls(args.rootPrefixCls, prefixCls);
const iconPrefixCls = getIconPrefixCls();
type Task =
| OpenTask
| TypeTask
| {
type: 'destroy';
key: React.Key;
skipped?: boolean;
};
if (messageInstance) {
callback({ prefixCls, rootPrefixCls, iconPrefixCls, instance: messageInstance });
return;
}
let taskQueue: Task[] = [];
const instanceConfig = {
prefixCls,
transitionName: hasTransitionName ? transitionName : `${rootPrefixCls}-${transitionName}`,
style: { top: defaultTop }, // 覆盖原来的样式
getContainer: getContainer || getContextPopupContainer,
let defaultGlobalConfig: ConfigOptions = {};
function getGlobalContext() {
const {
prefixCls: globalPrefixCls,
getContainer: globalGetContainer,
rtl,
maxCount,
};
top,
} = defaultGlobalConfig;
const mergedPrefixCls = globalPrefixCls ?? globalConfig().getPrefixCls('message');
const mergedContainer = globalGetContainer?.() || document.body;
RCNotification.newInstance(instanceConfig, (instance: any) => {
if (messageInstance) {
callback({ prefixCls, rootPrefixCls, iconPrefixCls, instance: messageInstance });
return;
}
messageInstance = instance;
if (process.env.NODE_ENV === 'test') {
(messageInstance as any).config = instanceConfig;
}
callback({ prefixCls, rootPrefixCls, iconPrefixCls, instance });
});
}
export interface ThenableArgument {
(val: any): void;
}
export interface MessageType extends PromiseLike<any> {
(): void;
}
const typeToIcon = {
info: InfoCircleFilled,
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,
prefixCls: mergedPrefixCls,
container: mergedContainer,
rtl,
maxCount,
top,
};
}
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);
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);
};
getRCNotificationInstance(args, ({ prefixCls, iconPrefixCls, instance }) => {
instance.notice(
getRCNoticeProps({ ...args, key: target, onClose: callback }, prefixCls, iconPrefixCls),
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,
);
});
return;
}
// Notification not ready
if (message && !message.instance) {
return;
}
// >>> Execute task
taskQueue.forEach(task => {
const { type, skipped } = task;
// Only `skipped` when user call notice but cancel it immediately
// and instance not ready
if (!skipped) {
switch (type) {
case 'open': {
act(() => {
const closeFn = message!.instance!.open({
...defaultGlobalConfig,
...task.config,
});
const result: any = () => {
if (messageInstance) {
messageInstance.removeNotice(target);
closeFn?.then(task.resolve);
task.setCloseFn(closeFn);
});
break;
}
case 'destroy':
act(() => {
message?.instance!.destroy(task.key);
});
break;
// Other type open
default: {
act(() => {
const closeFn = message!.instance![type](...task.args);
closeFn?.then(task.resolve);
task.setCloseFn(closeFn);
});
}
}
}
});
// Clean up
taskQueue = [];
}
// ==============================================================================
// == Export ==
// ==============================================================================
type MethodType = typeof methods[number];
function setMessageGlobalConfig(config: ConfigOptions) {
defaultGlobalConfig = {
...defaultGlobalConfig,
...config,
};
// Trigger sync for it
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;
}
};
result.then = (filled: ThenableArgument, rejected: ThenableArgument) =>
closePromise.then(filled, rejected);
result.promise = closePromise;
});
flushNotice();
return result;
}
type ConfigContent = React.ReactNode;
type ConfigDuration = number | (() => void);
type JointContent = ConfigContent | ArgsProps;
export type ConfigOnClose = () => void;
function typeOpen(type: NoticeType, args: Parameters<TypeOpen>): MessageType {
const result = wrapPromiseFn(resolve => {
let closeFn: VoidFunction;
function isArgsProps(content: JointContent): content is ArgsProps {
return (
Object.prototype.toString.call(content) === '[object Object]' &&
!!(content as ArgsProps).content
);
const task: TypeTask = {
type,
args,
resolve,
setCloseFn: fn => {
closeFn = fn;
},
};
taskQueue.push(task);
return () => {
if (closeFn) {
act(() => {
closeFn();
});
} else {
task.skipped = true;
}
};
});
flushNotice();
return result;
}
const api: any = {
open: notice,
config: setMessageConfig,
destroy(messageKey?: React.Key) {
if (messageInstance) {
if (messageKey) {
const { removeNotice } = messageInstance;
removeNotice(messageKey);
} else {
const { destroy } = messageInstance;
destroy();
messageInstance = null;
}
}
},
function destroy(key: React.Key) {
taskQueue.push({
type: 'destroy',
key,
});
flushNotice();
}
const baseStaticMethods: {
open: (config: ArgsProps) => MessageType;
destroy: (key?: React.Key) => void;
config: any;
useMessage: typeof useMessage;
} = {
open,
destroy,
config: setMessageGlobalConfig,
useMessage,
};
export function attachTypeApi(originalApi: MessageApi, type: NoticeType) {
originalApi[type] = (
content: JointContent,
duration?: ConfigDuration,
onClose?: ConfigOnClose,
) => {
if (isArgsProps(content)) {
return originalApi.open({ ...content, type });
}
const staticMethods: typeof baseStaticMethods & Record<MethodType, TypeOpen> =
baseStaticMethods as any;
if (typeof duration === 'function') {
onClose = duration;
duration = undefined;
}
methods.forEach(type => {
staticMethods[type] = (...args: Parameters<TypeOpen>) => typeOpen(type, args);
});
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;
api.useMessage = createUseMessage(getRCNotificationInstance, getRCNoticeProps);
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;
if (process.env.NODE_ENV === 'test') {
actDestroy = () => {
message = null;
};
}
export interface MessageApi extends MessageInstance {
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;
export default staticMethods;

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;
}
&-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-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 notification from '..';
import ConfigProvider from '../../config-provider';
import { render, fireEvent } from '../../../tests/utils';
describe('notification.hooks', () => {
beforeAll(() => {
beforeEach(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
afterEach(() => {
notification.destroy();
jest.useRealTimers();
});
it('should work', () => {
@ -30,6 +27,7 @@ describe('notification.hooks', () => {
type="button"
onClick={() => {
api.open({
message: null,
description: (
<Context.Consumer>
{name => <span className="hook-test-result">{name}</span>}
@ -45,10 +43,12 @@ describe('notification.hooks', () => {
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
expect(document.querySelectorAll('.my-test-notification-notice').length).toBe(1);
expect(document.querySelector('.hook-test-result').innerHTML).toEqual('bamboo');
const { container } = render(<Demo />);
fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-notification-notice')).toHaveLength(1);
expect(document.querySelector('.hook-test-result')!.textContent).toEqual('bamboo');
});
it('should work with success', () => {
@ -64,6 +64,7 @@ describe('notification.hooks', () => {
type="button"
onClick={() => {
api.success({
message: null,
description: (
<Context.Consumer>
{name => <span className="hook-test-result">{name}</span>}
@ -79,11 +80,12 @@ describe('notification.hooks', () => {
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
expect(document.querySelectorAll('.my-test-notification-notice').length).toBe(1);
expect(document.querySelectorAll('.anticon-check-circle').length).toBe(1);
expect(document.querySelector('.hook-test-result').innerHTML).toEqual('bamboo');
const { container } = render(<Demo />);
fireEvent.click(container.querySelector('button')!);
expect(document.querySelectorAll('.my-test-notification-notice')).toHaveLength(1);
expect(document.querySelectorAll('.anticon-check-circle')).toHaveLength(1);
expect(document.querySelector('.hook-test-result')!.textContent).toEqual('bamboo');
});
it('should be same hook', () => {
@ -96,7 +98,7 @@ describe('notification.hooks', () => {
React.useEffect(() => {
count += 1;
expect(count).toEqual(1);
forceUpdate();
forceUpdate({});
}, [api]);
return null;
@ -104,4 +106,53 @@ describe('notification.hooks', () => {
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:
zh-CN: 通过 Hooks 获取上下文
en-US: Get context with hooks
zh-CN: Hooks 调用(推荐)
en-US: Hooks usage (recommended)
---
## zh-CN
通过 `notification.useNotification` 创建支持读取 context 的 `contextHolder`
通过 `notification.useNotification` 创建支持读取 context 的 `contextHolder`请注意,我们推荐通过顶层注册的方式代替 `message` 静态方法,因为静态方法无法消费上下文,因而 ConfigProvider 的数据也不会生效。
## 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
import { Button, notification, Divider, Space } from 'antd';

View File

@ -14,7 +14,7 @@ title:
To customize the style or font of the close button.
```jsx
import { Button, notification } from 'antd';
import { Button, notification, Space } from 'antd';
const close = () => {
console.log(
@ -25,9 +25,14 @@ const close = () => {
const openNotification = () => {
const key = `open${Date.now()}`;
const btn = (
<Button type="primary" size="small" onClick={() => notification.close(key)}>
<Space>
<Button type="link" size="small" onClick={() => notification.destroy()}>
Destroy All
</Button>
<Button type="primary" size="small" onClick={() => notification.destroy(key)}>
Confirm
</Button>
</Space>
);
notification.open({
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.warn(config)`
- `notification.open(config)`
- `notification.close(key: String)`
- `notification.destroy()`
- `notification.destroy(key?: String)`
The properties of config are as follows:

View File

@ -1,345 +1,258 @@
import * as React from 'react';
import Notification from 'rc-notification';
import type { NotificationInstance as RCNotificationInstance } from 'rc-notification/lib/Notification';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
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 { render } from 'rc-util/lib/React/render';
import useNotification, { useInternalNotification } from './useNotification';
import type { ArgsProps, NotificationInstance, GlobalConfigProps } from './interface';
import ConfigProvider, { globalConfig } from '../config-provider';
export type NotificationPlacement =
| 'top'
| 'topLeft'
| 'topRight'
| 'bottom'
| 'bottomLeft'
| 'bottomRight';
let notification: GlobalNotification | null = null;
export type IconType = 'success' | 'info' | 'error' | 'warning';
let act: (callback: VoidFunction) => Promise<void> | void = (callback: VoidFunction) => callback();
const notificationInstance: {
[key: string]: Promise<RCNotificationInstance>;
} = {};
let defaultDuration = 4.5;
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;
interface GlobalNotification {
fragment: DocumentFragment;
instance?: NotificationInstance | null;
sync?: VoidFunction;
}
function setNotificationConfig(options: ConfigProps) {
const { duration, placement, bottom, top, getContainer, closeIcon, prefixCls } = options;
if (prefixCls !== undefined) {
defaultPrefixCls = prefixCls;
type Task =
| {
type: 'open';
config: ArgsProps;
}
if (duration !== undefined) {
defaultDuration = duration;
}
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;
}
}
| {
type: 'destroy';
key: React.Key;
};
function getPlacementStyle(
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;
}
let taskQueue: Task[] = [];
function getNotificationInstance(
args: ArgsProps,
callback: (info: {
prefixCls: string;
iconPrefixCls: string;
instance: RCNotificationInstance;
}) => void,
) {
let defaultGlobalConfig: GlobalConfigProps = {};
function getGlobalContext() {
const {
placement = defaultPlacement,
prefixCls: globalPrefixCls,
getContainer: globalGetContainer,
rtl,
maxCount,
top,
bottom,
getContainer = defaultGetContainer,
prefixCls: customizePrefixCls,
} = args;
const { getPrefixCls, getIconPrefixCls } = globalConfig();
const prefixCls = getPrefixCls('notification', customizePrefixCls || defaultPrefixCls);
const iconPrefixCls = getIconPrefixCls();
} = defaultGlobalConfig;
const mergedPrefixCls = globalPrefixCls ?? globalConfig().getPrefixCls('notification');
const mergedContainer = globalGetContainer?.() || document.body;
const cacheKey = `${prefixCls}-${placement}`;
const cacheInstance = notificationInstance[cacheKey];
return {
prefixCls: mergedPrefixCls,
container: mergedContainer,
rtl,
maxCount,
top,
bottom,
};
}
if (cacheInstance) {
Promise.resolve(cacheInstance).then(instance => {
callback({ prefixCls: `${prefixCls}-notice`, iconPrefixCls, instance });
interface GlobalHolderRef {
instance: NotificationInstance;
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;
}
const notificationClass = classNames(`${prefixCls}-${placement}`, {
[`${prefixCls}-rtl`]: rtl === true,
});
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}`,
});
// Notification not ready
if (notification && !notification.instance) {
return;
}
const closeIconToRender = (
<span className={`${prefixCls}-close-x`}>
{closeIcon || <CloseOutlined className={`${prefixCls}-close-icon`} />}
</span>
);
// >>> Execute task
taskQueue.forEach(task => {
// eslint-disable-next-line default-case
switch (task.type) {
case 'open': {
act(() => {
notification!.instance!.open({
...defaultGlobalConfig,
...task.config,
});
});
break;
}
const autoMarginTag =
!description && iconNode ? (
<span className={`${prefixCls}-message-single-line-auto-margin`} />
) : null;
case 'destroy':
act(() => {
notification?.instance!.destroy(task.key);
});
break;
}
});
return {
content: (
<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,
}),
// Clean up
taskQueue = [];
}
// ==============================================================================
// == Export ==
// ==============================================================================
const methods = ['success', 'info', 'warning', 'error'] as const;
type MethodType = typeof methods[number];
function setNotificationGlobalConfig(config: GlobalConfigProps) {
defaultGlobalConfig = {
...defaultGlobalConfig,
...config,
};
}
function notice(args: ArgsProps) {
getNotificationInstance(args, ({ prefixCls, iconPrefixCls, instance }) => {
instance.notice(getRCNoticeProps(args, prefixCls, iconPrefixCls));
// Trigger sync for it
act(() => {
notification?.sync?.();
});
}
const api: any = {
open: notice,
close(key: string) {
Object.keys(notificationInstance).forEach(cacheKey =>
Promise.resolve(notificationInstance[cacheKey]).then(instance => {
instance.removeNotice(key);
}),
);
},
config: setNotificationConfig,
destroy() {
Object.keys(notificationInstance).forEach(cacheKey => {
Promise.resolve(notificationInstance[cacheKey]).then(instance => {
instance.destroy();
function open(config: ArgsProps) {
taskQueue.push({
type: 'open',
config,
});
delete notificationInstance[cacheKey]; // lgtm[js/missing-await]
flushNotice();
}
function destroy(key: React.Key) {
taskQueue.push({
type: 'destroy',
key,
});
},
flushNotice();
}
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 => {
api[type] = (args: ArgsProps) =>
api.open({
...args,
const staticMethods: typeof baseStaticMethods & Record<MethodType, (config: ArgsProps) => void> =
baseStaticMethods as any;
methods.forEach(type => {
staticMethods[type] = config =>
open({
...config,
type,
});
});
api.warn = api.warning;
api.useNotification = createUseNotification(getNotificationInstance, getRCNoticeProps);
// ==============================================================================
// == Test ==
// ==============================================================================
const noop = () => {};
export interface NotificationInstance {
success(args: ArgsProps): void;
error(args: ArgsProps): void;
info(args: ArgsProps): void;
warning(args: ArgsProps): void;
open(args: ArgsProps): void;
/** @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;
};
}
export interface NotificationApi extends NotificationInstance {
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;
export default staticMethods;

View File

@ -25,8 +25,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/Jxm5nw61w/Notification.svg
- `notification.warning(config)`
- `notification.warn(config)`
- `notification.open(config)`
- `notification.close(key: String)`
- `notification.destroy()`
- `notification.destroy(key?: String)`
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-menu": "~9.6.0",
"rc-motion": "^2.5.1",
"rc-notification": "~4.6.0",
"rc-notification": "~5.0.0-alpha.8",
"rc-pagination": "~3.1.9",
"rc-picker": "~2.6.4",
"rc-progress": "~3.2.1",
@ -157,7 +157,7 @@
"rc-tree-select": "~5.3.0",
"rc-trigger": "^5.2.10",
"rc-upload": "~4.3.0",
"rc-util": "^5.20.0",
"rc-util": "^5.21.3",
"scroll-into-view-if-needed": "^2.2.25",
"shallowequal": "^1.1.0"
},
@ -306,7 +306,7 @@
"bundlesize": [
{
"path": "./dist/antd.min.js",
"maxSize": "356 kB"
"maxSize": "358 kB"
},
{
"path": "./dist/antd.min.css",

View File

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

View File

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