diff --git a/packages/plugins/workflow/src/client/calculators.tsx b/packages/plugins/workflow/src/client/calculators.tsx index 8ac82ccd1..0a8654886 100644 --- a/packages/plugins/workflow/src/client/calculators.tsx +++ b/packages/plugins/workflow/src/client/calculators.tsx @@ -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 ( 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 ( 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 ( - { - if (next.type !== operand.type && next.type === 'constant') { - onChange(null); - } else { - const { stringify } = VTypes[next.type]; - onChange(stringify(next)); - } - }} - > - {operand.type === 'constant' ? renderSchemaComponent() : null} + + {type === 'constant' ? renderSchemaComponent() : null} ); diff --git a/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx b/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx index c35bba36f..eaa8573bd 100644 --- a/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx +++ b/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx @@ -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) => { `}> { - 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' diff --git a/packages/plugins/workflow/src/client/locale/index.ts b/packages/plugins/workflow/src/client/locale/index.ts index 2cc84f046..eafdadf59 100644 --- a/packages/plugins/workflow/src/client/locale/index.ts +++ b/packages/plugins/workflow/src/client/locale/index.ts @@ -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() { diff --git a/packages/plugins/workflow/src/client/locale/zh-CN.ts b/packages/plugins/workflow/src/client/locale/zh-CN.ts index c7ee2e623..573b8e4b3 100644 --- a/packages/plugins/workflow/src/client/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/client/locale/zh-CN.ts @@ -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': '地址', diff --git a/packages/plugins/workflow/src/client/nodes/calculation.tsx b/packages/plugins/workflow/src/client/nodes/calculation.tsx index e682398e9..402ce1044 100644 --- a/packages/plugins/workflow/src/client/nodes/calculation.tsx +++ b/packages/plugins/workflow/src/client/nodes/calculation.tsx @@ -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'; diff --git a/packages/plugins/workflow/src/client/nodes/condition.tsx b/packages/plugins/workflow/src/client/nodes/condition.tsx index 026b25250..14a189737 100644 --- a/packages/plugins/workflow/src/client/nodes/condition.tsx +++ b/packages/plugins/workflow/src/client/nodes/condition.tsx @@ -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 (
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}}}`); }} /> ); diff --git a/packages/plugins/workflow/src/client/nodes/index.tsx b/packages/plugins/workflow/src/client/nodes/index.tsx index a956751a0..1018b4b50 100644 --- a/packages/plugins/workflow/src/client/nodes/index.tsx +++ b/packages/plugins/workflow/src/client/nodes/index.tsx @@ -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?') diff --git a/packages/plugins/workflow/src/client/nodes/query.tsx b/packages/plugins/workflow/src/client/nodes/query.tsx index 8f2dd5897..f22e4b821 100644 --- a/packages/plugins/workflow/src/client/nodes/query.tsx +++ b/packages/plugins/workflow/src/client/nodes/query.tsx @@ -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}}}`); }} /> ); diff --git a/packages/plugins/workflow/src/client/triggers/collection.tsx b/packages/plugins/workflow/src/client/triggers/collection.tsx index 130993253..eff78ccef 100644 --- a/packages/plugins/workflow/src/client/triggers/collection.tsx +++ b/packages/plugins/workflow/src/client/triggers/collection.tsx @@ -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 ( { - onChange({ type, options: { ...options, type: 'data', path } }); + onChange(`{{$context.data.${path}}}`); }} /> ); diff --git a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx index 379844049..2cb35106e 100644 --- a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx @@ -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} /> diff --git a/packages/plugins/workflow/src/server/Plugin.ts b/packages/plugins/workflow/src/server/Plugin.ts index b5f6bf4e6..55d4b0fac 100644 --- a/packages/plugins/workflow/src/server/Plugin.ts +++ b/packages/plugins/workflow/src/server/Plugin.ts @@ -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); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts index 09978be96..28ce689ac 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts @@ -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] } } ] diff --git a/packages/plugins/workflow/src/server/calculators/index.ts b/packages/plugins/workflow/src/server/calculators/index.ts index da3a9ac7b..14d627d4d 100644 --- a/packages/plugins/workflow/src/server/calculators/index.ts +++ b/packages/plugins/workflow/src/server/calculators/index.ts @@ -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) { // 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 diff --git a/packages/plugins/workflow/src/server/instructions/condition.ts b/packages/plugins/workflow/src/server/instructions/condition.ts index af1072ba9..ef3501064 100644 --- a/packages/plugins/workflow/src/server/instructions/condition.ts +++ b/packages/plugins/workflow/src/server/instructions/condition.ts @@ -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 { diff --git a/packages/plugins/workflow/src/server/migrations/20221129153547-calculation-variables.ts b/packages/plugins/workflow/src/server/migrations/20221129153547-calculation-variables.ts new file mode 100644 index 000000000..d68de4f90 --- /dev/null +++ b/packages/plugins/workflow/src/server/migrations/20221129153547-calculation-variables.ts @@ -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() {} +}