Fix(plugin-workflow): client refactor (#1163)

* fix(plugin-workflow): avoid to delete using node

* fix(plugin-workflow): refactor operand

* fix(plugin-workflow): simplify code

* fix(plugin-workflow): fix condition calculator
This commit is contained in:
Junyi 2022-12-06 02:18:40 -08:00 committed by GitHub
parent ca0621b517
commit a951d49f55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 247 additions and 132 deletions

View File

@ -141,7 +141,38 @@ function getGroupCalculators(group) {
const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/;
export function parseStringValue(value: string, Types) {
function getType(value) {
if (value == null) {
return 'null';
}
const type = typeof value;
switch (type) {
case 'object':
break;
default:
// 'boolean'
// 'number'
// 'bigint'
// 'string'
// 'symbol'
return type;
}
if (value instanceof Date) {
return 'date';
}
return 'object';
}
export function parseValue(value: any, Types): {
type: string;
value?: any;
options?: any;
} {
const valueType = getType(value);
if (valueType !== 'string') {
return { type: 'constant', value, options: { type: valueType } }
}
const matcher = value.match(JT_VALUE_RE);
if (!matcher) {
return { type: 'constant', value, options: { type: 'string' } };
@ -158,14 +189,20 @@ export function parseStringValue(value: string, Types) {
export const BaseTypeSet = new Set(['boolean', 'number', 'string', 'date']);
const ConstantTypes = {
null: {
title: `{{t("Null", { ns: "${NAMESPACE}" })}}`,
value: 'null',
default: null,
component: NullRender,
},
string: {
title: `{{t("String", { ns: "${NAMESPACE}" })}}`,
value: 'string',
component({ onChange, type, options, value }) {
component({ onChange, value }) {
return (
<Input
value={value}
onChange={ev => onChange({ value: ev.target.value, type, options })}
onChange={ev => onChange(ev.target.value)}
/>
);
},
@ -174,11 +211,11 @@ const ConstantTypes = {
number: {
title: '{{t("Number")}}',
value: 'number',
component({ onChange, type, options, value }) {
component({ onChange, value }) {
return (
<InputNumber
value={value}
onChange={v => onChange({ value: v, type, options })}
onChange={onChange}
/>
);
},
@ -187,12 +224,12 @@ const ConstantTypes = {
boolean: {
title: `{{t("Boolean", { ns: "${NAMESPACE}" })}}`,
value: 'boolean',
component({ onChange, type, options, value }) {
component({ onChange, value }) {
const { t } = useTranslation();
return (
<Select
value={value}
onChange={v => onChange({ value: v, type, options })}
onChange={onChange}
placeholder={t('Select')}
>
<Select.Option value={true}>{lang('True')}</Select.Option>
@ -220,22 +257,16 @@ export const VariableTypes = {
value: item.value,
label: item.title
})),
component({ options = { type: 'string' } }) {
return ConstantTypes[options.type]?.component ?? NullRender;
component(props) {
const { options = { type: 'string' } } = useOperandContext();
return ConstantTypes[options.type]?.component(props);
},
appendTypeValue({ options = { type: 'string' } }) {
return options?.type ? [options.type] : [];
},
onTypeChange(old, [type, optionsType], onChange) {
if (old?.options?.type === optionsType) {
return;
}
onTypeChange([type, optionsType], onChange) {
const { default: value } = ConstantTypes[optionsType];
onChange({
value,
type,
options: { ...old.options, type: optionsType }
});
onChange(value);
},
parse(path) {
return { path };
@ -260,40 +291,30 @@ export const VariableTypes = {
return stack;
},
component({ options }) {
component(props) {
const { nodes } = useFlowContext();
const { options } = useOperandContext();
if (!options?.nodeId) {
return NullRender;
return null;
}
const node = nodes.find(n => n.id == options.nodeId);
if (!node) {
return NullRender;
return null;
}
const instruction = instructions.get(node.type);
return instruction?.getter ?? NullRender;
return instruction?.getter(props);
},
appendTypeValue({ options = {} }: { type: string, options: any }) {
return options.nodeId ? [Number.parseInt(options.nodeId, 10)] : [];
},
onTypeChange(old, [type, nodeId], onChange) {
onChange({
// ...old,
type,
options: { nodeId }
});
onTypeChange([type, nodeId], onChange) {
onChange(`{{${type}.${nodeId}}}`);
},
parse([nodeId, ...path]) {
return { nodeId, path: path.join('.') };
},
stringify({ options }) {
const stack = ['$jobsMapByNodeId'];
if (options.nodeId) {
stack.push(options.nodeId);
if (options.path) {
stack.push(options.path);
}
}
return `{{${stack.join('.')}}}`;
stringify(next) {
return `{{${next.join('.')}}}`;
}
},
$context: {
@ -304,29 +325,22 @@ export const VariableTypes = {
const trigger = triggers.get(workflow.type);
return trigger?.getOptions?.(workflow.config) ?? null;
},
component() {
component(props) {
const { workflow } = useFlowContext();
const trigger = triggers.get(workflow.type);
return trigger?.getter ?? NullRender;
return trigger?.getter(props);
},
appendTypeValue({ options }) {
return options.type ? [options.type] : [];
},
onTypeChange(old, [type, optionType], onChange) {
onChange({ type, options: { type: optionType } });
onTypeChange([type, optionType], onChange) {
onChange(`{{${type}.${optionType}}}`);
},
parse([type, ...path]) {
return { type, ...( path?.length ? { path: path.join('.') } : {}) };
},
stringify({ options }) {
const stack = ['$context'];
if (options?.type) {
stack.push(options.type);
}
if (options?.path) {
stack.push(options.path);
}
return `{{${stack.join('.')}}}`;
stringify(next) {
return `{{${next.join('.')}}}`;
}
},
// calculation: Calculation
@ -339,27 +353,30 @@ export function useVariableTypes() {
}
interface OperandProps {
value: {
type: string;
value?: any;
options?: any;
};
value: any;
onChange(v: any): void;
children?: React.ReactNode;
}
const OperandContext = React.createContext(null);
export function useOperandContext() {
return React.useContext(OperandContext);
}
export function Operand({
value: operand = { type: 'constant', value: '', options: { type: 'string' } },
value = null,
onChange,
children
}: OperandProps) {
const compile = useCompile();
const Types = useVariableTypes();
const operand = parseValue(value, Types);
const { type } = operand;
const { component, appendTypeValue } = Types[type] || {};
const Variable = typeof component === 'function' ? component(operand) : NullRender;
const { component: Variable = NullRender, appendTypeValue } = Types[type] || {};
return (
<div className={css`
@ -380,23 +397,32 @@ export function Operand({
isLeaf: !options
};
})}
onChange={(next: Array<string | number>) => {
const { onTypeChange } = Types[next[0]];
onChange={(next: string[]) => {
// 类型变化,包括主类型和子类型
const { onTypeChange, stringify } = Types[next[0]];
// 自定义处理
if (typeof onTypeChange === 'function') {
onTypeChange(operand, next, onChange);
} else {
if (next[0] !== type) {
onChange({ type: next[0], value: null });
return onTypeChange(next, onChange);
}
// 主类型变化
if (next[0] !== type) {
if (next[0] === 'constant') {
return onChange(null);
} else if (stringify) {
return onChange(stringify(next));
}
return onChange({ type: next[0], value: null });
}
}}
/>
{children ?? <Variable {...operand} onChange={op => onChange({ ...op })} />}
<OperandContext.Provider value={operand}>
{children ?? <Variable value={operand.value} onChange={onChange} />}
</OperandContext.Provider>
</div>
);
}
export function Calculation({ calculator, operands = [], onChange }) {
export function Calculation({ calculator, operands = [null], onChange }) {
const { t } = useWorkflowTranslation();
const compile = useCompile();
return (
@ -407,7 +433,7 @@ export function Calculation({ calculator, operands = [], onChange }) {
align-items: center;
`}>
<Operand value={operands[0]} onChange={(v => onChange({ calculator, operands: [v, operands[1]] }))} />
{operands[0]
{typeof operands[0] !== 'undefined'
? (
<>
<Select
@ -434,32 +460,20 @@ export function Calculation({ calculator, operands = [], onChange }) {
}
export function VariableComponent({ value, onChange, renderSchemaComponent }) {
const VTypes = { ...VariableTypes,
const VTypes = {
...VariableTypes,
constant: {
title: `{{t("Constant", { ns: "${NAMESPACE}" })}}`,
value: 'constant',
options: undefined
}
};
const operand = typeof value === 'string'
? parseStringValue(value, VTypes)
: { type: 'constant', value };
const { type } = parseValue(value, VTypes);
return (
<VariableTypesContext.Provider value={VTypes}>
<Operand
value={operand}
onChange={(next) => {
if (next.type !== operand.type && next.type === 'constant') {
onChange(null);
} else {
const { stringify } = VTypes[next.type];
onChange(stringify(next));
}
}}
>
{operand.type === 'constant' ? renderSchemaComponent() : null}
<Operand value={value} onChange={onChange}>
{type === 'constant' ? renderSchemaComponent() : null}
</Operand>
</VariableTypesContext.Provider>
);

View File

@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { css } from "@emotion/css";
import { CollectionField, CollectionProvider, SchemaComponent, useCollectionManager, useCompile } from "@nocobase/client";
import { Operand, parseStringValue, VariableTypes, VariableTypesContext } from "../calculators";
import { Operand, parseValue, VariableTypes, VariableTypesContext } from "../calculators";
import { lang, NAMESPACE } from "../locale";
function AssociationInput(props) {
@ -66,14 +66,10 @@ export default observer(({ value, onChange }: any) => {
constant: {
title: `{{t("Constant", { ns: "${NAMESPACE}" })}}`,
value: 'constant',
options: undefined
}
};
const operand = typeof value[field.name] === 'string'
? parseStringValue(value[field.name], VTypes)
: { type: 'constant', value: value[field.name] };
const operand = parseValue(value[field.name], VTypes);
// constant for associations to use Input, others to use CollectionField
// dynamic values only support belongsTo/hasOne association, other association type should disable
@ -86,14 +82,9 @@ export default observer(({ value, onChange }: any) => {
`}>
<VariableTypesContext.Provider value={VTypes}>
<Operand
value={operand}
value={value[field.name]}
onChange={(next) => {
if (next.type !== operand.type && next.type === 'constant') {
onChange({ ...value, [field.name]: null });
} else {
const { stringify } = VTypes[next.type];
onChange({ ...value, [field.name]: stringify(next) });
}
onChange({ ...value, [field.name]: next });
}}
>
{operand.type === 'constant'

View File

@ -15,8 +15,8 @@ i18n.addResources('ja-JP', NAMESPACE, jaJP);
i18n.addResources('ru-RU', NAMESPACE, ruRU);
i18n.addResources('tr-TR', NAMESPACE, trTR);
export function lang(key: string) {
return i18n.t(key, { ns: NAMESPACE });
export function lang(key: string, options = {}) {
return i18n.t(key, { ...options, ns: NAMESPACE });
}
export function useWorkflowTranslation() {

View File

@ -65,6 +65,7 @@ export default {
'Node result': '节点数据',
'Constant': '常量',
'Null': '空值',
'Boolean': '逻辑值',
'String': '字符串',
@ -127,6 +128,8 @@ export default {
'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': '已经执行过工作流中的节点不能被修改',
'Can not delete': '无法删除',
'The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.': '该节点的执行结果已被其他节点({{nodes}})引用,删除前请先移除引用。',
'HTTP request': 'HTTP 请求',
'URL': '地址',

View File

@ -1,6 +1,5 @@
import React from 'react';
import { css } from '@emotion/css';
import { useTranslation } from 'react-i18next';
import { Calculation } from '../calculators';
import { NAMESPACE, useWorkflowTranslation } from '../locale';

View File

@ -4,8 +4,6 @@ import { Button, Select } from "antd";
import { CloseCircleOutlined } from '@ant-design/icons';
import { Trans, useTranslation } from "react-i18next";
import { i18n } from "@nocobase/client";
import { NodeDefaultView } from ".";
import { Branch } from "../Branch";
import { useFlowContext } from '../FlowContext';
@ -20,7 +18,7 @@ function CalculationItem({ value, onChange, onRemove }) {
return null;
}
const { calculator, operands = [] } = value;
const { calculator, operands = [null] } = value;
return (
<div className={css`

View File

@ -6,6 +6,7 @@ import { useFlowContext } from '../FlowContext';
import CollectionFieldSelect from '../components/CollectionFieldSelect';
import CollectionFieldset from '../components/CollectionFieldset';
import { NAMESPACE } from '../locale';
import { useOperandContext } from '../calculators';
@ -39,8 +40,8 @@ export default {
components: {
CollectionFieldset
},
getter(props) {
const { type, options, onChange } = props;
getter({ onChange }) {
const { options } = useOperandContext();
const { nodes } = useFlowContext();
const { config } = nodes.find(n => n.id == options.nodeId);
const value = options?.path;
@ -50,7 +51,7 @@ export default {
collection={config.collection}
value={value}
onChange={(path) => {
onChange({ type, options: { ...options, path } });
onChange(`{{$jobsMapByNodeId.${options.nodeId}.${path}}}`);
}}
/>
);

View File

@ -7,6 +7,8 @@ import { css, cx } from '@emotion/css';
import { ISchema, useForm } from '@formily/react';
import { Button, message, Modal, Tag } from 'antd';
import { useTranslation } from 'react-i18next';
import parse from 'json-templates';
import { Registry } from '@nocobase/utils/client';
import { SchemaComponent, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
@ -146,6 +148,24 @@ export function RemoveButton() {
onNodeRemoved(node);
}
const usingNodes = nodes.filter(node => {
if (node === current) {
return false;
}
const template = parse(node.config);
const refs = template.parameters.filter(({ key }) => key.startsWith(`$jobsMapByNodeId.${current.id}.`) || key === `$jobsMapByNodeId.${current.id}`);
return refs.length;
});
if (usingNodes.length) {
Modal.error({
title: lang('Can not delete'),
content: lang('The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.', { nodes: usingNodes.map(item => `#${item.id}`).join(', ') }),
});
return;
}
const hasBranches = !nodes.find(item => item.upstream === current && item.branchIndex != null);
const message = hasBranches
? t('Are you sure you want to delete it?')

View File

@ -3,7 +3,7 @@ import React from 'react';
import { useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client';
import { useFlowContext } from '../FlowContext';
import { VariableComponent } from '../calculators';
import { useOperandContext, VariableComponent } from '../calculators';
import { collection, filter } from '../schemas/collection';
import CollectionFieldSelect from '../components/CollectionFieldSelect';
import { NAMESPACE } from '../locale';
@ -46,8 +46,9 @@ export default {
VariableComponent
},
getter(props) {
const { type, options, onChange } = props;
const { onChange } = props;
const { nodes } = useFlowContext();
const { options } = useOperandContext();
const { config } = nodes.find(n => n.id == options.nodeId);
const value = options?.path;
@ -56,7 +57,7 @@ export default {
collection={config.collection}
value={value}
onChange={(path) => {
onChange({ type, options: { ...options, path } });
onChange(`{{$jobsMapByNodeId.${options.nodeId}.${path}}}`);
}}
/>
);

View File

@ -10,6 +10,7 @@ import { css } from '@emotion/css';
import { onFieldValueChange } from '@formily/core';
import CollectionFieldSelect from '../components/CollectionFieldSelect';
import { NAMESPACE, useWorkflowTranslation } from '../locale';
import { useOperandContext } from '../calculators';
const FieldsSelect = observer((props) => {
const compile = useCompile();
@ -147,15 +148,16 @@ export default {
return options;
},
getter(props) {
const { type, options, onChange } = props;
const { onChange } = props;
const { workflow } = useFlowContext();
const { options } = useOperandContext();
return (
<CollectionFieldSelect
collection={workflow.config.collection}
value={options?.path}
onChange={(path) => {
onChange({ type, options: { ...options, type: 'data', path } });
onChange(`{{$context.data.${path}}}`);
}}
/>
);

View File

@ -5,7 +5,7 @@ import { useCompile, useCollectionDataSource, useCollectionManager } from '@noco
import { ScheduleConfig } from './ScheduleConfig';
import { useFlowContext } from '../../FlowContext';
import { BaseTypeSet } from '../../calculators';
import { BaseTypeSet, useOperandContext } from '../../calculators';
import { SCHEDULE_MODE } from './constants';
import { NAMESPACE, useWorkflowTranslation } from '../../locale';
@ -40,11 +40,12 @@ export default {
}
return options;
},
getter({ type, options, onChange }) {
getter({ onChange }) {
const { t } = useWorkflowTranslation();
const compile = useCompile();
const { collections = [] } = useCollectionManager();
const { workflow } = useFlowContext();
const { options } = useOperandContext();
const path = options?.path ? options.path.split('.') : [];
if (!options.type || options.type === 'date') {
return null;
@ -61,7 +62,7 @@ export default {
label: compile(field.uiSchema?.title),
}))}
onChange={(next) => {
onChange({ type, options: { ...options, path: next.join('.') } });
onChange(`{{$context.${next.join('.')}}}`);
}}
allowClear={false}
/>

View File

@ -76,6 +76,14 @@ export default class WorkflowPlugin extends Plugin {
directory: path.resolve(__dirname, 'collections'),
});
this.db.addMigrations({
namespace: 'workflow',
directory: path.resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
initActions(this);
initTriggers(this, options.triggers);
initInstructions(this, options.instructions);

View File

@ -33,6 +33,26 @@ describe('workflow > instructions > calculation', () => {
describe('operand types', () => {
it('constant', async () => {
const n1 = await workflow.createNode({
type: 'calculation',
config: {
calculation: {
calculator: 'add',
operands: [1, 1]
}
}
});
const post = await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result).toBe(2);
});
it('constant (legacy)', async () => {
const n1 = await workflow.createNode({
type: 'calculation',
config: {
@ -84,10 +104,7 @@ describe('workflow > instructions > calculation', () => {
config: {
calculation: {
calculator: 'add',
operands: [
{ value: 1 },
{ value: '{{$context.data.read}}' }
]
operands: [1, '{{$context.data.read}}']
}
}
});
@ -141,10 +158,7 @@ describe('workflow > instructions > calculation', () => {
config: {
calculation: {
calculator: 'add',
operands: [
{ value: 1 },
{ value: `{{$jobsMapByNodeId.${n1.id}.data.read}}` }
]
operands: [1, `{{$jobsMapByNodeId.${n1.id}.data.read}}`]
}
},
upstreamId: n1.id
@ -198,10 +212,7 @@ describe('workflow > instructions > calculation', () => {
type: '$calculation',
options: {
calculator: 'minus',
operands: [
{ value: '{{$context.data.read}}' },
{ value: 2 }
]
operands: ['{{$context.data.read}}', 2]
}
}
]

View File

@ -25,6 +25,8 @@ export type CalculationOptions = {
operands: Operand[]
};
export type ValueOperand = string | number | boolean | null | Date;
export type ConstantOperand = {
type?: 'constant';
value: any
@ -51,7 +53,7 @@ export type Calculation = {
};
// TODO(type): union type here is wrong
export type Operand = ContextOperand | InputOperand | JobOperand | ConstantOperand | Calculation;
export type Operand = ValueOperand | ContextOperand | InputOperand | JobOperand | ConstantOperand | Calculation;
// @deprecated
// HACK: if no path provided, return self
@ -65,19 +67,20 @@ function get(object, path?: string | Array<string>) {
// this method could only be used in executing nodes.
// because type of 'job' need loaded jobs in runtime execution.
// or the execution should be prepared first.
export function calculate(operand: Operand, lastJob: JobModel, processor: Processor) {
export function calculate(operand, lastJob: JobModel, processor: Processor) {
if (typeof operand !== 'object' || operand == null) {
return operand;
}
// @Deprecated
switch (operand.type) {
// @Deprecated
// from execution context
case '$context':
return get(processor.execution.context, [operand.options.type, operand.options.path].filter(Boolean).join('.'));
// @Deprecated
// from last job (or input job)
case '$input':
return lastJob ?? get(lastJob.result, operand.options.path);
// @Deprecated
// from job in execution
case '$jobsMapByNodeId':
// assume jobs have been fetched from execution before

View File

@ -77,7 +77,8 @@ export default {
// TODO(optimize): loading of jobs could be reduced and turned into incrementally in processor
// const jobs = await processor.getJobs();
const { calculation, rejectOnFalse } = node.config || {};
const result = logicCalculate(calculation, prevJob, processor);
const result = logicCalculate(processor.getParsedValue(calculation), prevJob, processor);
if (!result && rejectOnFalse) {
return {

View File

@ -0,0 +1,62 @@
import { Migration } from '@nocobase/server';
const VTypes = {
constant(operand) {
return operand.value;
},
$jobsMapByNodeId({ options }) {
const paths = [options.nodeId, options.path].filter(Boolean);
return paths ? `{{$jobsMapByNodeId.${paths.join('.')}}}` : null;
},
$context({ options }) {
return `{{$context.${options.path}}}`;
}
};
function migrateConfig(config) {
if (Array.isArray(config)) {
return config.map(item => migrateConfig(item));
}
if (typeof config !== 'object') {
return config;
}
if (!config) {
return config;
}
if (config.type && VTypes[config.type] && (config.options || config.value)) {
return VTypes[config.type](config);
}
return Object.keys(config).reduce((memo, key) => ({ ...memo, [key]: migrateConfig(config[key]) }), {});
}
export default class AddUsersPhoneMigration extends Migration {
async up() {
const match = await this.app.version.satisfies('<=0.8.0-alpha.13');
if (!match) {
return;
}
const NodeRepo = this.context.db.getRepository('flow_nodes');
await this.context.db.sequelize.transaction(async (transaction) => {
const nodes = await NodeRepo.find({
filter: {
type: {
$or: ['calculation', 'condition']
}
},
transaction
});
console.log('%d nodes need to be migrated.', nodes.length);
await nodes.reduce((promise, node) => {
return node.update({
config: migrateConfig(node.config)
}, {
transaction
});
}, Promise.resolve());
});
}
async down() {}
}