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:
Junyi 2023-07-18 10:36:17 +07:00 committed by GitHub
parent 70d5b9e44b
commit 9f8460ca22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 375 additions and 242 deletions

View File

@ -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;

View File

@ -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}
/>
);
};

View File

@ -0,0 +1 @@
export * from './AppendsTreeSelect';

View File

@ -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';

View File

@ -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,
}} }}
> >

View File

@ -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}
/>
);
};

View File

@ -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],

View File

@ -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();

View File

@ -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': '自定义时间',

View File

@ -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();

View File

@ -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();

View File

@ -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': [
{ {

View File

@ -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'],

View File

@ -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' },
);

View File

@ -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>

View File

@ -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,27 +193,29 @@ export const ScheduleConfig = () => {
}} }}
/> />
<SchemaComponent <SchemaComponent
schema={{ schema={
type: 'void', {
properties: { type: 'void',
[`mode-${mode}`]: { properties: {
type: 'void', [`mode-${mode}`]: {
'x-component': 'fieldset', type: 'void',
'x-component-props': { 'x-component': 'fieldset',
className: css` 'x-component-props': {
.ant-input-number { className: css`
width: 4em; .ant-input-number {
} width: 4em;
}
.ant-picker { .ant-picker {
width: auto; width: auto;
} }
`, `,
},
properties: ModeFieldsets[mode],
}, },
properties: ModeFieldsets[mode],
}, },
}, } as ISchema
}} }
components={{ components={{
OnField, OnField,
RepeatField, RepeatField,

View File

@ -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({
depth,
...opts,
collection: config.collection,
compile,
getCollectionFields,
});
if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) { if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
if (fieldOptions.length) { const [fieldOption] = getCollectionFieldOptions({
options.push({ // depth,
key: 'data', ...opts,
value: 'data', fields: [
label: lang('Trigger data'), {
children: fieldOptions, collectionName: config.collection,
}); name: 'data',
type: 'hasOne',
target: config.collection,
uiSchema: {
title: lang('Trigger data'),
},
}
],
appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])],
compile,
getCollectionFields,
});
if (fieldOption) {
options.push(fieldOption);
} }
} }
return options; return options;

View File

@ -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;

View File

@ -19,5 +19,9 @@ export default {
type: 'text', type: 'text',
name: 'expression', name: 'expression',
}, },
{
type: 'hasMany',
name: 'posts',
},
], ],
} as CollectionOptions; } as CollectionOptions;

View File

@ -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]);
});
});
}); });
}); });

View File

@ -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,
}; };
}, },

View File

@ -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,
}; };
}, },

View File

@ -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,
}, },

View 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;
}