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 { message } from 'antd';
import omit from 'lodash/omit';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCollection, useCollectionManager } from '.';
import { useCompile } from '..';
@ -109,96 +109,98 @@ export const useChildrenCollections = (collectionName: string) => {
};
export const useCollectionFilterOptions = (collectionName: string) => {
const { getCollectionFields, getInterface } = 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 { getCollectionFields, getInterface, getChildrenCollections, getCollection } = useCollectionManager();
const compile = useCompile();
const { getChildrenCollections, getCollection } = useCollectionManager();
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("contains")}}',
value: '$childIn',
schema: {
'x-component': 'Select',
'x-component-props': { mode: 'tags' },
},
return useMemo(() => {
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 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),
};
}),
),
},
{
label: '{{t("does not contain")}}',
value: '$childNotIn',
schema: {
'x-component': 'Select',
'x-component-props': { mode: 'tags' },
operators: [
{
label: '{{t("contains")}}',
value: '$childIn',
schema: {
'x-component': 'Select',
'x-component-props': { mode: 'tags' },
},
},
},
],
});
}
return options;
{
label: '{{t("does not contain")}}',
value: '$childNotIn',
schema: {
'x-component': 'Select',
'x-component-props': { mode: 'tags' },
},
},
],
});
}
return options;
}, [collectionName]);
};
export const useLinkageCollectionFilterOptions = (collectionName: string) => {

View File

@ -1,6 +1,6 @@
import { ArrayItems } from '@formily/antd';
import { ISchema, useField, useFieldSchema } from '@formily/react';
import React from 'react';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useTableBlockContext } from '../../../block-provider';
import { mergeFilter } from '../../../block-provider/SharedFilterProvider';
@ -9,7 +9,7 @@ import { useCollectionFilterOptions, useSortFields } from '../../../collection-m
import { FilterBlockType } from '../../../filter-provider/utils';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks';
import { useCompile, useDesignable } from '../../hooks';
import { removeNullCondition } from '../filter';
import { FixedBlockDesignerItem } from '../page';
import { FilterDynamicComponent } from './FilterDynamicComponent';
@ -24,6 +24,7 @@ export const TableBlockDesigner = () => {
const { service } = useTableBlockContext();
const { t } = useTranslation();
const { dn } = useDesignable();
const compile = useCompile();
const defaultFilter = fieldSchema?.['x-decorator-props']?.params?.filter || {};
const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || [];
const defaultResource = fieldSchema?.['x-decorator-props']?.resource;
@ -43,6 +44,45 @@ export const TableBlockDesigner = () => {
const collection = useCollection();
const { dragSort, resource } = field.decoratorProps;
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 (
<GeneralSchemaDesigner template={template} title={title || name}>
<SchemaSettings.BlockTitleItem />
@ -84,44 +124,7 @@ export const TableBlockDesigner = () => {
/>
)}
<FixedBlockDesignerItem />
<SchemaSettings.ModalItem
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'],
},
});
}}
/>
<SchemaSettings.ModalItem title={t('Set the data scope')} schema={dataScopeSchema} onSubmit={onDataScopeSubmit} />
{!dragSort && (
<SchemaSettings.ModalItem
title={t('Set default sorting rules')}
@ -151,7 +154,7 @@ export const TableBlockDesigner = () => {
field: {
type: 'string',
enum: sortFields,
required:true,
required: true,
'x-decorator': 'FormItem',
'x-component': 'Select',
'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 { Input as AntInput, Cascader, DatePicker, InputNumber, Select, Tag } from 'antd';
import moment from 'moment';
import React from 'react';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { error } from '@nocobase/utils/client';
import classNames from 'classnames';
import { useMemo } from 'react';
import { useCompile } from '../..';
import { Option } from '../../../schema-settings/VariableInput/type';
import { XButton } from './XButton';
const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/;
@ -120,49 +123,91 @@ export function Input(props) {
const form = useForm();
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 type = isConstant ? 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
? {
value: '',
label: '{{t("Constant")}}',
}
: 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;
const loadData = async (selectedOptions: Option[]) => {
const option = selectedOptions[selectedOptions.length - 1];
if (option.loadChildren) {
// 需要保证 selectedOptions 是一个响应式对象,这样才能触发重新渲染
await option.loadChildren(option);
}
onChange(`{{${next.join('.')}}}`);
}
};
const variableText = variable
?.reduce((opts, key, i) => {
const option = (i ? (opts[i - 1] as VariableOptions)?.children : options)?.find((item) => item.value === key);
return option ? opts.concat(option) : opts;
}, [] as VariableOptions[])
.map((item) => item.label)
.join(' / ');
const { component: ConstantComponent, ...constantOption }: VariableOptions & { component?: React.FC<any> } =
useMemo(() => {
return children
? {
value: '',
label: '{{t("Constant")}}',
}
: 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;
@ -257,6 +302,7 @@ export function Input(props) {
options={options}
value={variable ?? ['', ...(children || !constantOption.children?.length ? [] : [type])]}
onChange={onSwitch}
loadData={loadData as any}
changeOnSelect
>
{button ?? <XButton type={variable ? 'primary' : 'default'} />}

View File

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

View File

@ -1,67 +1,97 @@
import { observable } from '@formily/reactive';
import { error } from '@nocobase/utils/client';
import { useMemo } from 'react';
import { useCompile, useGetFilterOptions } from '../../../schema-component';
import { FieldOption, Option } from '../type';
interface GetOptionsParams {
schema: any;
operator?: string;
maxDepth: number;
count?: number;
getFilterOptions: (collectionName: string) => any[];
depth: number;
maxDepth?: number;
loadChildren?: (option: Option) => Promise<void>;
}
const getChildren = (options: any[], { schema, operator, maxDepth, count = 1, getFilterOptions }: GetOptionsParams) => {
if (count > maxDepth) {
return [];
}
const getChildren = (options: FieldOption[], { schema, depth, maxDepth, loadChildren }: GetOptionsParams): Option[] => {
const result = options
.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 {
key: option.name,
value: option.name,
label: option.title,
// TODO: 现在是通过组件的名称来过滤能够被选择的选项,这样的坏处是不够精确,后续可以优化
disabled: schema?.['x-component'] !== option.schema?.['x-component'],
children: [],
isLeaf: false,
field: option,
depth,
loadChildren,
};
}
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),
};
});
})
.filter(Boolean);
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 getFilterOptions = useGetFilterOptions();
const children = useMemo(
() => getChildren(getFilterOptions('users'), { schema, operator, maxDepth: level || 3, getFilterOptions }) || [],
[operator, schema],
);
const loadChildren = (option: Option): Promise<void> => {
if (!option.field?.target) {
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({
label: `{{t("Current user")}}`,
value: '$user',
key: '$user',
disabled: children.every((option) => option.disabled),
children: children,
});
}, [children]);
children: [],
isLeaf: false,
field: {
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 = () => {
const { operator, schema } = useValues();
const userVariable = useUserVariable({ operator, schema });
const userVariable = useUserVariable({ maxDepth: 3, schema });
const dateVariable = useDateVariable({ operator, schema });
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;
}