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
scope={['hasMany', 'belongsToMany'].includes(field.type) ? [] : scope}
scope={scope}
value={value[field.name]}
onChange={(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': '发生变动的字段',
'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': '满足以下条件才触发',
'Preload associations': '预加载关联数据',
'Only configured association field could be accessed in following nodes': '仅已配置的关系数据可以在后续节点中被访问',
'Schedule event': '定时任务',
'Trigger mode': '触发模式',
'Based on certain date': '自定义时间',
@ -134,6 +136,7 @@ export default {
'Multiple records': '多条数据',
'Please select collection first': '请先选择数据表',
'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.': '未被赋值的字段将被设置为默认值,没有默认值的设置为空值。',
'Trigger 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 { collection, values } from '../schemas/collection';
import { appends, collection, values } from '../schemas/collection';
import CollectionFieldset from '../components/CollectionFieldset';
import { NAMESPACE } from '../locale';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
import { useCollectionFieldOptions } from '../variable';
import { FieldsSelect } from '../components/FieldsSelect';
@ -25,7 +26,13 @@ export default {
// disabled: true
// }
// },
'params.values': values
params: {
type: 'object',
properties: {
values,
appends
}
}
},
view: {
@ -34,7 +41,8 @@ export default {
useCollectionDataSource
},
components: {
CollectionFieldset
CollectionFieldset,
FieldsSelect
},
getOptions(config, types) {
return useCollectionFieldOptions({ collection: config.collection, types });

View File

@ -2,6 +2,8 @@ import { useCollectionDataSource } from '@nocobase/client';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import { collection, filter } from '../schemas/collection';
import { isValidFilter } from '../utils';
import { NAMESPACE } from '../locale';
export default {
title: '{{t("Delete record")}}',
@ -11,10 +13,13 @@ export default {
collection,
params: {
type: 'object',
title: '',
'x-decorator': 'FormItem',
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-input-number,
.ant-input-affix-wrapper{
width: auto;
min-width: 6em;
&:not(.full-width){
width: auto;
min-width: 6em;
}
}
`
},

View File

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

View File

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

View File

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

View File

@ -2,6 +2,8 @@ import { useCollectionDataSource } from '@nocobase/client';
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
import CollectionFieldset from '../components/CollectionFieldset';
import { isValidFilter } from '../utils';
import { NAMESPACE } from '../locale';
import { collection, filter, values } from '../schemas/collection';
@ -15,12 +17,13 @@ export default {
collection,
params: {
type: 'object',
title: '',
'x-decorator': 'FormItem',
properties: {
filter: {
...filter,
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
}

View File

@ -33,12 +33,6 @@ export const filter = {
type: 'object',
title: '{{t("Filter")}}',
'x-decorator': 'FormItem',
'x-decorator-props': {
labelAlign: 'left',
className: css`
flex-direction: column;
`
},
'x-component': 'Filter',
'x-component-props': {
useProps() {
@ -55,3 +49,28 @@ export const filter = {
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 {
SchemaInitializerItemOptions,
useCollectionDataSource,
useCollectionManager,
useCompile,
} from '@nocobase/client';
import { collection, filter } from '../schemas/collection';
import { appends, collection, filter } from '../schemas/collection';
import { useCollectionFieldOptions } from '../variable';
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
import { NAMESPACE, useWorkflowTranslation } from '../locale';
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>
);
});
import { FieldsSelect } from '../components/FieldsSelect';
const COLLECTION_TRIGGER_MODE = {
CREATED: 1,
@ -94,14 +72,6 @@ export default {
},
}
},
{
target: 'changed',
fulfill: {
state: {
disabled: `{{!($self.value & ${COLLECTION_TRIGGER_MODE.UPDATED})}}`,
},
}
},
]
},
changed: {
@ -123,10 +93,10 @@ export default {
},
'x-reactions': [
{
dependencies: ['collection'],
dependencies: ['collection', 'mode'],
fulfill: {
state: {
visible: '{{!!$deps[0]}}',
visible: `{{$deps[0] && $deps[1] & ${COLLECTION_TRIGGER_MODE.UPDATED}}}`,
},
}
},
@ -146,27 +116,20 @@ export default {
},
]
},
// appends: {
// type: 'array',
// title: `{{t("Prefetch fields", { ns: "${NAMESPACE}" })}}`,
// 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}" })}}`,
// 'x-decorator': 'FormItem',
// 'x-component': 'FieldsSelect',
// 'x-component-props': {
// mode: 'multiple',
// placeholder: '{{t("Select Field")}}'
// },
// 'x-reactions': [
// {
// dependencies: ['collection'],
// fulfill: {
// state: {
// visible: '{{!!$deps[0]}}',
// },
// }
// },
// ]
// },
appends: {
...appends,
'x-reactions': [
...appends['x-reactions'],
{
dependencies: ['mode'],
fulfill: {
state: {
visible: `{{!($deps[0] & ${COLLECTION_TRIGGER_MODE.DELETED})}}`,
},
}
},
]
},
},
scope: {
useCollectionDataSource

View File

@ -164,7 +164,7 @@ export const TriggerConfig = () => {
}
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) {
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);
ev.stopPropagation();
return;
@ -234,7 +234,7 @@ export const TriggerConfig = () => {
'x-component': 'fieldset',
'x-component-props': {
className: css`
.ant-select{
.ant-select:not(.full-width){
width: auto;
min-width: 6em;
}
@ -242,28 +242,30 @@ export const TriggerConfig = () => {
},
properties: fieldset
},
actions: executed
? null
: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
actions: {
...(executed
? {}
: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
},
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: useUpdateConfigAction
submit: {
title: '{{t("Submit")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: useUpdateConfigAction
}
}
}
}
})
}
}
}

View File

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

View File

@ -6,6 +6,8 @@ import { NAMESPACE, useWorkflowTranslation } from '../../locale';
import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers';
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
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-props': {
}
}
},
appends: {
...appends,
'x-reactions': [
{
dependencies: ['mode', 'collection'],
fulfill: {
state: {
visible: `{{$deps[0] === ${SCHEDULE_MODE.COLLECTION_FIELD} && $deps[1]}}`,
},
}
},
]
},
},
scope: {
useCollectionDataSource
},
components: {
ScheduleConfig
ScheduleConfig,
FieldsSelect
},
getOptions(config, types) {
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 { 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 = [
{
@ -97,17 +102,26 @@ export function useCollectionFieldOptions(props) {
const { fields, collection, types } = props;
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
return filterTypedFields((fields ?? getCollectionFields(collection)), types)
.filter(field => field.interface && (!field.target || field.type === 'belongsTo'))
.map(field => field.type === 'belongsTo'
? {
label: `${compile(field.uiSchema?.title || field.name)} ID`,
key: field.foreignKey,
value: field.foreignKey,
}
: {
const result = filterTypedFields((fields ?? getCollectionFields(collection)), types)
.map(field => ({
label: compile(field.uiSchema?.title || field.name),
key: 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',
name: 'status',
defaultValue: 0
},
{
type: 'hasMany',
name: 'replies',
}
],
} as CollectionOptions;

View File

@ -17,6 +17,10 @@ export default {
name: 'read',
defaultValue: 0
},
{
type: 'belongsTo',
name: 'category'
},
{
type: 'hasMany',
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 db: Database;
let PostRepo;
let ReplyRepo;
let WorkflowModel;
let workflow;
@ -17,6 +18,7 @@ describe('workflow > instructions > create', () => {
db = app.db;
WorkflowModel = db.getCollection('workflows').model;
PostRepo = db.getCollection('posts').repository;
ReplyRepo = db.getCollection('replies').repository;
workflow = await WorkflowModel.create({
title: 'test workflow',
@ -53,5 +55,77 @@ describe('workflow > instructions > create', () => {
const [job] = await execution.getJobs();
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 PostRepo;
let TagModel;
let CommentRepo;
let WorkflowModel;
let workflow;
@ -20,6 +21,7 @@ describe('workflow > instructions > query', () => {
WorkflowModel = db.getCollection('workflows').model;
PostCollection = db.getCollection('posts');
PostRepo = PostCollection.repository;
CommentRepo = db.getCollection('comments').repository;
TagModel = db.getCollection('tags').model;
workflow = await WorkflowModel.create({
@ -160,7 +162,7 @@ describe('workflow > instructions > query', () => {
});
const tag = await TagModel.create({ name: 'tag1' });
const post = await PostCollection.repository.create({
const post = await PostRepo.create({
values: { title: 't1', tags: [tag.id] }
});
@ -171,7 +173,31 @@ describe('workflow > instructions > query', () => {
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({
type: 'query',
config: {
@ -183,7 +209,7 @@ describe('workflow > instructions > query', () => {
});
const tag = await TagModel.create({ name: 'tag1' });
const post = await PostCollection.repository.create({
const post = await PostRepo.create({
values: { title: 't1', tags: [tag.id] }
});

View File

@ -8,7 +8,10 @@ import { EXECUTION_STATUS } from '../../constants';
describe('workflow > triggers > collection', () => {
let app: Application;
let db: Database;
let CategoryRepo;
let PostRepo;
let CommentRepo;
let TagRepo;
let WorkflowModel;
beforeEach(async () => {
@ -16,7 +19,10 @@ describe('workflow > triggers > collection', () => {
db = app.db;
WorkflowModel = db.getCollection('workflows').model;
CategoryRepo = db.getCollection('categories').repository;
PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository;
TagRepo = db.getCollection('tags').repository;
});
afterEach(() => db.close());
@ -111,4 +117,133 @@ describe('workflow > triggers > collection', () => {
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 db: Database;
let PostRepo;
let CategoryRepo;
let WorkflowModel;
let WorkflowRepo;
@ -19,6 +20,7 @@ describe.skip('workflow > triggers > schedule', () => {
WorkflowModel = workflow.model;
WorkflowRepo = workflow.repository;
PostRepo = db.getCollection('posts').repository;
CategoryRepo = db.getCollection('categories').repository;
});
afterEach(() => app.destroy());
@ -231,7 +233,6 @@ describe.skip('workflow > triggers > schedule', () => {
await sleep((2.5 - now.getSeconds() % 2) * 1000 - now.getMilliseconds());
const startTime = new Date();
startTime.setMilliseconds(500);
console.log(startTime);
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);
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) {
const {
collection,
params = {}
params: { appends = [], ...params } = {}
} = 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 result = await repo.create({
const result = await repository.create({
...options,
context: {
executionId: processor.execution.id
@ -18,6 +18,18 @@ export default {
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 {
// NOTE: get() for non-proxied instance (#380)
result: result.get(),

View File

@ -36,9 +36,10 @@ function getFieldRawName(collection: Collection, name: string) {
// async function, should return promise
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 { transaction, context } = options;
const { repository, model } = collection;
// NOTE: if no configured fields changed, do not trigger
if (changed
@ -53,7 +54,6 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
if (condition && condition.$and?.length) {
// TODO: change to map filter format to calculation format
// const calculation = toCalculation(condition);
const { repository, model } = collection;
const count = await repository.count({
filter: {
$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() }, {
context
});

View File

@ -273,7 +273,7 @@ ScheduleModes.set(SCHEDULE_MODE.COLLECTION_FIELD, {
},
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 startTimestamp = getOnTimestampWithOffset(startsOn, now);
@ -333,11 +333,12 @@ ScheduleModes.set(SCHEDULE_MODE.COLLECTION_FIELD, {
});
}
const { model } = this.plugin.app.db.getCollection(collection);
const instances = await model.findAll({
where: {
[Op.and]: conditions
}
const repo = this.plugin.app.db.getRepository(collection);
const instances = await repo.find({
filter: {
$and: conditions
},
appends
});
instances.forEach(item => {