From 9f8460ca2208d713258a757d44a9456859c1089d Mon Sep 17 00:00:00 2001 From: Junyi Date: Tue, 18 Jul 2023 10:36:17 +0700 Subject: [PATCH] feat(plugin-workflow): change to unlimited depth preloading associations in workflow (#2142) * refactor(plugin-snapshot): move AppendsTreeSelect component into client package * refactor(plugin-workflow): change all appends fields select to AppendsTreeSelect * refactor(plugin-workflow): change appends and toJSON logic on server side * fix(plugin-workflow): fix toJSON logic and build error * fix(plugin-workflow): fix missing component injection * fix(plugin-workflow): fix cycle association in variables * refactor(client): change AppendsTreeSelect to lazy load * fix(client): fix lazy load in option --- .../hooks/useCollectionManager.ts | 5 +- .../appends-tree-select/AppendsTreeSelect.tsx | 172 ++++++++++++++++++ .../antd/appends-tree-select/index.ts | 1 + .../client/src/schema-component/antd/index.ts | 1 + .../src/client/SnapshotFieldProvider.tsx | 2 - .../client/components/AppendsTreeSelect.tsx | 122 ------------- .../snapshot-field/src/client/interface.ts | 12 +- .../src/client/components/FieldsSelect.tsx | 6 +- .../workflow/src/client/locale/zh-CN.ts | 4 +- .../workflow/src/client/nodes/create.tsx | 1 - .../workflow/src/client/nodes/query.tsx | 1 - .../workflow/src/client/schemas/collection.ts | 12 +- .../src/client/triggers/collection.tsx | 4 + .../triggers/schedule/DateFieldsSelect.tsx | 28 --- .../src/client/triggers/schedule/OnField.tsx | 44 +++-- .../triggers/schedule/ScheduleConfig.tsx | 40 ++-- .../src/client/triggers/schedule/index.tsx | 41 +++-- .../plugins/workflow/src/server/Plugin.ts | 2 +- .../__tests__/collections/categories.ts | 4 + .../__tests__/triggers/collection.test.ts | 35 ++++ .../src/server/instructions/create.ts | 24 +-- .../workflow/src/server/instructions/query.ts | 13 +- .../src/server/triggers/collection.ts | 26 +-- packages/plugins/workflow/src/server/utils.ts | 17 ++ 24 files changed, 375 insertions(+), 242 deletions(-) create mode 100644 packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx create mode 100644 packages/core/client/src/schema-component/antd/appends-tree-select/index.ts delete mode 100644 packages/plugins/snapshot-field/src/client/components/AppendsTreeSelect.tsx delete mode 100644 packages/plugins/workflow/src/client/triggers/schedule/DateFieldsSelect.tsx create mode 100644 packages/plugins/workflow/src/server/utils.ts diff --git a/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts b/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts index f84937696..4b252f43d 100644 --- a/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts +++ b/packages/core/client/src/collection-manager/hooks/useCollectionManager.ts @@ -23,8 +23,9 @@ export const useCollectionManager = () => { return inheritedFields.filter(Boolean); }; - const getCollectionFields = (name: string): CollectionFieldOptions[] => { - const currentFields = collections?.find((collection) => collection.name === name)?.fields || []; + const getCollectionFields = (name: any): CollectionFieldOptions[] => { + const collection = getCollection(name); + const currentFields = collection?.fields || []; const inheritedFields = getInheritedFields(name); const totalFields = unionBy(currentFields?.concat(inheritedFields) || [], 'name').filter((v: any) => { return !v.isForeignKey; diff --git a/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx b/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx new file mode 100644 index 000000000..8b0b77607 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx @@ -0,0 +1,172 @@ +import { CollectionFieldOptions, useCollectionManager, useCompile } from '../../..'; +import { Tag, TreeSelect } from 'antd'; +import type { DefaultOptionType } from 'rc-tree-select/es/TreeSelect'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type AppendsTreeSelectProps = { + value: string[]; + onChange: (value: string[]) => void; + collection?: string; + useCollection?(props: Pick): string; +}; + +type TreeOptionType = Omit & { value: string }; + +function usePropsCollection({ collection }) { + return collection; +} + +type CallScope = { + compile?(value: string): string; + getCollectionFields?(name: any): CollectionFieldOptions[]; +} + +function loadChildren(this, option) { + const result = getCollectionFieldOptions.call(this, option.field.target, option); + if (result.length) { + if (!result.some((item) => isAssociation(item.field))) { + option.isLeaf = true; + } + } else { + option.isLeaf = true; + } + return result; +} + +function isAssociation(field) { + return field.target && field.interface; +} + +function getCollectionFieldOptions(this: CallScope, collection, parentNode?): TreeOptionType[] { + const fields = this.getCollectionFields(collection).filter(isAssociation); + const boundLoadChildren = loadChildren.bind(this); + return fields.map((field) => { + const key = parentNode ? `${parentNode.value}.${field.name}` : field.name; + const fieldTitle = this.compile(field.uiSchema?.title) ?? field.name; + const isLeaf = !this.getCollectionFields(field.target).filter(isAssociation).length; + return { + pId: parentNode?.key ?? null, + id: key, + key, + value: key, + title: fieldTitle, + isLeaf, + loadChildren: isLeaf ? null : boundLoadChildren, + field, + fullTitle: parentNode ? [...parentNode.fullTitle, fieldTitle] : [fieldTitle], + }; + }); +} + +export const AppendsTreeSelect: React.FC = (props) => { + const { value = [], onChange, collection, useCollection = usePropsCollection, ...restProps } = props; + const { getCollectionFields } = useCollectionManager(); + const compile = useCompile(); + const { t } = useTranslation(); + const [optionsMap, setOptionsMap] = useState({}); + const baseCollection = useCollection({ collection }); + const treeData = Object.values(optionsMap); + + const loadData = useCallback(async (option) => { + if (!option.isLeaf && option.loadChildren) { + const children = option.loadChildren(option); + setOptionsMap((prev) => { + return children.reduce((result, item) => Object.assign(result, { [item.value]: item }), { ...prev }); + }); + } + }, [setOptionsMap]); + + useEffect(() => { + const treeData = getCollectionFieldOptions.call({ compile, getCollectionFields }, baseCollection); + setOptionsMap(treeData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {})); + }, [collection, baseCollection]); + + useEffect(() => { + if (!value?.length || value.every(v => Boolean(optionsMap[v]))) { + return; + } + const loaded = []; + + value.forEach((v) => { + const paths = v.split('.'); + let option = optionsMap[paths[0]]; + for (let i = 1; i < paths.length; i++) { + if (!option) { + break; + } + const next = paths.slice(0, i + 1).join('.'); + if (optionsMap[next]) { + option = optionsMap[next]; + break; + } + if (!option.isLeaf && option.loadChildren) { + const children = option.loadChildren(option); + if (children?.length) { + loaded.push(...children); + option = children.find(item => item.value === paths.slice(0, i + 1).join('.')); + } + } + } + }); + setOptionsMap((prev) => { + return loaded.reduce((result, item) => Object.assign(result, { [item.value]: item }), { ...prev }); + }); + }, [value, treeData.length]); + + const handleChange = useCallback((newNodes: DefaultOptionType[]) => { + const newValue = newNodes.map((i) => i.value).filter(Boolean) as string[]; + const valueSet = new Set(newValue); + const delValue = value.find((i) => !newValue.includes(i)); + + if (delValue) { + const delNode = optionsMap[delValue]; + const prefix = `${delNode.value}.`; + Object.keys(optionsMap) + .forEach((key) => { + if (key.startsWith(prefix)) { + valueSet.delete(key); + } + }); + } else { + newValue.forEach((v) => { + const paths = v.split('.'); + if (paths.length) { + for (let i = 1; i < paths.length; i++) { + valueSet.add(paths.slice(0, i).join('.')); + } + } + }); + } + onChange(Array.from(valueSet)); + }, [value, optionsMap]); + + const TreeTag = useCallback((props) => { + const { value, onClose, disabled, closable } = props; + const { fullTitle } = optionsMap[value]; + return ( + {fullTitle.join(' / ')} + ); + }, [optionsMap]); + + const filterdValue = Array.isArray(value) ? value.filter((i) => i in optionsMap) : value; + + return ( + void} + treeDataSimpleMode + treeData={treeData} + loadData={loadData} + {...restProps} + /> + ); +}; diff --git a/packages/core/client/src/schema-component/antd/appends-tree-select/index.ts b/packages/core/client/src/schema-component/antd/appends-tree-select/index.ts new file mode 100644 index 000000000..221768530 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/appends-tree-select/index.ts @@ -0,0 +1 @@ +export * from './AppendsTreeSelect'; diff --git a/packages/core/client/src/schema-component/antd/index.ts b/packages/core/client/src/schema-component/antd/index.ts index edf45de12..1c7166c73 100644 --- a/packages/core/client/src/schema-component/antd/index.ts +++ b/packages/core/client/src/schema-component/antd/index.ts @@ -1,4 +1,5 @@ export * from './action'; +export * from './appends-tree-select'; export * from './association-field'; export * from './association-select'; export * from './auto-complete'; diff --git a/packages/plugins/snapshot-field/src/client/SnapshotFieldProvider.tsx b/packages/plugins/snapshot-field/src/client/SnapshotFieldProvider.tsx index d2ecf83f9..18f9f516e 100644 --- a/packages/plugins/snapshot-field/src/client/SnapshotFieldProvider.tsx +++ b/packages/plugins/snapshot-field/src/client/SnapshotFieldProvider.tsx @@ -6,7 +6,6 @@ import { SchemaInitializerProvider, } from '@nocobase/client'; import React, { useEffect } from 'react'; -import { AppendsTreeSelect } from './components/AppendsTreeSelect'; import { SnapshotOwnerCollectionFieldsSelect } from './components/SnapshotOwnerCollectionFieldsSelect'; import { snapshot } from './interface'; import { SnapshotBlockInitializers } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers'; @@ -36,7 +35,6 @@ export const SnapshotFieldProvider = React.memo((props) => { SnapshotRecordPicker, SnapshotBlockProvider, SnapshotBlockInitializersDetailItem, - AppendsTreeSelect, SnapshotOwnerCollectionFieldsSelect, }} > diff --git a/packages/plugins/snapshot-field/src/client/components/AppendsTreeSelect.tsx b/packages/plugins/snapshot-field/src/client/components/AppendsTreeSelect.tsx deleted file mode 100644 index f906b887c..000000000 --- a/packages/plugins/snapshot-field/src/client/components/AppendsTreeSelect.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useForm } from '@formily/react'; -import { CollectionFieldOptions, useCollectionManager, useCompile } from '@nocobase/client'; -import { Tag, TreeSelect } from 'antd'; -import type { DefaultOptionType } from 'rc-tree-select/es/TreeSelect'; -import React from 'react'; -import { useTopRecord } from '../interface'; -import { useSnapshotTranslation } from '../locale'; - -export type TreeCacheMapNode = { - parent?: TreeCacheMapNode; - title: string; - path: string; - children?: TreeCacheMapNode[]; -}; - -export type AppendsTreeSelectProps = { - value: string[]; - onChange: (value: string[]) => void; -}; - -type TreeOptionType = Omit & { value: string }; - -export const AppendsTreeSelect: React.FC = (props) => { - const { value = [], onChange, ...restProps } = props; - const record = useTopRecord(); - const { getCollectionFields, getCollectionField } = useCollectionManager(); - const compile = useCompile(); - const formValues = useForm().values; - const { t } = useSnapshotTranslation(); - - const fieldsToOptions = ( - fields: CollectionFieldOptions[] = [], - fieldPath: CollectionFieldOptions[] = [], - ): TreeOptionType[] => { - const filter = (i: CollectionFieldOptions) => - !!i.target && !!i.interface && !fieldPath.find((p) => p.target === i.target); - return fields.filter(filter).map((i) => ({ - title: compile(i.uiSchema?.title) ?? i.name, - value: fieldPath - .map((p) => p.name) - .concat(i.name) - .join('.'), - children: fieldsToOptions(getCollectionFields(i.target), [...fieldPath, i]), - })); - }; - - const treeData = fieldsToOptions( - getCollectionFields(getCollectionField(`${record.name}.${formValues.targetField}`)?.target), - ); - - const valueMap: Record = {}; - - function loops(list: TreeOptionType[], parent?: TreeCacheMapNode) { - return (list || []).map(({ children, value, title }) => { - const node: TreeCacheMapNode = (valueMap[value] = { - parent, - path: value, - title, - }); - node.children = loops(children, node); - return node; - }); - } - - loops(treeData); - - const handleChange = (newNodes: DefaultOptionType[]) => { - const newValue = newNodes.map((i) => i.value) as string[]; - const valueSet = new Set(newValue); - const delValue = value.find((i) => !newValue.includes(i)); - - if (delValue) { - const delNode = valueMap[delValue]; - const delNodeValue = (node: TreeCacheMapNode) => { - valueSet.delete(node.path); - node.children?.forEach((child) => delNodeValue(child)); - }; - delNodeValue(delNode); - } else { - newValue.forEach((v) => { - let current = valueMap[v]; - while ((current = current.parent)) { - valueSet.add(current.path); - } - }); - } - onChange(Array.from(valueSet)); - }; - - const TreeTag = (props) => { - const { value, onClose, disabled, closable } = props; - let node = valueMap[value]; - let text = node?.title; - while ((node = node?.parent)) { - text = `${node.title} / ${text}`; - } - return ( - - {text} - - ); - }; - - const filterdValue = Array.isArray(value) ? value.filter((i) => i in valueMap) : value; - - return ( - void} - treeData={treeData} - {...restProps} - /> - ); -}; diff --git a/packages/plugins/snapshot-field/src/client/interface.ts b/packages/plugins/snapshot-field/src/client/interface.ts index 060c26768..ba07cfe4e 100644 --- a/packages/plugins/snapshot-field/src/client/interface.ts +++ b/packages/plugins/snapshot-field/src/client/interface.ts @@ -1,5 +1,5 @@ import type { Field } from '@formily/core'; -import { ISchema } from '@formily/react'; +import { ISchema, useForm } from '@formily/react'; import { IField, interfacesProperties, useCollectionManager, useRecord } from '@nocobase/client'; import { lodash } from '@nocobase/utils/client'; import { NAMESPACE } from './locale'; @@ -18,6 +18,13 @@ export const useTopRecord = () => { return record; }; +function useRecordCollection() { + const { getCollectionField } = useCollectionManager(); + const record = useTopRecord(); + const formValues = useForm().values; + return getCollectionField(`${record.name}.${formValues.targetField}`)?.target; +} + const onTargetFieldChange = (field: Field) => { field.value; // for watch const targetField = field.query(`.${APPENDS}`).take() as Field | undefined; @@ -161,6 +168,9 @@ export const snapshot: IField = { title: `{{t("Snapshot the snapshot's association fields", {ns: "${NAMESPACE}"})}}`, 'x-decorator': 'FormItem', 'x-component': 'AppendsTreeSelect', + 'x-component-props': { + useCollection: useRecordCollection, + }, 'x-reactions': [ { dependencies: [TARGET_FIELD], diff --git a/packages/plugins/workflow/src/client/components/FieldsSelect.tsx b/packages/plugins/workflow/src/client/components/FieldsSelect.tsx index 484dc6d65..1282051c9 100644 --- a/packages/plugins/workflow/src/client/components/FieldsSelect.tsx +++ b/packages/plugins/workflow/src/client/components/FieldsSelect.tsx @@ -4,9 +4,13 @@ import React from 'react'; import { useCollectionManager, useCompile } from '@nocobase/client'; +function defaultFilter() { + return true; +} + export const FieldsSelect = observer( (props: any) => { - const { filter = () => true, ...others } = props; + const { filter = defaultFilter, ...others } = props; const compile = useCompile(); const { getCollectionFields } = useCollectionManager(); const { values } = useForm(); diff --git a/packages/plugins/workflow/src/client/locale/zh-CN.ts b/packages/plugins/workflow/src/client/locale/zh-CN.ts index 37f4c443f..e91b55f32 100644 --- a/packages/plugins/workflow/src/client/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/client/locale/zh-CN.ts @@ -28,8 +28,8 @@ export default { '只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。', 'Only triggers when match conditions': '满足以下条件才触发', 'Preload associations': '预加载关联数据', - 'Please select the associated fields that need to be accessed in subsequent nodes': - '请选中需要在后续节点中被访问的关系字段', + 'Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.': + '请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。', 'Schedule event': '定时任务', 'Trigger mode': '触发模式', 'Based on certain date': '自定义时间', diff --git a/packages/plugins/workflow/src/client/nodes/create.tsx b/packages/plugins/workflow/src/client/nodes/create.tsx index 9a45f3abc..f275a7ccb 100644 --- a/packages/plugins/workflow/src/client/nodes/create.tsx +++ b/packages/plugins/workflow/src/client/nodes/create.tsx @@ -38,7 +38,6 @@ export default { }, components: { CollectionFieldset, - FieldsSelect, }, useVariables({ id, title, config }, options) { const compile = useCompile(); diff --git a/packages/plugins/workflow/src/client/nodes/query.tsx b/packages/plugins/workflow/src/client/nodes/query.tsx index f84ff42e6..62aabc95a 100644 --- a/packages/plugins/workflow/src/client/nodes/query.tsx +++ b/packages/plugins/workflow/src/client/nodes/query.tsx @@ -41,7 +41,6 @@ export default { }, components: { FilterDynamicComponent, - FieldsSelect, }, useVariables({ id, title, config }, options) { const compile = useCompile(); diff --git a/packages/plugins/workflow/src/client/schemas/collection.ts b/packages/plugins/workflow/src/client/schemas/collection.ts index 56a978888..aa1be3dee 100644 --- a/packages/plugins/workflow/src/client/schemas/collection.ts +++ b/packages/plugins/workflow/src/client/schemas/collection.ts @@ -52,15 +52,15 @@ export const filter = { export const appends = { type: 'array', title: `{{t("Preload associations", { ns: "${NAMESPACE}" })}}`, - description: `{{t("Please select the associated fields that need to be accessed in subsequent nodes", { ns: "${NAMESPACE}" })}}`, + description: `{{t("Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', - 'x-component': 'FieldsSelect', + 'x-component': 'AppendsTreeSelect', 'x-component-props': { - mode: 'multiple', - placeholder: '{{t("Select field")}}', - filter(field) { - return ['linkTo', 'belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type); + useCollection() { + const { values } = useForm(); + return values?.collection; }, + className: 'full-width', }, 'x-reactions': [ { diff --git a/packages/plugins/workflow/src/client/triggers/collection.tsx b/packages/plugins/workflow/src/client/triggers/collection.tsx index b505ed275..153c0f04a 100644 --- a/packages/plugins/workflow/src/client/triggers/collection.tsx +++ b/packages/plugins/workflow/src/client/triggers/collection.tsx @@ -85,6 +85,7 @@ export default { 'x-decorator': 'FormItem', 'x-component': 'FieldsSelect', 'x-component-props': { + className: 'full-width', mode: 'multiple', placeholder: '{{t("Select field")}}', filter(field) { @@ -109,6 +110,9 @@ export default { condition: { ...filter, title: `{{t("Only triggers when match conditions", { ns: "${NAMESPACE}" })}}`, + 'x-component-props': { + useProps: filter['x-component-props'].useProps, + }, 'x-reactions': [ { dependencies: ['collection'], diff --git a/packages/plugins/workflow/src/client/triggers/schedule/DateFieldsSelect.tsx b/packages/plugins/workflow/src/client/triggers/schedule/DateFieldsSelect.tsx deleted file mode 100644 index a7ec5df77..000000000 --- a/packages/plugins/workflow/src/client/triggers/schedule/DateFieldsSelect.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { observer, useForm } from '@formily/react'; -import { useCollectionManager, useCompile } from '@nocobase/client'; -import { Select } from 'antd'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -export const DateFieldsSelect: React.FC = observer( - (props) => { - const { t } = useTranslation(); - const compile = useCompile(); - const { getCollectionFields } = useCollectionManager(); - const { values } = useForm(); - const fields = getCollectionFields(values?.collection); - - return ( - - ); - }, - { displayName: 'DateFieldsSelect' }, -); diff --git a/packages/plugins/workflow/src/client/triggers/schedule/OnField.tsx b/packages/plugins/workflow/src/client/triggers/schedule/OnField.tsx index 3ef7a8f80..5f3df9742 100644 --- a/packages/plugins/workflow/src/client/triggers/schedule/OnField.tsx +++ b/packages/plugins/workflow/src/client/triggers/schedule/OnField.tsx @@ -2,12 +2,16 @@ import { css } from '@nocobase/client'; import { InputNumber, Select } from 'antd'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useWorkflowTranslation } from '../../locale'; -import { DateFieldsSelect } from './DateFieldsSelect'; + +import { FieldsSelect } from '../../components/FieldsSelect'; +import { lang } from '../../locale'; + +function dateFieldFilter(field) { + return !field.hidden && (field.uiSchema ? field.type === 'date' : false); +} export function OnField({ value, onChange }) { const { t } = useTranslation(); - const { t: localT } = useWorkflowTranslation(); const [dir, setDir] = useState(value.offset ? value.offset / Math.abs(value.offset) : 0); return ( @@ -17,7 +21,12 @@ export function OnField({ value, onChange }) { gap: 0.5em; `} > - onChange({ ...value, field })} /> + onChange({ ...value, field })} + filter={dateFieldFilter} + placeholder={t('Select field')} + /> {value.field ? ( + options={[ + { value: 0, label: lang('Exactly at') }, + { value: -1, label: t('Before') }, + { value: 1, label: t('After') }, + ]} + /> ) : null} {dir ? ( <> @@ -37,12 +47,16 @@ export function OnField({ value, onChange }) { value={Math.abs(value.offset)} onChange={(v) => onChange({ ...value, offset: (v ?? 0) * dir })} /> - +