perf(data-scope): async loading of variable data (#1932)

* refactor: remove useless code

* perf: async loading of user variable data

* perf: async loading children

* perf: add maxDepth

* refactor: use useMemo

* fix: avoid old data
This commit is contained in:
被雨水过滤的空气-Rairn 2023-05-29 18:08:21 +08:00 committed by GitHub
parent 76ddbf2104
commit f286534752
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 322 additions and 284 deletions

View File

@ -1,7 +1,7 @@
import { useField, useForm } from '@formily/react'; import { useField, useForm } from '@formily/react';
import { message } from 'antd'; import { message } from 'antd';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { useEffect } from 'react'; import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCollection, useCollectionManager } from '.'; import { useCollection, useCollectionManager } from '.';
import { useCompile } from '..'; import { useCompile } from '..';
@ -109,96 +109,98 @@ export const useChildrenCollections = (collectionName: string) => {
}; };
export const useCollectionFilterOptions = (collectionName: string) => { export const useCollectionFilterOptions = (collectionName: string) => {
const { getCollectionFields, getInterface } = useCollectionManager(); const { getCollectionFields, getInterface, getChildrenCollections, getCollection } = useCollectionManager();
const fields = getCollectionFields(collectionName);
const field2option = (field, depth) => {
if (!field.interface) {
return;
}
const fieldInterface = getInterface(field.interface);
if (!fieldInterface?.filterable) {
return;
}
const { nested, children, operators } = fieldInterface.filterable;
const option = {
name: field.name,
title: field?.uiSchema?.title || field.name,
schema: field?.uiSchema,
operators:
operators?.filter?.((operator) => {
return !operator?.visible || operator.visible(field);
}) || [],
interface: field.interface,
};
if (field.target && depth > 2) {
return;
}
if (depth > 2) {
return option;
}
if (children?.length) {
option['children'] = children;
}
if (nested) {
const targetFields = getCollectionFields(field.target);
const options = getOptions(targetFields, depth + 1).filter(Boolean);
option['children'] = option['children'] || [];
option['children'].push(...options);
}
return option;
};
const getOptions = (fields, depth) => {
const options = [];
fields.forEach((field) => {
const option = field2option(field, depth);
if (option) {
options.push(option);
}
});
return options;
};
const options = getOptions(fields, 1);
const compile = useCompile(); const compile = useCompile();
const { getChildrenCollections, getCollection } = useCollectionManager();
const collection = getCollection(collectionName); return useMemo(() => {
const childrenCollections = getChildrenCollections(collectionName); const fields = getCollectionFields(collectionName);
if (childrenCollections.length > 0 && !options.find((v) => v.name == 'tableoid')) { const field2option = (field, depth) => {
options.push({ if (!field.interface) {
name: 'tableoid', return;
type: 'string', }
title: '{{t("Table OID(Inheritance)")}}', const fieldInterface = getInterface(field.interface);
schema: { if (!fieldInterface?.filterable) {
'x-component': 'Select', return;
enum: [{ value: collectionName, label: compile(collection.title) }].concat( }
childrenCollections.map((v) => { const { nested, children, operators } = fieldInterface.filterable;
return { const option = {
value: v.name, name: field.name,
label: compile(v.title), title: field?.uiSchema?.title || field.name,
}; schema: field?.uiSchema,
}), operators:
), operators?.filter?.((operator) => {
}, return !operator?.visible || operator.visible(field);
operators: [ }) || [],
{ interface: field.interface,
label: '{{t("contains")}}', };
value: '$childIn', if (field.target && depth > 2) {
schema: { return;
'x-component': 'Select', }
'x-component-props': { mode: 'tags' }, if (depth > 2) {
}, return option;
}
if (children?.length) {
option['children'] = children;
}
if (nested) {
const targetFields = getCollectionFields(field.target);
const options = getOptions(targetFields, depth + 1).filter(Boolean);
option['children'] = option['children'] || [];
option['children'].push(...options);
}
return option;
};
const getOptions = (fields, depth) => {
const options = [];
fields.forEach((field) => {
const option = field2option(field, depth);
if (option) {
options.push(option);
}
});
return options;
};
const options = getOptions(fields, 1);
const collection = getCollection(collectionName);
const childrenCollections = getChildrenCollections(collectionName);
if (childrenCollections.length > 0 && !options.find((v) => v.name == 'tableoid')) {
options.push({
name: 'tableoid',
type: 'string',
title: '{{t("Table OID(Inheritance)")}}',
schema: {
'x-component': 'Select',
enum: [{ value: collectionName, label: compile(collection.title) }].concat(
childrenCollections.map((v) => {
return {
value: v.name,
label: compile(v.title),
};
}),
),
}, },
{ operators: [
label: '{{t("does not contain")}}', {
value: '$childNotIn', label: '{{t("contains")}}',
schema: { value: '$childIn',
'x-component': 'Select', schema: {
'x-component-props': { mode: 'tags' }, 'x-component': 'Select',
'x-component-props': { mode: 'tags' },
},
}, },
}, {
], label: '{{t("does not contain")}}',
}); value: '$childNotIn',
} schema: {
return options; 'x-component': 'Select',
'x-component-props': { mode: 'tags' },
},
},
],
});
}
return options;
}, [collectionName]);
}; };
export const useLinkageCollectionFilterOptions = (collectionName: string) => { export const useLinkageCollectionFilterOptions = (collectionName: string) => {

View File

@ -1,6 +1,6 @@
import { ArrayItems } from '@formily/antd'; import { ArrayItems } from '@formily/antd';
import { ISchema, useField, useFieldSchema } from '@formily/react'; import { ISchema, useField, useFieldSchema } from '@formily/react';
import React from 'react'; import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useTableBlockContext } from '../../../block-provider'; import { useTableBlockContext } from '../../../block-provider';
import { mergeFilter } from '../../../block-provider/SharedFilterProvider'; import { mergeFilter } from '../../../block-provider/SharedFilterProvider';
@ -9,7 +9,7 @@ import { useCollectionFilterOptions, useSortFields } from '../../../collection-m
import { FilterBlockType } from '../../../filter-provider/utils'; import { FilterBlockType } from '../../../filter-provider/utils';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates'; import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks'; import { useCompile, useDesignable } from '../../hooks';
import { removeNullCondition } from '../filter'; import { removeNullCondition } from '../filter';
import { FixedBlockDesignerItem } from '../page'; import { FixedBlockDesignerItem } from '../page';
import { FilterDynamicComponent } from './FilterDynamicComponent'; import { FilterDynamicComponent } from './FilterDynamicComponent';
@ -24,6 +24,7 @@ export const TableBlockDesigner = () => {
const { service } = useTableBlockContext(); const { service } = useTableBlockContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { dn } = useDesignable(); const { dn } = useDesignable();
const compile = useCompile();
const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {}; const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {};
const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || []; const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || [];
const defaultResource = fieldSchema?.['x-decorator-props']?.resource; const defaultResource = fieldSchema?.['x-decorator-props']?.resource;
@ -43,6 +44,45 @@ export const TableBlockDesigner = () => {
const collection = useCollection(); const collection = useCollection();
const { dragSort, resource } = field.decoratorProps; const { dragSort, resource } = field.decoratorProps;
const treeChildren = resource?.includes('.') ? getCollectionField(resource)?.treeChildren : !!collection?.tree; const treeChildren = resource?.includes('.') ? getCollectionField(resource)?.treeChildren : !!collection?.tree;
const dataScopeSchema = useMemo(() => {
return {
type: 'object',
title: t('Set the data scope'),
properties: {
filter: {
default: defaultFilter,
// title: '数据范围',
enum: compile(dataSource),
'x-component': 'Filter',
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema;
}, [dataSource, defaultFilter]);
const onDataScopeSubmit = useCallback(
({ filter }) => {
filter = removeNullCondition(filter);
const params = field.decoratorProps.params || {};
params.filter = filter;
field.decoratorProps.params = params;
fieldSchema['x-decorator-props']['params'] = params;
const filters = service.params?.[1]?.filters || {};
service.run(
{ ...service.params?.[0], filter: mergeFilter([...Object.values(filters), filter]), page: 1 },
{ filters },
);
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-decorator-props': fieldSchema['x-decorator-props'],
},
});
},
[field],
);
return ( return (
<GeneralSchemaDesigner template={template} title={title || name}> <GeneralSchemaDesigner template={template} title={title || name}>
<SchemaSettings.BlockTitleItem /> <SchemaSettings.BlockTitleItem />
@ -84,44 +124,7 @@ export const TableBlockDesigner = () => {
/> />
)} )}
<FixedBlockDesignerItem /> <FixedBlockDesignerItem />
<SchemaSettings.ModalItem <SchemaSettings.ModalItem title={t('Set the data scope')} schema={dataScopeSchema} onSubmit={onDataScopeSubmit} />
title={t('Set the data scope')}
schema={
{
type: 'object',
title: t('Set the data scope'),
properties: {
filter: {
default: defaultFilter,
// title: '数据范围',
enum: dataSource,
'x-component': 'Filter',
'x-component-props': {
dynamicComponent: (props) => FilterDynamicComponent({ ...props }),
},
},
},
} as ISchema
}
onSubmit={({ filter }) => {
filter = removeNullCondition(filter);
const params = field.decoratorProps.params || {};
params.filter = filter;
field.decoratorProps.params = params;
fieldSchema['x-decorator-props']['params'] = params;
const filters = service.params?.[1]?.filters || {};
service.run(
{ ...service.params?.[0], filter: mergeFilter([...Object.values(filters), filter]), page: 1 },
{ filters },
);
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
'x-decorator-props': fieldSchema['x-decorator-props'],
},
});
}}
/>
{!dragSort && ( {!dragSort && (
<SchemaSettings.ModalItem <SchemaSettings.ModalItem
title={t('Set default sorting rules')} title={t('Set default sorting rules')}
@ -151,7 +154,7 @@ export const TableBlockDesigner = () => {
field: { field: {
type: 'string', type: 'string',
enum: sortFields, enum: sortFields,
required:true, required: true,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Select', 'x-component': 'Select',
'x-component-props': { 'x-component-props': {

View File

@ -1,74 +0,0 @@
import { CollectionFieldOptions } from '../../../../collection-manager';
import { useFilterOptions } from '../../filter';
interface Operator {
label: string;
value: string;
}
interface GetOptionsParams {
schema: any;
operator: Operator;
maxDepth: number;
count?: number;
}
const isOperatorSupportMultiRelation = (operator: Operator) => {
if (!operator) return false;
return ['$eq', '$ne'].includes(operator.value);
};
const isSingleRelationField = (field: CollectionFieldOptions) => {
if (!field) return false;
return field.type === 'belongsTo' || field.type === 'hasOne';
};
export const useOptions = (collectionName: string, { schema, operator, maxDepth, count = 1 }: GetOptionsParams) => {
if (count > maxDepth) {
return [];
}
const result = useFilterOptions(collectionName).map((option) => {
if (!option.target) {
return {
key: option.name,
value: option.name,
label: option.title,
// TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化
disabled: schema?.['x-component'] !== option.schema?.['x-component'],
};
}
const children =
useOptions(option.target, {
schema,
operator,
maxDepth,
count: count + 1,
}) || [];
return {
key: option.name,
value: option.name,
label: option.title,
children,
disabled:
(!isSingleRelationField(option) && !isOperatorSupportMultiRelation(operator)) ||
children.every((child) => child.disabled),
};
});
return result;
};
export const useUserVariable = ({ schema, operator }) => {
const options = useOptions('users', { schema, operator, maxDepth: 3 }) || [];
return {
label: `{{t("Current user")}}`,
value: '$user',
key: '$user',
disabled: options.every((option) => option.disabled),
children: options,
};
};

View File

@ -3,11 +3,14 @@ import { css, cx } from '@emotion/css';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { Input as AntInput, Cascader, DatePicker, InputNumber, Select, Tag } from 'antd'; import { Input as AntInput, Cascader, DatePicker, InputNumber, Select, Tag } from 'antd';
import moment from 'moment'; import moment from 'moment';
import React from 'react'; import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { error } from '@nocobase/utils/client';
import classNames from 'classnames'; import classNames from 'classnames';
import { useMemo } from 'react';
import { useCompile } from '../..'; import { useCompile } from '../..';
import { Option } from '../../../schema-settings/VariableInput/type';
import { XButton } from './XButton'; import { XButton } from './XButton';
const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/; const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/;
@ -120,49 +123,91 @@ export function Input(props) {
const form = useForm(); const form = useForm();
const { value = '', scope, onChange, children, button, useTypedConstant, style, className } = props; const { value = '', scope, onChange, children, button, useTypedConstant, style, className } = props;
const parsed = parseValue(value); const parsed = useMemo(() => parseValue(value), [value]);
const isConstant = typeof parsed === 'string'; const isConstant = typeof parsed === 'string';
const type = isConstant ? parsed : ''; const type = isConstant ? parsed : '';
const variable = isConstant ? null : parsed; const variable = isConstant ? null : parsed;
const variableOptions = typeof scope === 'function' ? scope() : scope ?? []; const variableOptions = useMemo(() => (typeof scope === 'function' ? scope() : scope ?? []), [scope]);
const [variableText, setVariableText] = React.useState('');
const { component: ConstantComponent, ...constantOption }: VariableOptions & { component?: React.FC<any> } = children const loadData = async (selectedOptions: Option[]) => {
? { const option = selectedOptions[selectedOptions.length - 1];
value: '', if (option.loadChildren) {
label: '{{t("Constant")}}', // 需要保证 selectedOptions 是一个响应式对象,这样才能触发重新渲染
} await option.loadChildren(option);
: useTypedConstant
? getTypedConstantOption(type, useTypedConstant)
: {
value: '',
label: '{{t("Null")}}',
component: ConstantTypes.null.component,
};
const options: VariableOptions[] = compile([constantOption, ...variableOptions]);
function onSwitch(next) {
if (next[0] === '') {
if (next[1]) {
if (next[1] !== type) {
onChange(ConstantTypes[next[1]]?.default ?? null);
}
} else {
if (variable) {
onChange(null);
}
}
return;
} }
onChange(`{{${next.join('.')}}}`); };
}
const variableText = variable const { component: ConstantComponent, ...constantOption }: VariableOptions & { component?: React.FC<any> } =
?.reduce((opts, key, i) => { useMemo(() => {
const option = (i ? (opts[i - 1] as VariableOptions)?.children : options)?.find((item) => item.value === key); return children
return option ? opts.concat(option) : opts; ? {
}, [] as VariableOptions[]) value: '',
.map((item) => item.label) label: '{{t("Constant")}}',
.join(' / '); }
: useTypedConstant
? getTypedConstantOption(type, useTypedConstant)
: {
value: '',
label: '{{t("Null")}}',
component: ConstantTypes.null.component,
};
}, [type, useTypedConstant]);
const options: VariableOptions[] = useMemo(
() => compile([constantOption, ...variableOptions]),
[constantOption, variableOptions],
);
const onSwitch = useCallback(
(next) => {
if (next[0] === '') {
if (next[1]) {
if (next[1] !== type) {
onChange(ConstantTypes[next[1]]?.default ?? null);
}
} else {
if (variable) {
onChange(null);
}
}
return;
}
onChange(`{{${next.join('.')}}}`);
},
[type, variable],
);
useEffect(() => {
const run = async () => {
if (!variable) {
return;
}
let prevOption: Option = null;
const labels = [];
for (let i = 0; i < variable.length; i++) {
const key = variable[i];
try {
if (i === 0) {
prevOption = options.find((item) => item.value === key);
} else {
if (prevOption.children?.length === 0 && prevOption.loadChildren) {
await prevOption.loadChildren(prevOption);
}
prevOption = prevOption.children.find((item) => item.value === key);
}
labels.push(prevOption.label);
setVariableText(labels.join(' / '));
} catch (err) {
error(err);
}
}
};
// 弹窗动画的延迟时间是 300 毫秒,动画结束之后再执行,防止动画卡顿
setTimeout(run, 300);
}, [variable]);
const disabled = props.disabled || form.disabled; const disabled = props.disabled || form.disabled;
@ -257,6 +302,7 @@ export function Input(props) {
options={options} options={options}
value={variable ?? ['', ...(children || !constantOption.children?.length ? [] : [type])]} value={variable ?? ['', ...(children || !constantOption.children?.length ? [] : [type])]}
onChange={onSwitch} onChange={onSwitch}
loadData={loadData as any}
changeOnSelect changeOnSelect
> >
{button ?? <XButton type={variable ? 'primary' : 'default'} />} {button ?? <XButton type={variable ? 'primary' : 'default'} />}

View File

@ -17,7 +17,7 @@ type Props = {
export const VariableInput = (props: Props) => { export const VariableInput = (props: Props) => {
const { value, onChange, renderSchemaComponent: RenderSchemaComponent, style, schema, className } = props; const { value, onChange, renderSchemaComponent: RenderSchemaComponent, style, schema, className } = props;
const compile = useCompile(); const compile = useCompile();
const userVariable = useUserVariable({ schema, level: 1 }); const userVariable = useUserVariable({ schema, maxDepth: 1 });
const scope = useMemo(() => { const scope = useMemo(() => {
return [ return [
userVariable, userVariable,

View File

@ -1,67 +1,97 @@
import { observable } from '@formily/reactive';
import { error } from '@nocobase/utils/client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useCompile, useGetFilterOptions } from '../../../schema-component'; import { useCompile, useGetFilterOptions } from '../../../schema-component';
import { FieldOption, Option } from '../type';
interface GetOptionsParams { interface GetOptionsParams {
schema: any; schema: any;
operator?: string; depth: number;
maxDepth: number; maxDepth?: number;
count?: number; loadChildren?: (option: Option) => Promise<void>;
getFilterOptions: (collectionName: string) => any[];
} }
const getChildren = (options: any[], { schema, operator, maxDepth, count = 1, getFilterOptions }: GetOptionsParams) => { const getChildren = (options: FieldOption[], { schema, depth, maxDepth, loadChildren }: GetOptionsParams): Option[] => {
if (count > maxDepth) { const result = options
return []; .map((option): Option => {
} if (!option.target) {
return {
key: option.name,
value: option.name,
label: option.title,
// TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化
disabled: schema?.['x-component'] !== option.schema?.['x-component'],
isLeaf: true,
depth,
};
}
if (depth >= maxDepth) {
return null;
}
const result = options.map((option) => {
if (!option.target) {
return { return {
key: option.name, key: option.name,
value: option.name, value: option.name,
label: option.title, label: option.title,
// TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化 children: [],
disabled: schema?.['x-component'] !== option.schema?.['x-component'], isLeaf: false,
field: option,
depth,
loadChildren,
}; };
} })
.filter(Boolean);
const children =
getChildren(getFilterOptions(option.target), {
schema,
operator,
maxDepth,
count: count + 1,
getFilterOptions,
}) || [];
return {
key: option.name,
value: option.name,
label: option.title,
children,
disabled: children.every((child) => child.disabled),
};
});
return result; return result;
}; };
export const useUserVariable = ({ operator, schema, level }: { operator?: any; schema: any; level?: number }) => { export const useUserVariable = ({ schema, maxDepth = 3 }: { schema: any; maxDepth?: number }) => {
const compile = useCompile(); const compile = useCompile();
const getFilterOptions = useGetFilterOptions(); const getFilterOptions = useGetFilterOptions();
const children = useMemo( const loadChildren = (option: Option): Promise<void> => {
() => getChildren(getFilterOptions('users'), { schema, operator, maxDepth: level || 3, getFilterOptions }) || [], if (!option.field?.target) {
[operator, schema], return new Promise((resolve) => {
); error('Must be set field target');
resolve(void 0);
});
}
return useMemo(() => { const collectionName = option.field.target;
return new Promise((resolve) => {
setTimeout(() => {
const children =
getChildren(getFilterOptions(collectionName), {
schema,
depth: option.depth + 1,
maxDepth,
loadChildren,
}) || [];
option.children = compile(children);
resolve(void 0);
// 延迟 5 毫秒,防止阻塞主线程,导致 UI 卡顿
}, 5);
});
};
const result = useMemo(() => {
return compile({ return compile({
label: `{{t("Current user")}}`, label: `{{t("Current user")}}`,
value: '$user', value: '$user',
key: '$user', key: '$user',
disabled: children.every((option) => option.disabled), children: [],
children: children, isLeaf: false,
}); field: {
}, [children]); target: 'users',
},
depth: 0,
loadChildren,
} as Option);
}, [schema]);
// 必须使用 observable 包一下,使其变成响应式对象,不然 children 加载后不会更新 UI
return observable(result);
}; };

View File

@ -4,7 +4,7 @@ import { useUserVariable } from './useUserVariable';
export const useVariableOptions = () => { export const useVariableOptions = () => {
const { operator, schema } = useValues(); const { operator, schema } = useValues();
const userVariable = useUserVariable({ operator, schema }); const userVariable = useUserVariable({ maxDepth: 3, schema });
const dateVariable = useDateVariable({ operator, schema }); const dateVariable = useDateVariable({ operator, schema });
if (!operator || !schema) return []; if (!operator || !schema) return [];

View File

@ -0,0 +1,31 @@
import { Schema } from '@formily/react';
export interface Option {
key?: string | number;
value?: string | number;
label?: React.ReactNode;
disabled?: boolean;
children?: Option[];
// 标记是否为叶子节点,设置了 `loadData` 时有效
// 设为 `false` 时会强制标记为父节点,即使当前节点没有 children也会显示展开图标
isLeaf?: boolean;
/** 当开启异步加载时有效,用于加载当前 node 的 children */
loadChildren?(option: Option): Promise<void>;
field?: FieldOption;
depth?: number;
}
export interface FieldOption {
name?: string;
type?: string;
target?: string;
title?: string;
schema?: Schema;
operators?: Operator[];
children?: FieldOption[];
}
interface Operator {
label: string;
value: string;
}