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
This commit is contained in:
被雨水过滤的空气-Rain 2023-09-26 13:47:20 +08:00 committed by GitHub
parent 8db9fda61b
commit ff16f59908
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 526 additions and 268 deletions

View File

@ -33,7 +33,7 @@ export const useMenuItem = () => {
const renderItems = useRef<() => JSX.Element>(null); const renderItems = useRef<() => JSX.Element>(null);
const shouldRerender = useRef(false); const shouldRerender = useRef(false);
const Component = useCallback(() => { const Component = useCallback(({ limitCount }) => {
if (!shouldRerender.current) { if (!shouldRerender.current) {
return null; return null;
} }
@ -43,6 +43,16 @@ export const useMenuItem = () => {
return renderItems.current(); return renderItems.current();
} }
if (limitCount && list.current.length > limitCount) {
return (
<>
{list.current.slice(0, limitCount).map((Com, index) => (
<Com key={index} />
))}
</>
);
}
return ( return (
<> <>
{list.current.map((Com, index) => ( {list.current.map((Com, index) => (

View File

@ -1,13 +1,15 @@
import { ISchema, observer, useForm } from '@formily/react'; import { ISchema, observer, useForm } from '@formily/react';
import { error, isString } from '@nocobase/utils/client'; 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'; import classNames from 'classnames';
// @ts-ignore // @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 { useCollectMenuItem, useMenuItem } from '../hooks/useMenuItem';
import { Icon } from '../icon'; import { Icon } from '../icon';
import { SchemaComponent, useActionContext } from '../schema-component'; import { SchemaComponent, useActionContext } from '../schema-component';
import { useCompile, useDesignable } from '../schema-component/hooks'; import { useCompile, useDesignable } from '../schema-component/hooks';
import { SearchCollections } from './SearchCollections';
import { useStyles } from './style'; import { useStyles } from './style';
import { import {
SchemaInitializerButtonProps, SchemaInitializerButtonProps,
@ -22,12 +24,212 @@ export const SchemaInitializerItemContext = createContext(null);
export const SchemaInitializerButtonContext = createContext<{ export const SchemaInitializerButtonContext = createContext<{
visible?: boolean; visible?: boolean;
setVisible?: (v: boolean) => void; setVisible?: (v: boolean) => void;
searchValue?: string;
setSearchValue?: (v: string) => void;
}>({}); }>({});
export const SchemaInitializer = () => null; 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 <SearchCollections value={searchValue} onChange={onChange} />;
};
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 (
<div ref={spinRef}>
<Spin size="small" style={{ width: '100%' }} />
</div>
);
};
// 清除所有的 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: (
<LoadingItem
loadMore={() => {
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 = (
<CollectionSearch
clearValueRef={item._clearSearchValueRef}
onChange={(value) => {
item._count = minStep;
beforeLoading?.();
item._allChildren = item.loadChildren({ searchValue: value });
if (isEmpty(item._allChildren)) {
item.children = [
{
key: 'empty',
label: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
},
];
} 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 */}
<Component limitCount={currentCountRef.current} />
<Menu style={style} items={items} />
</>
);
};
SchemaInitializer.Button = observer( SchemaInitializer.Button = observer(
(props: SchemaInitializerButtonProps) => { (props: SchemaInitializerButtonProps) => {
const { const {
@ -46,26 +248,19 @@ SchemaInitializer.Button = observer(
const compile = useCompile(); const compile = useCompile();
const { insertAdjacent, findComponent, designable } = useDesignable(); const { insertAdjacent, findComponent, designable } = useDesignable();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { Component: CollectionComponent, getMenuItem, clean } = useMenuItem(); const { Component: CollectComponent, getMenuItem, clean } = useMenuItem();
const [searchValue, setSearchValue] = useState('');
const [isPending, startTransition] = useTransition();
const menuItems = useRef([]); const menuItems = useRef([]);
const { styles } = useStyles(); const { styles } = useStyles();
const changeMenu = (v: boolean) => { const changeMenu = (v: boolean) => {
// 这里是为了防止当鼠标快速滑过时,终止菜单的渲染,防止卡顿 setVisible(v);
startTransition(() => {
setVisible(v);
});
}; };
if (!designable && props.designable !== true) { if (!designable && props.designable !== true) {
return null; return null;
} }
const buttonDom = component ? ( const buttonDom = component || (
component
) : (
<Button <Button
type={'dashed'} type={'dashed'}
style={{ style={{
@ -131,27 +326,30 @@ SchemaInitializer.Button = observer(
} }
if (item.type === 'itemGroup') { if (item.type === 'itemGroup') {
const label = isString(item.title) ? compile(item.title) : item.title; const label = isString(item.title) ? compile(item.title) : item.title;
return ( return {
!!item.children?.length && { type: 'group',
type: 'group', key: item.key || `item-group-${indexA}`,
key: item.key || `item-group-${indexA}`, label,
label, title: label,
title: label, style: item.style,
children: renderItems(item.children), loadChildren: isEmpty(item.children)
} ? ({ searchValue } = { searchValue: '' }) => renderItems(item.loadChildren?.({ searchValue }) || [])
); : null,
children: isEmpty(item.children) ? [] : renderItems(item.children),
};
} }
if (item.type === 'subMenu') { if (item.type === 'subMenu') {
const label = compile(item.title); const label = compile(item.title);
return ( return {
!!item.children?.length && { key: item.key || `item-group-${indexA}`,
key: item.key || `item-group-${indexA}`, label,
label, title: label,
title: label, popupClassName: styles.nbMenuItemSubMenu,
popupClassName: styles.nbMenuItemSubMenu, loadChildren: isEmpty(item.children)
children: renderItems(item.children), ? ({ searchValue } = { searchValue: '' }) => renderItems(item.loadChildren?.({ searchValue }) || [])
} : null,
); children: isEmpty(item.children) ? [] : renderItems(item.children),
};
} }
}); });
}; };
@ -161,28 +359,38 @@ SchemaInitializer.Button = observer(
menuItems.current = renderItems(items); menuItems.current = renderItems(items);
} }
const dropdownRender = () => (
<MenuWithLazyLoadChildren
style={{
maxHeight: '50vh',
overflowY: 'auto',
}}
items={menuItems.current}
clean={clean}
component={CollectComponent}
/>
);
useEffect(() => {
if (visible === false) {
clearSearchValue(menuItems.current);
}
}, [visible]);
return ( return (
<SchemaInitializerButtonContext.Provider value={{ visible, setVisible, searchValue, setSearchValue }}> <SchemaInitializerButtonContext.Provider value={{ visible, setVisible }}>
{visible ? <CollectionComponent /> : null} {visible ? <CollectComponent /> : null}
<Dropdown <Dropdown
className={classNames('nb-schema-initializer-button')} className={classNames('nb-schema-initializer-button')}
openClassName={`nb-schema-initializer-button-open`} openClassName={`nb-schema-initializer-button-open`}
open={visible} open={visible}
onOpenChange={(open) => { onOpenChange={(open) => {
// 如果不清空输入框的值,那么下次打开的时候会出现上次输入的值
setSearchValue('');
changeMenu(open); changeMenu(open);
}} }}
menu={{ dropdownRender={dropdownRender}
style: {
maxHeight: '50vh',
overflowY: 'auto',
},
items: menuItems.current,
}}
{...dropdown} {...dropdown}
> >
{component ? component : buttonDom} {component || buttonDom}
</Dropdown> </Dropdown>
</SchemaInitializerButtonContext.Provider> </SchemaInitializerButtonContext.Provider>
); );
@ -197,15 +405,14 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
const { items = [], children = info?.title, icon, onClick } = props; const { items = [], children = info?.title, icon, onClick } = props;
const { collectMenuItem } = useCollectMenuItem(); const { collectMenuItem } = useCollectMenuItem();
if (!collectMenuItem) { if (process.env.NODE_ENV !== 'production' && !collectMenuItem) {
error('SchemaInitializer.Item: collectMenuItem is undefined, please check the context'); throw new Error('SchemaInitializer.Item: collectMenuItem is undefined, please check the context');
return null;
} }
if (items?.length > 0) { if (items?.length > 0) {
const renderMenuItem = (items: SchemaInitializerItemOptions[], parentKey: string) => { const renderMenuItem = (items: SchemaInitializerItemOptions[], parentKey: string) => {
if (!items?.length) { if (!items?.length) {
return null; return [];
} }
return items.map((item, indexA) => { return items.map((item, indexA) => {
if (item.type === 'divider') { if (item.type === 'divider') {
@ -220,8 +427,12 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
label, label,
title: label, title: label,
className: styles.nbMenuItemGroup, className: styles.nbMenuItemGroup,
children: renderMenuItem(item.children, key), loadChildren: isEmpty(item.children)
} as MenuProps['items'][0]; ? ({ searchValue } = { searchValue: '' }) =>
renderMenuItem(item.loadChildren?.({ searchValue }) || [], key)
: null,
children: isEmpty(item.children) ? [] : renderMenuItem(item.children, key),
} as MenuProps['items'][0] & { loadChildren?: ({ searchValue }?: { searchValue: string }) => any[] };
} }
if (item.type === 'subMenu') { if (item.type === 'subMenu') {
const label = compile(item.title); const label = compile(item.title);
@ -230,8 +441,12 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
key, key,
label, label,
title: label, title: label,
children: renderMenuItem(item.children, key), loadChildren: isEmpty(item.children)
}; ? ({ searchValue } = { searchValue: '' }) =>
renderMenuItem(item.loadChildren?.({ searchValue }) || [], key)
: null,
children: isEmpty(item.children) ? [] : renderMenuItem(item.children, key),
} as MenuProps['items'][0] & { loadChildren?: ({ searchValue }?: { searchValue: string }) => any[] };
} }
const label = compile(item.title); const label = compile(item.title);
return { return {
@ -239,7 +454,6 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
label, label,
title: label, title: label,
onClick: (info) => { onClick: (info) => {
item?.clearKeywords?.();
if (item.onClick) { if (item.onClick) {
item.onClick({ ...info, item }); item.onClick({ ...info, item });
} else { } else {
@ -268,7 +482,6 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
title: label, title: label,
icon: typeof icon === 'string' ? <Icon type={icon as string} /> : icon, icon: typeof icon === 'string' ? <Icon type={icon as string} /> : icon,
onClick: (opts) => { onClick: (opts) => {
info?.clearKeywords?.();
onClick({ ...opts, item: info }); onClick({ ...opts, item: info });
}, },
}; };

View File

@ -2,7 +2,7 @@ import { Divider, Input } from 'antd';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export const SelectCollection = ({ value: outValue, onChange }) => { export const SearchCollections = ({ value: outValue, onChange }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState<string>(outValue); const [value, setValue] = useState<string>(outValue);
const inputRef = React.useRef(null); const inputRef = React.useRef(null);

View File

@ -22,11 +22,13 @@ interface ItemCommonOptions {
interface ItemGroupOptions extends ItemCommonOptions { interface ItemGroupOptions extends ItemCommonOptions {
type: 'itemGroup'; type: 'itemGroup';
children?: SchemaInitializerItemOptions[]; children?: SchemaInitializerItemOptions[];
loadChildren?: ({ searchValue }?: { searchValue: string }) => SchemaInitializerItemOptions[];
} }
interface SubMenuOptions extends ItemCommonOptions { interface SubMenuOptions extends ItemCommonOptions {
type: 'subMenu'; type: 'subMenu';
children?: SchemaInitializerItemOptions[]; children?: SchemaInitializerItemOptions[];
loadChildren?: ({ searchValue }?: { searchValue: string }) => SchemaInitializerItemOptions[];
} }
type BreakFn = (s: ISchema) => boolean; type BreakFn = (s: ISchema) => boolean;

View File

@ -1,15 +1,12 @@
import { ISchema, Schema, useFieldSchema, useForm } from '@formily/react'; import { ISchema, Schema, useFieldSchema, useForm } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { error } from '@nocobase/utils/client'; import { useContext, useMemo } from 'react';
import _ from 'lodash';
import React, { useCallback, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { BlockRequestContext, SchemaInitializerButtonContext, SchemaInitializerItemOptions } from '../'; import { BlockRequestContext, SchemaInitializerItemOptions } from '..';
import { FieldOptions, useCollection, useCollectionManager } from '../collection-manager'; import { CollectionFieldOptions, FieldOptions, useCollection, useCollectionManager } from '../collection-manager';
import { isAssocField } from '../filter-provider/utils'; import { isAssocField } from '../filter-provider/utils';
import { useActionContext, useDesignable } from '../schema-component'; import { useActionContext, useDesignable } from '../schema-component';
import { useSchemaTemplateManager } from '../schema-templates'; import { useSchemaTemplateManager } from '../schema-templates';
import { SelectCollection } from './SelectCollection';
export const itemsMerge = (items1) => { export const itemsMerge = (items1) => {
return items1; return items1;
@ -834,120 +831,23 @@ export const useCollectionDataSourceItems = (componentName) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { collections, getCollectionFields } = useCollectionManager(); const { collections, getCollectionFields } = useCollectionManager();
const { getTemplatesByCollection } = useSchemaTemplateManager(); const { getTemplatesByCollection } = useSchemaTemplateManager();
const { searchValue, setSearchValue } = useContext(SchemaInitializerButtonContext);
// eslint-disable-next-line react-hooks/exhaustive-deps
const onChange = useCallback(_.debounce(setSearchValue, 300), [setSearchValue]);
if (!setSearchValue) {
error('useCollectionDataSourceItems: please use in SchemaInitializerButtonContext and provide setSearchValue');
return [];
}
const clearKeywords = () => {
setSearchValue('');
};
return [ return [
{ {
key: 'tableBlock', key: 'tableBlock',
type: 'itemGroup', type: 'itemGroup',
title: React.createElement(SelectCollection, { title: null,
value: searchValue, children: [],
onChange, loadChildren: ({ searchValue } = { searchValue: '' }) => {
}), return getChildren({
children: collections collections,
?.filter((item) => { getCollectionFields,
if (item.inherit) { componentName,
return false; searchValue,
} getTemplatesByCollection,
const fields = getCollectionFields(item.name); t,
if (item.autoGenId === false && !fields.find((v) => v.primaryKey)) { });
return false; },
} else if (
['Kanban', 'FormItem'].includes(componentName) &&
((item.template === 'view' && !item.writableView) || item.template === 'sql')
) {
return false;
} else if (item.template === 'file' && ['Kanban', 'FormItem', 'Calendar'].includes(componentName)) {
return false;
} else {
if (!item.title) {
return false;
}
return (
item.title.toUpperCase().includes(searchValue.toUpperCase()) && !(item?.isThrough && item?.autoCreate)
);
}
})
?.map((item, index) => {
const templates = getTemplatesByCollection(item.name).filter((template) => {
return (
componentName &&
template.componentName === componentName &&
(!template.resourceName || template.resourceName === item.name)
);
});
if (!templates.length) {
return {
type: 'item',
name: item.name,
title: item.title,
clearKeywords,
};
}
return {
key: `${componentName}_table_subMenu_${index}`,
type: 'subMenu',
name: `${item.name}_${index}`,
title: item.title,
children: [
{
type: 'item',
name: item.name,
title: t('Blank block'),
clearKeywords,
},
{
type: 'divider',
},
{
key: `${componentName}_table_subMenu_${index}_copy`,
type: 'subMenu',
name: 'copy',
title: t('Duplicate template'),
children: templates.map((template) => {
const templateName =
template?.componentName === 'FormItem' ? `${template?.name} ${t('(Fields only)')}` : template?.name;
return {
type: 'item',
mode: 'copy',
name: item.name,
template,
clearKeywords,
title: templateName || t('Untitled'),
};
}),
},
{
key: `${componentName}_table_subMenu_${index}_ref`,
type: 'subMenu',
name: 'ref',
title: t('Reference template'),
children: templates.map((template) => {
const templateName =
template?.componentName === 'FormItem' ? `${template?.name} ${t('(Fields only)')}` : template?.name;
return {
type: 'item',
mode: 'reference',
clearKeywords,
name: item.name,
template,
title: templateName || t('Untitled'),
};
}),
},
],
};
}),
}, },
]; ];
}; };
@ -1878,3 +1778,108 @@ export const createKanbanBlockSchema = (options) => {
}; };
return schema; return schema;
}; };
const getChildren = ({
collections,
getCollectionFields,
componentName,
searchValue,
getTemplatesByCollection,
t,
}: {
collections: any[];
getCollectionFields: (name: any) => CollectionFieldOptions[];
componentName: any;
searchValue: string;
getTemplatesByCollection: (collectionName: string, resourceName?: string) => any;
t;
}) => {
return collections
?.filter((item) => {
if (item.inherit) {
return false;
}
const fields = getCollectionFields(item.name);
if (item.autoGenId === false && !fields.find((v) => v.primaryKey)) {
return false;
} else if (
['Kanban', 'FormItem'].includes(componentName) &&
((item.template === 'view' && !item.writableView) || item.template === 'sql')
) {
return false;
} else if (item.template === 'file' && ['Kanban', 'FormItem', 'Calendar'].includes(componentName)) {
return false;
} else {
if (!item.title) {
return false;
}
return item.title.toUpperCase().includes(searchValue.toUpperCase()) && !(item?.isThrough && item?.autoCreate);
}
})
?.map((item, index) => {
const templates = getTemplatesByCollection(item.name).filter((template) => {
return (
componentName &&
template.componentName === componentName &&
(!template.resourceName || template.resourceName === item.name)
);
});
if (!templates.length) {
return {
type: 'item',
name: item.name,
title: item.title,
};
}
return {
key: `${componentName}_table_subMenu_${index}`,
type: 'subMenu',
name: `${item.name}_${index}`,
title: item.title,
children: [
{
type: 'item',
name: item.name,
title: t('Blank block'),
},
{
type: 'divider',
},
{
key: `${componentName}_table_subMenu_${index}_copy`,
type: 'subMenu',
name: 'copy',
title: t('Duplicate template'),
children: templates.map((template) => {
const templateName =
template?.componentName === 'FormItem' ? `${template?.name} ${t('(Fields only)')}` : template?.name;
return {
type: 'item',
mode: 'copy',
name: item.name,
template,
title: templateName || t('Untitled'),
};
}),
},
{
key: `${componentName}_table_subMenu_${index}_ref`,
type: 'subMenu',
name: 'ref',
title: t('Reference template'),
children: templates.map((template) => {
const templateName =
template?.componentName === 'FormItem' ? `${template?.name} ${t('(Fields only)')}` : template?.name;
return {
type: 'item',
mode: 'reference',
name: item.name,
template,
title: templateName || t('Untitled'),
};
}),
},
],
};
});
};

View File

@ -151,7 +151,7 @@ export const SchemaSettings: React.FC<SchemaSettingsProps> & SchemaSettingsNeste
const [isPending, startTransition] = useReactTransition(); const [isPending, startTransition] = useReactTransition();
const changeMenu = (v: boolean) => { const changeMenu = (v: boolean) => {
// 这里是为了防止当鼠标快速滑过时,终止菜单的渲染,防止卡顿 // 当鼠标快速滑过时,终止菜单的渲染,防止卡顿
startTransition(() => { startTransition(() => {
setVisible(v); setVisible(v);
}); });

View File

@ -45,6 +45,7 @@ const ChartsProvider = React.memo((props) => {
const api = useAPIClient(); const api = useAPIClient();
const items = useContext<any>(SchemaInitializerContext); const items = useContext<any>(SchemaInitializerContext);
const children = items.BlockInitializers.items[0].children; const children = items.BlockInitializers.items[0].children;
if (children) { if (children) {
const hasChartItem = children.some((child) => child?.component === 'ChartBlockInitializer'); const hasChartItem = children.some((child) => child?.component === 'ChartBlockInitializer');
if (!hasChartItem) { if (!hasChartItem) {

View File

@ -1,18 +1,15 @@
import { SchemaInitializerButtonContext, useDesignable } from '@nocobase/client'; import { SchemaInitializerButtonContext, useDesignable } from '@nocobase/client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ChartRendererProvider } from '../renderer';
import { ChartConfigContext, ChartConfigCurrent, ChartConfigure } from '../configure/ChartConfigure'; import { ChartConfigContext, ChartConfigCurrent, ChartConfigure } from '../configure/ChartConfigure';
import { ChartRendererProvider } from '../renderer';
export const ChartV2Block: React.FC = (props) => { export const ChartV2Block: React.FC = (props) => {
const { insertAdjacent } = useDesignable(); const { insertAdjacent } = useDesignable();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [current, setCurrent] = useState<ChartConfigCurrent>({} as any); const [current, setCurrent] = useState<ChartConfigCurrent>({} as any);
const [initialVisible, setInitialVisible] = useState(false); const [initialVisible, setInitialVisible] = useState(false);
const [searchValue, setSearchValue] = useState('');
return ( return (
<SchemaInitializerButtonContext.Provider <SchemaInitializerButtonContext.Provider value={{ visible: initialVisible, setVisible: setInitialVisible }}>
value={{ visible: initialVisible, setVisible: setInitialVisible, searchValue, setSearchValue }}
>
<ChartConfigContext.Provider value={{ visible, setVisible, current, setCurrent }}> <ChartConfigContext.Provider value={{ visible, setVisible, current, setCurrent }}>
{props.children} {props.children}
<ChartRendererProvider {...current.field?.decoratorProps}> <ChartRendererProvider {...current.field?.decoratorProps}>

View File

@ -3,8 +3,8 @@ import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { SchemaInitializer, useACLRoleContext, useCollectionDataSourceItems } from '@nocobase/client'; import { SchemaInitializer, useACLRoleContext, useCollectionDataSourceItems } from '@nocobase/client';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { useChartsTranslation } from '../locale';
import { ChartConfigContext } from '../configure/ChartConfigure'; import { ChartConfigContext } from '../configure/ChartConfigure';
import { useChartsTranslation } from '../locale';
const itemWrap = SchemaInitializer.itemWrap; const itemWrap = SchemaInitializer.itemWrap;
const ConfigureButton = itemWrap((props) => { const ConfigureButton = itemWrap((props) => {
@ -24,23 +24,28 @@ export const ChartInitializers = () => {
const { t } = useChartsTranslation(); const { t } = useChartsTranslation();
const collections = useCollectionDataSourceItems('Chart'); const collections = useCollectionDataSourceItems('Chart');
const { allowAll, parseAction } = useACLRoleContext(); const { allowAll, parseAction } = useACLRoleContext();
const children = collections[0].children
.filter((item) => { if (collections[0].loadChildren) {
if (allowAll) { const originalLoadChildren = collections[0].loadChildren;
return true; collections[0].loadChildren = ({ searchValue }) => {
} const children = originalLoadChildren({ searchValue });
const params = parseAction(`${item.name}:list`); const result = children
return params; .filter((item) => {
}) if (allowAll) {
.map((item) => ({ return true;
...item, }
component: ConfigureButton, const params = parseAction(`${item.name}:list`);
})); return params;
if (!children.length) { })
// Leave a blank item to show the filter component .map((item) => ({
children.push({} as any); ...item,
component: ConfigureButton,
}));
return result;
};
} }
collections[0].children = children;
return ( return (
<SchemaInitializer.Button <SchemaInitializer.Button
icon={'PlusOutlined'} icon={'PlusOutlined'}

View File

@ -1,5 +1,5 @@
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { cx, css, useAPIClient, useCompile } from '@nocobase/client'; import { css, useAPIClient, useCompile } from '@nocobase/client';
import { Button, Dropdown, MenuProps } from 'antd'; import { Button, Dropdown, MenuProps } from 'antd';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useFlowContext } from './FlowContext'; import { useFlowContext } from './FlowContext';
@ -98,7 +98,7 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
menu={menu} menu={menu}
disabled={workflow.executed} disabled={workflow.executed}
overlayClassName={css` overlayClassName={css`
.ant-dropdown-menu-root{ .ant-dropdown-menu-root {
max-height: 30em; max-height: 30em;
overflow-y: auto; overflow-y: auto;
} }

View File

@ -1,8 +1,8 @@
import React, { useContext, useEffect, useMemo, useState } from 'react'; import { FormLayout } from '@formily/antd-v5';
import { createForm } from '@formily/core'; import { createForm } from '@formily/core';
import { FormProvider, ISchema, Schema, useFieldSchema, useForm } from '@formily/react'; import { FormProvider, ISchema, Schema, useFieldSchema, useForm } from '@formily/react';
import { FormLayout } from '@formily/antd-v5';
import { Alert, Button, Modal, Space } from 'antd'; import { Alert, Button, Modal, Space } from 'antd';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@ -18,22 +18,23 @@ import {
SchemaSettings, SchemaSettings,
VariableScopeProvider, VariableScopeProvider,
gridRowColWrap, gridRowColWrap,
useCollectionManager,
useCompile, useCompile,
useFormBlockContext, useFormBlockContext,
useSchemaOptionsContext, useSchemaOptionsContext,
} from '@nocobase/client'; } from '@nocobase/client';
import { Registry, lodash } from '@nocobase/utils/client'; import { Registry, lodash } from '@nocobase/utils/client';
import { instructions, useAvailableUpstreams, useNodeContext } from '..'; import { instructions, useAvailableUpstreams, useNodeContext } from '..';
import { JOB_STATUS } from '../../constants';
import { useFlowContext } from '../../FlowContext'; import { useFlowContext } from '../../FlowContext';
import { JOB_STATUS } from '../../constants';
import { NAMESPACE, lang } from '../../locale'; import { NAMESPACE, lang } from '../../locale';
import { useTrigger } from '../../triggers'; import { useTrigger } from '../../triggers';
import { useWorkflowVariableOptions } from '../../variable';
import { DetailsBlockProvider } from './DetailsBlockProvider'; import { DetailsBlockProvider } from './DetailsBlockProvider';
import { FormBlockProvider } from './FormBlockProvider'; import { FormBlockProvider } from './FormBlockProvider';
import createRecordForm from './forms/create'; import createRecordForm from './forms/create';
import customRecordForm from './forms/custom'; import customRecordForm from './forms/custom';
import updateRecordForm from './forms/update'; import updateRecordForm from './forms/update';
import { useWorkflowVariableOptions } from '../../variable';
type ValueOf<T> = T[keyof T]; type ValueOf<T> = T[keyof T];
@ -53,7 +54,7 @@ export type FormType = {
export type ManualFormType = { export type ManualFormType = {
title: string; title: string;
config: { config: {
useInitializer: () => SchemaInitializerItemOptions; useInitializer: ({ collections }?: { collections: any[] }) => SchemaInitializerItemOptions;
initializers?: { initializers?: {
[key: string]: React.FC; [key: string]: React.FC;
}; };
@ -108,6 +109,7 @@ function SimpleDesigner() {
} }
function AddBlockButton(props: any) { function AddBlockButton(props: any) {
const { collections } = useCollectionManager();
const current = useNodeContext(); const current = useNodeContext();
const nodes = useAvailableUpstreams(current); const nodes = useAvailableUpstreams(current);
const triggerInitializers = [useTriggerInitializers()].filter(Boolean); const triggerInitializers = [useTriggerInitializers()].filter(Boolean);
@ -146,7 +148,7 @@ function AddBlockButton(props: any) {
title: '{{t("Form")}}', title: '{{t("Form")}}',
children: Array.from(manualFormTypes.getValues()).map((item: ManualFormType) => { children: Array.from(manualFormTypes.getValues()).map((item: ManualFormType) => {
const { useInitializer: getInitializer } = item.config; const { useInitializer: getInitializer } = item.config;
return getInitializer(); return getInitializer({ collections });
}), }),
}, },
{ {
@ -171,31 +173,32 @@ function AssignedFieldValues() {
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const scope = useWorkflowVariableOptions({ fieldNames: { label: 'title', value: 'name' } }); const scope = useWorkflowVariableOptions({ fieldNames: { label: 'title', value: 'name' } });
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [initialSchema, setInitialSchema] = useState(fieldSchema?.['x-action-settings']?.assignedValues?.schema ?? { const [initialSchema, setInitialSchema] = useState(
type: 'void', fieldSchema?.['x-action-settings']?.assignedValues?.schema ?? {
'x-component': 'Grid', type: 'void',
'x-initializer': 'CustomFormItemInitializers', 'x-component': 'Grid',
properties: {}, 'x-initializer': 'CustomFormItemInitializers',
}); properties: {},
},
);
const [schema, setSchema] = useState<Schema>(null); const [schema, setSchema] = useState<Schema>(null);
const { components } = useSchemaOptionsContext(); const { components } = useSchemaOptionsContext();
useEffect(() => { useEffect(() => {
setSchema(new Schema({ setSchema(
properties: { new Schema({
grid: initialSchema properties: {
}, grid: initialSchema,
})); },
}),
);
}, [initialSchema]); }, [initialSchema]);
const form = useMemo( const form = useMemo(() => {
() => { const initialValues = fieldSchema?.['x-action-settings']?.assignedValues?.values;
const initialValues = fieldSchema?.['x-action-settings']?.assignedValues?.values; return createForm({
return createForm({ initialValues: lodash.cloneDeep(initialValues),
initialValues: lodash.cloneDeep(initialValues), values: lodash.cloneDeep(initialValues),
values: lodash.cloneDeep(initialValues), });
}); }, []);
},
[],
);
const title = t('Assign field values'); const title = t('Assign field values');
@ -220,9 +223,7 @@ function AssignedFieldValues() {
return ( return (
<> <>
<SchemaSettings.Item onClick={() => setOpen(true)}> <SchemaSettings.Item onClick={() => setOpen(true)}>{title}</SchemaSettings.Item>
{title}
</SchemaSettings.Item>
<Modal <Modal
width={'50%'} width={'50%'}
title={title} title={title}
@ -231,14 +232,18 @@ function AssignedFieldValues() {
footer={ footer={
<Space> <Space>
<Button onClick={onCancel}>{t('Cancel')}</Button> <Button onClick={onCancel}>{t('Cancel')}</Button>
<Button type="primary" onClick={onSubmit}>{t('Submit')}</Button> <Button type="primary" onClick={onSubmit}>
{t('Submit')}
</Button>
</Space> </Space>
} }
> >
<VariableScopeProvider scope={scope}> <VariableScopeProvider scope={scope}>
<FormProvider form={form}> <FormProvider form={form}>
<FormLayout layout={'vertical'}> <FormLayout layout={'vertical'}>
<Alert message={lang('Values preset in this form will override user submitted ones when continue or reject.')} /> <Alert
message={lang('Values preset in this form will override user submitted ones when continue or reject.')}
/>
<br /> <br />
{open && schema && ( {open && schema && (
<SchemaComponentContext.Provider <SchemaComponentContext.Provider
@ -246,7 +251,7 @@ function AssignedFieldValues() {
...ctx, ...ctx,
refresh() { refresh() {
setInitialSchema(lodash.get(schema.toJSON(), 'properties.grid')); setInitialSchema(lodash.get(schema.toJSON(), 'properties.grid'));
} },
}} }}
> >
<SchemaComponent schema={schema} components={components} /> <SchemaComponent schema={schema} components={components} />

View File

@ -1,12 +1,11 @@
import React from 'react'; 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 { NAMESPACE } from '../../../locale';
import { findSchema } from '../utils';
import { ManualFormType } from '../SchemaConfig';
import { FormBlockInitializer } from '../FormBlockInitializer'; import { FormBlockInitializer } from '../FormBlockInitializer';
import { ManualFormType } from '../SchemaConfig';
import { findSchema } from '../utils';
function CreateFormDesigner() { function CreateFormDesigner() {
const { name, title } = useCollection(); const { name, title } = useCollection();
@ -30,26 +29,37 @@ function CreateFormDesigner() {
export default { export default {
title: `{{t("Create record form", { ns: "${NAMESPACE}" })}}`, title: `{{t("Create record form", { ns: "${NAMESPACE}" })}}`,
config: { config: {
useInitializer() { useInitializer({ collections }) {
const { collections } = useCollectionManager();
return { return {
key: 'createRecordForm', key: 'createRecordForm',
type: 'subMenu', type: 'subMenu',
title: `{{t("Create record form", { ns: "${NAMESPACE}" })}}`, title: `{{t("Create record form", { ns: "${NAMESPACE}" })}}`,
children: collections children: [
.filter((item) => !item.hidden) {
.map((item) => ({ key: 'createRecordForm-child',
key: `createForm-${item.name}`, type: 'itemGroup',
type: 'item', style: {
title: item.title, maxHeight: '48vh',
schema: { overflowY: 'auto',
collection: item.name,
title: `{{t("Create record", { ns: "${NAMESPACE}" })}}`,
formType: 'create',
'x-designer': 'CreateFormDesigner',
}, },
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: { initializers: {

View File

@ -1,5 +1,5 @@
import React from 'react';
import { useFieldSchema } from '@formily/react'; import { useFieldSchema } from '@formily/react';
import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@ -7,15 +7,14 @@ import {
SchemaSettings, SchemaSettings,
useCollection, useCollection,
useCollectionFilterOptions, useCollectionFilterOptions,
useCollectionManager,
useDesignable, useDesignable,
} from '@nocobase/client'; } from '@nocobase/client';
import { NAMESPACE } from '../../../locale';
import { findSchema } from '../utils';
import { ManualFormType } from '../SchemaConfig';
import { FilterDynamicComponent } from '../../../components/FilterDynamicComponent'; import { FilterDynamicComponent } from '../../../components/FilterDynamicComponent';
import { NAMESPACE } from '../../../locale';
import { FormBlockInitializer } from '../FormBlockInitializer'; import { FormBlockInitializer } from '../FormBlockInitializer';
import { ManualFormType } from '../SchemaConfig';
import { findSchema } from '../utils';
function UpdateFormDesigner() { function UpdateFormDesigner() {
const { name, title } = useCollection(); const { name, title } = useCollection();
@ -71,26 +70,37 @@ function UpdateFormDesigner() {
export default { export default {
title: `{{t("Update record form", { ns: "${NAMESPACE}" })}}`, title: `{{t("Update record form", { ns: "${NAMESPACE}" })}}`,
config: { config: {
useInitializer() { useInitializer({ collections }) {
const { collections } = useCollectionManager();
return { return {
key: 'updateRecordForm', key: 'updateRecordForm',
type: 'subMenu', type: 'subMenu',
title: `{{t("Update record form", { ns: "${NAMESPACE}" })}}`, title: `{{t("Update record form", { ns: "${NAMESPACE}" })}}`,
children: collections children: [
.filter((item) => !item.hidden) {
.map((item) => ({ key: 'updateRecordForm-child',
key: `updateForm-${item.name}`, type: 'itemGroup',
type: 'item', style: {
title: item.title, maxHeight: '48vh',
schema: { overflowY: 'auto',
collection: item.name,
title: `{{t("Update record", { ns: "${NAMESPACE}" })}}`,
formType: 'update',
'x-designer': 'UpdateFormDesigner',
}, },
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: { initializers: {