diff --git a/packages/core/client/src/collection-manager/action-hooks.ts b/packages/core/client/src/collection-manager/action-hooks.ts index 1f7e1f9b1c..d212202adb 100644 --- a/packages/core/client/src/collection-manager/action-hooks.ts +++ b/packages/core/client/src/collection-manager/action-hooks.ts @@ -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) => { diff --git a/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx b/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx index d8478b8d45..914710b8c4 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx @@ -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 ( @@ -84,44 +124,7 @@ export const TableBlockDesigner = () => { /> )} - 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 && ( { field: { type: 'string', enum: sortFields, - required:true, + required: true, 'x-decorator': 'FormItem', 'x-component': 'Select', 'x-component-props': { diff --git a/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts b/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts deleted file mode 100644 index be7e17b3eb..0000000000 --- a/packages/core/client/src/schema-component/antd/table-v2/hooks/useUserVariable.ts +++ /dev/null @@ -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, - }; -}; diff --git a/packages/core/client/src/schema-component/antd/variable/Input.tsx b/packages/core/client/src/schema-component/antd/variable/Input.tsx index 73885ba79d..70a6c014e4 100644 --- a/packages/core/client/src/schema-component/antd/variable/Input.tsx +++ b/packages/core/client/src/schema-component/antd/variable/Input.tsx @@ -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 } = 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 } = + 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 ?? } diff --git a/packages/core/client/src/schema-settings/VariableInput/VariableInput.tsx b/packages/core/client/src/schema-settings/VariableInput/VariableInput.tsx index e2a93d750b..d7085908b4 100644 --- a/packages/core/client/src/schema-settings/VariableInput/VariableInput.tsx +++ b/packages/core/client/src/schema-settings/VariableInput/VariableInput.tsx @@ -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, diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts index 3a87af0a0b..1983c11623 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useUserVariable.ts @@ -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; } -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 => { + 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); }; diff --git a/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts b/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts index 83f5f269e2..9bfcf9fe02 100644 --- a/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts +++ b/packages/core/client/src/schema-settings/VariableInput/hooks/useVariableOptions.ts @@ -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 []; diff --git a/packages/core/client/src/schema-settings/VariableInput/type.ts b/packages/core/client/src/schema-settings/VariableInput/type.ts new file mode 100644 index 0000000000..252c653a69 --- /dev/null +++ b/packages/core/client/src/schema-settings/VariableInput/type.ts @@ -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; + 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; +}