mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 08:55:33 +00:00
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:
parent
8db9fda61b
commit
ff16f59908
@ -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) => (
|
||||
<Com key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{list.current.map((Com, index) => (
|
||||
|
@ -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 <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(
|
||||
(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 || (
|
||||
<Button
|
||||
type={'dashed'}
|
||||
style={{
|
||||
@ -131,27 +326,30 @@ SchemaInitializer.Button = observer(
|
||||
}
|
||||
if (item.type === 'itemGroup') {
|
||||
const label = isString(item.title) ? compile(item.title) : item.title;
|
||||
return (
|
||||
!!item.children?.length && {
|
||||
type: 'group',
|
||||
key: item.key || `item-group-${indexA}`,
|
||||
label,
|
||||
title: label,
|
||||
children: renderItems(item.children),
|
||||
}
|
||||
);
|
||||
return {
|
||||
type: 'group',
|
||||
key: item.key || `item-group-${indexA}`,
|
||||
label,
|
||||
title: label,
|
||||
style: item.style,
|
||||
loadChildren: isEmpty(item.children)
|
||||
? ({ searchValue } = { searchValue: '' }) => renderItems(item.loadChildren?.({ searchValue }) || [])
|
||||
: null,
|
||||
children: isEmpty(item.children) ? [] : renderItems(item.children),
|
||||
};
|
||||
}
|
||||
if (item.type === 'subMenu') {
|
||||
const label = compile(item.title);
|
||||
return (
|
||||
!!item.children?.length && {
|
||||
key: item.key || `item-group-${indexA}`,
|
||||
label,
|
||||
title: label,
|
||||
popupClassName: styles.nbMenuItemSubMenu,
|
||||
children: renderItems(item.children),
|
||||
}
|
||||
);
|
||||
return {
|
||||
key: item.key || `item-group-${indexA}`,
|
||||
label,
|
||||
title: label,
|
||||
popupClassName: styles.nbMenuItemSubMenu,
|
||||
loadChildren: isEmpty(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);
|
||||
}
|
||||
|
||||
const dropdownRender = () => (
|
||||
<MenuWithLazyLoadChildren
|
||||
style={{
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
items={menuItems.current}
|
||||
clean={clean}
|
||||
component={CollectComponent}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible === false) {
|
||||
clearSearchValue(menuItems.current);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<SchemaInitializerButtonContext.Provider value={{ visible, setVisible, searchValue, setSearchValue }}>
|
||||
{visible ? <CollectionComponent /> : null}
|
||||
<SchemaInitializerButtonContext.Provider value={{ visible, setVisible }}>
|
||||
{visible ? <CollectComponent /> : null}
|
||||
<Dropdown
|
||||
className={classNames('nb-schema-initializer-button')}
|
||||
openClassName={`nb-schema-initializer-button-open`}
|
||||
open={visible}
|
||||
onOpenChange={(open) => {
|
||||
// 如果不清空输入框的值,那么下次打开的时候会出现上次输入的值
|
||||
setSearchValue('');
|
||||
changeMenu(open);
|
||||
}}
|
||||
menu={{
|
||||
style: {
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
items: menuItems.current,
|
||||
}}
|
||||
dropdownRender={dropdownRender}
|
||||
{...dropdown}
|
||||
>
|
||||
{component ? component : buttonDom}
|
||||
{component || buttonDom}
|
||||
</Dropdown>
|
||||
</SchemaInitializerButtonContext.Provider>
|
||||
);
|
||||
@ -197,15 +405,14 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
|
||||
const { items = [], children = info?.title, icon, onClick } = props;
|
||||
const { collectMenuItem } = useCollectMenuItem();
|
||||
|
||||
if (!collectMenuItem) {
|
||||
error('SchemaInitializer.Item: collectMenuItem is undefined, please check the context');
|
||||
return null;
|
||||
if (process.env.NODE_ENV !== 'production' && !collectMenuItem) {
|
||||
throw new Error('SchemaInitializer.Item: collectMenuItem is undefined, please check the context');
|
||||
}
|
||||
|
||||
if (items?.length > 0) {
|
||||
const renderMenuItem = (items: SchemaInitializerItemOptions[], parentKey: string) => {
|
||||
if (!items?.length) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
return items.map((item, indexA) => {
|
||||
if (item.type === 'divider') {
|
||||
@ -220,8 +427,12 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
|
||||
label,
|
||||
title: label,
|
||||
className: styles.nbMenuItemGroup,
|
||||
children: renderMenuItem(item.children, key),
|
||||
} as MenuProps['items'][0];
|
||||
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[] };
|
||||
}
|
||||
if (item.type === 'subMenu') {
|
||||
const label = compile(item.title);
|
||||
@ -230,8 +441,12 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
|
||||
key,
|
||||
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);
|
||||
return {
|
||||
@ -239,7 +454,6 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
|
||||
label,
|
||||
title: label,
|
||||
onClick: (info) => {
|
||||
item?.clearKeywords?.();
|
||||
if (item.onClick) {
|
||||
item.onClick({ ...info, item });
|
||||
} else {
|
||||
@ -268,7 +482,6 @@ SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
|
||||
title: label,
|
||||
icon: typeof icon === 'string' ? <Icon type={icon as string} /> : icon,
|
||||
onClick: (opts) => {
|
||||
info?.clearKeywords?.();
|
||||
onClick({ ...opts, item: info });
|
||||
},
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import { Divider, Input } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SelectCollection = ({ value: outValue, onChange }) => {
|
||||
export const SearchCollections = ({ value: outValue, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState<string>(outValue);
|
||||
const inputRef = React.useRef(null);
|
@ -22,11 +22,13 @@ interface ItemCommonOptions {
|
||||
interface ItemGroupOptions extends ItemCommonOptions {
|
||||
type: 'itemGroup';
|
||||
children?: SchemaInitializerItemOptions[];
|
||||
loadChildren?: ({ searchValue }?: { searchValue: string }) => SchemaInitializerItemOptions[];
|
||||
}
|
||||
|
||||
interface SubMenuOptions extends ItemCommonOptions {
|
||||
type: 'subMenu';
|
||||
children?: SchemaInitializerItemOptions[];
|
||||
loadChildren?: ({ searchValue }?: { searchValue: string }) => SchemaInitializerItemOptions[];
|
||||
}
|
||||
|
||||
type BreakFn = (s: ISchema) => boolean;
|
||||
|
@ -1,15 +1,12 @@
|
||||
import { ISchema, Schema, useFieldSchema, useForm } from '@formily/react';
|
||||
import { uid } from '@formily/shared';
|
||||
import { error } from '@nocobase/utils/client';
|
||||
import _ from 'lodash';
|
||||
import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BlockRequestContext, SchemaInitializerButtonContext, SchemaInitializerItemOptions } from '../';
|
||||
import { FieldOptions, useCollection, useCollectionManager } from '../collection-manager';
|
||||
import { BlockRequestContext, SchemaInitializerItemOptions } from '..';
|
||||
import { CollectionFieldOptions, FieldOptions, useCollection, useCollectionManager } from '../collection-manager';
|
||||
import { isAssocField } from '../filter-provider/utils';
|
||||
import { useActionContext, useDesignable } from '../schema-component';
|
||||
import { useSchemaTemplateManager } from '../schema-templates';
|
||||
import { SelectCollection } from './SelectCollection';
|
||||
|
||||
export const itemsMerge = (items1) => {
|
||||
return items1;
|
||||
@ -834,120 +831,23 @@ export const useCollectionDataSourceItems = (componentName) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections, getCollectionFields } = useCollectionManager();
|
||||
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 [
|
||||
{
|
||||
key: 'tableBlock',
|
||||
type: 'itemGroup',
|
||||
title: React.createElement(SelectCollection, {
|
||||
value: searchValue,
|
||||
onChange,
|
||||
}),
|
||||
children: 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,
|
||||
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'),
|
||||
};
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
title: null,
|
||||
children: [],
|
||||
loadChildren: ({ searchValue } = { searchValue: '' }) => {
|
||||
return getChildren({
|
||||
collections,
|
||||
getCollectionFields,
|
||||
componentName,
|
||||
searchValue,
|
||||
getTemplatesByCollection,
|
||||
t,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@ -1878,3 +1778,108 @@ export const createKanbanBlockSchema = (options) => {
|
||||
};
|
||||
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'),
|
||||
};
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
@ -151,7 +151,7 @@ export const SchemaSettings: React.FC<SchemaSettingsProps> & SchemaSettingsNeste
|
||||
const [isPending, startTransition] = useReactTransition();
|
||||
|
||||
const changeMenu = (v: boolean) => {
|
||||
// 这里是为了防止当鼠标快速滑过时,终止菜单的渲染,防止卡顿
|
||||
// 当鼠标快速滑过时,终止菜单的渲染,防止卡顿
|
||||
startTransition(() => {
|
||||
setVisible(v);
|
||||
});
|
||||
|
@ -45,6 +45,7 @@ const ChartsProvider = React.memo((props) => {
|
||||
const api = useAPIClient();
|
||||
const items = useContext<any>(SchemaInitializerContext);
|
||||
const children = items.BlockInitializers.items[0].children;
|
||||
|
||||
if (children) {
|
||||
const hasChartItem = children.some((child) => child?.component === 'ChartBlockInitializer');
|
||||
if (!hasChartItem) {
|
||||
|
@ -1,18 +1,15 @@
|
||||
import { SchemaInitializerButtonContext, useDesignable } from '@nocobase/client';
|
||||
import React, { useState } from 'react';
|
||||
import { ChartRendererProvider } from '../renderer';
|
||||
import { ChartConfigContext, ChartConfigCurrent, ChartConfigure } from '../configure/ChartConfigure';
|
||||
import { ChartRendererProvider } from '../renderer';
|
||||
|
||||
export const ChartV2Block: React.FC = (props) => {
|
||||
const { insertAdjacent } = useDesignable();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [current, setCurrent] = useState<ChartConfigCurrent>({} as any);
|
||||
const [initialVisible, setInitialVisible] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
return (
|
||||
<SchemaInitializerButtonContext.Provider
|
||||
value={{ visible: initialVisible, setVisible: setInitialVisible, searchValue, setSearchValue }}
|
||||
>
|
||||
<SchemaInitializerButtonContext.Provider value={{ visible: initialVisible, setVisible: setInitialVisible }}>
|
||||
<ChartConfigContext.Provider value={{ visible, setVisible, current, setCurrent }}>
|
||||
{props.children}
|
||||
<ChartRendererProvider {...current.field?.decoratorProps}>
|
||||
|
@ -3,8 +3,8 @@ import { ISchema } from '@formily/react';
|
||||
import { uid } from '@formily/shared';
|
||||
import { SchemaInitializer, useACLRoleContext, useCollectionDataSourceItems } from '@nocobase/client';
|
||||
import React, { useContext } from 'react';
|
||||
import { useChartsTranslation } from '../locale';
|
||||
import { ChartConfigContext } from '../configure/ChartConfigure';
|
||||
import { useChartsTranslation } from '../locale';
|
||||
|
||||
const itemWrap = SchemaInitializer.itemWrap;
|
||||
const ConfigureButton = itemWrap((props) => {
|
||||
@ -24,23 +24,28 @@ export const ChartInitializers = () => {
|
||||
const { t } = useChartsTranslation();
|
||||
const collections = useCollectionDataSourceItems('Chart');
|
||||
const { allowAll, parseAction } = useACLRoleContext();
|
||||
const children = collections[0].children
|
||||
.filter((item) => {
|
||||
if (allowAll) {
|
||||
return true;
|
||||
}
|
||||
const params = parseAction(`${item.name}:list`);
|
||||
return params;
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
component: ConfigureButton,
|
||||
}));
|
||||
if (!children.length) {
|
||||
// Leave a blank item to show the filter component
|
||||
children.push({} as any);
|
||||
|
||||
if (collections[0].loadChildren) {
|
||||
const originalLoadChildren = collections[0].loadChildren;
|
||||
collections[0].loadChildren = ({ searchValue }) => {
|
||||
const children = originalLoadChildren({ searchValue });
|
||||
const result = children
|
||||
.filter((item) => {
|
||||
if (allowAll) {
|
||||
return true;
|
||||
}
|
||||
const params = parseAction(`${item.name}:list`);
|
||||
return params;
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
component: ConfigureButton,
|
||||
}));
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
collections[0].children = children;
|
||||
|
||||
return (
|
||||
<SchemaInitializer.Button
|
||||
icon={'PlusOutlined'}
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 React, { useCallback, useMemo } from 'react';
|
||||
import { useFlowContext } from './FlowContext';
|
||||
@ -98,7 +98,7 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
||||
menu={menu}
|
||||
disabled={workflow.executed}
|
||||
overlayClassName={css`
|
||||
.ant-dropdown-menu-root{
|
||||
.ant-dropdown-menu-root {
|
||||
max-height: 30em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { FormLayout } from '@formily/antd-v5';
|
||||
import { createForm } from '@formily/core';
|
||||
import { FormProvider, ISchema, Schema, useFieldSchema, useForm } from '@formily/react';
|
||||
import { FormLayout } from '@formily/antd-v5';
|
||||
import { Alert, Button, Modal, Space } from 'antd';
|
||||
import React, { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@ -18,22 +18,23 @@ import {
|
||||
SchemaSettings,
|
||||
VariableScopeProvider,
|
||||
gridRowColWrap,
|
||||
useCollectionManager,
|
||||
useCompile,
|
||||
useFormBlockContext,
|
||||
useSchemaOptionsContext,
|
||||
} from '@nocobase/client';
|
||||
import { Registry, lodash } from '@nocobase/utils/client';
|
||||
import { instructions, useAvailableUpstreams, useNodeContext } from '..';
|
||||
import { JOB_STATUS } from '../../constants';
|
||||
import { useFlowContext } from '../../FlowContext';
|
||||
import { JOB_STATUS } from '../../constants';
|
||||
import { NAMESPACE, lang } from '../../locale';
|
||||
import { useTrigger } from '../../triggers';
|
||||
import { useWorkflowVariableOptions } from '../../variable';
|
||||
import { DetailsBlockProvider } from './DetailsBlockProvider';
|
||||
import { FormBlockProvider } from './FormBlockProvider';
|
||||
import createRecordForm from './forms/create';
|
||||
import customRecordForm from './forms/custom';
|
||||
import updateRecordForm from './forms/update';
|
||||
import { useWorkflowVariableOptions } from '../../variable';
|
||||
|
||||
type ValueOf<T> = T[keyof T];
|
||||
|
||||
@ -53,7 +54,7 @@ export type FormType = {
|
||||
export type ManualFormType = {
|
||||
title: string;
|
||||
config: {
|
||||
useInitializer: () => SchemaInitializerItemOptions;
|
||||
useInitializer: ({ collections }?: { collections: any[] }) => SchemaInitializerItemOptions;
|
||||
initializers?: {
|
||||
[key: string]: React.FC;
|
||||
};
|
||||
@ -108,6 +109,7 @@ function SimpleDesigner() {
|
||||
}
|
||||
|
||||
function AddBlockButton(props: any) {
|
||||
const { collections } = useCollectionManager();
|
||||
const current = useNodeContext();
|
||||
const nodes = useAvailableUpstreams(current);
|
||||
const triggerInitializers = [useTriggerInitializers()].filter(Boolean);
|
||||
@ -146,7 +148,7 @@ function AddBlockButton(props: any) {
|
||||
title: '{{t("Form")}}',
|
||||
children: Array.from(manualFormTypes.getValues()).map((item: ManualFormType) => {
|
||||
const { useInitializer: getInitializer } = item.config;
|
||||
return getInitializer();
|
||||
return getInitializer({ collections });
|
||||
}),
|
||||
},
|
||||
{
|
||||
@ -171,31 +173,32 @@ function AssignedFieldValues() {
|
||||
const fieldSchema = useFieldSchema();
|
||||
const scope = useWorkflowVariableOptions({ fieldNames: { label: 'title', value: 'name' } });
|
||||
const [open, setOpen] = useState(false);
|
||||
const [initialSchema, setInitialSchema] = useState(fieldSchema?.['x-action-settings']?.assignedValues?.schema ?? {
|
||||
type: 'void',
|
||||
'x-component': 'Grid',
|
||||
'x-initializer': 'CustomFormItemInitializers',
|
||||
properties: {},
|
||||
});
|
||||
const [initialSchema, setInitialSchema] = useState(
|
||||
fieldSchema?.['x-action-settings']?.assignedValues?.schema ?? {
|
||||
type: 'void',
|
||||
'x-component': 'Grid',
|
||||
'x-initializer': 'CustomFormItemInitializers',
|
||||
properties: {},
|
||||
},
|
||||
);
|
||||
const [schema, setSchema] = useState<Schema>(null);
|
||||
const { components } = useSchemaOptionsContext();
|
||||
useEffect(() => {
|
||||
setSchema(new Schema({
|
||||
properties: {
|
||||
grid: initialSchema
|
||||
},
|
||||
}));
|
||||
setSchema(
|
||||
new Schema({
|
||||
properties: {
|
||||
grid: initialSchema,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [initialSchema]);
|
||||
const form = useMemo(
|
||||
() => {
|
||||
const initialValues = fieldSchema?.['x-action-settings']?.assignedValues?.values;
|
||||
return createForm({
|
||||
initialValues: lodash.cloneDeep(initialValues),
|
||||
values: lodash.cloneDeep(initialValues),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const form = useMemo(() => {
|
||||
const initialValues = fieldSchema?.['x-action-settings']?.assignedValues?.values;
|
||||
return createForm({
|
||||
initialValues: lodash.cloneDeep(initialValues),
|
||||
values: lodash.cloneDeep(initialValues),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const title = t('Assign field values');
|
||||
|
||||
@ -220,9 +223,7 @@ function AssignedFieldValues() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SchemaSettings.Item onClick={() => setOpen(true)}>
|
||||
{title}
|
||||
</SchemaSettings.Item>
|
||||
<SchemaSettings.Item onClick={() => setOpen(true)}>{title}</SchemaSettings.Item>
|
||||
<Modal
|
||||
width={'50%'}
|
||||
title={title}
|
||||
@ -231,14 +232,18 @@ function AssignedFieldValues() {
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onCancel}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={onSubmit}>{t('Submit')}</Button>
|
||||
<Button type="primary" onClick={onSubmit}>
|
||||
{t('Submit')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<VariableScopeProvider scope={scope}>
|
||||
<FormProvider form={form}>
|
||||
<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 />
|
||||
{open && schema && (
|
||||
<SchemaComponentContext.Provider
|
||||
@ -246,7 +251,7 @@ function AssignedFieldValues() {
|
||||
...ctx,
|
||||
refresh() {
|
||||
setInitialSchema(lodash.get(schema.toJSON(), 'properties.grid'));
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SchemaComponent schema={schema} components={components} />
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
Loading…
Reference in New Issue
Block a user