Skip to content

Commit

Permalink
feat: Message hooks API (ant-design#25422)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
07akioni authored Jul 15, 2020
1 parent 6a65b47 commit 01cec29
Show file tree
Hide file tree
Showing 10 changed files with 515 additions and 84 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ lib/**/*
node_modules
_site
dist
coverage
**/*.d.ts
# Scripts
scripts/previewEditor/**/*
3 changes: 3 additions & 0 deletions components/_util/usePatchElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};
Expand Down
11 changes: 11 additions & 0 deletions components/message/__tests__/__snapshots__/demo.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
195 changes: 195 additions & 0 deletions components/message/__tests__/hooks.test.js
Original file line number Diff line number Diff line change
@@ -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 />);
});
});
42 changes: 42 additions & 0 deletions components/message/demo/hooks.md
Original file line number Diff line number Diff line change
@@ -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);
```
92 changes: 92 additions & 0 deletions components/message/hooks/useMessage.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 24 additions & 0 deletions components/message/index.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading

0 comments on commit 01cec29

Please sign in to comment.