From ff16f59908e68fd9d3a94aa0a2e55be722227b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=AB=E9=9B=A8=E6=B0=B4=E8=BF=87=E6=BB=A4=E7=9A=84?= =?UTF-8?q?=E7=A9=BA=E6=B0=94-Rain?= <958414905@qq.com> Date: Tue, 26 Sep 2023 13:47:20 +0800 Subject: [PATCH] perf: improve the UX of SchemaInitializer (#2666) * perf: improve the UX of SchemaInitializer * fix: fix error of Charts block * fix: fix fields * fix: fix search * chore: avoid crash * chore: fix build * chore: avoid crash * refactor: rename SelectCollection to SearchCollections * refactor: increased code versatility for improved reusability * fix: fix Add chart * perf: workflow * refactor: remove useless code * fix: fix block template * fix: should clean search value when creating a block --- .../core/client/src/hooks/useMenuItem.tsx | 12 +- .../schema-initializer/SchemaInitializer.tsx | 321 +++++++++++++++--- ...ctCollection.tsx => SearchCollections.tsx} | 2 +- .../client/src/schema-initializer/types.ts | 2 + .../client/src/schema-initializer/utils.ts | 235 ++++++------- .../src/schema-settings/SchemaSettings.tsx | 2 +- .../plugin-charts/src/client/index.tsx | 1 + .../src/client/block/ChartBlock.tsx | 7 +- .../client/block/ChartBlockInitializer.tsx | 39 ++- .../plugin-workflow/src/client/AddButton.tsx | 4 +- .../src/client/nodes/manual/SchemaConfig.tsx | 71 ++-- .../src/client/nodes/manual/forms/create.tsx | 48 +-- .../src/client/nodes/manual/forms/update.tsx | 50 +-- 13 files changed, 526 insertions(+), 268 deletions(-) rename packages/core/client/src/schema-initializer/{SelectCollection.tsx => SearchCollections.tsx} (95%) diff --git a/packages/core/client/src/hooks/useMenuItem.tsx b/packages/core/client/src/hooks/useMenuItem.tsx index bc5cc6e504..6794e1811a 100644 --- a/packages/core/client/src/hooks/useMenuItem.tsx +++ b/packages/core/client/src/hooks/useMenuItem.tsx @@ -33,7 +33,7 @@ export const useMenuItem = () => { const renderItems = useRef<() => JSX.Element>(null); const shouldRerender = useRef(false); - const Component = useCallback(() => { + const Component = useCallback(({ limitCount }) => { if (!shouldRerender.current) { return null; } @@ -43,6 +43,16 @@ export const useMenuItem = () => { return renderItems.current(); } + if (limitCount && list.current.length > limitCount) { + return ( + <> + {list.current.slice(0, limitCount).map((Com, index) => ( + + ))} + + ); + } + return ( <> {list.current.map((Com, index) => ( diff --git a/packages/core/client/src/schema-initializer/SchemaInitializer.tsx b/packages/core/client/src/schema-initializer/SchemaInitializer.tsx index 6466fc2e28..307e7317b5 100644 --- a/packages/core/client/src/schema-initializer/SchemaInitializer.tsx +++ b/packages/core/client/src/schema-initializer/SchemaInitializer.tsx @@ -1,13 +1,15 @@ import { ISchema, observer, useForm } from '@formily/react'; import { error, isString } from '@nocobase/utils/client'; -import { Button, Dropdown, MenuProps, Switch } from 'antd'; +import { Button, Dropdown, Empty, Menu, MenuProps, Spin, Switch } from 'antd'; import classNames from 'classnames'; // @ts-ignore -import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useTransition } from 'react'; +import { isEmpty } from 'lodash'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useCollectMenuItem, useMenuItem } from '../hooks/useMenuItem'; import { Icon } from '../icon'; import { SchemaComponent, useActionContext } from '../schema-component'; import { useCompile, useDesignable } from '../schema-component/hooks'; +import { SearchCollections } from './SearchCollections'; import { useStyles } from './style'; import { SchemaInitializerButtonProps, @@ -22,12 +24,212 @@ export const SchemaInitializerItemContext = createContext(null); export const SchemaInitializerButtonContext = createContext<{ visible?: boolean; setVisible?: (v: boolean) => void; - searchValue?: string; - setSearchValue?: (v: string) => void; }>({}); export const SchemaInitializer = () => null; +const CollectionSearch = ({ + onChange: _onChange, + clearValueRef, +}: { + onChange: (value: string) => void; + clearValueRef?: React.MutableRefObject<() => void>; +}) => { + const [searchValue, setSearchValue] = useState(''); + const onChange = useCallback( + (value) => { + setSearchValue(value); + _onChange(value); + }, + [_onChange], + ); + + if (clearValueRef) { + clearValueRef.current = () => { + setSearchValue(''); + }; + } + + return ; +}; + +const LoadingItem = ({ loadMore }) => { + const spinRef = React.useRef(null); + + useEffect(() => { + let root = spinRef.current; + + while (root) { + if (root.classList?.contains('ant-dropdown-menu')) { + break; + } + root = root.parentNode; + } + + const observer = new IntersectionObserver( + (entries) => { + for (const item of entries) { + if (item.isIntersecting) { + return loadMore(); + } + } + }, + { + root, + }, + ); + + observer.observe(spinRef.current); + return () => { + observer.disconnect(); + }; + }, [loadMore]); + + return ( +
+ +
+ ); +}; + +// 清除所有的 searchValue +const clearSearchValue = (items: any[]) => { + items.forEach((item) => { + if (item._clearSearchValueRef?.current) { + item._clearSearchValueRef.current(); + } + if (item.children?.length) { + clearSearchValue(item.children); + } + }); +}; + +/** + * 当存在 loadChildren 方法时,且 children 为空时,会触发懒加载 + * + * 1. 如果存在 loadChildren 方法,则需要懒加载 children + * 2. 一开始先注入一个 loading 组件,当 loading 显示的时候,触发 loadChildren 方法 + * 3. 每次在 loading 后显示的数目增加 minStep,直到显示完所有 children + * 4. 重置 item.label 为一个搜索框,用于筛选列表 + * 5. 每次组件刷新的时候,会重复上面的步骤 + * @param param0 + * @returns + */ +const lazyLoadChildren = ({ + items, + minStep = 30, + beforeLoading, + afterLoading, +}: { + items: any[]; + minStep?: number; + beforeLoading?: () => void; + afterLoading?: ({ currentCount }) => void; +}) => { + if (isEmpty(items)) { + return; + } + + const addLoading = (item: any, searchValue: string) => { + if (isEmpty(item.children)) { + item.children = []; + } + + item.children.push({ + key: `${item.key}-loading`, + label: ( + { + beforeLoading?.(); + item._allChildren = item.loadChildren({ searchValue }); + item._count += minStep; + item.children = item._allChildren?.slice(0, item._count); + if (item.children?.length < item._allChildren?.length) { + addLoading(item, searchValue); + } + afterLoading?.({ currentCount: item._count }); + }} + /> + ), + }); + }; + + for (const item of items) { + if (!item) { + continue; + } + + if (item.loadChildren && isEmpty(item.children)) { + item._count = 0; + item._clearSearchValueRef = {}; + item.label = ( + { + item._count = minStep; + beforeLoading?.(); + item._allChildren = item.loadChildren({ searchValue: value }); + + if (isEmpty(item._allChildren)) { + item.children = [ + { + key: 'empty', + label: , + }, + ]; + } else { + item.children = item._allChildren?.slice(0, item._count); + } + + if (item.children?.length < item._allChildren?.length) { + addLoading(item, value); + } + afterLoading?.({ currentCount: item._count }); + }} + /> + ); + + // 通过 loading 加载新数据 + addLoading(item, ''); + } + + lazyLoadChildren({ + items: item.children, + minStep, + beforeLoading, + afterLoading, + }); + } +}; + +const MenuWithLazyLoadChildren = ({ items: _items, style, clean, component: Component }) => { + const [items, setItems] = useState(_items); + const currentCountRef = useRef(0); + + useEffect(() => { + setItems(_items); + }, [_items]); + + lazyLoadChildren({ + items, + beforeLoading: () => { + clean(); + }, + afterLoading: ({ currentCount }) => { + currentCountRef.current = currentCount; + setItems([...items]); + }, + }); + + return ( + <> + {/* 用于收集 menu item */} + + + + ); +}; + SchemaInitializer.Button = observer( (props: SchemaInitializerButtonProps) => { const { @@ -46,26 +248,19 @@ SchemaInitializer.Button = observer( const compile = useCompile(); const { insertAdjacent, findComponent, designable } = useDesignable(); const [visible, setVisible] = useState(false); - const { Component: CollectionComponent, getMenuItem, clean } = useMenuItem(); - const [searchValue, setSearchValue] = useState(''); - const [isPending, startTransition] = useTransition(); + const { Component: CollectComponent, getMenuItem, clean } = useMenuItem(); const menuItems = useRef([]); const { styles } = useStyles(); const changeMenu = (v: boolean) => { - // 这里是为了防止当鼠标快速滑过时,终止菜单的渲染,防止卡顿 - startTransition(() => { - setVisible(v); - }); + setVisible(v); }; if (!designable && props.designable !== true) { return null; } - const buttonDom = component ? ( - component - ) : ( + const buttonDom = component || ( - + } > - +
{open && schema && ( diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/forms/create.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/forms/create.tsx index b36f4d7fcf..62a72aaebd 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/forms/create.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/forms/create.tsx @@ -1,12 +1,11 @@ import React from 'react'; -import { useFieldSchema } from '@formily/react'; -import { GeneralSchemaDesigner, SchemaSettings, useCollection, useCollectionManager } from '@nocobase/client'; +import { GeneralSchemaDesigner, SchemaSettings, useCollection } from '@nocobase/client'; import { NAMESPACE } from '../../../locale'; -import { findSchema } from '../utils'; -import { ManualFormType } from '../SchemaConfig'; import { FormBlockInitializer } from '../FormBlockInitializer'; +import { ManualFormType } from '../SchemaConfig'; +import { findSchema } from '../utils'; function CreateFormDesigner() { const { name, title } = useCollection(); @@ -30,26 +29,37 @@ function CreateFormDesigner() { export default { title: `{{t("Create record form", { ns: "${NAMESPACE}" })}}`, config: { - useInitializer() { - const { collections } = useCollectionManager(); + useInitializer({ collections }) { return { key: 'createRecordForm', type: 'subMenu', title: `{{t("Create record form", { ns: "${NAMESPACE}" })}}`, - children: collections - .filter((item) => !item.hidden) - .map((item) => ({ - key: `createForm-${item.name}`, - type: 'item', - title: item.title, - schema: { - collection: item.name, - title: `{{t("Create record", { ns: "${NAMESPACE}" })}}`, - formType: 'create', - 'x-designer': 'CreateFormDesigner', + children: [ + { + key: 'createRecordForm-child', + type: 'itemGroup', + style: { + maxHeight: '48vh', + overflowY: 'auto', }, - component: FormBlockInitializer, - })), + loadChildren: ({ searchValue }) => { + return collections + .filter((item) => !item.hidden && item.title.includes(searchValue)) + .map((item) => ({ + key: `createRecordForm-child-${item.name}`, + type: 'item', + title: item.title, + schema: { + collection: item.name, + title: `{{t("Create record", { ns: "${NAMESPACE}" })}}`, + formType: 'create', + 'x-designer': 'CreateFormDesigner', + }, + component: FormBlockInitializer, + })); + }, + }, + ], }; }, initializers: { diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/forms/update.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/forms/update.tsx index 3facff263e..0263351455 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/forms/update.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/manual/forms/update.tsx @@ -1,5 +1,5 @@ -import React from 'react'; import { useFieldSchema } from '@formily/react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -7,15 +7,14 @@ import { SchemaSettings, useCollection, useCollectionFilterOptions, - useCollectionManager, useDesignable, } from '@nocobase/client'; -import { NAMESPACE } from '../../../locale'; -import { findSchema } from '../utils'; -import { ManualFormType } from '../SchemaConfig'; import { FilterDynamicComponent } from '../../../components/FilterDynamicComponent'; +import { NAMESPACE } from '../../../locale'; import { FormBlockInitializer } from '../FormBlockInitializer'; +import { ManualFormType } from '../SchemaConfig'; +import { findSchema } from '../utils'; function UpdateFormDesigner() { const { name, title } = useCollection(); @@ -71,26 +70,37 @@ function UpdateFormDesigner() { export default { title: `{{t("Update record form", { ns: "${NAMESPACE}" })}}`, config: { - useInitializer() { - const { collections } = useCollectionManager(); + useInitializer({ collections }) { return { key: 'updateRecordForm', type: 'subMenu', title: `{{t("Update record form", { ns: "${NAMESPACE}" })}}`, - children: collections - .filter((item) => !item.hidden) - .map((item) => ({ - key: `updateForm-${item.name}`, - type: 'item', - title: item.title, - schema: { - collection: item.name, - title: `{{t("Update record", { ns: "${NAMESPACE}" })}}`, - formType: 'update', - 'x-designer': 'UpdateFormDesigner', + children: [ + { + key: 'updateRecordForm-child', + type: 'itemGroup', + style: { + maxHeight: '48vh', + overflowY: 'auto', }, - component: FormBlockInitializer, - })), + loadChildren: ({ searchValue }) => { + return collections + .filter((item) => !item.hidden && item.title.includes(searchValue)) + .map((item) => ({ + key: `updateRecordForm-child-${item.name}`, + type: 'item', + title: item.title, + schema: { + collection: item.name, + title: `{{t("Update record", { ns: "${NAMESPACE}" })}}`, + formType: 'update', + 'x-designer': 'UpdateFormDesigner', + }, + component: FormBlockInitializer, + })); + }, + }, + ], }; }, initializers: {