feat: Message hooks API (#25422)

* chore: comment on usePatchElement

* refactor: conform message & notifaction code logic

* feat: message useMessage, wip

* feat: message.useMessage, it works now

* fix: promise on regular api

* feat: message hooks

* chore: fix lint

* chore: new line

* chore: revert new line

* refactor: prefixCls

* fix: prefixCls

* test: cov

* chore

* chore

* chore

* chore

* docs

* docs: message hooks faq

* test: remove useless config provider

* chore: remove some test codes

* chore

* docs: hooks version
This commit is contained in:
07akioni 2020-07-15 19:51:56 +08:00 committed by GitHub
parent 6a65b47df3
commit 01cec29a8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 515 additions and 84 deletions

View File

@ -23,6 +23,7 @@ lib/**/*
node_modules
_site
dist
coverage
**/*.d.ts
# Scripts
scripts/previewEditor/**/*

View File

@ -7,8 +7,11 @@ export default function usePatchElement(): [
const [elements, setElements] = React.useState<React.ReactElement[]>([]);
function patchElement(element: React.ReactElement) {
// append a new element to elements (and create a new ref)
setElements(originElements => [...originElements, element]);
// return a function that removes the new element out of elements (and create a new ref)
// it works a little like useEffect
return () => {
setElements(originElements => originElements.filter(ele => ele !== element));
};

View File

@ -22,6 +22,17 @@ exports[`renders ./components/message/demo/duration.md correctly 1`] = `
</button>
`;
exports[`renders ./components/message/demo/hooks.md correctly 1`] = `
<button
class="ant-btn ant-btn-primary"
type="button"
>
<span>
Display normal message
</span>
</button>
`;
exports[`renders ./components/message/demo/info.md correctly 1`] = `
<button
class="ant-btn ant-btn-primary"

View File

@ -0,0 +1,195 @@
/* eslint-disable jsx-a11y/control-has-associated-label */
import React from 'react';
import { mount } from 'enzyme';
import message from '..';
import ConfigProvider from '../../config-provider';
describe('message.hooks', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
afterEach(() => {
message.destroy();
});
it('should work', () => {
const Context = React.createContext('light');
const Demo = () => {
const [api, holder] = message.useMessage();
return (
<ConfigProvider prefixCls="my-test">
<Context.Provider value="bamboo">
<button
type="button"
onClick={() => {
api.open({
content: (
<Context.Consumer>
{name => <span className="hook-test-result">{name}</span>}
</Context.Consumer>
),
duration: 0,
});
}}
/>
{holder}
</Context.Provider>
</ConfigProvider>
);
};
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');
});
it('should work with success', () => {
const Context = React.createContext('light');
const Demo = () => {
const [api, holder] = message.useMessage();
return (
<ConfigProvider prefixCls="my-test">
<Context.Provider value="bamboo">
<button
type="button"
onClick={() => {
api.success({
content: (
<Context.Consumer>
{name => <span className="hook-test-result">{name}</span>}
</Context.Consumer>
),
duration: 0,
});
}}
/>
{holder}
</Context.Provider>
</ConfigProvider>
);
};
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');
});
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 (
<>
<button
type="button"
onClick={() => {
api.open({
content: 'amazing',
duration: 1,
onClose() {
done();
},
});
}}
/>
{holder}
</>
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
jest.useFakeTimers();
});
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 (
<>
<button
type="button"
onClick={() => {
api
.open({
content: 'good',
duration: 1,
})
.then(() => {
done();
});
}}
/>
{holder}
</>
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
jest.useFakeTimers();
});
it('should work with hide', () => {
let hide;
const Demo = () => {
const [api, holder] = message.useMessage();
return (
<ConfigProvider prefixCls="my-test">
<button
type="button"
onClick={() => {
hide = api.open({
content: 'nice',
duration: 0,
});
}}
/>
{holder}
</ConfigProvider>
);
};
const wrapper = mount(<Demo />);
wrapper.find('button').simulate('click');
jest.runAllTimers();
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(1);
hide();
jest.runAllTimers();
expect(document.querySelectorAll('.my-test-message-notice').length).toBe(0);
});
it('should be same hook', () => {
let count = 0;
const Demo = () => {
const [, forceUpdate] = React.useState({});
const [api] = message.useMessage();
React.useEffect(() => {
count += 1;
expect(count).toEqual(1);
forceUpdate();
}, [api]);
return null;
};
mount(<Demo />);
});
});

View File

@ -0,0 +1,42 @@
---
order: 10
title:
zh-CN: 通过 Hooks 获取上下文4.5.0+
en-US: Get context with hooks (4.5.0+)
---
## zh-CN
通过 `message.useMessage` 创建支持读取 context 的 `contextHolder`
## en-US
Use `message.useMessage` to get `contextHolder` with context accessible issue.
```jsx
import { message, Button } from 'antd';
const Context = React.createContext({ name: 'Default' });
function Demo() {
const [messsageApi, contextHolder] = message.useMessage();
const info = () => {
messsageApi.open({
type: 'info',
content: <Context.Consumer>{({ name }) => `Hello, ${name}!`}</Context.Consumer>,
duration: 1,
});
};
return (
<Context.Provider value={{ name: 'Ant Design' }}>
{contextHolder}
<Button type="primary" onClick={info}>
Display normal message
</Button>
</Context.Provider>
);
}
ReactDOM.render(<Demo />, mountNode);
```

View File

@ -0,0 +1,92 @@
import * as React from 'react';
import useRCNotification from 'rc-notification/lib/useNotification';
import {
NotificationInstance as RCNotificationInstance,
NoticeContent as RCNoticeContent,
HolderReadyCallback as RCHolderReadyCallback,
} from 'rc-notification/lib/Notification';
import { ConfigConsumer, ConfigConsumerProps } from '../../config-provider';
import {
MessageInstance,
ArgsProps,
attachTypeApi,
ThenableArgument,
getKeyThenIncreaseKey,
} 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'];
// 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 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,
},
({ 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;
['success', 'info', 'warning', 'error', 'loading'].forEach(type =>
attachTypeApi(hookApiRef.current, type),
);
return [
hookApiRef.current,
<ConfigConsumer key="holder">
{(context: ConfigConsumerProps) => {
({ getPrefixCls } = context);
return holder;
}}
</ConfigConsumer>,
];
};
return useMessage;
}

View File

@ -90,3 +90,27 @@ message.config({
| top | Distance from top | number | 24 | |
| rtl | Whether to enable RTL mode | boolean | false | |
| prefixCls | The prefix className of message node | string | `ant-message` | 4.5.0 |
## FAQ
### Why I can not access context, redux in message?
antd will dynamic create React instance by `ReactDOM.render` when call message methods. Whose context is different with origin code located context.
When you need context info (like ConfigProvider context), you can use `message.useMessage` to get `api` instance and `contextHolder` node. And put it in your children:
```tsx
const [api, contextHolder] = message.useMessage();
return (
<Context1.Provider value="Ant">
{/* contextHolder is inside Context1 which means api will get value of Context1 */}
{contextHolder}
<Context2.Provider value="Design">
{/* contextHolder is outside Context2 which means api will **not** get value of Context2 */}
</Context2.Provider>
</Context1.Provider>
);
```
**Note:** You must insert `contextHolder` into your children with hooks. You can use origin method if you do not need context connection.

View File

@ -1,28 +1,83 @@
import * as React from 'react';
import classNames from 'classnames';
import Notification from 'rc-notification';
import RCNotification from 'rc-notification';
import {
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';
type NoticeType = 'info' | 'success' | 'error' | 'warning' | 'loading';
let messageInstance: RCNotificationInstance | null;
let defaultDuration = 3;
let defaultTop: number;
let messageInstance: any;
let key = 1;
let prefixCls = 'ant-message';
let localPrefixCls = 'ant-message';
let transitionName = 'move-up';
let getContainer: () => HTMLElement;
let maxCount: number;
let rtl = false;
function getMessageInstance(callback: (i: any) => void) {
export function getKeyThenIncreaseKey() {
return key++;
}
export interface ConfigOptions {
top?: number;
duration?: number;
prefixCls?: string;
getContainer?: () => HTMLElement;
transitionName?: string;
maxCount?: number;
rtl?: 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;
}
if (options.transitionName !== undefined) {
transitionName = options.transitionName;
messageInstance = null; // delete messageInstance for new transitionName
}
if (options.maxCount !== undefined) {
maxCount = options.maxCount;
messageInstance = null;
}
if (options.rtl !== undefined) {
rtl = options.rtl;
}
}
function getRCNotificationInstance(
args: ArgsProps,
callback: (info: { prefixCls: string; instance: RCNotificationInstance }) => void,
) {
const prefixCls = args.prefixCls || localPrefixCls;
if (messageInstance) {
callback(messageInstance);
callback({
prefixCls,
instance: messageInstance,
});
return;
}
Notification.newInstance(
RCNotification.newInstance(
{
prefixCls,
transitionName,
@ -32,17 +87,21 @@ function getMessageInstance(callback: (i: any) => void) {
},
(instance: any) => {
if (messageInstance) {
callback(messageInstance);
callback({
prefixCls,
instance: messageInstance,
});
return;
}
messageInstance = instance;
callback(instance);
callback({
prefixCls,
instance,
});
},
);
}
type NoticeType = 'info' | 'success' | 'error' | 'warning' | 'loading';
export interface ThenableArgument {
(val: any): void;
}
@ -53,10 +112,18 @@ export interface MessageType {
promise: Promise<void>;
}
const typeToIcon = {
info: InfoCircleFilled,
success: CheckCircleFilled,
error: CloseCircleFilled,
warning: ExclamationCircleFilled,
loading: LoadingOutlined,
};
export interface ArgsProps {
content: React.ReactNode;
duration: number | null;
type: NoticeType;
prefixCls?: string;
onClose?: () => void;
icon?: React.ReactNode;
key?: string | number;
@ -64,34 +131,15 @@ export interface ArgsProps {
className?: string;
}
const iconMap = {
info: InfoCircleFilled,
success: CheckCircleFilled,
error: CloseCircleFilled,
warning: ExclamationCircleFilled,
loading: LoadingOutlined,
};
function notice(args: ArgsProps): MessageType {
function getRCNoticeProps(args: ArgsProps, prefixCls: string): NoticeContent {
const duration = args.duration !== undefined ? args.duration : defaultDuration;
const IconComponent = iconMap[args.type];
const IconComponent = typeToIcon[args.type];
const messageClass = classNames(`${prefixCls}-custom-content`, {
[`${prefixCls}-${args.type}`]: args.type,
[`${prefixCls}-rtl`]: rtl === true,
});
const target = args.key || key++;
const closePromise = new Promise(resolve => {
const callback = () => {
if (typeof args.onClose === 'function') {
args.onClose();
}
return resolve(true);
};
getMessageInstance(instance => {
instance.notice({
key: target,
return {
key: args.key,
duration,
style: args.style || {},
className: args.className,
@ -101,8 +149,21 @@ function notice(args: ArgsProps): MessageType {
<span>{args.content}</span>
</div>
),
onClose: callback,
});
onClose: args.onClose,
};
}
function notice(args: ArgsProps): MessageType {
const target = args.key || key++;
const closePromise = new Promise(resolve => {
const callback = () => {
if (typeof args.onClose === 'function') {
args.onClose();
}
return resolve(true);
};
getRCNotificationInstance(args, ({ prefixCls, instance }) => {
instance.notice(getRCNoticeProps({ ...args, key: target, onClose: callback }, prefixCls));
});
});
const result: any = () => {
@ -128,44 +189,9 @@ function isArgsProps(content: JointContent): content is ArgsProps {
);
}
export interface ConfigOptions {
top?: number;
duration?: number;
prefixCls?: string;
getContainer?: () => HTMLElement;
transitionName?: string;
maxCount?: number;
rtl?: boolean;
}
const api: any = {
open: notice,
config(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) {
prefixCls = options.prefixCls;
}
if (options.getContainer !== undefined) {
getContainer = options.getContainer;
}
if (options.transitionName !== undefined) {
transitionName = options.transitionName;
messageInstance = null; // delete messageInstance for new transitionName
}
if (options.maxCount !== undefined) {
maxCount = options.maxCount;
messageInstance = null;
}
if (options.rtl !== undefined) {
rtl = options.rtl;
}
},
config: setMessageConfig,
destroy() {
if (messageInstance) {
messageInstance.destroy();
@ -174,10 +200,14 @@ const api: any = {
},
};
['success', 'info', 'warning', 'error', 'loading'].forEach(type => {
api[type] = (content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose) => {
export function attachTypeApi(originalApi: any, type: string) {
originalApi[type] = (
content: JointContent,
duration?: ConfigDuration,
onClose?: ConfigOnClose,
) => {
if (isArgsProps(content)) {
return api.open({ ...content, type });
return originalApi.open({ ...content, type });
}
if (typeof duration === 'function') {
@ -185,22 +215,29 @@ const api: any = {
duration = undefined;
}
return api.open({ content, duration, type, onClose });
return originalApi.open({ content, duration, type, onClose });
};
});
}
['success', 'info', 'warning', 'error', 'loading'].forEach(type => attachTypeApi(api, type));
api.warn = api.warning;
api.useMessage = createUseMessage(getRCNotificationInstance, getRCNoticeProps);
export interface MessageApi {
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;
warn(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
warning(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
loading(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
open(args: ArgsProps): MessageType;
}
export interface MessageApi extends MessageInstance {
warn(content: JointContent, duration?: ConfigDuration, onClose?: ConfigOnClose): MessageType;
config(options: ConfigOptions): void;
destroy(): void;
useMessage(): [MessageInstance, React.ReactElement];
}
export default api as MessageApi;

View File

@ -91,3 +91,27 @@ message.config({
| top | 消息距离顶部的位置 | number | 24 | |
| rtl | 是否开启 RTL 模式 | boolean | false | |
| prefixCls | 消息节点的 className 前缀 | string | `ant-message` | 4.5.0 |
## FAQ
### 为什么 message 不能获取 context、redux 的内容?
直接调用 message 方法antd 会通过 `ReactDOM.render` 动态创建新的 React 实体。其 context 与当前代码所在 context 并不相同,因而无法获取 context 信息。
当你需要 context 信息(例如 ConfigProvider 配置的内容)时,可以通过 `message.useMessage` 方法会返回 `api` 实体以及 `contextHolder` 节点。将其插入到你需要获取 context 位置即可:
```tsx
const [api, contextHolder] = message.useMessage();
return (
<Context1.Provider value="Ant">
{/* contextHolder 在 Context1 内,它可以获得 Context1 的 context */}
{contextHolder}
<Context2.Provider value="Design">
{/* contextHolder 在 Context2 外,因而不会获得 Context2 的 context */}
</Context2.Provider>
</Context1.Provider>
);
```
**异同:**通过 hooks 创建的 `contextHolder` 必须插入到子元素节点中才会生效,当你不需要上下文信息时请直接调用。

View File

@ -23,6 +23,7 @@ let defaultPrefixCls = 'ant-notification';
let defaultPlacement: NotificationPlacement = 'topRight';
let defaultGetContainer: () => HTMLElement;
let defaultCloseIcon: React.ReactNode;
let rtl = false;
export interface ConfigProps {
top?: number;
@ -35,7 +36,6 @@ export interface ConfigProps {
rtl?: boolean;
}
let rtl = false;
function setNotificationConfig(options: ConfigProps) {
const { duration, placement, bottom, top, getContainer, closeIcon, prefixCls } = options;
if (prefixCls !== undefined) {
@ -225,12 +225,14 @@ function getRCNoticeProps(args: ArgsProps, prefixCls: string) {
};
}
const api: any = {
open: (args: ArgsProps) => {
function notice(args: ArgsProps) {
getNotificationInstance(args, ({ prefixCls, instance }) => {
instance.notice(getRCNoticeProps(args, prefixCls));
});
},
}
const api: any = {
open: notice,
close(key: string) {
Object.keys(notificationInstance).forEach(cacheKey =>
Promise.resolve(notificationInstance[cacheKey]).then(instance => {