-
-
- {compile(instruction.title)}
-
-
- {data.title}
- #{data.id}
-
-
+
+
+
+
+ {compile(instruction.title)}
-
{
- return Promise.resolve({ data: d });
- }, options);
- }
+
+ {data.title}
+ #{data.id}
+
+
+
+
{
+ return Promise.resolve({ data: d });
+ }, options);
+ }
+ },
+ properties: {
+ title: {
+ type: 'string',
+ name: 'title',
+ title: '{{t("Name")}}',
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Input',
},
- properties: {
- title: {
- type: 'string',
- name: 'title',
- title: '{{t("Name")}}',
- 'x-decorator': 'FormItem',
- 'x-component': 'Input',
+ config: {
+ type: 'void',
+ name: 'config',
+ 'x-component': 'fieldset',
+ 'x-component-props': {
+ disabled: workflow.executed
},
- config: {
- type: 'void',
- name: 'config',
- 'x-component': 'fieldset',
- 'x-component-props': {},
- properties: instruction.fieldset
- },
- actions: {
- type: 'void',
- 'x-component': 'Action.Drawer.Footer',
- properties: {
- cancel: {
- title: '{{t("Cancel")}}',
- 'x-component': 'Action',
- 'x-component-props': {
- useAction: '{{ cm.useCancelAction }}',
- },
+ properties: instruction.fieldset
+ },
+ actions: {
+ type: 'void',
+ 'x-component': 'Action.Drawer.Footer',
+ properties: workflow.executed
+ ? {
+ close: {
+ title: '{{t("Close")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
},
- submit: {
- title: '{{t("Submit")}}',
- 'x-component': 'Action',
- 'x-component-props': {
- type: 'primary',
- useAction: useUpdateAction,
- },
+ }
+ }
+ : {
+ cancel: {
+ title: '{{t("Cancel")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
},
},
- } as ISchema
- }
+ submit: {
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ useAction: useUpdateAction,
+ },
+ },
+ },
+ } as ISchema
}
}
}
}
- }}
- />
-
- {children}
+ }
+ }}
+ />
-
+ {children}
+
);
}
diff --git a/packages/core/client/src/workflow/nodes/update.tsx b/packages/core/client/src/workflow/nodes/update.tsx
index 2dbd9e911..970df36cd 100644
--- a/packages/core/client/src/workflow/nodes/update.tsx
+++ b/packages/core/client/src/workflow/nodes/update.tsx
@@ -8,19 +8,11 @@ export default {
group: 'collection',
fieldset: {
'config.collection': collection,
- 'config.params': {
- type: 'object',
- name: 'config.params',
- title: '',
- 'x-decorator': 'FormItem',
- properties: {
- filter: {
- ...filter,
- title: '{{t("Only update records matching conditions")}}',
- },
- values
- }
- }
+ 'config.params.filter': {
+ ...filter,
+ title: '{{t("Only update records matching conditions")}}',
+ },
+ 'config.params.values': values
},
view: {
diff --git a/packages/core/client/src/workflow/schemas/workflows.ts b/packages/core/client/src/workflow/schemas/workflows.ts
index 4fc0909d9..c853c4720 100644
--- a/packages/core/client/src/workflow/schemas/workflows.ts
+++ b/packages/core/client/src/workflow/schemas/workflows.ts
@@ -2,6 +2,8 @@ import { ISchema } from '@formily/react';
import { triggers } from '../triggers';
import { executionSchema } from './executions';
+
+
const collection = {
name: 'workflows',
fields: [
@@ -50,8 +52,8 @@ const collection = {
title: '{{t("Status")}}',
type: 'string',
enum: [
- { label: '{{t("Enabled")}}', value: true },
- { label: '{{t("Disabled")}}', value: false },
+ { label: '{{t("Started")}}', value: true },
+ { label: '{{t("Stopped")}}', value: false },
],
'x-component': 'Radio.Group',
'x-decorator': 'FormItem',
@@ -75,7 +77,9 @@ export const workflowSchema: ISchema = {
action: 'list',
params: {
pageSize: 50,
- filter: {},
+ filter: {
+ current: true
+ },
sort: ['createdAt'],
except: ['config'],
},
@@ -119,6 +123,11 @@ export const workflowSchema: ISchema = {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
+ 'x-decorator-props': {
+ initialValue: {
+ current: true
+ }
+ },
title: '{{t("Add new")}}',
properties: {
title: {
@@ -138,14 +147,14 @@ export const workflowSchema: ISchema = {
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
- title: 'Cancel',
+ title: '{{ t("Cancel") }}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ cm.useCancelAction }}',
},
},
submit: {
- title: 'Submit',
+ title: '{{ t("Submit") }}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
diff --git a/packages/core/client/src/workflow/style.tsx b/packages/core/client/src/workflow/style.tsx
index 90ad958fa..9b95a3a36 100644
--- a/packages/core/client/src/workflow/style.tsx
+++ b/packages/core/client/src/workflow/style.tsx
@@ -5,6 +5,26 @@ export const workflowPageClass = css`
width: 100%;
overflow: auto;
+ .workflow-toolbar{
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ background: #fff;
+
+ header{
+ display: flex;
+ align-items: center;
+ gap: .5em;
+ }
+
+ aside{
+ display: flex;
+ align-items: center;
+ gap: .5em;
+ }
+ }
+
.workflow-canvas{
width: min-content;
min-width: 100%;
@@ -15,6 +35,25 @@ export const workflowPageClass = css`
}
`;
+export const workflowVersionDropdownClass = css`
+ .ant-dropdown-menu-item{
+
+ &.unexecuted{
+ font-style: italic;
+ }
+
+ .ant-dropdown-menu-title-content{
+ text-align: right;
+
+ time{
+ margin-left: 0.5rem;
+ color: #999;
+ font-size: 80%;
+ }
+ }
+ }
+`;
+
export const branchBlockClass = css`
display: flex;
position: relative;
@@ -117,6 +156,10 @@ export const nodeCardClass = css`
opacity: 0;
transition: opacity .3s ease;
+ &[disabled]{
+ display: none;
+ }
+
&:hover {
color: red;
}
diff --git a/packages/core/client/src/workflow/triggers/index.tsx b/packages/core/client/src/workflow/triggers/index.tsx
index 351f084e6..66c7db30f 100644
--- a/packages/core/client/src/workflow/triggers/index.tsx
+++ b/packages/core/client/src/workflow/triggers/index.tsx
@@ -2,15 +2,16 @@ import React from "react";
import { ISchema, useForm } from "@formily/react";
import { cx } from "@emotion/css";
import { Registry } from "@nocobase/utils";
+import { useTranslation } from "react-i18next";
+import { message, Tag } from "antd";
import { SchemaComponent, useActionContext, useAPIClient, useCompile, useRecord, useRequest, useResourceActionContext } from '../../';
import collection from './collection';
import { nodeCardClass, nodeMetaClass } from "../style";
-import { useTranslation } from "react-i18next";
-import { Tag } from "antd";
function useUpdateConfigAction() {
+ const { t } = useTranslation();
const form = useForm();
const api = useAPIClient();
const record = useRecord();
@@ -18,6 +19,10 @@ function useUpdateConfigAction() {
const { refresh } = useResourceActionContext();
return {
async run() {
+ if (record.executed) {
+ message.error(t('Trigger in executed workflow cannot be modified'));
+ return;
+ }
await form.submit();
await api.resource('workflows', record.id).update({
filterByTk: record.id,
@@ -53,8 +58,10 @@ export const TriggerConfig = () => {
if (!data) {
return null;
}
- const { type, config } = data.data;
+ const { type, config, executed } = data.data;
const { title, fieldset, scope, components } = triggers.get(type);
+ const detailText = executed ? '{{t("View")}}' : '{{t("Configure")}}';
+
return (
@@ -64,13 +71,13 @@ export const TriggerConfig = () => {
{
actions: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
- properties: {
+ properties: executed
+ ? {
+ close: {
+ title: '{{t("Close")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{ cm.useCancelAction }}',
+ },
+ }
+ }
+ : {
cancel: {
title: '{{t("Cancel")}}',
'x-component': 'Action',
diff --git a/packages/plugins/workflow/src/__tests__/workflow.test.ts b/packages/plugins/workflow/src/__tests__/workflow.test.ts
index 3a78eec79..63d7ec559 100644
--- a/packages/plugins/workflow/src/__tests__/workflow.test.ts
+++ b/packages/plugins/workflow/src/__tests__/workflow.test.ts
@@ -1,7 +1,6 @@
import { Application } from '@nocobase/server';
import Database from '@nocobase/database';
import { getApp } from '.';
-import { EXECUTION_STATUS } from '../constants';
diff --git a/packages/plugins/workflow/src/actions/index.ts b/packages/plugins/workflow/src/actions/index.ts
index 607e6b69b..32140ba30 100644
--- a/packages/plugins/workflow/src/actions/index.ts
+++ b/packages/plugins/workflow/src/actions/index.ts
@@ -1,4 +1,5 @@
-import * as flow_nodes from './flow_nodes';
+import * as workflows from './workflows';
+import * as nodes from './nodes';
function make(name, mod) {
return Object.keys(mod).reduce((result, key) => ({
@@ -9,6 +10,13 @@ function make(name, mod) {
export default function(app) {
app.actions({
- ...make('flow_nodes', flow_nodes)
+ ...make('workflows', workflows),
+ ...make('workflows.nodes', {
+ create: nodes.create,
+ destroy: nodes.destroy
+ }),
+ ...make('flow_nodes', {
+ update: nodes.update
+ })
});
}
diff --git a/packages/plugins/workflow/src/actions/flow_nodes.ts b/packages/plugins/workflow/src/actions/nodes.ts
similarity index 56%
rename from packages/plugins/workflow/src/actions/flow_nodes.ts
rename to packages/plugins/workflow/src/actions/nodes.ts
index 22d4cb461..9d8bc1771 100644
--- a/packages/plugins/workflow/src/actions/flow_nodes.ts
+++ b/packages/plugins/workflow/src/actions/nodes.ts
@@ -1,10 +1,27 @@
import { Op } from 'sequelize';
-import actions, { Context, utils } from '@nocobase/actions';
+import { Context, utils } from '@nocobase/actions';
+import { MultipleRelationRepository } from '@nocobase/database';
+import WorkflowModel from '../models/Workflow';
export async function create(context: Context, next) {
- return actions.create(context, async () => {
- const { body: instance, db } = context;
- const repository = utils.getRepositoryFromParams(context);
+ const { db } = context;
+ const repository = utils.getRepositoryFromParams(context) as MultipleRelationRepository;
+ const { whitelist, blacklist, updateAssociationValues, values, associatedIndex: workflowId } = context.action.params;
+
+ context.body = await db.sequelize.transaction(async transaction => {
+ const workflow = await repository.getSourceModel(transaction) as WorkflowModel;
+ if (workflow.executed) {
+ context.throw(400, 'Node could not be created in executed workflow');
+ }
+
+ const instance = await repository.create({
+ values,
+ whitelist,
+ blacklist,
+ updateAssociationValues,
+ context,
+ transaction
+ });
if (!instance.upstreamId) {
const previousHead = await repository.findOne({
@@ -13,30 +30,31 @@ export async function create(context: Context, next) {
$ne: instance.id
},
upstreamId: null
- }
+ },
+ transaction
});
if (previousHead) {
- await previousHead.setUpstream(instance);
- await instance.setDownstream(previousHead);
+ await previousHead.setUpstream(instance, { transaction });
+ await instance.setDownstream(previousHead, { transaction });
instance.set('downstream', previousHead);
}
- return next();
+ return instance;
}
- const upstream = await instance.getUpstream();
+ const upstream = await instance.getUpstream({ transaction });
if (instance.branchIndex == null) {
- const downstream = await upstream.getDownstream();
+ const downstream = await upstream.getDownstream({ transaction });
if (downstream) {
- await downstream.setUpstream(instance);
- await instance.setDownstream(downstream);
+ await downstream.setUpstream(instance, { transaction });
+ await instance.setDownstream(downstream, { transaction });
instance.set('downstream', downstream);
}
await upstream.update({
downstreamId: instance.id
- });
+ }, { transaction });
upstream.set('downstream', instance);
} else {
@@ -46,23 +64,24 @@ export async function create(context: Context, next) {
[Op.ne]: instance.id
},
branchIndex: instance.branchIndex
- }
+ },
+ transaction
});
if (downstream) {
await downstream.update({
upstreamId: instance.id,
branchIndex: null
- });
- await instance.setDownstream(downstream);
+ }, { transaction });
+ await instance.setDownstream(downstream, { transaction });
instance.set('downstream', downstream);
}
}
instance.set('upstream', upstream);
-
- await next();
});
+
+ await next();
}
function searchBranchNodes(nodes, from): any[] {
@@ -80,11 +99,16 @@ function searchBranchDownstreams(nodes, from) {
}
export async function destroy(context: Context, next) {
- const repository = utils.getRepositoryFromParams(context);
const { db } = context;
+ const repository = utils.getRepositoryFromParams(context) as MultipleRelationRepository;
const { filterByTk } = context.action.params;
context.body = await db.sequelize.transaction(async transaction => {
+ const workflow = await repository.getSourceModel(transaction) as WorkflowModel;
+ if (workflow.executed) {
+ context.throw(400, 'Nodes in executed workflow could not be deleted');
+ }
+
const fields = ['id', 'upstreamId', 'downstreamId', 'branchIndex'];
const instance = await repository.findOne({
filterByTk,
@@ -143,3 +167,33 @@ export async function destroy(context: Context, next) {
await next();
}
+
+export async function update(context: Context, next) {
+ const { db } = context;
+ const repository = utils.getRepositoryFromParams(context);
+ const { filterByTk, values, whitelist, blacklist, filter, updateAssociationValues } = context.action.params;
+ context.body = await db.sequelize.transaction(async transaction => {
+ // TODO(optimize): duplicated instance query
+ const { workflow } = await repository.findOne({
+ filterByTk,
+ appends: ['workflow.executed'],
+ transaction
+ });
+ if (workflow.executed) {
+ context.throw(400, 'Nodes in executed workflow could not be reconfigured');
+ }
+
+ return repository.update({
+ filterByTk,
+ values,
+ whitelist,
+ blacklist,
+ filter,
+ updateAssociationValues,
+ context,
+ transaction
+ });
+ });
+
+ await next();
+}
diff --git a/packages/plugins/workflow/src/actions/workflows.ts b/packages/plugins/workflow/src/actions/workflows.ts
new file mode 100644
index 000000000..4be8eb676
--- /dev/null
+++ b/packages/plugins/workflow/src/actions/workflows.ts
@@ -0,0 +1,145 @@
+import parse from 'json-templates';
+
+import { Context, utils } from '@nocobase/actions';
+import { Op } from '@nocobase/database';
+
+
+
+export async function update(context: Context, next) {
+ const { db } = context;
+ const repository = utils.getRepositoryFromParams(context);
+ const { filterByTk, values, whitelist, blacklist, filter, updateAssociationValues } = context.action.params;
+
+ context.body = await db.sequelize.transaction(async transaction => {
+ const others: { enabled?: boolean, current?: boolean } = {};
+
+ if (values.enabled) {
+ values.current = true;
+ others.enabled = false;
+ }
+
+ if (values.current) {
+ others.current = false;
+ await repository.update({
+ filter: {
+ key: values.key,
+ id: {
+ [Op.ne]: filterByTk
+ }
+ },
+ values: others,
+ context,
+ transaction
+ });
+ }
+
+ const instance = await repository.update({
+ filterByTk,
+ values,
+ whitelist,
+ blacklist,
+ filter,
+ updateAssociationValues,
+ context,
+ transaction
+ });
+
+ return instance;
+ });
+
+ await next();
+}
+
+function typeOf(value) {
+ if (Array.isArray(value)) {
+ return 'array';
+ } else if (value instanceof Date) {
+ return 'date';
+ } else if (value === null) {
+ return 'null';
+ }
+
+ return typeof value;
+}
+
+function migrateConfig(config, oldToNew) {
+ function migrate(value) {
+ switch (typeOf(value)) {
+ case 'object':
+ return Object.keys(value).reduce((result, key) => ({ ...result, [key]: migrate(value[key]) }), {});
+ case 'array':
+ return value.map(item => migrate(item));
+ case 'string':
+ return value
+ .replace(
+ /(\{\{\$jobsMapByNodeId\.)(\d+)/,
+ (_, prefix, id) => `${prefix}${oldToNew.get(Number.parseInt(id, 10)).id}`
+ );
+ default:
+ return value;
+ }
+ }
+
+ return migrate(config);
+}
+
+export async function duplicate(context: Context, next) {
+ const { db } = context;
+ const repository = utils.getRepositoryFromParams(context);
+ const { filterByTk } = context.action.params;
+
+ context.body = await db.sequelize.transaction(async transaction => {
+ const origin = await repository.findOne({
+ filterByTk,
+ appends: ['nodes'],
+ context,
+ transaction
+ });
+
+ const instance = await repository.create({
+ values: {
+ key: origin.key,
+ title: origin.title,
+ description: origin.description,
+ type: origin.type,
+ config: origin.config
+ },
+ transaction
+ });
+
+ const originalNodesMap = new Map();
+ origin.nodes.forEach((node) => {
+ originalNodesMap.set(node.id, node);
+ });
+
+ const oldToNew = new Map();
+ const newToOld = new Map();
+ for await (const node of origin.nodes) {
+ const newNode = await instance.createNode({
+ type: node.type,
+ config: node.config,
+ title: node.title,
+ branchIndex: node.branchIndex
+ }, { transaction });
+ // NOTE: keep original node references for later replacement
+ oldToNew.set(node.id, newNode);
+ newToOld.set(newNode.id, node);
+ }
+
+ for await (const [oldId, newNode] of oldToNew.entries()) {
+ const oldNode = originalNodesMap.get(oldId);
+ const newUpstream = oldNode.upstreamId ? oldToNew.get(oldNode.upstreamId) : null;
+ const newDownstream = oldNode.downstreamId ? oldToNew.get(oldNode.downstreamId) : null;
+
+ await newNode.update({
+ upstreamId: newUpstream?.id ?? null,
+ downstreamId: newDownstream?.id ?? null,
+ config: migrateConfig(oldNode.config, oldToNew)
+ }, { transaction });
+ }
+
+ return instance;
+ });
+
+ await next();
+}
diff --git a/packages/plugins/workflow/src/collections/workflows.ts b/packages/plugins/workflow/src/collections/workflows.ts
index ca89e997a..406b92480 100644
--- a/packages/plugins/workflow/src/collections/workflows.ts
+++ b/packages/plugins/workflow/src/collections/workflows.ts
@@ -5,6 +5,10 @@ export default {
model: 'WorkflowModel',
title: '自动化',
fields: [
+ {
+ name: 'key',
+ type: 'uid'
+ },
{
interface: 'string',
type: 'string',
@@ -66,15 +70,19 @@ export default {
name: 'executed',
defaultValue: false
},
+ {
+ type: 'boolean',
+ name: 'current',
+ defaultValue: false
+ },
{
type: 'hasMany',
name: 'revisions',
target: 'workflows',
- },
- {
- type: 'belongsTo',
- name: 'current',
- target: 'workflows'
+ foreignKey: 'key',
+ sourceKey: 'key',
+ // NOTE: no constraints needed here because tricky self-referencing
+ constraints: false
}
]
} as CollectionOptions;
diff --git a/packages/plugins/workflow/src/models/Workflow.ts b/packages/plugins/workflow/src/models/Workflow.ts
index dcf7137b3..0772afc2a 100644
--- a/packages/plugins/workflow/src/models/Workflow.ts
+++ b/packages/plugins/workflow/src/models/Workflow.ts
@@ -16,6 +16,7 @@ export default class WorkflowModel extends Model {
declare type: string;
declare config: any;
declare useTransaction: boolean;
+ declare executed: boolean;
declare createdAt: Date;
declare updatedAt: Date;
@@ -101,6 +102,10 @@ export default class WorkflowModel extends Model {
await execution.start({ transaction });
+ if (!this.executed) {
+ await this.update({ executed: true }, { transaction });
+ }
+
if (transaction && (!options.transaction || options.transaction.finished)) {
await transaction.commit();
}