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);
|
||||
};
|
||||
|
||||
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;
|
||||
|
@ -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 './appends-tree-select';
|
||||
export * from './association-field';
|
||||
export * from './association-select';
|
||||
export * from './auto-complete';
|
||||
|
@ -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,
|
||||
}}
|
||||
>
|
||||
|
@ -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 { 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],
|
||||
|
@ -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();
|
||||
|
@ -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': '自定义时间',
|
||||
|
@ -38,7 +38,6 @@ export default {
|
||||
},
|
||||
components: {
|
||||
CollectionFieldset,
|
||||
FieldsSelect,
|
||||
},
|
||||
useVariables({ id, title, config }, options) {
|
||||
const compile = useCompile();
|
||||
|
@ -41,7 +41,6 @@ export default {
|
||||
},
|
||||
components: {
|
||||
FilterDynamicComponent,
|
||||
FieldsSelect,
|
||||
},
|
||||
useVariables({ id, title, config }, options) {
|
||||
const compile = useCompile();
|
||||
|
@ -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': [
|
||||
{
|
||||
|
@ -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'],
|
||||
|
@ -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 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;
|
||||
`}
|
||||
>
|
||||
<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 ? (
|
||||
<Select
|
||||
value={dir}
|
||||
@ -25,11 +34,12 @@ export function OnField({ value, onChange }) {
|
||||
setDir(v);
|
||||
onChange({ ...value, offset: Math.abs(value.offset) * v });
|
||||
}}
|
||||
>
|
||||
<Select.Option value={0}>{localT('Exactly at')}</Select.Option>
|
||||
<Select.Option value={-1}>{t('Before')}</Select.Option>
|
||||
<Select.Option value={1}>{t('After')}</Select.Option>
|
||||
</Select>
|
||||
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 })}
|
||||
/>
|
||||
<Select value={value.unit || 86400000} onChange={(unit) => onChange({ ...value, unit })}>
|
||||
<Select.Option value={86400000}>{localT('Days')}</Select.Option>
|
||||
<Select.Option value={3600000}>{localT('Hours')}</Select.Option>
|
||||
<Select.Option value={60000}>{localT('Minutes')}</Select.Option>
|
||||
<Select.Option value={1000}>{localT('Seconds')}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
value={value.unit || 86400000}
|
||||
onChange={(unit) => onChange({ ...value, unit })}
|
||||
options={[
|
||||
{ value: 86400000, label: lang('Days') },
|
||||
{ value: 3600000, label: lang('Hours') },
|
||||
{ value: 60000, label: lang('Minutes') },
|
||||
{ value: 1000, label: lang('Seconds') },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 React, { useState } from 'react';
|
||||
import { NAMESPACE } from '../../locale';
|
||||
@ -193,27 +193,29 @@ export const ScheduleConfig = () => {
|
||||
}}
|
||||
/>
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[`mode-${mode}`]: {
|
||||
type: 'void',
|
||||
'x-component': 'fieldset',
|
||||
'x-component-props': {
|
||||
className: css`
|
||||
.ant-input-number {
|
||||
width: 4em;
|
||||
}
|
||||
schema={
|
||||
{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[`mode-${mode}`]: {
|
||||
type: 'void',
|
||||
'x-component': 'fieldset',
|
||||
'x-component-props': {
|
||||
className: css`
|
||||
.ant-input-number {
|
||||
width: 4em;
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
width: auto;
|
||||
}
|
||||
`,
|
||||
.ant-picker {
|
||||
width: auto;
|
||||
}
|
||||
`,
|
||||
},
|
||||
properties: ModeFieldsets[mode],
|
||||
},
|
||||
properties: ModeFieldsets[mode],
|
||||
},
|
||||
},
|
||||
}}
|
||||
} as ISchema
|
||||
}
|
||||
components={{
|
||||
OnField,
|
||||
RepeatField,
|
||||
|
@ -22,7 +22,6 @@ export default {
|
||||
},
|
||||
components: {
|
||||
ScheduleConfig,
|
||||
FieldsSelect,
|
||||
},
|
||||
useVariables(config, opts) {
|
||||
const compile = useCompile();
|
||||
@ -32,25 +31,31 @@ export default {
|
||||
options.push({ key: 'date', value: 'date', label: lang('Trigger time') });
|
||||
}
|
||||
|
||||
const depth = config.appends?.length
|
||||
? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1
|
||||
: 1;
|
||||
// const depth = config.appends?.length
|
||||
// ? config.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) + 1
|
||||
// : 1;
|
||||
|
||||
const fieldOptions = getCollectionFieldOptions({
|
||||
depth,
|
||||
...opts,
|
||||
collection: config.collection,
|
||||
compile,
|
||||
getCollectionFields,
|
||||
});
|
||||
if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
|
||||
if (fieldOptions.length) {
|
||||
options.push({
|
||||
key: 'data',
|
||||
value: 'data',
|
||||
label: lang('Trigger data'),
|
||||
children: fieldOptions,
|
||||
});
|
||||
const [fieldOption] = getCollectionFieldOptions({
|
||||
// depth,
|
||||
...opts,
|
||||
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,
|
||||
getCollectionFields,
|
||||
});
|
||||
if (fieldOption) {
|
||||
options.push(fieldOption);
|
||||
}
|
||||
}
|
||||
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
|
||||
if (context == null) {
|
||||
return;
|
||||
|
@ -19,5 +19,9 @@ export default {
|
||||
type: 'text',
|
||||
name: 'expression',
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'posts',
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
||||
|
@ -294,5 +294,40 @@ describe('workflow > triggers > collection', () => {
|
||||
const [job] = await execution.getJobs();
|
||||
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 { toJSON } from '../utils';
|
||||
import type { FlowNodeModel } from '../types';
|
||||
|
||||
export default {
|
||||
@ -7,7 +8,7 @@ export default {
|
||||
|
||||
const { repository, model } = (<typeof FlowNodeModel>node.constructor).database.getCollection(collection);
|
||||
const options = processor.getParsedValue(params, node);
|
||||
const result = await repository.create({
|
||||
const created = await repository.create({
|
||||
...options,
|
||||
context: {
|
||||
executionId: processor.execution.id,
|
||||
@ -15,22 +16,23 @@ export default {
|
||||
transaction: processor.transaction,
|
||||
});
|
||||
|
||||
if (result && appends.length) {
|
||||
const includeFields = appends.filter((field) => !result.get(field) || !result[field]);
|
||||
const included = await model.findByPk(result[model.primaryKeyAttribute], {
|
||||
attributes: [model.primaryKeyAttribute],
|
||||
include: includeFields,
|
||||
let result = created;
|
||||
if (created && appends.length) {
|
||||
const includeFields = appends.reduce((set, field) => {
|
||||
set.add(field.split('.')[0]);
|
||||
set.add(field);
|
||||
return set;
|
||||
}, new Set());
|
||||
result = await repository.findOne({
|
||||
filterByTk: created[model.primaryKeyAttribute],
|
||||
appends: Array.from(includeFields),
|
||||
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 {
|
||||
// NOTE: get() for non-proxied instance (#380)
|
||||
result: result?.toJSON(),
|
||||
result: toJSON(result),
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
};
|
||||
},
|
||||
|
@ -1,5 +1,6 @@
|
||||
import Processor from '../Processor';
|
||||
import { JOB_STATUS } from '../constants';
|
||||
import { toJSON } from '../utils';
|
||||
import type { FlowNodeModel } from '../types';
|
||||
|
||||
export default {
|
||||
@ -8,8 +9,18 @@ export default {
|
||||
|
||||
const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection);
|
||||
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, {
|
||||
...options,
|
||||
appends: appends,
|
||||
transaction: processor.transaction,
|
||||
});
|
||||
|
||||
@ -24,7 +35,7 @@ export default {
|
||||
// e.g. Object.prototype.hasOwnProperty.call(result, 'id') // false
|
||||
// so the properties can not be get by json-templates(object-path)
|
||||
return {
|
||||
result: multiple ? result.map((item) => item.toJSON()) : result?.toJSON(),
|
||||
result: toJSON(result),
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
};
|
||||
},
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Collection, Model } from '@nocobase/database';
|
||||
import { Trigger } from '..';
|
||||
import { toJSON } from '../utils';
|
||||
import type { WorkflowModel } from '../types';
|
||||
|
||||
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)) {
|
||||
const includeFields = appends.filter((field) => !data.get(field) || !data[field]);
|
||||
const included = await model.findByPk(data[model.primaryKeyAttribute], {
|
||||
attributes: [model.primaryKeyAttribute],
|
||||
include: includeFields,
|
||||
const includeFields = appends.reduce((set, field) => {
|
||||
set.add(field.split('.')[0]);
|
||||
set.add(field);
|
||||
return set;
|
||||
}, new Set());
|
||||
result = await repository.findOne({
|
||||
filterByTk: data[model.primaryKeyAttribute],
|
||||
appends: Array.from(includeFields),
|
||||
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(
|
||||
workflow,
|
||||
{ data: data.toJSON() },
|
||||
{ data: json },
|
||||
{
|
||||
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