feat(plugin-workflow) config preload associations in triggers and nodes (#1548)

* feat(plugin-workflow): add preload associations for triggers and nodes

* feat(plugin-workflow): add appends parameter to schedule trigger

* fix(plugin-workflow): fix import

* fix(plugin-workflow): fix component injection

* test(plugin-workflow): add test case
This commit is contained in:
Junyi 2023-03-10 16:36:58 +08:00 committed by GitHub
parent bc5156d458
commit ea4d4ac062
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 533 additions and 134 deletions

View File

@ -78,7 +78,7 @@ export default observer(({ value, disabled, onChange }: any) => {
} }
`}> `}>
<Variable.Input <Variable.Input
scope={['hasMany', 'belongsToMany'].includes(field.type) ? [] : scope} scope={scope}
value={value[field.name]} value={value[field.name]}
onChange={(next) => { onChange={(next) => {
onChange({ ...value, [field.name]: next }); onChange({ ...value, [field.name]: next });

View File

@ -0,0 +1,32 @@
import React from 'react';
import { Select } from 'antd';
import { observer, useForm } from '@formily/react';
import {
useCollectionManager,
useCompile
} from '@nocobase/client';
export const FieldsSelect = observer((props: any) => {
const { filter = () => true, ...others } = props;
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const { values } = useForm();
const fields = getCollectionFields(values?.collection);
return (
<Select
className="full-width"
{...others}
options={fields
.filter(filter)
.map(field => ({
label: compile(field.uiSchema?.title),
value: field.name
}))
}
/>
);
});

View File

@ -25,6 +25,8 @@ export default {
'Changed fields': '发生变动的字段', 'Changed fields': '发生变动的字段',
'Triggered only if one of the selected fields changes. If unselected, it means that it will be triggered when any field changes. When record is added or deleted, any field is considered to have been changed.': '只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。', 'Triggered only if one of the selected fields changes. If unselected, it means that it will be triggered when any field changes. When record is added or deleted, any field is considered to have been changed.': '只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。',
'Only triggers when match conditions': '满足以下条件才触发', 'Only triggers when match conditions': '满足以下条件才触发',
'Preload associations': '预加载关联数据',
'Only configured association field could be accessed in following nodes': '仅已配置的关系数据可以在后续节点中被访问',
'Schedule event': '定时任务', 'Schedule event': '定时任务',
'Trigger mode': '触发模式', 'Trigger mode': '触发模式',
'Based on certain date': '自定义时间', 'Based on certain date': '自定义时间',
@ -134,6 +136,7 @@ export default {
'Multiple records': '多条数据', 'Multiple records': '多条数据',
'Please select collection first': '请先选择数据表', 'Please select collection first': '请先选择数据表',
'Only update records matching conditions': '只更新满足条件的数据', 'Only update records matching conditions': '只更新满足条件的数据',
'Please add at least one condition': '请添加至少一个条件',
'Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.': '未被赋值的字段将被设置为默认值,没有默认值的设置为空值。', 'Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.': '未被赋值的字段将被设置为默认值,没有默认值的设置为空值。',
'Trigger in executed workflow cannot be modified': '已经执行过工作流的触发器不能被修改', 'Trigger in executed workflow cannot be modified': '已经执行过工作流的触发器不能被修改',
'Node in executed workflow cannot be modified': '已经执行过工作流中的节点不能被修改', 'Node in executed workflow cannot be modified': '已经执行过工作流中的节点不能被修改',

View File

@ -1,11 +1,12 @@
import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client'; import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client';
import { collection, values } from '../schemas/collection'; import { appends, collection, values } from '../schemas/collection';
import CollectionFieldset from '../components/CollectionFieldset'; import CollectionFieldset from '../components/CollectionFieldset';
import { NAMESPACE } from '../locale'; import { NAMESPACE } from '../locale';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers'; import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
import { useCollectionFieldOptions } from '../variable'; import { useCollectionFieldOptions } from '../variable';
import { FieldsSelect } from '../components/FieldsSelect';
@ -25,7 +26,13 @@ export default {
// disabled: true // disabled: true
// } // }
// }, // },
'params.values': values params: {
type: 'object',
properties: {
values,
appends
}
}
}, },
view: { view: {
@ -34,7 +41,8 @@ export default {
useCollectionDataSource useCollectionDataSource
}, },
components: { components: {
CollectionFieldset CollectionFieldset,
FieldsSelect
}, },
getOptions(config, types) { getOptions(config, types) {
return useCollectionFieldOptions({ collection: config.collection, types }); return useCollectionFieldOptions({ collection: config.collection, types });

View File

@ -2,6 +2,8 @@ import { useCollectionDataSource } from '@nocobase/client';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent'; import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import { collection, filter } from '../schemas/collection'; import { collection, filter } from '../schemas/collection';
import { isValidFilter } from '../utils';
import { NAMESPACE } from '../locale';
export default { export default {
title: '{{t("Delete record")}}', title: '{{t("Delete record")}}',
@ -11,10 +13,13 @@ export default {
collection, collection,
params: { params: {
type: 'object', type: 'object',
title: '',
'x-decorator': 'FormItem',
properties: { properties: {
filter filter: {
...filter,
['x-validator'](value) {
return isValidFilter(value) ? '' : `{{t("Please add at least one condition", { ns: "${NAMESPACE}" })}}`;
},
}
} }
} }
}, },

View File

@ -426,8 +426,10 @@ export function NodeDefaultView(props) {
.ant-picker, .ant-picker,
.ant-input-number, .ant-input-number,
.ant-input-affix-wrapper{ .ant-input-affix-wrapper{
width: auto; &:not(.full-width){
min-width: 6em; width: auto;
min-width: 6em;
}
} }
` `
}, },

View File

@ -7,7 +7,6 @@ import { useWorkflowVariableOptions } from '../../variable';
export function AssigneesSelect({ multiple = false, value = [], onChange }) { export function AssigneesSelect({ multiple = false, value = [], onChange }) {
const scope = useWorkflowVariableOptions(); const scope = useWorkflowVariableOptions();
console.log(value);
return ( return (
<Variable.Input <Variable.Input

View File

@ -543,7 +543,7 @@ export function SchemaConfig({ value, onChange }) {
const actionKeys = (Object.values(footer.properties.actions.properties ?? {}) as any[]) const actionKeys = (Object.values(footer.properties.actions.properties ?? {}) as any[])
.reduce((actions: number[], { ['x-action']: status }) => actions.concat(Number.parseInt(status, 10)), []); .reduce((actions: number[], { ['x-action']: status }) => actions.concat(Number.parseInt(status, 10)), []);
form.setValuesIn('config.actions', actionKeys); form.setValuesIn('actions', actionKeys);
onChange({ onChange({
collection, collection,

View File

@ -1,11 +1,12 @@
import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client'; import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client';
import { collection, filter } from '../schemas/collection'; import { appends, collection, filter } from '../schemas/collection';
import { NAMESPACE } from '../locale'; import { NAMESPACE } from '../locale';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers'; import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent'; import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import { useCollectionFieldOptions } from '../variable'; import { useCollectionFieldOptions } from '../variable';
import { FieldsSelect } from '../components/FieldsSelect';
@ -27,10 +28,9 @@ export default {
// }, // },
params: { params: {
type: 'object', type: 'object',
title: '',
'x-decorator': 'FormItem',
properties: { properties: {
filter filter,
appends
} }
} }
}, },
@ -41,7 +41,8 @@ export default {
useCollectionDataSource useCollectionDataSource
}, },
components: { components: {
FilterDynamicComponent FilterDynamicComponent,
FieldsSelect
}, },
getOptions(config, types) { getOptions(config, types) {
return useCollectionFieldOptions({ collection: config.collection, types }); return useCollectionFieldOptions({ collection: config.collection, types });

View File

@ -2,6 +2,8 @@ import { useCollectionDataSource } from '@nocobase/client';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent'; import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import CollectionFieldset from '../components/CollectionFieldset'; import CollectionFieldset from '../components/CollectionFieldset';
import { isValidFilter } from '../utils';
import { NAMESPACE } from '../locale'; import { NAMESPACE } from '../locale';
import { collection, filter, values } from '../schemas/collection'; import { collection, filter, values } from '../schemas/collection';
@ -15,12 +17,13 @@ export default {
collection, collection,
params: { params: {
type: 'object', type: 'object',
title: '',
'x-decorator': 'FormItem',
properties: { properties: {
filter: { filter: {
...filter, ...filter,
title: `{{t("Only update records matching conditions", { ns: "${NAMESPACE}" })}}`, title: `{{t("Only update records matching conditions", { ns: "${NAMESPACE}" })}}`,
['x-validator'](value) {
return isValidFilter(value) ? '' : `{{t("Please add at least one condition", { ns: "${NAMESPACE}" })}}`;
},
}, },
values values
} }

View File

@ -33,12 +33,6 @@ export const filter = {
type: 'object', type: 'object',
title: '{{t("Filter")}}', title: '{{t("Filter")}}',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-decorator-props': {
labelAlign: 'left',
className: css`
flex-direction: column;
`
},
'x-component': 'Filter', 'x-component': 'Filter',
'x-component-props': { 'x-component-props': {
useProps() { useProps() {
@ -55,3 +49,28 @@ export const filter = {
dynamicComponent: 'FilterDynamicComponent' dynamicComponent: 'FilterDynamicComponent'
} }
}; };
export const appends = {
type: 'array',
title: `{{t("Preload associations", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Only configured association field could be accessed in following nodes", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'FieldsSelect',
'x-component-props': {
mode: 'multiple',
placeholder: '{{t("Select Field")}}',
filter(field) {
return ['linkTo', 'belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type);
}
},
'x-reactions': [
{
dependencies: ['collection'],
fulfill: {
state: {
visible: '{{!!$deps[0]}}',
},
}
},
]
};

View File

@ -1,37 +1,15 @@
import React from 'react';
import { Select } from 'antd';
import { observer, useForm } from '@formily/react';
import { import {
SchemaInitializerItemOptions, SchemaInitializerItemOptions,
useCollectionDataSource, useCollectionDataSource,
useCollectionManager,
useCompile,
} from '@nocobase/client'; } from '@nocobase/client';
import { collection, filter } from '../schemas/collection'; import { appends, collection, filter } from '../schemas/collection';
import { useCollectionFieldOptions } from '../variable'; import { useCollectionFieldOptions } from '../variable';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers'; import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
import { NAMESPACE, useWorkflowTranslation } from '../locale'; import { NAMESPACE, useWorkflowTranslation } from '../locale';
import { FieldsSelect } from '../components/FieldsSelect';
const FieldsSelect = observer((props: any) => {
const { filter = () => true, ...others } = props;
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const { values } = useForm();
const fields = getCollectionFields(values?.collection);
return (
<Select {...others}>
{fields
.filter(filter)
.map(field => (
<Select.Option key={field.name} value={field.name}>{compile(field.uiSchema?.title)}</Select.Option>
))}
</Select>
);
});
const COLLECTION_TRIGGER_MODE = { const COLLECTION_TRIGGER_MODE = {
CREATED: 1, CREATED: 1,
@ -94,14 +72,6 @@ export default {
}, },
} }
}, },
{
target: 'changed',
fulfill: {
state: {
disabled: `{{!($self.value & ${COLLECTION_TRIGGER_MODE.UPDATED})}}`,
},
}
},
] ]
}, },
changed: { changed: {
@ -123,10 +93,10 @@ export default {
}, },
'x-reactions': [ 'x-reactions': [
{ {
dependencies: ['collection'], dependencies: ['collection', 'mode'],
fulfill: { fulfill: {
state: { state: {
visible: '{{!!$deps[0]}}', visible: `{{$deps[0] && $deps[1] & ${COLLECTION_TRIGGER_MODE.UPDATED}}}`,
}, },
} }
}, },
@ -146,27 +116,20 @@ export default {
}, },
] ]
}, },
// appends: { appends: {
// type: 'array', ...appends,
// title: `{{t("Prefetch fields", { ns: "${NAMESPACE}" })}}`, 'x-reactions': [
// description: `{{t("Triggered only if one of the selected fields changes. If unselected, it means that it will be triggered when any field changes. When record is added or deleted, any field is considered to have been changed.", { ns: "${NAMESPACE}" })}}`, ...appends['x-reactions'],
// 'x-decorator': 'FormItem', {
// 'x-component': 'FieldsSelect', dependencies: ['mode'],
// 'x-component-props': { fulfill: {
// mode: 'multiple', state: {
// placeholder: '{{t("Select Field")}}' visible: `{{!($deps[0] & ${COLLECTION_TRIGGER_MODE.DELETED})}}`,
// }, },
// 'x-reactions': [ }
// { },
// dependencies: ['collection'], ]
// fulfill: { },
// state: {
// visible: '{{!!$deps[0]}}',
// },
// }
// },
// ]
// },
}, },
scope: { scope: {
useCollectionDataSource useCollectionDataSource

View File

@ -164,7 +164,7 @@ export const TriggerConfig = () => {
} }
const whiteSet = new Set(['workflow-node-meta', 'workflow-node-config-button', 'ant-input-disabled']); const whiteSet = new Set(['workflow-node-meta', 'workflow-node-config-button', 'ant-input-disabled']);
for (let el = ev.target; el && el !== ev.currentTarget; el = el.parentNode) { for (let el = ev.target; el && el !== ev.currentTarget; el = el.parentNode) {
if (Array.from(el.classList).some((name: string) => whiteSet.has(name))) { if ((Array.from(el.classList) as string[]).some((name: string) => whiteSet.has(name))) {
setEditingConfig(true); setEditingConfig(true);
ev.stopPropagation(); ev.stopPropagation();
return; return;
@ -234,7 +234,7 @@ export const TriggerConfig = () => {
'x-component': 'fieldset', 'x-component': 'fieldset',
'x-component-props': { 'x-component-props': {
className: css` className: css`
.ant-select{ .ant-select:not(.full-width){
width: auto; width: auto;
min-width: 6em; min-width: 6em;
} }
@ -242,28 +242,30 @@ export const TriggerConfig = () => {
}, },
properties: fieldset properties: fieldset
}, },
actions: executed actions: {
? null ...(executed
: { ? {}
type: 'void', : {
'x-component': 'Action.Drawer.Footer', type: 'void',
properties: { 'x-component': 'Action.Drawer.Footer',
cancel: { properties: {
title: '{{t("Cancel")}}', cancel: {
'x-component': 'Action', title: '{{t("Cancel")}}',
'x-component-props': { 'x-component': 'Action',
useAction: '{{ cm.useCancelAction }}', 'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
}, },
}, submit: {
submit: { title: '{{t("Submit")}}',
title: '{{t("Submit")}}', 'x-component': 'Action',
'x-component': 'Action', 'x-component-props': {
'x-component-props': { type: 'primary',
type: 'primary', useAction: useUpdateConfigAction
useAction: useUpdateConfigAction }
} }
} }
} })
} }
} }
} }

View File

@ -16,7 +16,6 @@ const ModeFieldsets = {
[SCHEDULE_MODE.STATIC]: { [SCHEDULE_MODE.STATIC]: {
startsOn: { startsOn: {
type: 'datetime', type: 'datetime',
name: 'startsOn',
title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`, title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'DatePicker', 'x-component': 'DatePicker',
@ -27,7 +26,6 @@ const ModeFieldsets = {
}, },
repeat: { repeat: {
type: 'string', type: 'string',
name: 'repeat',
title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`, title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'RepeatField', 'x-component': 'RepeatField',
@ -52,7 +50,6 @@ const ModeFieldsets = {
}, },
endsOn: { endsOn: {
type: 'datetime', type: 'datetime',
name: 'endsOn',
title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`, title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'DatePicker', 'x-component': 'DatePicker',
@ -62,7 +59,6 @@ const ModeFieldsets = {
}, },
limit: { limit: {
type: 'number', type: 'number',
name: 'limit',
title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`, title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'InputNumber', 'x-component': 'InputNumber',
@ -107,7 +103,6 @@ const ModeFieldsets = {
}, },
repeat: { repeat: {
type: 'string', type: 'string',
name: 'repeat',
title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`, title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'RepeatField', 'x-component': 'RepeatField',
@ -138,7 +133,6 @@ const ModeFieldsets = {
}, },
limit: { limit: {
type: 'number', type: 'number',
name: 'limit',
title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`, title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'InputNumber', 'x-component': 'InputNumber',

View File

@ -6,6 +6,8 @@ import { NAMESPACE, useWorkflowTranslation } from '../../locale';
import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers'; import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers';
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
import { useCollectionFieldOptions } from '../../variable'; import { useCollectionFieldOptions } from '../../variable';
import { appends } from '../../schemas/collection';
import { FieldsSelect } from '../../components/FieldsSelect';
@ -18,13 +20,27 @@ export default {
'x-component': 'ScheduleConfig', 'x-component': 'ScheduleConfig',
'x-component-props': { 'x-component-props': {
} }
} },
appends: {
...appends,
'x-reactions': [
{
dependencies: ['mode', 'collection'],
fulfill: {
state: {
visible: `{{$deps[0] === ${SCHEDULE_MODE.COLLECTION_FIELD} && $deps[1]}}`,
},
}
},
]
},
}, },
scope: { scope: {
useCollectionDataSource useCollectionDataSource
}, },
components: { components: {
ScheduleConfig ScheduleConfig,
FieldsSelect
}, },
getOptions(config, types) { getOptions(config, types) {
const { t } = useWorkflowTranslation(); const { t } = useWorkflowTranslation();

View File

@ -11,3 +11,26 @@ export function linkNodes(nodes): void {
} }
} }
} }
export function isValidFilter(condition) {
const group = condition.$and || condition.$or;
if (!group) {
return false;
}
return group.some(item => {
if (item.$and || item.$or) {
return isValidFilter(item);
}
const [name] = Object.keys(item);
if (!name || !item[name]) {
return false;
}
const [op] = Object.keys(item[name]);
if (!op || typeof item[name][op] === 'undefined') {
return false;
}
return true;
});
}

View File

@ -5,7 +5,12 @@ import { useFlowContext } from "./FlowContext";
import { triggers } from "./triggers"; import { triggers } from "./triggers";
import { NAMESPACE } from "./locale"; import { NAMESPACE } from "./locale";
export type VariableOption = { key: string, value: string; label: string; children?: VariableOption[] }; export type VariableOption = {
key: string;
value: string;
label: string;
children?: VariableOption[]
};
const VariableTypes = [ const VariableTypes = [
{ {
@ -97,17 +102,26 @@ export function useCollectionFieldOptions(props) {
const { fields, collection, types } = props; const { fields, collection, types } = props;
const compile = useCompile(); const compile = useCompile();
const { getCollectionFields } = useCollectionManager(); const { getCollectionFields } = useCollectionManager();
return filterTypedFields((fields ?? getCollectionFields(collection)), types) const result = filterTypedFields((fields ?? getCollectionFields(collection)), types)
.filter(field => field.interface && (!field.target || field.type === 'belongsTo')) .map(field => ({
.map(field => field.type === 'belongsTo'
? {
label: `${compile(field.uiSchema?.title || field.name)} ID`,
key: field.foreignKey,
value: field.foreignKey,
}
: {
label: compile(field.uiSchema?.title || field.name), label: compile(field.uiSchema?.title || field.name),
key: field.name, key: field.name,
value: field.name, value: field.name,
}); children: ['linkTo', 'belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type)
? getCollectionFields(field.target)?.filter(subField => subField.interface && (!subField.target || subField.type === 'belongsTo'))
.map(subField => subField.type === 'belongsTo'
? {
label: `${compile(subField.uiSchema?.title || subField.name)} ID`,
key: subField.foreignKey,
value: subField.foreignKey,
}
: {
label: compile(subField.uiSchema?.title || subField.name),
key: subField.name,
value: subField.name,
})
: null
}));
return result;
} }

View File

@ -0,0 +1,11 @@
import { CollectionOptions } from '@nocobase/database';
export default {
name: 'categories',
fields: [
{
type: 'string',
name: 'title',
}
],
} as CollectionOptions;

View File

@ -11,6 +11,10 @@ export default {
type: 'integer', type: 'integer',
name: 'status', name: 'status',
defaultValue: 0 defaultValue: 0
},
{
type: 'hasMany',
name: 'replies',
} }
], ],
} as CollectionOptions; } as CollectionOptions;

View File

@ -17,6 +17,10 @@ export default {
name: 'read', name: 'read',
defaultValue: 0 defaultValue: 0
}, },
{
type: 'belongsTo',
name: 'category'
},
{ {
type: 'hasMany', type: 'hasMany',
name: 'comments' name: 'comments'

View File

@ -0,0 +1,9 @@
export default {
name: 'replies',
fields: [
{
type: 'string',
name: 'content',
}
]
}

View File

@ -8,6 +8,7 @@ describe('workflow > instructions > create', () => {
let app: Application; let app: Application;
let db: Database; let db: Database;
let PostRepo; let PostRepo;
let ReplyRepo;
let WorkflowModel; let WorkflowModel;
let workflow; let workflow;
@ -17,6 +18,7 @@ describe('workflow > instructions > create', () => {
db = app.db; db = app.db;
WorkflowModel = db.getCollection('workflows').model; WorkflowModel = db.getCollection('workflows').model;
PostRepo = db.getCollection('posts').repository; PostRepo = db.getCollection('posts').repository;
ReplyRepo = db.getCollection('replies').repository;
workflow = await WorkflowModel.create({ workflow = await WorkflowModel.create({
title: 'test workflow', title: 'test workflow',
@ -53,5 +55,77 @@ describe('workflow > instructions > create', () => {
const [job] = await execution.getJobs(); const [job] = await execution.getJobs();
expect(job.result.postId).toBe(post.id); expect(job.result.postId).toBe(post.id);
}); });
it('params.values with hasMany', async () => {
const replies = await ReplyRepo.create({ values: [{}, {}] });
const n1 = await workflow.createNode({
type: 'create',
config: {
collection: 'comments',
params: {
values: {
replies: replies.map(item => item.id)
},
appends: ['replies']
}
}
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result.replies.length).toBe(2);
});
it('params.appends: belongsTo', async () => {
const n1 = await workflow.createNode({
type: 'create',
config: {
collection: 'comments',
params: {
values: {
postId: '{{$context.data.id}}'
},
appends: ['post'],
}
}
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result.post.id).toBe(post.id);
});
it('params.appends: belongsToMany', async () => {
const n1 = await workflow.createNode({
type: 'create',
config: {
collection: 'tags',
params: {
values: {
posts: ['{{$context.data.id}}']
},
appends: ['posts'],
}
}
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result.posts.length).toBe(1);
expect(job.result.posts[0].id).toBe(post.id);
});
}); });
}); });

View File

@ -10,6 +10,7 @@ describe('workflow > instructions > query', () => {
let PostCollection; let PostCollection;
let PostRepo; let PostRepo;
let TagModel; let TagModel;
let CommentRepo;
let WorkflowModel; let WorkflowModel;
let workflow; let workflow;
@ -20,6 +21,7 @@ describe('workflow > instructions > query', () => {
WorkflowModel = db.getCollection('workflows').model; WorkflowModel = db.getCollection('workflows').model;
PostCollection = db.getCollection('posts'); PostCollection = db.getCollection('posts');
PostRepo = PostCollection.repository; PostRepo = PostCollection.repository;
CommentRepo = db.getCollection('comments').repository;
TagModel = db.getCollection('tags').model; TagModel = db.getCollection('tags').model;
workflow = await WorkflowModel.create({ workflow = await WorkflowModel.create({
@ -160,7 +162,7 @@ describe('workflow > instructions > query', () => {
}); });
const tag = await TagModel.create({ name: 'tag1' }); const tag = await TagModel.create({ name: 'tag1' });
const post = await PostCollection.repository.create({ const post = await PostRepo.create({
values: { title: 't1', tags: [tag.id] } values: { title: 't1', tags: [tag.id] }
}); });
@ -171,7 +173,31 @@ describe('workflow > instructions > query', () => {
expect(job.result.id).toBe(tag.id); expect(job.result.id).toBe(tag.id);
}); });
it('params.appends: with associations', async () => { it('params.appends: hasMany', async () => {
const n1 = await workflow.createNode({
type: 'query',
config: {
collection: 'posts',
params: {
appends: ['comments']
}
}
});
const comment = await CommentRepo.create({});
const post = await PostRepo.create({
values: { title: 't1', comments: [comment.id] }
});
await sleep(500);
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result.comments.length).toBe(1);
expect(job.result.comments[0].id).toBe(comment.id);
});
it('params.appends: belongsToMany', async () => {
const n1 = await workflow.createNode({ const n1 = await workflow.createNode({
type: 'query', type: 'query',
config: { config: {
@ -183,7 +209,7 @@ describe('workflow > instructions > query', () => {
}); });
const tag = await TagModel.create({ name: 'tag1' }); const tag = await TagModel.create({ name: 'tag1' });
const post = await PostCollection.repository.create({ const post = await PostRepo.create({
values: { title: 't1', tags: [tag.id] } values: { title: 't1', tags: [tag.id] }
}); });

View File

@ -8,7 +8,10 @@ import { EXECUTION_STATUS } from '../../constants';
describe('workflow > triggers > collection', () => { describe('workflow > triggers > collection', () => {
let app: Application; let app: Application;
let db: Database; let db: Database;
let CategoryRepo;
let PostRepo; let PostRepo;
let CommentRepo;
let TagRepo;
let WorkflowModel; let WorkflowModel;
beforeEach(async () => { beforeEach(async () => {
@ -16,7 +19,10 @@ describe('workflow > triggers > collection', () => {
db = app.db; db = app.db;
WorkflowModel = db.getCollection('workflows').model; WorkflowModel = db.getCollection('workflows').model;
CategoryRepo = db.getCollection('categories').repository;
PostRepo = db.getCollection('posts').repository; PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository;
TagRepo = db.getCollection('tags').repository;
}); });
afterEach(() => db.close()); afterEach(() => db.close());
@ -111,4 +117,133 @@ describe('workflow > triggers > collection', () => {
expect(executions.length).toBe(0); expect(executions.length).toBe(0);
}); });
}); });
describe('config.appends', () => {
it('non-appended association could not be accessed', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'posts',
}
});
await workflow.createNode({
type: 'echo'
});
const category = await CategoryRepo.create({ values: { title: 'c1' } });
const post = await PostRepo.create({
values: {
title: 't1',
categoryId: category.id
}
});
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.result.data.category).toBeUndefined();
});
it('appends association could be accessed', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'posts',
appends: ['category']
}
});
await workflow.createNode({
type: 'echo'
});
const category = await CategoryRepo.create({ values: { title: 'c1' } });
const post = await PostRepo.create({
values: {
title: 't1',
categoryId: category.id
}
});
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.result.data.category.title).toBe('c1');
});
it('appends hasMany', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'posts',
appends: ['comments']
}
});
await workflow.createNode({
type: 'echo'
});
const comments = await CommentRepo.create({ values: [{}] });
const post = await PostRepo.create({
values: {
title: 't1',
comments: comments.map(item => item.id)
}
});
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.result.data.comments.length).toBe(1);
});
it('appends belongsToMany', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'posts',
appends: ['tags']
}
});
await workflow.createNode({
type: 'echo'
});
const tags = await TagRepo.create({ values: [{}] });
const post = await PostRepo.create({
values: {
title: 't1',
tags: tags.map(item => item.id)
}
});
await sleep(500);
const [execution] = await workflow.getExecutions();
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [job] = await execution.getJobs();
expect(job.result.data.tags.length).toBe(1);
});
});
}); });

View File

@ -8,6 +8,7 @@ describe.skip('workflow > triggers > schedule', () => {
let app: Application; let app: Application;
let db: Database; let db: Database;
let PostRepo; let PostRepo;
let CategoryRepo;
let WorkflowModel; let WorkflowModel;
let WorkflowRepo; let WorkflowRepo;
@ -19,6 +20,7 @@ describe.skip('workflow > triggers > schedule', () => {
WorkflowModel = workflow.model; WorkflowModel = workflow.model;
WorkflowRepo = workflow.repository; WorkflowRepo = workflow.repository;
PostRepo = db.getCollection('posts').repository; PostRepo = db.getCollection('posts').repository;
CategoryRepo = db.getCollection('categories').repository;
}); });
afterEach(() => app.destroy()); afterEach(() => app.destroy());
@ -231,7 +233,6 @@ describe.skip('workflow > triggers > schedule', () => {
await sleep((2.5 - now.getSeconds() % 2) * 1000 - now.getMilliseconds()); await sleep((2.5 - now.getSeconds() % 2) * 1000 - now.getMilliseconds());
const startTime = new Date(); const startTime = new Date();
startTime.setMilliseconds(500); startTime.setMilliseconds(500);
console.log(startTime);
const post = await PostRepo.create({ values: { title: 't1' }}); const post = await PostRepo.create({ values: { title: 't1' }});
@ -340,5 +341,31 @@ describe.skip('workflow > triggers > schedule', () => {
const d1 = Date.parse(executions[0].context.date); const d1 = Date.parse(executions[0].context.date);
expect(d1 - 1500).toBe(startTime.getTime()); expect(d1 - 1500).toBe(startTime.getTime());
}); });
it('appends', async () => {
const category = await CategoryRepo.create({ values: { name: 'c1' } });
const workflow = await WorkflowModel.create({
enabled: true,
type: 'schedule',
config: {
mode: 1,
collection: 'posts',
startsOn: {
field: 'createdAt',
offset: 2
},
appends: ['category']
}
});
const post = await PostRepo.create({ values: { title: 't1', categoryId: category.id } });
await sleep(5000);
const executions = await workflow.getExecutions();
expect(executions.length).toBe(1);
expect(executions[0].context.data.category.id).toBe(category.id);
});
}); });
}); });

View File

@ -5,12 +5,12 @@ export default {
async run(node: FlowNodeModel, input, processor) { async run(node: FlowNodeModel, input, processor) {
const { const {
collection, collection,
params = {} params: { appends = [], ...params } = {}
} = node.config; } = node.config;
const repo = (<typeof FlowNodeModel>node.constructor).database.getRepository(collection); const { repository, model } = (<typeof FlowNodeModel>node.constructor).database.getCollection(collection);
const options = processor.getParsedValue(params); const options = processor.getParsedValue(params);
const result = await repo.create({ const result = await repository.create({
...options, ...options,
context: { context: {
executionId: processor.execution.id executionId: processor.execution.id
@ -18,6 +18,18 @@ export default {
transaction: processor.transaction transaction: processor.transaction
}); });
if (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,
transaction: processor.transaction
});
includeFields.forEach(field => {
result.set(field, included!.get(field), { raw: true });
});
}
return { return {
// NOTE: get() for non-proxied instance (#380) // NOTE: get() for non-proxied instance (#380)
result: result.get(), result: result.get(),

View File

@ -36,9 +36,10 @@ function getFieldRawName(collection: Collection, name: string) {
// async function, should return promise // async function, should return promise
async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: Model, options) { async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: Model, options) {
const { collection: collectionName, condition, changed } = workflow.config; const { collection: collectionName, condition, changed, mode, appends } = workflow.config;
const collection = (<typeof Model>data.constructor).database.getCollection(collectionName); const collection = (<typeof Model>data.constructor).database.getCollection(collectionName);
const { transaction, context } = options; const { transaction, context } = options;
const { repository, model } = collection;
// NOTE: if no configured fields changed, do not trigger // NOTE: if no configured fields changed, do not trigger
if (changed if (changed
@ -53,7 +54,6 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
if (condition && condition.$and?.length) { if (condition && condition.$and?.length) {
// TODO: change to map filter format to calculation format // TODO: change to map filter format to calculation format
// const calculation = toCalculation(condition); // const calculation = toCalculation(condition);
const { repository, model } = collection;
const count = await repository.count({ const count = await repository.count({
filter: { filter: {
$and: [ $and: [
@ -70,6 +70,18 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
} }
} }
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,
transaction
});
includeFields.forEach(field => {
data.set(field, included!.get(field), { raw: true });
});
}
this.plugin.trigger(workflow, { data: data.get() }, { this.plugin.trigger(workflow, { data: data.get() }, {
context context
}); });

View File

@ -273,7 +273,7 @@ ScheduleModes.set(SCHEDULE_MODE.COLLECTION_FIELD, {
}, },
async trigger(workflow, now: Date) { async trigger(workflow, now: Date) {
const { startsOn, repeat, endsOn, collection } = workflow.config; const { startsOn, repeat, endsOn, collection, appends } = workflow.config;
const timestamp = now.getTime(); const timestamp = now.getTime();
const startTimestamp = getOnTimestampWithOffset(startsOn, now); const startTimestamp = getOnTimestampWithOffset(startsOn, now);
@ -333,11 +333,12 @@ ScheduleModes.set(SCHEDULE_MODE.COLLECTION_FIELD, {
}); });
} }
const { model } = this.plugin.app.db.getCollection(collection); const repo = this.plugin.app.db.getRepository(collection);
const instances = await model.findAll({ const instances = await repo.find({
where: { filter: {
[Op.and]: conditions $and: conditions
} },
appends
}); });
instances.forEach(item => { instances.forEach(item => {