Browse Source

feat: toast support change toast content by id. (#1106)

* chore: change “rm -rf” to rimraf, because cross platform

* feat: toast, update content by id

* docs: add toast update content with id EN docs

* chore: remove trash

* chore: reverse change

Co-authored-by: gwsbhqt <[email protected]>
Co-authored-by: pointhalo <[email protected]>
代强 3 năm trước cách đây
mục cha
commit
f71d6fade6

+ 27 - 0
content/feedback/toast/index-en-US.md

@@ -237,6 +237,33 @@ function Demo() {
 render(Demo);
 ```
 
+### Update Toast Content
+
+Use unique Toast `id` to update toast content.
+
+```jsx live=true noInline=true hideInDSM
+import React, { useState } from 'react';
+import { Toast, Button } from '@douyinfe/semi-ui';
+
+function Demo() {
+    function show() {
+        const id = 'toastid';
+        Toast.info({ content: 'Update Content By Id', id });
+        setTimeout(() => {
+            Toast.success({ content: 'Id By Content Update', id });
+        }, 1000);
+    }
+
+    return (
+        <Button type="primary" onClick={show}>
+            Update Content By Id
+        </Button>
+    );
+}
+
+render(Demo);
+```
+
 ### useToast Hooks
 
 You could use `Toast.useToast` to create a `contextHolder` that could access context. Created toast will be inserted to where contextHolder is placed.

+ 29 - 2
content/feedback/toast/index.md

@@ -238,6 +238,33 @@ function Demo() {
 render(Demo);
 ```
 
+### 更新消息内容
+
+可以通过唯一的 `id` 来更新内容。
+
+```jsx live=true noInline=true hideInDSM
+import React, { useState } from 'react';
+import { Toast, Button } from '@douyinfe/semi-ui';
+
+function Demo() {
+    function show() {
+        const id = 'toastid';
+        Toast.info({ content: 'Update Content By Id', id });
+        setTimeout(() => {
+            Toast.success({ content: 'Id By Content Update', id });
+        }, 1000);
+    }
+
+    return (
+        <Button type="primary" onClick={show}>
+            Update Content By Id
+        </Button>
+    );
+}
+
+render(Demo);
+```
+
 ### Hooks 用法
 
 通过 Toast.useToast 创建支持读取 context 的 contextHolder。此时的 toast 会渲染在 contextHolder 所在的节点处。
@@ -438,8 +465,8 @@ HookToast ( >= 1.2.0 ):
   - 不使用类似于「已读」类的动作,例如 OK, Got it, Dismiss, Cancel
 
 
-| ✅ 推荐用法 | ❌ 不推荐用法 |   
-| --- | --- | 
+| ✅ 推荐用法 | ❌ 不推荐用法 |
+| --- | --- |
 |  <ToastCard type='error' content={<div>Ticket transfer failed <span style={{ color: 'var(--semi-color-primary)', marginLeft: 4, cursor: 'pointer' }}>Retry</span> </div>} /> |  <ToastCard type='error' content={<div>Ticket transfer failed <span style={{ color: 'var(--semi-color-primary)', marginLeft: 4, cursor: 'pointer' }}>Dismiss</span> </div>} /> |
 
 ## 设计变量

+ 2 - 2
package.json

@@ -15,7 +15,7 @@
         "develop": "npm run pre-develop && gatsby clean && lerna run build:lib --scope @douyinfe/semi-webpack-plugin --scope eslint-plugin-semi-design && gatsby develop -H 0.0.0.0 --port=3666 --verbose",
         "scripts:changelog": "node scripts/changelog.js",
         "start": "npm run story",
-        "pre-story": "lerna exec --scope=@douyinfe/semi-ui --scope=@douyinfe/semi-foundation -- rm -rf ./lib && lerna run build:lib --scope @douyinfe/semi-webpack-plugin --scope eslint-plugin-semi-design",
+        "pre-story": "lerna exec --scope=@douyinfe/semi-ui --scope=@douyinfe/semi-foundation -- rimraf ./lib && lerna run build:lib --scope @douyinfe/semi-webpack-plugin --scope eslint-plugin-semi-design",
         "story": "npm run pre-story && start-storybook -c ./.storybook/js/ -p 6006",
         "story:ts": "npm run pre-story && && start-storybook -c ./.storybook/ts/ -p 6007",
         "story:ani": "npm run pre-story && && start-storybook -c ./.storybook/animation/react -p 6008",
@@ -34,7 +34,7 @@
         "build:js": "lerna run build:js",
         "build:css": "lerna run build:css",
         "build-storybook": "build-storybook  -c ./.storybook/js/",
-        "build:gatsbydoc": "lerna run build:lib --scope @douyinfe/semi-webpack-plugin --scope eslint-plugin-semi-design && cross-env NODE_ENV=production node --max_old_space_size=16384 ./node_modules/gatsby/cli.js build --prefix-paths --verbose && rm -rf build && mv public build",
+        "build:gatsbydoc": "lerna run build:lib --scope @douyinfe/semi-webpack-plugin --scope eslint-plugin-semi-design && cross-env NODE_ENV=production node --max_old_space_size=16384 ./node_modules/gatsby/cli.js build --prefix-paths --verbose && rimraf build && mv public build",
         "build:icon": "lerna run build:icon --scope='@douyinfe/semi-{icons,illustrations}'",
         "cypress:coverage": "npx wait-on http://127.0.0.1:6006 && ./node_modules/.bin/cypress run",
         "postcypress:coverage": "yarn coverage:merge",

+ 16 - 4
packages/semi-foundation/toast/toastListFoundation.ts

@@ -9,10 +9,11 @@ export interface ToastListProps{
 export interface ToastListState{
     list: ToastInstance[];
     removedItems: ToastInstance[];
+    updatedItems: ToastInstance[];
 }
 
 export interface ToastListAdapter extends DefaultAdapter<ToastListProps, ToastListState>{
-    updateToast: (list: ToastListState['list'], removedItems: ToastListState['removedItems']) => void;
+    updateToast: (list: ToastListState['list'], removedItems: ToastListState['removedItems'], updatedItems: ToastListState['updatedItems']) => void;
 }
 
 export default class ToastListFoundation extends BaseFoundation<ToastListAdapter> {
@@ -22,6 +23,10 @@ export default class ToastListFoundation extends BaseFoundation<ToastListAdapter
         super({ ...ToastListFoundation.defaultAdapter, ...adapter });
     }
 
+    hasToast(id: string) {
+        const toastList = this._adapter.getState('list') as ToastListState['list'];
+        return toastList.map(({ id }) =>id).includes(id);
+    }
 
     addToast(toastOpts: ToastProps) {
         const toastList = this._adapter.getState('list') as ToastListState['list'];
@@ -29,10 +34,17 @@ export default class ToastListFoundation extends BaseFoundation<ToastListAdapter
         // let toastOpts = { ...opts, id };
         // console.log(toastOpts);
         toastList.push(toastOpts);
-        this._adapter.updateToast(toastList, []);
+        this._adapter.updateToast(toastList, [], []);
         // return id;
     }
 
+    updateToast(id: string, toastOpts: ToastProps) {
+        let toastList = this._adapter.getState('list') as ToastListState['list'];
+        toastList = toastList.map((toast) => toast.id === id ? { ...toast, ...toastOpts }: toast);
+        const updatedItems = toastList.filter((toast => toast.id === id));
+        this._adapter.updateToast(toastList, [], updatedItems);
+    }
+
     removeToast(id: string) {
         let toastList = this._adapter.getState('list') as ToastListState['list'];
         const removedItems: ToastListState['removedItems'] = [];
@@ -43,13 +55,13 @@ export default class ToastListFoundation extends BaseFoundation<ToastListAdapter
             }
             return true;
         });
-        this._adapter.updateToast(toastList, removedItems);
+        this._adapter.updateToast(toastList, removedItems, []);
     }
 
     destroyAll() {
         const toastList = this._adapter.getState('list');
         if (toastList.length > 0) {
-            this._adapter.updateToast([], toastList);
+            this._adapter.updateToast([], toastList, []);
         }
     }
 }

+ 16 - 0
packages/semi-ui/toast/__test__/toast.test.js

@@ -45,6 +45,22 @@ describe('Toast', () => {
             done();
         }, 1500);
     });
+    it('update content by id', done => {
+        const id = 'toastid'
+        Toast.info({ content: 'bytedance', id });
+        let toast = document.querySelector(`.${BASE_CLASS_PREFIX}-toast-info`);
+        expect(toast.textContent).toEqual('bytedance');
+        setTimeout(() => {
+            Toast.info({ content: 'dancebyte', id });
+            expect(toast.textContent).toEqual('dancebyte');
+            setTimeout(() => {
+                Toast.error({ content: 'error', id });
+                expect(toast.textContent).toEqual('error');
+                expect(toast?.className).toEqual(`${BASE_CLASS_PREFIX}-toast ${BASE_CLASS_PREFIX}-toast-error`)
+                done()
+            }, 1000)
+        }, 1000)
+    });
     it('should trigger onClose after duration', done => {
         let spyOnClose = sinon.spy(() => {});
         let opts = {

+ 12 - 1
packages/semi-ui/toast/_story/toast.stories.js

@@ -77,6 +77,17 @@ export const _Toast = () => (
         After 3s
       </Button>
     </div>
+    <div style={{ margin: '10px' }}>
+      <Button onClick={() => {
+        const id = 'toastid'
+        Toast.error({ id, content: 'error' })
+        setTimeout(() => Toast.info({ id, content: 'info' }), 2000)
+        setTimeout(() => Toast.success({ id, content: 'success' }), 4000)
+        setTimeout(() => Toast.info({ id, content: 'duration 3 -> 0', duration: 0 }), 6000)
+      }}>
+        update content by id
+      </Button>
+    </div>
     <div style={{ width: '300px', height: '300px', background: '#cccccc' }} id="popup-container">
       popup-container
     </div>
@@ -101,7 +112,7 @@ const ReachableContext = React.createContext();
 
 /**
  * test with cypress
- * @returns 
+ * @returns
  */
 export const useToastDemo = () => {
   const [toast, contextHolder] = Toast.useToast();

+ 29 - 6
packages/semi-ui/toast/index.tsx

@@ -19,6 +19,7 @@ import { Motion } from '../_base/base';
 
 export { ToastTransitionProps } from './ToastTransition';
 export interface ToastReactProps extends ToastProps{
+    id?: string;
     style?: CSSProperties;
     icon?: React.ReactNode;
     content: React.ReactNode;
@@ -55,6 +56,7 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
         this.state = {
             list: [],
             removedItems: [],
+            updatedItems: []
         };
         this.foundation = new ToastListFoundation(this.adapter);
     }
@@ -62,14 +64,14 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
     get adapter(): ToastListAdapter {
         return {
             ...super.adapter,
-            updateToast: (list: ToastInstance[], removedItems: ToastInstance[]) => {
-                this.setState({ list, removedItems });
+            updateToast: (list: ToastInstance[], removedItems: ToastInstance[], updatedItems: ToastInstance[]) => {
+                this.setState({ list, removedItems, updatedItems });
             },
         };
     }
 
     static create(opts: ToastReactProps) {
-        const id = getUuid('toast');
+        const id = opts.id ?? getUuid('toast');
         // this.id = id;
         if (!ToastList.ref) {
             const div = document.createElement('div');
@@ -108,7 +110,11 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
                     node.style[pos] = typeof opts[pos] === 'number' ? `${opts[pos]}px` : opts[pos];
                 }
             });
-            ToastList.ref.add({ ...opts, id });
+            if (ToastList.ref.has(id)) {
+                ToastList.ref.update(id, { ...opts, id });
+            } else {
+                ToastList.ref.add({ ...opts, id });
+            }
         }
         return id;
     }
@@ -178,10 +184,18 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
         }
     }
 
+    has(id: string) {
+        return this.foundation.hasToast(id);
+    }
+
     add(opts: ToastInstance) {
         return this.foundation.addToast(opts);
     }
 
+    update(id: string, opts: ToastInstance) {
+        return this.foundation.updateToast(id, opts);
+    }
+
     remove(id: string) {
         return this.foundation.removeToast(id);
     }
@@ -192,8 +206,16 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
 
     render() {
         let { list } = this.state;
-        const { removedItems } = this.state;
+        const { removedItems, updatedItems } = this.state;
         list = Array.from(new Set([...list, ...removedItems]));
+        const updatedIds = updatedItems.map(({ id }) => id);
+
+        const refFn: React.LegacyRef<Toast> = (toast) => {
+            if (toast?.foundation?._id && updatedIds.includes(toast.foundation._id)) {
+                toast.foundation.setState({ duration: toast.props.duration });
+                toast.foundation.restartCloseTimer();
+            }
+        };
 
         return (
             <React.Fragment>
@@ -207,11 +229,12 @@ const createBaseToast = () => class ToastList extends BaseComponent<ToastListPro
                                         {...item}
                                         style={{ ...transitionStyle, ...item.style }}
                                         close={id => this.remove(id)}
+                                        ref={refFn}
                                     />
                                 )}
                         </ToastTransition>
                     ) : (
-                        <Toast {...item} style={{ ...item.style }} close={id => this.remove(id)} />
+                        <Toast {...item} style={{ ...item.style }} close={id => this.remove(id)} ref={refFn} />
                     ))
                 )}
             </React.Fragment>

+ 4 - 0
packages/semi-ui/toast/toast.tsx

@@ -86,6 +86,10 @@ class Toast extends BaseComponent<ToastReactProps, ToastState> {
         this.foundation.startCloseTimer_();
     };
 
+    restartCloseTimer = () => {
+        this.foundation.restartCloseTimer();
+    }
+
     renderIcon() {
         const { type, icon } = this.props;
         const iconMap = {