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
This commit is contained in:
parent
70d5b9e44b
commit
9f8460ca22
@ -23,8 +23,9 @@ export const useCollectionManager = () => {
|
|||||||
return inheritedFields.filter(Boolean);
|
return inheritedFields.filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCollectionFields = (name: string): CollectionFieldOptions[] => {
|
const getCollectionFields = (name: any): CollectionFieldOptions[] => {
|
||||||
const currentFields = collections?.find((collection) => collection.name === name)?.fields || [];
|
const collection = getCollection(name);
|
||||||
|
const currentFields = collection?.fields || [];
|
||||||
const inheritedFields = getInheritedFields(name);
|
const inheritedFields = getInheritedFields(name);
|
||||||
const totalFields = unionBy(currentFields?.concat(inheritedFields) || [], 'name').filter((v: any) => {
|
const totalFields = unionBy(currentFields?.concat(inheritedFields) || [], 'name').filter((v: any) => {
|
||||||
return !v.isForeignKey;
|
return !v.isForeignKey;
|
||||||
|
@ -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<AppendsTreeSelectProps, 'collection'>): string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeOptionType = Omit<DefaultOptionType, 'value'> & { 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<AppendsTreeSelectProps> = (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 (
|
||||||
|
<Tag closable={closable && !disabled} onClose={onClose}>{fullTitle.join(' / ')}</Tag>
|
||||||
|
);
|
||||||
|
}, [optionsMap]);
|
||||||
|
|
||||||
|
const filterdValue = Array.isArray(value) ? value.filter((i) => i in optionsMap) : value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeSelect
|
||||||
|
value={filterdValue}
|
||||||
|
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||||
|
placeholder={t('Select field')}
|
||||||
|
showCheckedStrategy="SHOW_ALL"
|
||||||
|
allowClear
|
||||||
|
multiple
|
||||||
|
treeCheckStrictly
|
||||||
|
treeCheckable
|
||||||
|
tagRender={TreeTag}
|
||||||
|
onChange={handleChange as unknown as () => void}
|
||||||
|
treeDataSimpleMode
|
||||||
|
treeData={treeData}
|
||||||
|
loadData={loadData}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './AppendsTreeSelect';
|
@ -1,4 +1,5 @@
|
|||||||
export * from './action';
|
export * from './action';
|
||||||
|
export * from './appends-tree-select';
|
||||||
export * from './association-field';
|
export * from './association-field';
|
||||||
export * from './association-select';
|
export * from './association-select';
|
||||||
export * from './auto-complete';
|
export * from './auto-complete';
|
||||||
|
@ -6,7 +6,6 @@ import {
|
|||||||
SchemaInitializerProvider,
|
SchemaInitializerProvider,
|
||||||
} from '@nocobase/client';
|
} from '@nocobase/client';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { AppendsTreeSelect } from './components/AppendsTreeSelect';
|
|
||||||
import { SnapshotOwnerCollectionFieldsSelect } from './components/SnapshotOwnerCollectionFieldsSelect';
|
import { SnapshotOwnerCollectionFieldsSelect } from './components/SnapshotOwnerCollectionFieldsSelect';
|
||||||
import { snapshot } from './interface';
|
import { snapshot } from './interface';
|
||||||
import { SnapshotBlockInitializers } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers';
|
import { SnapshotBlockInitializers } from './SnapshotBlock/SnapshotBlockInitializers/SnapshotBlockInitializers';
|
||||||
@ -36,7 +35,6 @@ export const SnapshotFieldProvider = React.memo((props) => {
|
|||||||
SnapshotRecordPicker,
|
SnapshotRecordPicker,
|
||||||
SnapshotBlockProvider,
|
SnapshotBlockProvider,
|
||||||
SnapshotBlockInitializersDetailItem,
|
SnapshotBlockInitializersDetailItem,
|
||||||
AppendsTreeSelect,
|
|
||||||
SnapshotOwnerCollectionFieldsSelect,
|
SnapshotOwnerCollectionFieldsSelect,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -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<DefaultOptionType, 'value'> & { value: string };
|
|
||||||
|
|
||||||
export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (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<string, TreeCacheMapNode> = {};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Tag closable={closable && !disabled} onClose={onClose}>
|
|
||||||
{text}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterdValue = Array.isArray(value) ? value.filter((i) => i in valueMap) : value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TreeSelect
|
|
||||||
value={filterdValue}
|
|
||||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
|
||||||
placeholder={t('Please select')}
|
|
||||||
showCheckedStrategy="SHOW_ALL"
|
|
||||||
allowClear
|
|
||||||
multiple
|
|
||||||
treeCheckStrictly
|
|
||||||
treeCheckable
|
|
||||||
tagRender={TreeTag}
|
|
||||||
onChange={handleChange as unknown as () => void}
|
|
||||||
treeData={treeData}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,5 +1,5 @@
|
|||||||
import type { Field } from '@formily/core';
|
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 { IField, interfacesProperties, useCollectionManager, useRecord } from '@nocobase/client';
|
||||||
import { lodash } from '@nocobase/utils/client';
|
import { lodash } from '@nocobase/utils/client';
|
||||||
import { NAMESPACE } from './locale';
|
import { NAMESPACE } from './locale';
|
||||||
@ -18,6 +18,13 @@ export const useTopRecord = () => {
|
|||||||
return record;
|
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) => {
|
const onTargetFieldChange = (field: Field) => {
|
||||||
field.value; // for watch
|
field.value; // for watch
|
||||||
const targetField = field.query(`.${APPENDS}`).take() as Field | undefined;
|
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}"})}}`,
|
title: `{{t("Snapshot the snapshot's association fields", {ns: "${NAMESPACE}"})}}`,
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'AppendsTreeSelect',
|
'x-component': 'AppendsTreeSelect',
|
||||||
|
'x-component-props': {
|
||||||
|
useCollection: useRecordCollection,
|
||||||
|
},
|
||||||
'x-reactions': [
|
'x-reactions': [
|
||||||
{
|
{
|
||||||
dependencies: [TARGET_FIELD],
|
dependencies: [TARGET_FIELD],
|
||||||
|
@ -4,9 +4,13 @@ import React from 'react';
|
|||||||
|
|
||||||
import { useCollectionManager, useCompile } from '@nocobase/client';
|
import { useCollectionManager, useCompile } from '@nocobase/client';
|
||||||
|
|
||||||
|
function defaultFilter() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export const FieldsSelect = observer(
|
export const FieldsSelect = observer(
|
||||||
(props: any) => {
|
(props: any) => {
|
||||||
const { filter = () => true, ...others } = props;
|
const { filter = defaultFilter, ...others } = props;
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
const { getCollectionFields } = useCollectionManager();
|
const { getCollectionFields } = useCollectionManager();
|
||||||
const { values } = useForm();
|
const { values } = useForm();
|
||||||
|
@ -28,8 +28,8 @@ export default {
|
|||||||
'只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。',
|
'只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。',
|
||||||
'Only triggers when match conditions': '满足以下条件才触发',
|
'Only triggers when match conditions': '满足以下条件才触发',
|
||||||
'Preload associations': '预加载关联数据',
|
'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': '定时任务',
|
'Schedule event': '定时任务',
|
||||||
'Trigger mode': '触发模式',
|
'Trigger mode': '触发模式',
|
||||||
'Based on certain date': '自定义时间',
|
'Based on certain date': '自定义时间',
|
||||||
|
@ -38,7 +38,6 @@ export default {
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
CollectionFieldset,
|
CollectionFieldset,
|
||||||
FieldsSelect,
|
|
||||||
},
|
},
|
||||||
useVariables({ id, title, config }, options) {
|
useVariables({ id, title, config }, options) {
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
|
@ -41,7 +41,6 @@ export default {
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
FilterDynamicComponent,
|
FilterDynamicComponent,
|
||||||
FieldsSelect,
|
|
||||||
},
|
},
|
||||||
useVariables({ id, title, config }, options) {
|
useVariables({ id, title, config }, options) {
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
|
@ -52,15 +52,15 @@ export const filter = {
|
|||||||
export const appends = {
|
export const appends = {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
title: `{{t("Preload associations", { ns: "${NAMESPACE}" })}}`,
|
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-decorator': 'FormItem',
|
||||||
'x-component': 'FieldsSelect',
|
'x-component': 'AppendsTreeSelect',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
mode: 'multiple',
|
useCollection() {
|
||||||
placeholder: '{{t("Select field")}}',
|
const { values } = useForm();
|
||||||
filter(field) {
|
return values?.collection;
|
||||||
return ['linkTo', 'belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type);
|
|
||||||
},
|
},
|
||||||
|
className: 'full-width',
|
||||||
},
|
},
|
||||||
'x-reactions': [
|
'x-reactions': [
|
||||||
{
|
{
|
||||||
|
@ -85,6 +85,7 @@ export default {
|
|||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'FieldsSelect',
|
'x-component': 'FieldsSelect',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
|
className: 'full-width',
|
||||||
mode: 'multiple',
|
mode: 'multiple',
|
||||||
placeholder: '{{t("Select field")}}',
|
placeholder: '{{t("Select field")}}',
|
||||||
filter(field) {
|
filter(field) {
|
||||||
@ -109,6 +110,9 @@ export default {
|
|||||||
condition: {
|
condition: {
|
||||||
...filter,
|
...filter,
|
||||||
title: `{{t("Only triggers when match conditions", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("Only triggers when match conditions", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-component-props': {
|
||||||
|
useProps: filter['x-component-props'].useProps,
|
||||||
|
},
|
||||||
'x-reactions': [
|
'x-reactions': [
|
||||||
{
|
{
|
||||||
dependencies: ['collection'],
|
dependencies: ['collection'],
|
||||||
|
@ -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<any> = observer(
|
|
||||||
(props) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const compile = useCompile();
|
|
||||||
const { getCollectionFields } = useCollectionManager();
|
|
||||||
const { values } = useForm();
|
|
||||||
const fields = getCollectionFields(values?.collection);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select popupMatchSelectWidth={false} placeholder={t('Select field')} {...props}>
|
|
||||||
{fields
|
|
||||||
.filter((field) => !field.hidden && (field.uiSchema ? field.type === 'date' : false))
|
|
||||||
.map((field) => (
|
|
||||||
<Select.Option key={field.name} value={field.name}>
|
|
||||||
{compile(field.uiSchema?.title)}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ displayName: 'DateFieldsSelect' },
|
|
||||||
);
|
|
@ -2,12 +2,16 @@ import { css } from '@nocobase/client';
|
|||||||
import { InputNumber, Select } from 'antd';
|
import { InputNumber, Select } from 'antd';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 }) {
|
export function OnField({ value, onChange }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { t: localT } = useWorkflowTranslation();
|
|
||||||
const [dir, setDir] = useState(value.offset ? value.offset / Math.abs(value.offset) : 0);
|
const [dir, setDir] = useState(value.offset ? value.offset / Math.abs(value.offset) : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -17,7 +21,12 @@ export function OnField({ value, onChange }) {
|
|||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<DateFieldsSelect value={value.field} onChange={(field) => onChange({ ...value, field })} />
|
<FieldsSelect
|
||||||
|
value={value.field}
|
||||||
|
onChange={(field) => onChange({ ...value, field })}
|
||||||
|
filter={dateFieldFilter}
|
||||||
|
placeholder={t('Select field')}
|
||||||
|
/>
|
||||||
{value.field ? (
|
{value.field ? (
|
||||||
<Select
|
<Select
|
||||||
value={dir}
|
value={dir}
|
||||||
@ -25,11 +34,12 @@ export function OnField({ value, onChange }) {
|
|||||||
setDir(v);
|
setDir(v);
|
||||||
onChange({ ...value, offset: Math.abs(value.offset) * v });
|
onChange({ ...value, offset: Math.abs(value.offset) * v });
|
||||||
}}
|
}}
|
||||||
>
|
options={[
|
||||||
<Select.Option value={0}>{localT('Exactly at')}</Select.Option>
|
{ value: 0, label: lang('Exactly at') },
|
||||||
<Select.Option value={-1}>{t('Before')}</Select.Option>
|
{ value: -1, label: t('Before') },
|
||||||
<Select.Option value={1}>{t('After')}</Select.Option>
|
{ value: 1, label: t('After') },
|
||||||
</Select>
|
]}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{dir ? (
|
{dir ? (
|
||||||
<>
|
<>
|
||||||
@ -37,12 +47,16 @@ export function OnField({ value, onChange }) {
|
|||||||
value={Math.abs(value.offset)}
|
value={Math.abs(value.offset)}
|
||||||
onChange={(v) => onChange({ ...value, offset: (v ?? 0) * dir })}
|
onChange={(v) => onChange({ ...value, offset: (v ?? 0) * dir })}
|
||||||
/>
|
/>
|
||||||
<Select value={value.unit || 86400000} onChange={(unit) => onChange({ ...value, unit })}>
|
<Select
|
||||||
<Select.Option value={86400000}>{localT('Days')}</Select.Option>
|
value={value.unit || 86400000}
|
||||||
<Select.Option value={3600000}>{localT('Hours')}</Select.Option>
|
onChange={(unit) => onChange({ ...value, unit })}
|
||||||
<Select.Option value={60000}>{localT('Minutes')}</Select.Option>
|
options={[
|
||||||
<Select.Option value={1000}>{localT('Seconds')}</Select.Option>
|
{ value: 86400000, label: lang('Days') },
|
||||||
</Select>
|
{ value: 3600000, label: lang('Hours') },
|
||||||
|
{ value: 60000, label: lang('Minutes') },
|
||||||
|
{ value: 1000, label: lang('Seconds') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { onFieldValueChange } from '@formily/core';
|
import { onFieldValueChange } from '@formily/core';
|
||||||
import { useForm, useFormEffects } from '@formily/react';
|
import { useForm, useFormEffects, ISchema } from '@formily/react';
|
||||||
import { css, SchemaComponent } from '@nocobase/client';
|
import { css, SchemaComponent } from '@nocobase/client';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NAMESPACE } from '../../locale';
|
import { NAMESPACE } from '../../locale';
|
||||||
@ -193,7 +193,8 @@ export const ScheduleConfig = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<SchemaComponent
|
<SchemaComponent
|
||||||
schema={{
|
schema={
|
||||||
|
{
|
||||||
type: 'void',
|
type: 'void',
|
||||||
properties: {
|
properties: {
|
||||||
[`mode-${mode}`]: {
|
[`mode-${mode}`]: {
|
||||||
@ -213,7 +214,8 @@ export const ScheduleConfig = () => {
|
|||||||
properties: ModeFieldsets[mode],
|
properties: ModeFieldsets[mode],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
} as ISchema
|
||||||
|
}
|
||||||
components={{
|
components={{
|
||||||
OnField,
|
OnField,
|
||||||
RepeatField,
|
RepeatField,
|
||||||
|
@ -22,7 +22,6 @@ export default {
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
ScheduleConfig,
|
ScheduleConfig,
|
||||||
FieldsSelect,
|
|
||||||
},
|
},
|
||||||
useVariables(config, opts) {
|
useVariables(config, opts) {
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
@ -32,25 +31,31 @@ export default {
|
|||||||
options.push({ key: 'date', value: 'date', label: lang('Trigger time') });
|
options.push({ key: 'date', value: 'date', label: lang('Trigger time') });
|
||||||
}
|
}
|
||||||
|
|
||||||
const depth = config.appends?.length
|
// const depth = config.appends?.length
|
||||||
? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1
|
// ? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1
|
||||||
: 1;
|
// : 1;
|
||||||
|
|
||||||
const fieldOptions = getCollectionFieldOptions({
|
if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
|
||||||
depth,
|
const [fieldOption] = getCollectionFieldOptions({
|
||||||
|
// depth,
|
||||||
...opts,
|
...opts,
|
||||||
collection: config.collection,
|
fields: [
|
||||||
|
{
|
||||||
|
collectionName: config.collection,
|
||||||
|
name: 'data',
|
||||||
|
type: 'hasOne',
|
||||||
|
target: config.collection,
|
||||||
|
uiSchema: {
|
||||||
|
title: lang('Trigger data'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
|
||||||
compile,
|
compile,
|
||||||
getCollectionFields,
|
getCollectionFields,
|
||||||
});
|
});
|
||||||
if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
|
if (fieldOption) {
|
||||||
if (fieldOptions.length) {
|
options.push(fieldOption);
|
||||||
options.push({
|
|
||||||
key: 'data',
|
|
||||||
value: 'data',
|
|
||||||
label: lang('Trigger data'),
|
|
||||||
children: fieldOptions,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
|
@ -194,7 +194,7 @@ export default class WorkflowPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public trigger(workflow: WorkflowModel, context: { [key: string]: any }, options: { context?: any } = {}): void {
|
public trigger(workflow: WorkflowModel, context: object, options: { context?: any } = {}): void {
|
||||||
// `null` means not to trigger
|
// `null` means not to trigger
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
return;
|
return;
|
||||||
|
@ -19,5 +19,9 @@ export default {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'expression',
|
name: 'expression',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'hasMany',
|
||||||
|
name: 'posts',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
} as CollectionOptions;
|
} as CollectionOptions;
|
||||||
|
@ -294,5 +294,40 @@ describe('workflow > triggers > collection', () => {
|
|||||||
const [job] = await execution.getJobs();
|
const [job] = await execution.getJobs();
|
||||||
expect(job.result.data.tags.length).toBe(1);
|
expect(job.result.data.tags.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('appends depth > 1', () => {
|
||||||
|
it('create with associtions', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'collection',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'categories',
|
||||||
|
appends: ['posts.tags'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags = await TagRepo.create({ values: [{}] });
|
||||||
|
const tagIds = tags.map((item) => item.id);
|
||||||
|
|
||||||
|
const category = await CategoryRepo.create({
|
||||||
|
values: {
|
||||||
|
title: 't1',
|
||||||
|
posts: [
|
||||||
|
{ title: 't1', tags: tagIds },
|
||||||
|
{ title: 't2', tags: tagIds },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [execution] = await workflow.getExecutions();
|
||||||
|
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
expect(execution.context.data.posts.length).toBe(2);
|
||||||
|
expect(execution.context.data.posts.map((item) => item.title)).toEqual(['t1', 't2']);
|
||||||
|
expect(execution.context.data.posts.map((item) => item.tags.map((tag) => tag.id))).toEqual([tagIds, tagIds]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { JOB_STATUS } from '../constants';
|
import { JOB_STATUS } from '../constants';
|
||||||
|
import { toJSON } from '../utils';
|
||||||
import type { FlowNodeModel } from '../types';
|
import type { FlowNodeModel } from '../types';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -7,7 +8,7 @@ export default {
|
|||||||
|
|
||||||
const { repository, model } = (<typeof FlowNodeModel>node.constructor).database.getCollection(collection);
|
const { repository, model } = (<typeof FlowNodeModel>node.constructor).database.getCollection(collection);
|
||||||
const options = processor.getParsedValue(params, node);
|
const options = processor.getParsedValue(params, node);
|
||||||
const result = await repository.create({
|
const created = await repository.create({
|
||||||
...options,
|
...options,
|
||||||
context: {
|
context: {
|
||||||
executionId: processor.execution.id,
|
executionId: processor.execution.id,
|
||||||
@ -15,22 +16,23 @@ export default {
|
|||||||
transaction: processor.transaction,
|
transaction: processor.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result && appends.length) {
|
let result = created;
|
||||||
const includeFields = appends.filter((field) => !result.get(field) || !result[field]);
|
if (created && appends.length) {
|
||||||
const included = await model.findByPk(result[model.primaryKeyAttribute], {
|
const includeFields = appends.reduce((set, field) => {
|
||||||
attributes: [model.primaryKeyAttribute],
|
set.add(field.split('.')[0]);
|
||||||
include: includeFields,
|
set.add(field);
|
||||||
|
return set;
|
||||||
|
}, new Set());
|
||||||
|
result = await repository.findOne({
|
||||||
|
filterByTk: created[model.primaryKeyAttribute],
|
||||||
|
appends: Array.from(includeFields),
|
||||||
transaction: processor.transaction,
|
transaction: processor.transaction,
|
||||||
});
|
});
|
||||||
includeFields.forEach((field) => {
|
|
||||||
const value = included!.get(field);
|
|
||||||
result.set(field, Array.isArray(value) ? value.map((item) => item.toJSON()) : value.toJSON(), { raw: true });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// NOTE: get() for non-proxied instance (#380)
|
// NOTE: get() for non-proxied instance (#380)
|
||||||
result: result?.toJSON(),
|
result: toJSON(result),
|
||||||
status: JOB_STATUS.RESOLVED,
|
status: JOB_STATUS.RESOLVED,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import Processor from '../Processor';
|
import Processor from '../Processor';
|
||||||
import { JOB_STATUS } from '../constants';
|
import { JOB_STATUS } from '../constants';
|
||||||
|
import { toJSON } from '../utils';
|
||||||
import type { FlowNodeModel } from '../types';
|
import type { FlowNodeModel } from '../types';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -8,8 +9,18 @@ export default {
|
|||||||
|
|
||||||
const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection);
|
const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection);
|
||||||
const options = processor.getParsedValue(params, node);
|
const options = processor.getParsedValue(params, node);
|
||||||
|
const appends = options.appends
|
||||||
|
? Array.from(
|
||||||
|
options.appends.reduce((set, field) => {
|
||||||
|
set.add(field.split('.')[0]);
|
||||||
|
set.add(field);
|
||||||
|
return set;
|
||||||
|
}, new Set()),
|
||||||
|
)
|
||||||
|
: options.appends;
|
||||||
const result = await (multiple ? repo.find : repo.findOne).call(repo, {
|
const result = await (multiple ? repo.find : repo.findOne).call(repo, {
|
||||||
...options,
|
...options,
|
||||||
|
appends: appends,
|
||||||
transaction: processor.transaction,
|
transaction: processor.transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -24,7 +35,7 @@ export default {
|
|||||||
// e.g. Object.prototype.hasOwnProperty.call(result, 'id') // false
|
// e.g. Object.prototype.hasOwnProperty.call(result, 'id') // false
|
||||||
// so the properties can not be get by json-templates(object-path)
|
// so the properties can not be get by json-templates(object-path)
|
||||||
return {
|
return {
|
||||||
result: multiple ? result.map((item) => item.toJSON()) : result?.toJSON(),
|
result: toJSON(result),
|
||||||
status: JOB_STATUS.RESOLVED,
|
status: JOB_STATUS.RESOLVED,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Collection, Model } from '@nocobase/database';
|
import { Collection, Model } from '@nocobase/database';
|
||||||
import { Trigger } from '..';
|
import { Trigger } from '..';
|
||||||
|
import { toJSON } from '../utils';
|
||||||
import type { WorkflowModel } from '../types';
|
import type { WorkflowModel } from '../types';
|
||||||
|
|
||||||
export interface CollectionChangeTriggerConfig {
|
export interface CollectionChangeTriggerConfig {
|
||||||
@ -66,24 +67,27 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let result = data;
|
||||||
|
|
||||||
if (appends?.length && !(mode & MODE_BITMAP.DESTROY)) {
|
if (appends?.length && !(mode & MODE_BITMAP.DESTROY)) {
|
||||||
const includeFields = appends.filter((field) => !data.get(field) || !data[field]);
|
const includeFields = appends.reduce((set, field) => {
|
||||||
const included = await model.findByPk(data[model.primaryKeyAttribute], {
|
set.add(field.split('.')[0]);
|
||||||
attributes: [model.primaryKeyAttribute],
|
set.add(field);
|
||||||
include: includeFields,
|
return set;
|
||||||
|
}, new Set());
|
||||||
|
result = await repository.findOne({
|
||||||
|
filterByTk: data[model.primaryKeyAttribute],
|
||||||
|
appends: Array.from(includeFields),
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
includeFields.forEach((field) => {
|
|
||||||
const value = included!.get(field);
|
|
||||||
data.set(field, Array.isArray(value) ? value.map((item) => item.toJSON()) : value ? value.toJSON() : null, {
|
|
||||||
raw: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: `result.toJSON()` throws error
|
||||||
|
const json = toJSON(result);
|
||||||
|
|
||||||
this.plugin.trigger(
|
this.plugin.trigger(
|
||||||
workflow,
|
workflow,
|
||||||
{ data: data.toJSON() },
|
{ data: json },
|
||||||
{
|
{
|
||||||
context,
|
context,
|
||||||
},
|
},
|
||||||
|
17
packages/plugins/workflow/src/server/utils.ts
Normal file
17
packages/plugins/workflow/src/server/utils.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Model } from '@nocobase/database';
|
||||||
|
|
||||||
|
export function toJSON(data: Model | Model[]): object {
|
||||||
|
if (typeof data !== 'object' || !data) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map(toJSON);
|
||||||
|
}
|
||||||
|
const result = data.get();
|
||||||
|
Object.keys((<typeof Model>data.constructor).associations).forEach((key) => {
|
||||||
|
if (result[key] != null) {
|
||||||
|
result[key] = toJSON(result[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user