Feat: plugin-workflow (#288)

* refactor(plugin-workflow): change variable type keys to align to backend

* feat(plugin-workflow): add more configuration fields for model trigger

* refactor(plugin-workflow): improve transaction and error handling

* chore(plugin-workflow): add note for todos

* fix(plugin-workflow): fix calculator onchange handlers

* fix(plugin-workflow): fix transaction

* refactor(plugin-workflow): change transaction to configurable and default to false in execution due to defective solution

* fix(plugin-workflow): fix operand type to be consolidated and compatible with legacies
This commit is contained in:
Junyi 2022-04-14 00:05:13 +08:00 committed by GitHub
parent 9ffe6418f9
commit fda9c71d66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 167 additions and 66 deletions

View File

@ -75,25 +75,35 @@ const ConstantTypes = {
string: { string: {
title: '字符串', title: '字符串',
value: 'string', value: 'string',
component({ onChange, type, options, ...props }) { component({ onChange, type, options, value }) {
return <Input {...props} onChange={ev => onChange(ev.target.value)} />; return (
<Input
value={value}
onChange={ev => onChange({ value: ev.target.value, type, options })}
/>
);
}, },
default: '' default: ''
}, },
number: { number: {
title: '数字', title: '数字',
value: 'number', value: 'number',
component({ type, options, ...props }) { component({ onChange, type, options, value }) {
return <InputNumber {...props} />; return (
<InputNumber
value={value}
onChange={v => onChange({ value: v, type, options })}
/>
);
}, },
default: 0 default: 0
}, },
boolean: { boolean: {
title: '逻辑值', title: '逻辑值',
value: 'boolean', value: 'boolean',
component({ type, options, ...props }) { component({ onChange, type, options, value }) {
return ( return (
<Select {...props}> <Select value={value} onChange={v => onChange({ value: v, type, options })}>
<Select.Option value={true}></Select.Option> <Select.Option value={true}></Select.Option>
<Select.Option value={false}></Select.Option> <Select.Option value={false}></Select.Option>
</Select> </Select>
@ -104,8 +114,8 @@ const ConstantTypes = {
// date: { // date: {
// title: '日期', // title: '日期',
// value: 'date', // value: 'date',
// component({ type, options, ...props }) { // component({ onChange, type, options, value }) {
// return <DatePicker {...props} />; // return <DatePicker value={value} onChange={v => onChange({ value: v, type, options })}/>;
// }, // },
// default: new Date() // default: new Date()
// } // }
@ -119,13 +129,16 @@ export const VariableTypes = {
value: item.value, value: item.value,
label: item.title label: item.title
})), })),
component({ options: { type } = { type: 'string' } }) { component({ options = { type: 'string' } }) {
return ConstantTypes[type]?.component ?? NullRender; return ConstantTypes[options.type]?.component ?? NullRender;
}, },
appendTypeValue({ options = { type: 'string' } }) { appendTypeValue({ options = { type: 'string' } }) {
return options?.type ? [options.type] : []; return options?.type ? [options.type] : [];
}, },
onTypeChange(old, [type, optionsType], onChange) { onTypeChange(old, [type, optionsType], onChange) {
if (old?.options?.type === optionsType) {
return;
}
const { default: value } = ConstantTypes[optionsType]; const { default: value } = ConstantTypes[optionsType];
onChange({ onChange({
value, value,
@ -137,9 +150,9 @@ export const VariableTypes = {
return { path }; return { path };
} }
}, },
job: { $jobsMapByNodeId: {
title: '节点数据', title: '节点数据',
value: 'job', value: '$jobsMapByNodeId',
options() { options() {
const node = useNodeContext(); const node = useNodeContext();
const stack = []; const stack = [];
@ -182,7 +195,7 @@ export const VariableTypes = {
return { nodeId, path: path.join('.') }; return { nodeId, path: path.join('.') };
}, },
stringify({ options }) { stringify({ options }) {
const stack = ['job']; const stack = ['$jobsMapByNodeId'];
if (options.nodeId) { if (options.nodeId) {
stack.push(options.nodeId); stack.push(options.nodeId);
if (options.path) { if (options.path) {
@ -192,9 +205,9 @@ export const VariableTypes = {
return `{{${stack.join('.')}}}`; return `{{${stack.join('.')}}}`;
} }
}, },
context: { $context: {
title: '触发数据', title: '触发数据',
value: 'context', value: '$context',
component() { component() {
const { workflow } = useFlowContext(); const { workflow } = useFlowContext();
const trigger = triggers.get(workflow.type); const trigger = triggers.get(workflow.type);
@ -204,7 +217,7 @@ export const VariableTypes = {
return { path: path.join('.') }; return { path: path.join('.') };
}, },
stringify({ options }) { stringify({ options }) {
const stack = ['context']; const stack = ['$context'];
if (options?.path) { if (options?.path) {
stack.push(options.path); stack.push(options.path);
} }

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React from 'react';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { action } from '@formily/reactive'; import { action } from '@formily/reactive';
import { Cascader, Select } from 'antd'; import { Select } from 'antd';
import { t } from 'i18next'; import { t } from 'i18next';
import { css } from '@emotion/css'; import { css } from '@emotion/css';

View File

@ -6,6 +6,8 @@ import { Select } from 'antd';
import { useCollectionManager } from '../../collection-manager'; import { useCollectionManager } from '../../collection-manager';
import { useFlowContext } from '../WorkflowCanvas'; import { useFlowContext } from '../WorkflowCanvas';
import { BaseTypeSet } from '../calculators'; import { BaseTypeSet } from '../calculators';
import { useForm } from '@formily/react';
import { useCollectionFilterOptions } from '../../collection-manager/action-hooks';
export default { export default {
title: '数据表事件', title: '数据表事件',
@ -20,11 +22,36 @@ export default {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Select', 'x-component': 'Select',
}, },
// mode: { mode: {
// type: 'number', type: 'number',
// title: '触发时机', title: '触发时机',
// name: 'mode', name: 'mode',
// } 'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
options: [
{ value: 1, label: '新增数据' },
{ value: 2, label: '更新数据' },
{ value: 3, label: '新增或更新数据' },
{ value: 4, label: '删除数据' }
]
}
},
filter: {
type: 'object',
title: '满足条件',
description: 'not supported by server yet',
name: 'filter',
'x-decorator': 'FormItem',
'x-component': 'Filter',
'x-component-props': {
useProps() {
const { values } = useForm();
const options = useCollectionFilterOptions(values.collection);
return { options };
}
}
}
}, },
scope: { scope: {
useAsyncDataSource() { useAsyncDataSource() {
@ -45,7 +72,7 @@ export default {
const collection = collections.find(item => item.name === workflow.config.collection) ?? { fields: [] }; const collection = collections.find(item => item.name === workflow.config.collection) ?? { fields: [] };
return ( return (
<Select value={options.path} placeholder="选择字段" onChange={path => { <Select value={options?.path.replace(/^data\./, '')} placeholder="选择字段" onChange={path => {
onChange({ type, options: { ...options, path: `data.${path}` } }); onChange({ type, options: { ...options, path: `data.${path}` } });
}}> }}>
{collection.fields {collection.fields

View File

@ -119,7 +119,7 @@ describe('execution', () => {
expect(jobs.length).toEqual(1); expect(jobs.length).toEqual(1);
const { status, result } = jobs[0].get(); const { status, result } = jobs[0].get();
expect(status).toEqual(JOB_STATUS.REJECTED); expect(status).toEqual(JOB_STATUS.REJECTED);
expect(result).toBe('Error: definite error'); expect(result.message).toBe('definite error');
}); });
}); });
@ -185,7 +185,7 @@ describe('execution', () => {
const jobs = await execution.getJobs(); const jobs = await execution.getJobs();
expect(jobs.length).toEqual(1); expect(jobs.length).toEqual(1);
expect(jobs[0].status).toEqual(JOB_STATUS.REJECTED); expect(jobs[0].status).toEqual(JOB_STATUS.REJECTED);
expect(jobs[0].result).toEqual('Error: input failed'); expect(jobs[0].result.message).toEqual('input failed');
}); });
}); });

View File

@ -60,7 +60,7 @@ describe('workflow > instructions > calculation', () => {
calculator: 'add', calculator: 'add',
operands: [ operands: [
{ value: 1 }, { value: 1 },
{ type: 'context', options: { path: 'data.read' } } { type: '$context', options: { path: 'data.read' } }
] ]
} }
} }
@ -104,7 +104,7 @@ describe('workflow > instructions > calculation', () => {
calculator: 'add', calculator: 'add',
operands: [ operands: [
{ value: 1 }, { value: 1 },
{ type: 'job', options: { nodeId: n1.id, path: 'data.read' } } { type: '$jobsMapByNodeId', options: { nodeId: n1.id, path: 'data.read' } }
] ]
} }
}, },
@ -177,7 +177,7 @@ describe('workflow > instructions > calculation', () => {
operands: [ operands: [
{ value: 1 }, { value: 1 },
{ {
type: 'calculation', type: '$calculation',
options: { options: {
calculator: 'minus', calculator: 'minus',
operands: [ operands: [

View File

@ -9,7 +9,7 @@ export const calculators = new Registry<Function>();
export default calculators; export default calculators;
export type OperandType = 'context' | 'input' | 'job' | 'calculation'; export type OperandType = '$context' | '$input' | '$jobsMapByNodeId' | '$calculation';
export type ObjectGetterOptions = { export type ObjectGetterOptions = {
path?: string path?: string
@ -30,22 +30,22 @@ export type ConstantOperand = {
}; };
export type ContextOperand = { export type ContextOperand = {
type: 'context'; type: '$context';
options: ObjectGetterOptions; options: ObjectGetterOptions;
}; };
export type InputOperand = { export type InputOperand = {
type: 'input'; type: '$input';
options: ObjectGetterOptions; options: ObjectGetterOptions;
}; };
export type JobOperand = { export type JobOperand = {
type: 'job'; type: '$jobsMapByNodeId';
options: JobGetterOptions; options: JobGetterOptions;
}; };
export type Calculation = { export type Calculation = {
type: 'calculation'; type: '$calculation';
options: CalculationOptions options: CalculationOptions
}; };
@ -68,22 +68,22 @@ export function calculate(operand: Operand, lastJob: JobModel, execution: Execut
switch (operand.type) { switch (operand.type) {
// @Deprecated // @Deprecated
// from execution context // from execution context
case 'context': case '$context':
return get(execution.context, operand.options.path); return get(execution.context, operand.options.path);
// @Deprecated // @Deprecated
// from last job (or input job) // from last job (or input job)
case 'input': case '$input':
return lastJob ?? get(lastJob.result, operand.options.path); return lastJob ?? get(lastJob.result, operand.options.path);
// @Deprecated // @Deprecated
// from job in execution // from job in execution
case 'job': case '$jobsMapByNodeId':
// assume jobs have been fetched from execution before // assume jobs have been fetched from execution before
const job = execution.jobsMapByNodeId[operand.options.nodeId]; const job = execution.jobsMapByNodeId[operand.options.nodeId];
return job && get(job, operand.options.path); return job && get(job, operand.options.path);
case 'calculation': case '$calculation':
const fn = calculators.get(operand.options.calculator); const fn = calculators.get(operand.options.calculator);
if (!fn) { if (!fn) {
throw new Error(`no calculator function registered for "${operand.options.calculator}"`); throw new Error(`no calculator function registered for "${operand.options.calculator}"`);

View File

@ -11,6 +11,12 @@ export default {
name: 'workflow', name: 'workflow',
title: '所属工作流' title: '所属工作流'
}, },
{
type: 'boolean',
name: 'useTransaction',
title: '使用事务',
defaultValue: false
},
{ {
interface: 'linkTo', interface: 'linkTo',
type: 'hasMany', type: 'hasMany',

View File

@ -39,6 +39,13 @@ export default {
required: true, required: true,
defaultValue: {} defaultValue: {}
}, },
{
interface: 'boolean',
type: 'boolean',
title: '使用事务',
name: 'useTransaction',
defaultValue: false
},
{ {
interface: 'linkTo', interface: 'linkTo',
type: 'hasMany', type: 'hasMany',

View File

@ -35,7 +35,7 @@ export default {
const result = calculation const result = calculation
? calculate({ ? calculate({
type: 'calculation', type: '$calculation',
options: execution.getParsedValue(calculation) options: execution.getParsedValue(calculation)
}, prevJob, execution) }, prevJob, execution)
: null; : null;

View File

@ -9,8 +9,9 @@ export default {
} = this.config; } = this.config;
const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection); const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection);
const options = execution.getParsedValue(params);
const result = await repo.create({ const result = await repo.create({
...execution.getParsedValue(params), ...options,
transaction: execution.transaction transaction: execution.transaction
}); });

View File

@ -9,8 +9,9 @@ export default {
} = this.config; } = this.config;
const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection); const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection);
const options = execution.getParsedValue(params);
const result = await repo.destroy({ const result = await repo.destroy({
...execution.getParsedValue(params), ...options,
transaction: execution.transaction transaction: execution.transaction
}); });

View File

@ -10,8 +10,9 @@ export default {
} = this.config; } = this.config;
const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection); const repo = (<typeof FlowNodeModel>this.constructor).database.getRepository(collection);
const options = execution.getParsedValue(params);
const result = await repo.update({ const result = await repo.update({
...execution.getParsedValue(params), ...options,
transaction: execution.transaction transaction: execution.transaction
}); });

View File

@ -19,6 +19,8 @@ export default class ExecutionModel extends Model {
declare title: string; declare title: string;
declare context: any; declare context: any;
declare status: number; declare status: number;
// NOTE: this duplicated column is for transaction in preparing cycle from workflow
declare useTransaction: boolean;
declare createdAt: Date; declare createdAt: Date;
declare updatedAt: Date; declare updatedAt: Date;
@ -71,10 +73,25 @@ export default class ExecutionModel extends Model {
}); });
} }
getTransaction() {
const { sequelize } = (<typeof WorkflowModel>this.constructor).database;
// @ts-ignore
if (!this.useTransaction || sequelize.options.dialect === 'sqlite') {
return undefined;
}
const { options } = this;
// @ts-ignore
return options.transaction && !options.transaction.finished
? options.transaction
: sequelize.transaction();
}
async prepare(options, commit = false) { async prepare(options, commit = false) {
this.options = options || {}; this.options = options || {};
const { transaction = await (<typeof ExecutionModel>this.constructor).database.sequelize.transaction() } = // @ts-ignore
this.options; const transaction = await this.getTransaction()
this.transaction = transaction; this.transaction = transaction;
if (!this.workflow) { if (!this.workflow) {
@ -122,7 +139,8 @@ export default class ExecutionModel extends Model {
} }
private async commit() { private async commit() {
if (!this.options || !this.options.transaction) { // @ts-ignore
if (this.transaction && (!this.options.transaction || this.options.transaction.finished)) {
await this.transaction.commit(); await this.transaction.commit();
} }
} }
@ -138,7 +156,9 @@ export default class ExecutionModel extends Model {
} catch (err) { } catch (err) {
// for uncaught error, set to rejected // for uncaught error, set to rejected
job = { job = {
result: err instanceof Error ? err.toString() : err, result: err instanceof Error
? { message: err.message, stack: process.env.NODE_ENV === 'production' ? [] : err.stack }
: err,
status: JOB_STATUS.REJECTED, status: JOB_STATUS.REJECTED,
}; };
// if previous job is from resuming // if previous job is from resuming
@ -148,7 +168,7 @@ export default class ExecutionModel extends Model {
} }
} }
let savedJob: JobModel; let savedJob;
// TODO(optimize): many checking of resuming or new could be improved // TODO(optimize): many checking of resuming or new could be improved
// could be implemented separately in exec() / resume() // could be implemented separately in exec() / resume()
if (job instanceof Model) { if (job instanceof Model) {
@ -162,7 +182,7 @@ export default class ExecutionModel extends Model {
}); });
} }
if (savedJob.get('status') === JOB_STATUS.RESOLVED && node.downstream) { if (savedJob.status === JOB_STATUS.RESOLVED && node.downstream) {
// run next node // run next node
return this.run(node.downstream, savedJob); return this.run(node.downstream, savedJob);
} }

View File

@ -15,6 +15,7 @@ export default class WorkflowModel extends Model {
declare description?: string; declare description?: string;
declare type: string; declare type: string;
declare config: any; declare config: any;
declare useTransaction: boolean;
declare createdAt: Date; declare createdAt: Date;
declare updatedAt: Date; declare updatedAt: Date;
@ -46,30 +47,48 @@ export default class WorkflowModel extends Model {
return `workflow-${this.get('id')}`; return `workflow-${this.get('id')}`;
} }
getTransaction(options) {
if (!this.useTransaction) {
return undefined;
}
return options.transaction && !options.transaction.finished
? options.transaction
: (<typeof WorkflowModel>this.constructor).database.sequelize.transaction();
}
async toggle(enable?: boolean) { async toggle(enable?: boolean) {
const type = this.get('type'); const type = this.get('type');
const { on, off } = triggers.get(type); const { on, off } = triggers.get(type);
if (typeof enable !== 'undefined' ? enable : this.get('enabled')) { if (typeof enable !== 'undefined' ? enable : this.get('enabled')) {
on.call(this, this.start.bind(this)); on.call(this, this.trigger.bind(this));
} else { } else {
off.call(this); off.call(this);
} }
} }
async start(context: Object, options) { async trigger(context: Object, options) {
// `null` means not to trigger // `null` means not to trigger
if (context === null) { if (context === null) {
return; return;
} }
const transaction = await this.getTransaction(options);
const execution = await this.createExecution({ const execution = await this.createExecution({
context, context,
status: EXECUTION_STATUS.STARTED, status: EXECUTION_STATUS.STARTED,
}); useTransaction: this.useTransaction
}, { transaction });
execution.workflow = this; execution.workflow = this;
await execution.start(options); await execution.start({ transaction });
if (transaction && (!options.transaction || options.transaction.finished)) {
await transaction.commit();
}
return execution; return execution;
} }
} }

View File

@ -22,12 +22,18 @@ export default {
name: 'model', name: 'model',
on(this: WorkflowModel, callback: Function) { on(this: WorkflowModel, callback: Function) {
const { database } = <typeof WorkflowModel>this.constructor; const { database } = <typeof WorkflowModel>this.constructor;
const { collection, mode } = this.config; const { collection, mode, filter } = this.config;
const Collection = database.getCollection(collection); const Collection = database.getCollection(collection);
if (!Collection) { if (!Collection) {
return; return;
} }
const handler = (data: any, options) => callback({ data: data.get() }, options); // async function, should return promise
const handler = (data: any, options) => {
if (filter) {
// TODO: check all conditions in filter against data
}
return callback({ data: data.get() }, options);
};
// TODO: duplication when mode change should be considered // TODO: duplication when mode change should be considered
for (let [key, event] of MODE_BITMAP_EVENTS.entries()) { for (let [key, event] of MODE_BITMAP_EVENTS.entries()) {
if (mode & key) { if (mode & key) {