feat(plugin-workflow-action-trigger): add global action events (#3883)

* feat(plugin-workflow-action-trigger): add global action events

* feat(plugin-workflow): refine trigger select component style

* fix(plugin-workflow-action-trigger): fix unexpected triggering on any action

* fix(plugin-workflow-action-trigger): fix global check logic

* fix(plugin-workflow): adjust workflow type select style

* refactor(plugin-workflow): adjust triggers description

* fix(plugin-workflow): adjust events descriptions

* fix(plugin-workflow): fix locale

* fix(plugin-workflow-action-trigger): fix workflow filter rule in binding configuration

* fix(plugin-workflow-action-trigger): fix locale

* fix(plugin-workflow-action-trigger): fix locale

* fix(plugin-workflow-action-trigger): fix binding filter condition

* fix(plugin-workflow-action-trigger): fix locale

* fix: trigger type locator

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: hongboji <j414562100@qq.com>
This commit is contained in:
Junyi 2024-04-03 15:39:56 +08:00 committed by GitHub
parent d23f3a3999
commit 0544b8df45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 302 additions and 62 deletions

View File

@ -1,9 +1,9 @@
{
"name": "@nocobase/plugin-workflow-action-trigger",
"displayName": "Workflow: Action trigger",
"displayName.zh-CN": "工作流:数据操作触发器",
"description": "Bind action buttons to trigger workflow events when clicked.",
"description.zh-CN": "可对数据操作按钮绑定,在点击后触发对应的工作流事件。",
"displayName": "Workflow: Post-action event",
"displayName.zh-CN": "工作流:操作后事件",
"description": "Triggered after the completion of a request initiated through an action button or API, such as after adding, updating, deleting data, or \"submit to workflow\". Suitable for data processing, sending notifications, etc., after actions are completed.",
"description.zh-CN": "通过操作按钮或 API 发起请求并在执行完成后触发,比如新增、更新、删除数据或者“提交至工作流”之后。适用于在操作完成后进行数据处理、发送通知等。",
"version": "0.21.0-alpha.1",
"license": "AGPL-3.0",
"main": "./dist/server/index.js",

View File

@ -11,21 +11,32 @@ import {
CollectionBlockInitializer,
getCollectionFieldOptions,
useWorkflowAnyExecuted,
CheckboxGroupWithTooltip,
RadioWithTooltip,
} from '@nocobase/plugin-workflow/client';
import { NAMESPACE, useLang } from '../locale';
const COLLECTION_TRIGGER_ACTION = {
CREATE: 'create',
UPDATE: 'update',
UPSERT: 'updateOrCreate',
DESTROY: 'destroy',
};
export default class extends Trigger {
title = `{{t("Action event", { ns: "${NAMESPACE}" })}}`;
description = `{{t("Triggers after specific operations on data are submitted, such as create, update, delete, etc., or directly submitting a record to the workflow.", { ns: "${NAMESPACE}" })}}`;
title = `{{t("Post-action event", { ns: "${NAMESPACE}" })}}`;
description = `{{t('Triggered after the completion of a request initiated through an action button or API, such as after adding, updating, deleting data, or "submit to workflow". Suitable for data processing, sending notifications, etc., after actions are completed.', { ns: "${NAMESPACE}" })}}`;
fieldset = {
collection: {
type: 'string',
required: true,
'x-decorator': 'FormItem',
'x-decorator-props': {
tooltip: `{{t("The collection to which the triggered data belongs.", { ns: "${NAMESPACE}" })}}`,
},
'x-component': 'DataSourceCollectionCascader',
'x-disabled': '{{ useWorkflowAnyExecuted() }}',
title: `{{t("Collection", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Which collection record belongs to.", { ns: "${NAMESPACE}" })}}`,
'x-reactions': [
{
target: 'appends',
@ -38,6 +49,65 @@ export default class extends Trigger {
},
],
},
global: {
type: 'boolean',
title: `{{t("Trigger mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'x-component-props': {
direction: 'vertical',
options: [
{
label: `{{t("Local mode, triggered after the completion of actions bound to this workflow", { ns: "${NAMESPACE}" })}}`,
value: false,
},
{
label: `{{t("Global mode, triggered after the completion of the following actions", { ns: "${NAMESPACE}" })}}`,
value: true,
},
],
},
default: false,
'x-reactions': [
{
dependencies: ['collection'],
fulfill: {
state: {
visible: '{{!!$deps[0]}}',
},
},
},
],
},
actions: {
type: 'number',
title: `{{t("Select actions", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'CheckboxGroupWithTooltip',
'x-component-props': {
direction: 'vertical',
options: [
{ label: `{{t("Create record action", { ns: "${NAMESPACE}" })}}`, value: COLLECTION_TRIGGER_ACTION.CREATE },
{ label: `{{t("Update record action", { ns: "${NAMESPACE}" })}}`, value: COLLECTION_TRIGGER_ACTION.UPDATE },
// { label: `{{t("upsert", { ns: "${NAMESPACE}" })}}`, value: COLLECTION_TRIGGER_ACTION.UPSERT },
// {
// label: `{{t("Delete single or many records", { ns: "${NAMESPACE}" })}}`,
// value: COLLECTION_TRIGGER_ACTION.DESTROY,
// },
],
},
required: true,
'x-reactions': [
{
dependencies: ['collection', 'global'],
fulfill: {
state: {
visible: '{{!!$deps[0] && !!$deps[1]}}',
},
},
},
],
},
appends: {
type: 'array',
title: `{{t("Associations to use", { ns: "${NAMESPACE}" })}}`,
@ -68,8 +138,15 @@ export default class extends Trigger {
useCollectionDataSource,
useWorkflowAnyExecuted,
};
components = {
RadioWithTooltip,
CheckboxGroupWithTooltip,
};
isActionTriggerable = (config, context) => {
return ['create', 'update', 'customize:update', 'customize:triggerWorkflows'].includes(context.action);
return (
context.action === 'customize:triggerWorkflows' ||
(['create', 'update', 'customize:update'].includes(context.action) && !config.global)
);
};
useVariables(config, options) {
// eslint-disable-next-line react-hooks/rules-of-hooks

View File

@ -56,7 +56,7 @@ test.describe('Add new', () => {
const workFlowName = faker.string.alphanumeric(5);
await createWorkFlow.name.fill(workFlowName);
await createWorkFlow.triggerType.click();
await page.getByRole('option', { name: 'Action event' }).click();
await page.getByTitle('Post-action event').click();
await page.getByLabel('action-Action-Submit-workflows').click();
// 3、预期结果列表中出现新建的工作流

View File

@ -1,8 +1,16 @@
{
"Action event": "操作事件",
"Triggers after specific operations on data are submitted, such as create, update, delete, etc., or directly submitting a record to the workflow.": "在对数据的特定操作提交后触发,如创建、更新、删除等,或直接提交一条数据至工作流。",
"Post-action event": "操作后事件",
"Triggered after the completion of a request initiated through an action button or API, such as after adding, updating, deleting data, or \"submit to workflow\". Suitable for data processing, sending notifications, etc., after actions are completed.":
"通过操作按钮或 API 发起请求并在执行完成后触发,比如新增、更新、删除数据或者“提交至工作流”之后。适用于在操作完成后进行数据处理、发送通知等。",
"Collection": "数据表",
"Which collection record belongs to.": "数据所属的数据表。",
"The collection to which the triggered data belongs.": "触发数据所属的数据表。",
"Trigger mode": "触发模式",
"Local mode, triggered after the completion of actions bound to this workflow": "局部模式,绑定该工作流的操作执行完成后触发",
"Global mode, triggered after the completion of the following actions": "全局模式,以下操作执行完成后都触发",
"Action to submit to workflow directly is only supported on bound buttons, and will not be affected under global mode.": "直接提交至工作流的操作仅支持使用按钮绑定,不受全局模式的影响。",
"Select actions": "选择操作",
"Create record action": "创建记录操作",
"Update record action": "更新记录操作",
"Associations to use": "待使用的关系数据",
"Trigger data": "触发器数据",
"User submitted action": "提交操作的用户",

View File

@ -5,18 +5,20 @@ import Application, { DefaultContext } from '@nocobase/server';
import { Context as ActionContext, Next } from '@nocobase/actions';
import WorkflowPlugin, { Trigger, WorkflowModel, toJSON } from '@nocobase/plugin-workflow';
import { parseCollectionName } from '@nocobase/data-source-manager';
import { joinCollectionName, parseCollectionName } from '@nocobase/data-source-manager';
interface Context extends ActionContext, DefaultContext {}
export default class extends Trigger {
static TYPE = 'action';
constructor(workflow: WorkflowPlugin) {
super(workflow);
workflow.app.use(this.middleware, { after: 'dataSource' });
}
async triggerAction(context: Context, next: Next) {
async workflowTriggerAction(context: Context, next: Next) {
const { triggerWorkflows } = context.action.params;
if (!triggerWorkflows) {
@ -26,37 +28,33 @@ export default class extends Trigger {
context.status = 202;
await next();
this.trigger(context);
return this.collectionTriggerAction(context);
}
middleware = async (context: Context, next: Next) => {
const {
resourceName,
actionName,
params: { triggerWorkflows },
} = context.action;
const { resourceName, actionName } = context.action;
if (resourceName === 'workflows' && actionName === 'trigger') {
return this.triggerAction(context, next);
return this.workflowTriggerAction(context, next);
}
await next();
if (!triggerWorkflows) {
return;
}
if (!['create', 'update'].includes(actionName)) {
return;
}
return this.trigger(context);
return this.collectionTriggerAction(context);
};
private async trigger(context: Context) {
const { triggerWorkflows = '', values } = context.action.params;
private async collectionTriggerAction(context: Context) {
const {
resourceName,
actionName,
params: { triggerWorkflows = '', values },
} = context.action;
const dataSourceHeader = context.get('x-data-source') || 'main';
const fullCollectionName = joinCollectionName(dataSourceHeader, resourceName);
const { currentUser, currentRole } = context.state;
const { model: UserModel } = this.workflow.db.getCollection('users');
const userInfo = {
@ -65,23 +63,41 @@ export default class extends Trigger {
};
const triggers = triggerWorkflows.split(',').map((trigger) => trigger.split('!'));
const workflowRepo = this.workflow.db.getRepository('workflows');
const workflows = (
await workflowRepo.find({
filter: {
key: triggers.map((trigger) => trigger[0]),
current: true,
type: 'action',
enabled: true,
},
})
).filter((workflow) => Boolean(workflow.config.collection));
const triggersKeysMap = new Map<string, string>(triggers);
const workflows = Array.from(this.workflow.enabledCache.values()).filter(
(item) => item.type === 'action' && item.config.collection,
);
const globalWorkflows = new Map();
const localWorkflows = new Map();
workflows.forEach((item) => {
if (resourceName === 'workflows' && actionName === 'trigger') {
localWorkflows.set(item.key, item);
} else if (item.config.collection === fullCollectionName) {
if (item.config.global) {
if (item.config.actions?.includes(actionName)) {
globalWorkflows.set(item.key, item);
}
} else {
localWorkflows.set(item.key, item);
}
}
});
const triggeringLocalWorkflows = [];
const uniqueTriggersMap = new Map();
triggers.forEach((trigger) => {
const [key] = trigger;
const workflow = localWorkflows.get(key);
if (workflow && !uniqueTriggersMap.has(key)) {
triggeringLocalWorkflows.push(workflow);
uniqueTriggersMap.set(key, true);
}
});
const syncGroup = [];
const asyncGroup = [];
for (const workflow of workflows) {
for (const workflow of triggeringLocalWorkflows.concat(...globalWorkflows.values())) {
const { collection, appends = [] } = workflow.config;
const [dataSourceName, collectionName] = parseCollectionName(collection);
const trigger = triggers.find((trigger) => trigger[0] == workflow.key);
const dataPath = triggersKeysMap.get(workflow.key);
const event = [workflow];
if (context.action.resourceName !== 'workflows') {
if (!context.body) {
@ -93,9 +109,12 @@ export default class extends Trigger {
const { body: data } = context;
for (const row of Array.isArray(data) ? data : [data]) {
let payload = row;
if (trigger[1]) {
const paths = trigger[1].split('.');
if (dataPath) {
const paths = dataPath.split('.');
for (const field of paths) {
if (!payload) {
break;
}
if (payload.get(field)) {
payload = payload.get(field);
} else {
@ -104,37 +123,32 @@ export default class extends Trigger {
}
}
}
const model = payload.constructor;
if (payload instanceof Model) {
const model = payload.constructor as unknown as Model;
if (collectionName !== model.collection.name) {
continue;
}
if (appends.length) {
payload = await model.collection.repository.findOne({
filterByTk: payload.get(model.primaryKeyAttribute),
filterByTk: payload.get(model.collection.filterTargetKey),
appends,
});
}
}
// this.workflow.trigger(workflow, { data: toJSON(payload), ...userInfo });
event.push({ data: toJSON(payload), ...userInfo });
}
} else {
const { model, repository } = (<Application>context.app).dataSourceManager.dataSources
const { filterTargetKey, repository } = (<Application>context.app).dataSourceManager.dataSources
.get(dataSourceName)
.collectionManager.getCollection(collectionName);
let data = trigger[1] ? get(values, trigger[1]) : values;
const pk = get(data, model.primaryKeyAttribute);
let data = dataPath ? get(values, dataPath) : values;
const pk = get(data, filterTargetKey);
if (appends.length && pk != null) {
data = await repository.findOne({
filterByTk: pk,
appends,
});
}
// this.workflow.trigger(workflow, {
// data,
// ...userInfo,
// });
event.push({ data, ...userInfo });
}
(workflow.sync ? syncGroup : asyncGroup).push(event);

View File

@ -572,6 +572,126 @@ describe('workflow > action-trigger', () => {
});
});
describe('global workflow', () => {
it('no action configured should not be triggered', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'action',
config: {
collection: 'posts',
global: true,
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
});
expect(res1.status).toBe(200);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(0);
const res2 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res2.status).toBe(200);
await sleep(500);
const e2 = await workflow.getExecutions();
expect(e2.length).toBe(0);
});
it('trigger on both create and update actions', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'action',
config: {
collection: 'posts',
global: true,
actions: ['create', 'update'],
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
});
expect(res1.status).toBe(200);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(1);
expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1[0].context.data).toMatchObject({ title: 't1' });
const res2 = await userAgents[0].resource('posts').update({
filterByTk: res1.body.data.id,
values: { title: 't2' },
});
await sleep(500);
const e2 = await workflow.getExecutions({ order: [['id', 'ASC']] });
expect(e2.length).toBe(2);
expect(e2[1].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e2[1].context.data).toMatchObject({ title: 't2' });
});
it('trigger on action when bound to button', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'action',
config: {
collection: 'posts',
global: true,
actions: ['create', 'update'],
},
});
const res1 = await userAgents[0].resource('posts').create({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res1.status).toBe(200);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(1);
expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1[0].context.data).toMatchObject({ title: 't1' });
});
it('trigger on action directly submit to workflow', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'action',
config: {
collection: 'posts',
global: true,
actions: ['create', 'update'],
},
});
const res1 = await userAgents[0].resource('workflows').trigger({
values: { title: 't1' },
triggerWorkflows: `${workflow.key}`,
});
expect(res1.status).toBe(202);
await sleep(500);
const e1 = await workflow.getExecutions();
expect(e1.length).toBe(1);
expect(e1[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e1[0].context.data).toMatchObject({ title: 't1' });
});
});
describe('multiple data source', () => {
it('trigger on different data source', async () => {
const workflow = await WorkflowModel.create({

View File

@ -43,7 +43,7 @@ test.describe('Add new', () => {
const workFlowName = faker.string.alphanumeric(5);
await createWorkFlow.name.fill(workFlowName);
await createWorkFlow.triggerType.click();
await page.getByRole('option', { name: 'Schedule event' }).click();
await page.getByTitle('Schedule event').click();
await page.getByLabel('action-Action-Submit-workflows').click();
// 3、预期结果列表中出现新建的工作流

View File

@ -55,7 +55,7 @@ test.describe('Add new', () => {
const workFlowName = faker.string.alphanumeric(5);
await createWorkFlow.name.fill(workFlowName);
await createWorkFlow.triggerType.click();
await page.getByRole('option', { name: 'Collection event' }).click();
await page.getByTitle('Collection event').click();
await page.getByLabel('action-Action-Submit-workflows').click();
// 3、预期结果列表中出现新建的工作流

View File

@ -0,0 +1,17 @@
import React from 'react';
import { Space, Tag, Typography } from 'antd';
import { useCompile } from '@nocobase/client';
export function TriggerOptionRender({ data }) {
const { label, color, options } = data;
const compile = useCompile();
return (
<Space direction="vertical">
<Tag color={color}>{compile(label)}</Tag>
<Typography.Text type="secondary" style={{ whiteSpace: 'normal' }}>
{compile(options.description)}
</Typography.Text>
</Space>
);
}

View File

@ -1,11 +1,12 @@
import React from 'react';
import { ISchema, useForm } from '@formily/react';
import { useActionContext, useRecord, useResourceActionContext, useResourceContext } from '@nocobase/client';
import { message } from 'antd';
import { useTranslation } from 'react-i18next';
import { NAMESPACE } from '../locale';
// import { triggers } from '../triggers';
import React from 'react';
import { executionSchema } from './executions';
import { TriggerOptionRender } from '../components/TriggerOptionRender';
const collection = {
name: 'workflows',
@ -32,6 +33,9 @@ const collection = {
'x-component': 'Select',
'x-component-props': {
options: `{{getTriggersOptions()}}`,
optionRender: TriggerOptionRender,
popupMatchSelectWidth: true,
listHeight: 300,
},
required: true,
} as ISchema,

View File

@ -28,7 +28,7 @@ const collectionModeOptions = [
export default class extends Trigger {
title = `{{t("Collection event", { ns: "${NAMESPACE}" })}}`;
description = `{{t("Event will be triggered on collection data row created, updated or deleted.", { ns: "${NAMESPACE}" })}}`;
description = `{{t('Triggered when data changes in the collection, such as after adding, updating, or deleting a record. Unlike "Post-action event", Collection event listens for data changes rather than HTTP requests. Unless you understand the exact meaning, it is recommended to use "Post-action event".', { ns: "${NAMESPACE}" })}}`;
fieldset = {
collection: {
...collection,

View File

@ -15,7 +15,7 @@ import { SCHEDULE_MODE } from './constants';
export default class extends Trigger {
sync = false;
title = `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`;
description = `{{t("Event will be scheduled and triggered base on time conditions.", { ns: "${NAMESPACE}" })}}`;
description = `{{t("Triggered according to preset time conditions. Suitable for one-time or periodic tasks, such as sending notifications and cleaning data on a schedule.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
config: {
type: 'void',

View File

@ -46,8 +46,8 @@
"Full form data": "完整表单数据",
"Select context": "选择上下文",
"Collection event": "数据表事件",
"Event will be triggered on collection data row created, updated or deleted.":
"当数据表中的数据被新增、更新或删除时触发。",
"Triggered when data changes in the collection, such as after adding, updating, or deleting a record. Unlike \"Post-action event\", Collection event listens for data changes rather than HTTP requests. Unless you understand the exact meaning, it is recommended to use \"Post-action event\".":
"当数据表中的数据发生变化时触发,比如新增、更新或删除一条数据后。与“操作后事件”不同,数据表事件监听数据变动而不是 HTTP 请求,除非你明白确切含义,否则推荐使用“操作后事件”。",
"Trigger on": "触发时机",
"After record added": "新增数据后",
"After record updated": "更新数据后",
@ -61,7 +61,7 @@
"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": "定时任务",
"Event will be scheduled and triggered base on time conditions.": "基于时间条件进行定时触发的事件。",
"Triggered according to preset time conditions. Suitable for one-time or periodic tasks, such as sending notifications and cleaning data on a schedule.": "按预设的时间条件定时触发。适用于一次性或周期性的任务,如定时发送通知、清理数据等。",
"Trigger mode": "触发模式",
"Based on certain date": "自定义时间",
"Based on date field of collection": "根据数据表时间字段",