refactor(plugin-workflow): change to use node key for variables (#2909)

* refactor(plugin-workflow): change to use node key for variables

* refactor(plugin-workflow): add node key to node panel

* fix(plugin-workflow): fix locale

* fix(plugin-workflow): fix nodes variables

* fix(plugin-workflow): fix migration

* fix(plugin-workflow): fix migration and manual detail block
This commit is contained in:
Junyi 2023-10-25 23:14:54 +08:00 committed by GitHub
parent b8bc32f421
commit a845dc0937
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 178 additions and 180 deletions

View File

@ -32,6 +32,7 @@ function attachJobs(nodes, jobs: any[] = []): void {
node.jobs.push(item); node.jobs.push(item);
item.node = { item.node = {
id: node.id, id: node.id,
key: node.key,
title: node.title, title: node.title,
type: node.type, type: node.type,
}; };

View File

@ -32,7 +32,7 @@ function Initializer({ node, resultTitle, insert, ...props }) {
'x-component': 'ValueBlock.Result', 'x-component': 'ValueBlock.Result',
'x-component-props': { 'x-component-props': {
// NOTE: as same format as other reference for migration of revision // NOTE: as same format as other reference for migration of revision
dataSource: `{{$jobsMapByNodeId.${node.id}}}`, dataSource: `{{$jobsMapByNodeKey.${node.key}}}`,
}, },
}, },
}, },
@ -49,7 +49,10 @@ function Result({ dataSource }) {
return field.title; return field.title;
} }
const result = parse(dataSource)({ const result = parse(dataSource)({
$jobsMapByNodeId: (execution.jobs ?? []).reduce((map, job) => Object.assign(map, { [job.nodeId]: job.result }), {}), $jobsMapByNodeKey: (execution.jobs ?? []).reduce(
(map, job) => Object.assign(map, { [job.nodeId]: job.result }),
{},
),
}); });
return ( return (

View File

@ -346,7 +346,7 @@ export default {
ValueBlock, ValueBlock,
AssociatedConfig, AssociatedConfig,
}, },
useVariables({ id, title }, { types, fieldNames = defaultFieldNames }) { useVariables({ key, title }, { types, fieldNames = defaultFieldNames }) {
if ( if (
types && types &&
!types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type))) !types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type)))
@ -354,7 +354,7 @@ export default {
return null; return null;
} }
return { return {
[fieldNames.value]: `${id}`, [fieldNames.value]: key,
[fieldNames.label]: title, [fieldNames.label]: title,
}; };
}, },

View File

@ -1,11 +1,9 @@
import { FormItem, FormLayout } from '@formily/antd-v5'; import { FormItem, FormLayout } from '@formily/antd-v5';
import { SchemaInitializerItemOptions, Variable, css, defaultFieldNames, useCollectionManager } from '@nocobase/client'; import { SchemaInitializerItemOptions, Variable, css, defaultFieldNames, useCollectionManager } from '@nocobase/client';
import { Evaluator, evaluators, getOptions } from '@nocobase/evaluators/client'; import { Evaluator, evaluators, getOptions } from '@nocobase/evaluators/client';
import { parse } from '@nocobase/utils/client';
import { Radio } from 'antd'; import { Radio } from 'antd';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFlowContext } from '../FlowContext';
import { RadioWithTooltip } from '../components/RadioWithTooltip'; import { RadioWithTooltip } from '../components/RadioWithTooltip';
import { ValueBlock } from '../components/ValueBlock'; import { ValueBlock } from '../components/ValueBlock';
import { renderEngineReference } from '../components/renderEngineReference'; import { renderEngineReference } from '../components/renderEngineReference';
@ -167,32 +165,11 @@ export default {
}); });
return <Variable.Input scope={scope} {...props} />; return <Variable.Input scope={scope} {...props} />;
}, },
CalculationResult({ dataSource }) {
const { execution } = useFlowContext();
if (!execution) {
return lang('Calculation result');
}
const result = parse(dataSource)({
$jobsMapByNodeId: (execution.jobs ?? []).reduce(
(map, job) => Object.assign(map, { [job.nodeId]: job.result }),
{},
),
});
return (
<pre
className={css`
margin: 0;
`}
>
{JSON.stringify(result, null, 2)}
</pre>
);
},
RadioWithTooltip, RadioWithTooltip,
DynamicConfig, DynamicConfig,
ValueBlock,
}, },
useVariables({ id, title }, { types, fieldNames = defaultFieldNames }) { useVariables({ key, title }, { types, fieldNames = defaultFieldNames }) {
if ( if (
types && types &&
!types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type))) !types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type)))
@ -200,7 +177,7 @@ export default {
return null; return null;
} }
return { return {
[fieldNames.value]: `${id}`, [fieldNames.value]: key,
[fieldNames.label]: title, [fieldNames.label]: title,
}; };
}, },

View File

@ -44,13 +44,12 @@ export default {
components: { components: {
CollectionFieldset, CollectionFieldset,
}, },
useVariables({ id, title, config }, options) { useVariables({ key: name, title, config }, options) {
const compile = useCompile(); const compile = useCompile();
const { getCollectionFields } = useCollectionManager(); const { getCollectionFields } = useCollectionManager();
// const depth = config?.params?.appends?.length // const depth = config?.params?.appends?.length
// ? config?.params?.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) // ? config?.params?.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1)
// : 0; // : 0;
const name = `${id}`;
const [result] = getCollectionFieldOptions({ const [result] = getCollectionFieldOptions({
// collection: config.collection, // collection: config.collection,
// depth: options?.depth ?? depth, // depth: options?.depth ?? depth,
@ -83,7 +82,7 @@ export default {
title: node.title ?? `#${node.id}`, title: node.title ?? `#${node.id}`,
component: CollectionBlockInitializer, component: CollectionBlockInitializer,
collection: node.config.collection, collection: node.config.collection,
dataSource: `{{$jobsMapByNodeId.${node.id}}}`, dataSource: `{{$jobsMapByNodeKey.${node.key}}}`,
}; };
}, },
initializers: {}, initializers: {},

View File

@ -13,7 +13,7 @@ import {
useResourceActionContext, useResourceActionContext,
} from '@nocobase/client'; } from '@nocobase/client';
import { Registry, parse, str2moment } from '@nocobase/utils/client'; import { Registry, parse, str2moment } from '@nocobase/utils/client';
import { App, Button, Dropdown, Input, Tag, message } from 'antd'; import { App, Button, Dropdown, Input, Tag, Tooltip, message } from 'antd';
import React, { useContext, useState } from 'react'; import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AddButton } from '../AddButton'; import { AddButton } from '../AddButton';
@ -199,7 +199,7 @@ export function RemoveButton() {
const template = parse(node.config); const template = parse(node.config);
const refs = template.parameters.filter( const refs = template.parameters.filter(
({ key }) => key.startsWith(`$jobsMapByNodeId.${current.id}.`) || key === `$jobsMapByNodeId.${current.id}`, ({ key }) => key.startsWith(`$jobsMapByNodeKey.${current.key}.`) || key === `$jobsMapByNodeKey.${current.key}`,
); );
return refs.length; return refs.length;
}); });
@ -371,7 +371,33 @@ export function NodeDefaultView(props) {
}, },
[`${instruction.type}_${data.id}`]: { [`${instruction.type}_${data.id}`]: {
type: 'void', type: 'void',
title: data.title, title: (
<div
className={css`
display: flex;
justify-content: space-between;
strong {
font-weight: bold;
}
.ant-tag {
margin-inline-end: 0;
}
code {
font-weight: normal;
}
`}
>
<strong>{data.title}</strong>
<Tooltip title={lang('Variable key of node')}>
<Tag>
<code>{data.key}</code>
</Tag>
</Tooltip>
</div>
),
'x-component': 'Action.Drawer', 'x-component': 'Action.Drawer',
'x-decorator': 'Form', 'x-decorator': 'Form',
'x-decorator-props': { 'x-decorator-props': {

View File

@ -118,7 +118,7 @@ export default {
// const current = useNodeContext(); // const current = useNodeContext();
// const upstreams = useAvailableUpstreams(current); // const upstreams = useAvailableUpstreams(current);
// find target data model by path described in `config.target` // find target data model by path described in `config.target`
// 1. get options from $context/$jobsMapByNodeId // 1. get options from $context/$jobsMapByNodeKey
// 2. route to sub-options and use as loop target options // 2. route to sub-options and use as loop target options
let targetOption: VariableOption = { let targetOption: VariableOption = {
key: 'item', key: 'item',

View File

@ -13,12 +13,17 @@ import { useFlowContext } from '../../FlowContext';
import { parse } from '@nocobase/utils/client'; import { parse } from '@nocobase/utils/client';
function useFlowContextData(dataSource) { function useFlowContextData(dataSource) {
const { execution } = useFlowContext(); const { execution, nodes } = useFlowContext();
const nodesKeyMap = useMemo(() => {
return nodes.reduce((map, node) => Object.assign(map, { [node.id]: node.key }), {});
}, [nodes]);
const data = useMemo( const data = useMemo(
() => ({ () => ({
$context: execution?.context, $context: execution?.context,
$jobsMapByNodeId: (execution?.jobs ?? []).reduce( $jobsMapByNodeKey: (execution?.jobs ?? []).reduce(
(map, job) => Object.assign(map, { [job.nodeId]: job.result }), (map, job) => Object.assign(map, { [nodesKeyMap[job.nodeId]]: job.result }),
{}, {},
), ),
}), }),

View File

@ -85,7 +85,7 @@ export default {
ModeConfig, ModeConfig,
AssigneesSelect, AssigneesSelect,
}, },
useVariables({ id, title, config }, { types, fieldNames = defaultFieldNames }) { useVariables({ key, title, config }, { types, fieldNames = defaultFieldNames }) {
const compile = useCompile(); const compile = useCompile();
const { getCollectionFields } = useCollectionManager(); const { getCollectionFields } = useCollectionManager();
const formKeys = Object.keys(config.forms ?? {}); const formKeys = Object.keys(config.forms ?? {});
@ -119,7 +119,7 @@ export default {
return options.length return options.length
? { ? {
[fieldNames.value]: `${id}`, [fieldNames.value]: key,
[fieldNames.label]: title, [fieldNames.label]: title,
[fieldNames.children]: options, [fieldNames.children]: options,
} }
@ -143,7 +143,7 @@ export default {
title: form.title ?? formKey, title: form.title ?? formKey,
component: CollectionBlockInitializer, component: CollectionBlockInitializer,
collection: form.collection, collection: form.collection,
dataSource: `{{$jobsMapByNodeId.${node.id}.${formKey}}}`, dataSource: `{{$jobsMapByNodeKey.${node.key}.${formKey}}}`,
} as SchemaInitializerItemOptions) } as SchemaInitializerItemOptions)
: null; : null;
}) })

View File

@ -89,13 +89,12 @@ export default {
FilterDynamicComponent, FilterDynamicComponent,
SchemaComponentContext, SchemaComponentContext,
}, },
useVariables({ id, title, config }, options) { useVariables({ key: name, title, config }, options) {
const compile = useCompile(); const compile = useCompile();
const { getCollectionFields } = useCollectionManager(); const { getCollectionFields } = useCollectionManager();
// const depth = config?.params?.appends?.length // const depth = config?.params?.appends?.length
// ? config?.params?.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1) // ? config?.params?.appends.reduce((max, item) => Math.max(max, item.split('.').length), 1)
// : 0; // : 0;
const name = `${id}`;
const [result] = getCollectionFieldOptions({ const [result] = getCollectionFieldOptions({
// collection: config.collection, // collection: config.collection,
// depth: options?.depth ?? depth, // depth: options?.depth ?? depth,
@ -128,7 +127,7 @@ export default {
title: node.title ?? `#${node.id}`, title: node.title ?? `#${node.id}`,
component: CollectionBlockInitializer, component: CollectionBlockInitializer,
collection: node.config.collection, collection: node.config.collection,
dataSource: `{{$jobsMapByNodeId.${node.id}}}`, dataSource: `{{$jobsMapByNodeKey.${node.key}}}`,
}; };
}, },
initializers: {}, initializers: {},

View File

@ -37,7 +37,7 @@ export const defaultFieldNames = { label: 'label', value: 'value', children: 'ch
export const nodesOptions = { export const nodesOptions = {
label: `{{t("Node result", { ns: "${NAMESPACE}" })}}`, label: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
value: '$jobsMapByNodeId', value: '$jobsMapByNodeKey',
useOptions(options: OptionsOfUseVariableOptions) { useOptions(options: OptionsOfUseVariableOptions) {
const current = useNodeContext(); const current = useNodeContext();
const upstreams = useAvailableUpstreams(current); const upstreams = useAvailableUpstreams(current);
@ -76,8 +76,8 @@ export const scopeOptions = {
const subOptions = instruction.useScopeVariables?.(node, options); const subOptions = instruction.useScopeVariables?.(node, options);
if (subOptions) { if (subOptions) {
result.push({ result.push({
key: node.id.toString(), key: node.key,
[fieldNames.value]: node.id.toString(), [fieldNames.value]: node.key,
[fieldNames.label]: node.title ?? `#${node.id}`, [fieldNames.label]: node.title ?? `#${node.id}`,
[fieldNames.children]: subOptions, [fieldNames.children]: subOptions,
}); });

View File

@ -89,6 +89,7 @@ export default {
Advanced: '高级模式', Advanced: '高级模式',
End: '结束', End: '结束',
'Node result': '节点数据', 'Node result': '节点数据',
'Variable key of node': '节点变量标识',
Calculator: '运算', Calculator: '运算',
'Calculate an expression based on a calculation engine and obtain a value as the result. Variables in the upstream nodes can be used in the expression. The expression can be static or dynamic one from an expression collections.': 'Calculate an expression based on a calculation engine and obtain a value as the result. Variables in the upstream nodes can be used in the expression. The expression can be static or dynamic one from an expression collections.':
'基于计算引擎对一个表达式进行计算,并获得一个值作为结果。表达式中可以使用上游节点里的变量。表达式可以是静态的,也可以是表达式表中的动态表达式。', '基于计算引擎对一个表达式进行计算,并获得一个值作为结果。表达式中可以使用上游节点里的变量。表达式可以是静态的,也可以是表达式表中的动态表达式。',

View File

@ -30,7 +30,7 @@ export default class Processor {
nodes: FlowNodeModel[] = []; nodes: FlowNodeModel[] = [];
nodesMap = new Map<number, FlowNodeModel>(); nodesMap = new Map<number, FlowNodeModel>();
jobsMap = new Map<number, JobModel>(); jobsMap = new Map<number, JobModel>();
jobsMapByNodeId: { [key: number]: any } = {}; jobsMapByNodeKey: { [key: string]: any } = {};
constructor( constructor(
public execution: ExecutionModel, public execution: ExecutionModel,
@ -61,8 +61,9 @@ export default class Processor {
private makeJobs(jobs: Array<JobModel>) { private makeJobs(jobs: Array<JobModel>) {
jobs.forEach((job) => { jobs.forEach((job) => {
this.jobsMap.set(job.id, job); this.jobsMap.set(job.id, job);
// TODO: should consider cycle, and from previous job
this.jobsMapByNodeId[job.nodeId] = job.result; const node = this.nodesMap.get(job.nodeId);
this.jobsMapByNodeKey[node.key] = job.result;
}); });
} }
@ -252,7 +253,9 @@ export default class Processor {
); );
} }
this.jobsMap.set(job.id, job); this.jobsMap.set(job.id, job);
this.jobsMapByNodeId[job.nodeId] = job.result;
const node = this.nodesMap.get(job.nodeId);
this.jobsMapByNodeKey[node.key] = job.result;
return job; return job;
} }
@ -339,13 +342,13 @@ export default class Processor {
for (let n = this.findBranchParentNode(node); n; n = this.findBranchParentNode(n)) { for (let n = this.findBranchParentNode(node); n; n = this.findBranchParentNode(n)) {
const instruction = this.options.plugin.instructions.get(n.type); const instruction = this.options.plugin.instructions.get(n.type);
if (typeof instruction.getScope === 'function') { if (typeof instruction.getScope === 'function') {
$scopes[n.id] = instruction.getScope(n, this.jobsMapByNodeId[n.id], this); $scopes[n.id] = $scopes[n.key] = instruction.getScope(n, this.jobsMapByNodeKey[n.key], this);
} }
} }
return { return {
$context: this.execution.context, $context: this.execution.context,
$jobsMapByNodeId: this.jobsMapByNodeId, $jobsMapByNodeKey: this.jobsMapByNodeKey,
$system: systemFns, $system: systemFns,
$scopes, $scopes,
}; };

View File

@ -260,7 +260,7 @@ describe('workflow > actions > workflows', () => {
type: 'calculation', type: 'calculation',
config: { config: {
engine: 'math.js', engine: 'math.js',
expression: `{{$jobsMapByNodeId.${n1.id}.data.read}} + {{$jobsMapByNodeId.${n1.id}.data.read}}`, expression: `{{$jobsMapByNodeKey.${n1.key}.data.read}} + {{$jobsMapByNodeKey.${n1.key}.data.read}}`,
}, },
upstreamId: n1.id, upstreamId: n1.id,
}); });
@ -279,11 +279,12 @@ describe('workflow > actions > workflows', () => {
const n1_2 = w2.nodes.find((n) => !n.upstreamId); const n1_2 = w2.nodes.find((n) => !n.upstreamId);
const n2_2 = w2.nodes.find((n) => !n.downstreamId); const n2_2 = w2.nodes.find((n) => !n.downstreamId);
expect(n1_2.key).toBe(n1.key);
expect(n1_2.type).toBe('echo'); expect(n1_2.type).toBe('echo');
expect(n2_2.type).toBe('calculation'); expect(n2_2.type).toBe('calculation');
expect(n2_2.config).toMatchObject({ expect(n2_2.config).toMatchObject({
engine: 'math.js', engine: 'math.js',
expression: `{{$jobsMapByNodeId.${n1_2.id}.data.read}} + {{$jobsMapByNodeId.${n1_2.id}.data.read}}`, expression: `{{$jobsMapByNodeKey.${n1_2.key}.data.read}} + {{$jobsMapByNodeKey.${n1_2.key}.data.read}}`,
}); });
await w2.update({ enabled: true }); await w2.update({ enabled: true });
@ -299,47 +300,6 @@ describe('workflow > actions > workflows', () => {
expect(calculation.result).toBe(2); expect(calculation.result).toBe(2);
}); });
it('revision with using of deleted nodes', async () => {
const w1 = await WorkflowModel.create({
enabled: true,
type: 'collection',
config: {
mode: 1,
collection: 'posts',
},
});
const n2 = await w1.createNode({
type: 'calculation',
config: {
calculation: {
calculator: 'add',
operands: [
{
type: '$jobsMapByNodeId',
options: {
nodeId: 0,
path: 'data.read',
},
},
{
value: `{{$jobsMapByNodeId.0.data.read}}`,
},
],
},
},
});
const { status } = await agent.resource(`workflows`).revision({
filterByTk: w1.id,
filter: {
key: w1.key,
},
});
expect(status).toBe(400);
});
it('duplicate workflow', async () => { it('duplicate workflow', async () => {
const w1 = await WorkflowModel.create({ const w1 = await WorkflowModel.create({
enabled: true, enabled: true,

View File

@ -248,7 +248,7 @@ describe('workflow > instructions > aggregate', () => {
associated: true, associated: true,
association: { association: {
name: 'posts', name: 'posts',
associatedKey: `{{$jobsMapByNodeId.${n1.id}.id}}`, associatedKey: `{{$jobsMapByNodeKey.${n1.key}.id}}`,
associatedCollection: 'tags', associatedCollection: 'tags',
}, },
params: { params: {
@ -266,7 +266,7 @@ describe('workflow > instructions > aggregate', () => {
associated: true, associated: true,
association: { association: {
name: 'posts', name: 'posts',
associatedKey: `{{$jobsMapByNodeId.${n1.id}.id}}`, associatedKey: `{{$jobsMapByNodeKey.${n1.key}.id}}`,
associatedCollection: 'tags', associatedCollection: 'tags',
}, },
params: { params: {

View File

@ -88,7 +88,7 @@ describe('workflow > instructions > calculation', () => {
expect(job.result).toBe(2); expect(job.result).toBe(2);
}); });
it('$jobsMapByNodeId', async () => { it('$jobsMapByNodeKey', async () => {
const n1 = await workflow.createNode({ const n1 = await workflow.createNode({
type: 'echo', type: 'echo',
}); });
@ -97,7 +97,7 @@ describe('workflow > instructions > calculation', () => {
type: 'calculation', type: 'calculation',
config: { config: {
engine: 'math.js', engine: 'math.js',
expression: `{{$jobsMapByNodeId.${n1.id}.data.read}} + 1`, expression: `{{$jobsMapByNodeKey.${n1.key}.data.read}} + 1`,
}, },
upstreamId: n1.id, upstreamId: n1.id,
}); });
@ -231,7 +231,7 @@ describe('workflow > instructions > calculation', () => {
const n2 = await workflow.createNode({ const n2 = await workflow.createNode({
type: 'calculation', type: 'calculation',
config: { config: {
dynamic: `{{$jobsMapByNodeId.${n1.id}}}`, dynamic: `{{$jobsMapByNodeKey.${n1.key}}}`,
scope: '{{$context.data}}', scope: '{{$context.data}}',
}, },
upstreamId: n1.id, upstreamId: n1.id,

View File

@ -867,7 +867,7 @@ describe('workflow > instructions > manual', () => {
type: 'calculation', type: 'calculation',
config: { config: {
engine: 'math.js', engine: 'math.js',
expression: `{{$jobsMapByNodeId.${n1.id}.f1.number}} + 1`, expression: `{{$jobsMapByNodeKey.${n1.key}.f1.number}} + 1`,
}, },
upstreamId: n1.id, upstreamId: n1.id,
}); });

View File

@ -130,7 +130,7 @@ describe('workflow > instructions > query', () => {
collection: 'posts', collection: 'posts',
params: { params: {
filter: { filter: {
title: `{{$jobsMapByNodeId.${n1.id}.data.title}}`, title: `{{$jobsMapByNodeKey.${n1.key}.data.title}}`,
}, },
}, },
}, },

View File

@ -78,7 +78,7 @@ describe('workflow > instructions > update', () => {
collection: 'posts', collection: 'posts',
params: { params: {
filter: { filter: {
id: `{{$jobsMapByNodeId.${n1.id}.id}}`, id: `{{$jobsMapByNodeKey.${n1.key}.id}}`,
}, },
values: { values: {
title: 'changed', title: 'changed',

View File

@ -1,6 +1,8 @@
import actions, { Context, utils } from '@nocobase/actions'; import actions, { Context, utils } from '@nocobase/actions';
import { Op, Repository } from '@nocobase/database'; import { Op, Repository } from '@nocobase/database';
import Plugin from '../Plugin';
export async function update(context: Context, next) { export async function update(context: Context, next) {
const repository = utils.getRepositoryFromParams(context) as Repository; const repository = utils.getRepositoryFromParams(context) as Repository;
const { filterByTk, values } = context.action.params; const { filterByTk, values } = context.action.params;
@ -51,51 +53,8 @@ export async function destroy(context: Context, next) {
next(); 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':
if (value.type === '$jobsMapByNodeId') {
return {
...value,
options: {
...value.options,
nodeId: oldToNew.get(value.options?.nodeId)?.id,
},
};
}
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|{{\$scopes)\.([\w-]+)/g, (_, jobVar, oldNodeId) => {
const newNode = oldToNew.get(Number.parseInt(oldNodeId, 10));
if (!newNode) {
throw new Error('node configurated for result is not existed');
}
return `${jobVar}.${newNode.id}`;
});
default:
return value;
}
}
return migrate(config);
}
export async function revision(context: Context, next) { export async function revision(context: Context, next) {
const plugin = context.app.getPlugin('workflow') as Plugin;
const { db } = context; const { db } = context;
const repository = utils.getRepositoryFromParams(context); const repository = utils.getRepositoryFromParams(context);
const { filterByTk, filter = {}, values = {} } = context.action.params; const { filterByTk, filter = {}, values = {} } = context.action.params;
@ -109,6 +68,8 @@ export async function revision(context: Context, next) {
transaction, transaction,
}); });
const trigger = plugin.triggers.get(origin.type);
const revisionData = filter.key const revisionData = filter.key
? { ? {
key: filter.key, key: filter.key,
@ -121,9 +82,12 @@ export async function revision(context: Context, next) {
values: { values: {
title: `${origin.title} copy`, title: `${origin.title} copy`,
description: origin.description, description: origin.description,
type: origin.type,
config: origin.config,
...revisionData, ...revisionData,
type: origin.type,
config:
typeof trigger.duplicateConfig === 'function'
? await trigger.duplicateConfig(origin, { transaction })
: origin.config,
}, },
transaction, transaction,
}); });
@ -136,10 +100,15 @@ export async function revision(context: Context, next) {
const oldToNew = new Map(); const oldToNew = new Map();
const newToOld = new Map(); const newToOld = new Map();
for await (const node of origin.nodes) { for await (const node of origin.nodes) {
const instruction = plugin.instructions.get(node.type);
const newNode = await instance.createNode( const newNode = await instance.createNode(
{ {
type: node.type, type: node.type,
config: node.config, key: node.key,
config:
typeof instruction.duplicateConfig === 'function'
? await instruction.duplicateConfig(node, { transaction })
: node.config,
title: node.title, title: node.title,
branchIndex: node.branchIndex, branchIndex: node.branchIndex,
}, },
@ -154,18 +123,11 @@ export async function revision(context: Context, next) {
const oldNode = originalNodesMap.get(oldId); const oldNode = originalNodesMap.get(oldId);
const newUpstream = oldNode.upstreamId ? oldToNew.get(oldNode.upstreamId) : null; const newUpstream = oldNode.upstreamId ? oldToNew.get(oldNode.upstreamId) : null;
const newDownstream = oldNode.downstreamId ? oldToNew.get(oldNode.downstreamId) : null; const newDownstream = oldNode.downstreamId ? oldToNew.get(oldNode.downstreamId) : null;
let migratedConfig;
try {
migratedConfig = migrateConfig(oldNode.config, oldToNew);
} catch (err) {
return context.throw(400, err.message);
}
await newNode.update( await newNode.update(
{ {
upstreamId: newUpstream?.id ?? null, upstreamId: newUpstream?.id ?? null,
downstreamId: newDownstream?.id ?? null, downstreamId: newDownstream?.id ?? null,
config: migratedConfig,
}, },
{ transaction }, { transaction },
); );

View File

@ -5,6 +5,10 @@ export default {
duplicator: 'required', duplicator: 'required',
name: 'flow_nodes', name: 'flow_nodes',
fields: [ fields: [
{
type: 'uid',
name: 'key',
},
{ {
type: 'string', type: 'string',
name: 'title', name: 'title',

View File

@ -1,6 +1,7 @@
import path from 'path'; import path from 'path';
import { requireModule } from '@nocobase/utils'; import { requireModule } from '@nocobase/utils';
import { Transactionable } from '@nocobase/database';
import Plugin from '..'; import Plugin from '..';
import Processor from '../Processor'; import Processor from '../Processor';
@ -25,7 +26,9 @@ export interface Instruction {
// for start node in main flow (or branch) to resume when manual sub branch triggered // for start node in main flow (or branch) to resume when manual sub branch triggered
resume?: Runner; resume?: Runner;
getScope?: (node: FlowNodeModel, job: any, processor: Processor) => any; getScope?: (node: FlowNodeModel, data: any, processor: Processor) => any;
duplicateConfig?: (node: FlowNodeModel, options: Transactionable) => object | Promise<object>;
} }
type InstructionConstructor<T> = { new (p: Plugin): T }; type InstructionConstructor<T> = { new (p: Plugin): T };

View File

@ -30,22 +30,6 @@ export default class extends Migration {
}, },
{ transaction }, { transaction },
); );
await queryInterface.changeColumn(
db.getCollection('executions').model.getTableName(),
'context',
{
type: DataTypes.JSON,
},
{ transaction },
);
await queryInterface.changeColumn(
db.getCollection('jobs').model.getTableName(),
'result',
{
type: DataTypes.JSON,
},
{ transaction },
);
}); });
} }
} }

View File

@ -2,12 +2,12 @@ import { Migration } from '@nocobase/server';
export default class extends Migration { export default class extends Migration {
async up() { async up() {
const match = await this.app.version.satisfies('<0.11.0-alpha.2'); const match = await this.app.version.satisfies('<0.14.0-alpha.8');
if (!match) { if (!match) {
return; return;
} }
const { db } = this.context; const { db } = this.context;
const WorkflowRepo = db.getRepository('flow_nodes'); const WorkflowRepo = db.getRepository('workflows');
await db.sequelize.transaction(async (transaction) => { await db.sequelize.transaction(async (transaction) => {
const workflows = await WorkflowRepo.find({ const workflows = await WorkflowRepo.find({
transaction, transaction,
@ -16,6 +16,9 @@ export default class extends Migration {
await workflows.reduce( await workflows.reduce(
(promise, workflow) => (promise, workflow) =>
promise.then(() => { promise.then(() => {
if (!workflow.useTransaction) {
return;
}
workflow.set('options', { workflow.set('options', {
useTransaction: workflow.get('useTransaction'), useTransaction: workflow.get('useTransaction'),
}); });

View File

@ -0,0 +1,65 @@
import { DataTypes } from 'sequelize';
import { Migration } from '@nocobase/server';
import { uid } from '@nocobase/utils';
function migrateNodeConfig(config = {}, nodesMap) {
Object.keys(config).forEach((key) => {
const valueType = typeof config[key];
if (valueType === 'string') {
config[key] = config[key]
.replace(/{{\s*\$jobsMapByNodeId\.(\d+)(\.[^}]+)?\s*}}/g, (matched, id, path) => {
return `{{$jobsMapByNodeKey.${nodesMap[id].key}${path || ''}}}`;
})
.replace(/{{\s*\$scopes\.(\d+)(\.[^}]+)?\s*}}/g, (matched, id, path) => {
return `{{$scopes.${nodesMap[id].key}${path || ''}}}`;
});
} else if (valueType === 'object' && config[key]) {
migrateNodeConfig(config[key], nodesMap);
}
});
return config;
}
export default class extends Migration {
async up() {
const match = await this.app.version.satisfies('<0.14.0-alpha.8');
if (!match) {
return;
}
const { db } = this.context;
const NodeRepo = db.getRepository('flow_nodes');
const { key } = await this.queryInterface.describeTable('flow_nodes');
await db.sequelize.transaction(async (transaction) => {
if (!key) {
await this.queryInterface.addColumn('flow_nodes', 'key', DataTypes.STRING, {
transaction,
});
}
const nodes = await NodeRepo.find({
transaction,
});
const nodesMap = nodes.reduce((map, node) => {
map[node.id] = node;
if (!node.get('key')) {
node.set('key', uid());
}
return map;
}, {});
await nodes.reduce(
(promise, node) =>
promise.then(() => {
node.set('config', migrateNodeConfig(node.config, nodesMap));
node.changed('config', true);
return node.save({
silent: true,
transaction,
});
}),
Promise.resolve(),
);
});
}
}

View File

@ -1,6 +1,8 @@
import { requireModule } from '@nocobase/utils';
import path from 'path'; import path from 'path';
import { requireModule } from '@nocobase/utils';
import { Transactionable } from '@nocobase/database';
import Plugin from '..'; import Plugin from '..';
import type { WorkflowModel } from '../types'; import type { WorkflowModel } from '../types';
@ -8,6 +10,7 @@ export abstract class Trigger {
constructor(public readonly plugin: Plugin) {} constructor(public readonly plugin: Plugin) {}
abstract on(workflow: WorkflowModel): void; abstract on(workflow: WorkflowModel): void;
abstract off(workflow: WorkflowModel): void; abstract off(workflow: WorkflowModel): void;
duplicateConfig?(workflow: WorkflowModel, options: Transactionable): object | Promise<object>;
} }
export default function <T extends Trigger>(plugin, more: { [key: string]: { new (p: Plugin): T } } = {}) { export default function <T extends Trigger>(plugin, more: { [key: string]: { new (p: Plugin): T } } = {}) {