From 05b970310187c5107801cea5f5e7a2767ed0e2b5 Mon Sep 17 00:00:00 2001 From: Junyi Date: Wed, 16 Oct 2024 21:15:35 +0800 Subject: [PATCH] feat(plugin-workflow-loop): add more configuration (#5342) * feat(plugin-workflow-loop): add more configuration * fix(plugin-workflow-loop): fix scope variable on current loop node * refactor(plugin-workflow-loop): adjust order of exit options * chore(plugin-workflow-loop): add migration * fix(plugin-workflow): fix condition branch constant * fix(plugin-workflow): fix additionalScope argument in parsing variable * fix(plugin-workflow-loop): dependencies * fix(plugin-workflow-loop): fix type * fix(plugin-workflow-loop): fix client variable issues * fix(plugin-workflow-loop): only use basic calculation due to expression variable issues * refactor(plugin-workflow-loop): adjust configuration fields * fix(plugin-workflow-loop): fix locale * refactor(plugin-workflow-loop): adjust order of configuration fields * fix(plugin-workflow-loop): fix number type properties * fix(plugin-workflow-loop): fix null items in array are skipped --- .../antd/variable/TextArea.tsx | 4 +- .../plugin-workflow-loop/package.json | 1 + .../src/client/LoopInstruction.tsx | 353 ++++++-- .../plugin-workflow-loop/src/constants.ts | 24 + .../src/locale/zh-CN.json | 18 +- .../src/server/LoopInstruction.ts | 110 ++- .../src/server/__tests__/instruction.test.ts | 798 ++++++++++++++---- .../20240929212031-change-loop-result.ts | 45 + .../src/server/actions.ts | 22 +- .../src/locale/zh-CN.json | 4 +- .../src/server/instructions.ts | 9 + .../src/client/components/Calculation.tsx | 351 ++++++++ .../src/client/components/index.ts | 2 + .../src/client/nodes/condition.tsx | 338 +------- .../src/client/nodes/index.tsx | 21 +- .../plugin-workflow/src/server/Processor.ts | 14 +- .../plugin-workflow/src/server/index.ts | 1 + .../instructions/ConditionInstruction.ts | 113 +-- .../src/server/logicCalculate.ts | 127 +++ 19 files changed, 1631 insertions(+), 724 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-workflow-loop/src/constants.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow-loop/src/server/migrations/20240929212031-change-loop-result.ts create mode 100644 packages/plugins/@nocobase/plugin-workflow/src/client/components/Calculation.tsx create mode 100644 packages/plugins/@nocobase/plugin-workflow/src/server/logicCalculate.ts diff --git a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx index 47efceee9..1e91101d8 100644 --- a/packages/core/client/src/schema-component/antd/variable/TextArea.tsx +++ b/packages/core/client/src/schema-component/antd/variable/TextArea.tsx @@ -41,7 +41,7 @@ function pasteHTML( if (indexes) { const children = Array.from(container.childNodes); if (indexes[0] === -1) { - if (indexes[1]) { + if (indexes[1] && children[indexes[1] - 1]) { range.setStartAfter(children[indexes[1] - 1]); } else { range.setStart(container, 0); @@ -51,7 +51,7 @@ function pasteHTML( } if (indexes[2] === -1) { - if (indexes[3]) { + if (indexes[3] && children[indexes[3] - 1]) { range.setEndAfter(children[indexes[3] - 1]); } else { range.setEnd(container, 0); diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/package.json b/packages/plugins/@nocobase/plugin-workflow-loop/package.json index 0c03dfd8b..749398b65 100644 --- a/packages/plugins/@nocobase/plugin-workflow-loop/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-loop/package.json @@ -17,6 +17,7 @@ "peerDependencies": { "@nocobase/client": "1.x", "@nocobase/database": "1.x", + "@nocobase/evaluators": "1.x", "@nocobase/plugin-workflow": ">=0.17.0-alpha.3", "@nocobase/server": "1.x", "@nocobase/test": "1.x" diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/src/client/LoopInstruction.tsx b/packages/plugins/@nocobase/plugin-workflow-loop/src/client/LoopInstruction.tsx index 1de609447..6a26da0a3 100644 --- a/packages/plugins/@nocobase/plugin-workflow-loop/src/client/LoopInstruction.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-loop/src/client/LoopInstruction.tsx @@ -7,11 +7,12 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { ArrowUpOutlined } from '@ant-design/icons'; - -import { css, cx, useCompile } from '@nocobase/client'; - +import { Card, Checkbox } from 'antd'; +import { FormLayout, FormItem } from '@formily/antd-v5'; +import { useForm } from '@formily/react'; +import { css, cx, SchemaComponent, useCompile, Variable } from '@nocobase/client'; import { NodeDefaultView, Branch, @@ -19,13 +20,23 @@ import { useStyles, VariableOption, WorkflowVariableInput, + WorkflowVariableTextArea, defaultFieldNames, nodesOptions, scopeOptions, triggerOptions, Instruction, + RadioWithTooltip, + renderEngineReference, + RadioWithTooltipOption, + CalculationConfig, + useWorkflowVariableOptions, + UseVariableOptions, + useNodeContext, } from '@nocobase/plugin-workflow/client'; + import { NAMESPACE, useLang } from '../locale'; +import { useTranslation } from 'react-i18next'; function findOption(options: VariableOption[], paths: string[]) { let opts = options; @@ -47,6 +58,189 @@ function findOption(options: VariableOption[], paths: string[]) { return option; } +function LoopCondition({ value, onChange }) { + const { t } = useTranslation(); + const onCheckpointChange = useCallback( + (ev) => { + onChange({ ...value, checkpoint: ev.target.value }); + }, + [value, onChange], + ); + const onContinueOnFalseChange = useCallback( + (ev) => { + onChange({ ...value, continueOnFalse: ev.target.value }); + }, + [value, onChange], + ); + const onCalculationChange = useCallback( + (calculation) => { + onChange({ ...value, calculation }); + }, + [value, onChange], + ); + return ( + <> + { + onChange( + ev.target.checked + ? { checkpoint: 0, continueOnFalse: false, calculation: { group: { type: 'and', calculations: [] } } } + : false, + ); + }} + > + {t('Enable loop condition', { ns: NAMESPACE })} + + {value ? ( + + + + + + + + + + + + + + ) : null} + + ); +} + +function useScopeVariables(node, options) { + const compile = useCompile(); + const langLoopTarget = useLang('Loop target'); + const langLoopIndex = useLang('Loop index (starts from 0)'); + const langLoopSequence = useLang('Loop sequence (starts from 1)'); + const langLoopLength = useLang('Loop length'); + const { target } = node.config; + if (target == null) { + return null; + } + + const { fieldNames = defaultFieldNames, scope } = options; + + // const { workflow } = useFlowContext(); + // const current = useNodeContext(); + // const upstreams = useAvailableUpstreams(current); + // find target data model by path described in `config.target` + // 1. get options from $context/$jobsMapByNodeKey + // 2. route to sub-options and use as loop target options + let targetOption: VariableOption = { + key: 'item', + [fieldNames.value]: 'item', + [fieldNames.label]: langLoopTarget, + }; + + if (typeof target === 'string' && target.startsWith('{{') && target.endsWith('}}')) { + const paths = target + .slice(2, -2) + .split('.') + .map((path) => path.trim()); + + const targetOptions = + scope ?? + [scopeOptions, nodesOptions, triggerOptions].map((item: any) => { + const opts = item.useOptions({ ...options, current: node }).filter(Boolean); + return { + [fieldNames.label]: compile(item.label), + [fieldNames.value]: item.value, + key: item.value, + [fieldNames.children]: opts, + disabled: opts && !opts.length, + }; + }); + + const found = findOption(targetOptions, paths); + + targetOption = Object.assign({}, found, targetOption); + } + + return [ + targetOption, + { key: 'index', [fieldNames.value]: 'index', [fieldNames.label]: langLoopIndex }, + { key: 'sequence', [fieldNames.value]: 'sequence', [fieldNames.label]: langLoopSequence }, + { key: 'length', [fieldNames.value]: 'length', [fieldNames.label]: langLoopLength }, + ]; +} + +function useVariableHook(options: UseVariableOptions = {}) { + const { values } = useForm(); + const node = useNodeContext(); + const current = { + ...node, + config: { + ...node.config, + target: values.target, + }, + }; + const result = useWorkflowVariableOptions(options); + const subOptions = useScopeVariables(current, { + ...options, + scope: result.filter((item) => ['$scopes', '$jobsMapByNodeKey', '$context'].includes(item.key)), + }); + const { fieldNames = defaultFieldNames } = options; + if (!subOptions) { + return result; + } + + const scope = result.find((item) => item[fieldNames.value] === '$scopes'); + if (scope) { + if (!scope[fieldNames.children]) { + scope[fieldNames.children] = []; + } + + scope[fieldNames.children].unshift({ + key: node.key, + [fieldNames.value]: node.key, + [fieldNames.label]: node.title ?? `#${node.id}`, + [fieldNames.children]: subOptions, + }); + scope.disabled = false; + } + + return [...result]; +} + +function LoopVariableTextArea({ variableOptions, ...props }): JSX.Element { + const scope = useVariableHook(variableOptions); + return ; +} + export default class extends Instruction { title = `{{t("Loop", { ns: "${NAMESPACE}" })}}`; type = 'loop'; @@ -61,7 +255,8 @@ export default class extends Instruction { 'x-component': 'WorkflowVariableInput', 'x-component-props': { changeOnSelect: true, - useTypedConstant: ['string', 'number', 'null'], + nullable: false, + useTypedConstant: ['string', ['number', { step: 1, min: 0, precision: 0 }]], className: css` width: 100%; @@ -75,10 +270,101 @@ export default class extends Instruction { `, }, required: true, + default: 1, + 'x-reactions': [ + { + target: 'calculation', + effects: ['onFieldValueChange'], + fulfill: { + state: { + value: null, + }, + }, + }, + { + target: 'expression', + effects: ['onFieldValueChange'], + fulfill: { + state: { + value: '', + }, + }, + }, + ], + }, + // startIndex: { + // type: 'number', + // title: `{{t("Start index", { ns: "${NAMESPACE}" })}}`, + // description: `{{t("The index number used in loop scope variable.", { ns: "${NAMESPACE}" })}}`, + // 'x-decorator': 'FormItem', + // 'x-component': 'RadioWithTooltip', + // 'x-component-props': { + // options: [ + // { + // label: `{{t("From 0", { ns: "${NAMESPACE}" })}}`, + // value: 0, + // tooltip: `{{t("Follow programming language conventions.", { ns: "${NAMESPACE}" })}}`, + // }, + // { + // label: `{{t("From 1", { ns: "${NAMESPACE}" })}}`, + // value: 1, + // tooltip: `{{t("Follow natural language conventions.", { ns: "${NAMESPACE}" })}}`, + // }, + // ], + // }, + // default: 0, + // }, + condition: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'LoopCondition', + 'x-reactions': [ + { + dependencies: ['target'], + fulfill: { + state: { + visible: '{{Boolean($deps[0])}}', + }, + }, + }, + ], + default: false, + }, + exit: { + type: 'number', + title: `{{t("When node inside loop failed", { ns: "${NAMESPACE}" })}}`, + 'x-decorator': 'FormItem', + 'x-component': 'RadioWithTooltip', + 'x-component-props': { + direction: 'vertical', + options: [ + { + label: `{{t("Exit workflow", { ns: "${NAMESPACE}" })}}`, + value: 0, + }, + { + label: `{{t("Exit loop and continue workflow", { ns: "${NAMESPACE}" })}}`, + value: 1, + }, + { + label: `{{t("Continue loop on next item", { ns: "${NAMESPACE}" })}}`, + value: 2, + }, + ], + }, + default: 0, }, }; + scope = { + renderEngineReference, + }; components = { + LoopCondition, WorkflowVariableInput, + WorkflowVariableTextArea, + LoopVariableTextArea, + RadioWithTooltip, + CalculationConfig, }; Component({ data }) { // eslint-disable-next-line react-hooks/rules-of-hooks @@ -111,60 +397,5 @@ export default class extends Instruction { ); } - useScopeVariables(node, options) { - // eslint-disable-next-line react-hooks/rules-of-hooks - const compile = useCompile(); - // eslint-disable-next-line react-hooks/rules-of-hooks - const langLoopTarget = useLang('Loop target'); - // eslint-disable-next-line react-hooks/rules-of-hooks - const langLoopIndex = useLang('Loop index'); - // eslint-disable-next-line react-hooks/rules-of-hooks - const langLoopLength = useLang('Loop length'); - const { target } = node.config; - if (!target) { - return null; - } - - const { fieldNames = defaultFieldNames } = options; - - // const { workflow } = useFlowContext(); - // const current = useNodeContext(); - // const upstreams = useAvailableUpstreams(current); - // find target data model by path described in `config.target` - // 1. get options from $context/$jobsMapByNodeKey - // 2. route to sub-options and use as loop target options - let targetOption: VariableOption = { - key: 'item', - [fieldNames.value]: 'item', - [fieldNames.label]: langLoopTarget, - }; - - if (typeof target === 'string' && target.startsWith('{{') && target.endsWith('}}')) { - const paths = target - .slice(2, -2) - .split('.') - .map((path) => path.trim()); - - const targetOptions = [scopeOptions, nodesOptions, triggerOptions].map((item: any) => { - const opts = item.useOptions({ ...options, current: node }).filter(Boolean); - return { - [fieldNames.label]: compile(item.label), - [fieldNames.value]: item.value, - key: item.value, - [fieldNames.children]: opts, - disabled: opts && !opts.length, - }; - }); - - const found = findOption(targetOptions, paths); - - targetOption = Object.assign({}, found, targetOption); - } - - return [ - targetOption, - { key: 'index', [fieldNames.value]: 'index', [fieldNames.label]: langLoopIndex }, - { key: 'length', [fieldNames.value]: 'length', [fieldNames.label]: langLoopLength }, - ]; - } + useScopeVariables = useScopeVariables; } diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/src/constants.ts b/packages/plugins/@nocobase/plugin-workflow-loop/src/constants.ts new file mode 100644 index 000000000..d62c0aa0d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-loop/src/constants.ts @@ -0,0 +1,24 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export const CHECKPOINT = { + BEFORE: 0, + AFTER: 1, +}; + +export const CONTINUE_ON_FALSE = { + BREAK: false, + CONTINUE: true, +}; + +export const EXIT = { + CONTINUE: 2, + BREAK: 1, + RETURN: 0, +}; diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-loop/src/locale/zh-CN.json index 19b014bd4..46084d64f 100644 --- a/packages/plugins/@nocobase/plugin-workflow-loop/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-workflow-loop/src/locale/zh-CN.json @@ -1,8 +1,22 @@ { "Loop": "循环", "Loop target": "循环对象", - "Loop index": "当前索引", + "Loop index (starts from 0)": "当前索引(从 0 开始)", + "Loop sequence (starts from 1)": "当前序号(从 1 开始)", "Loop length": "循环长度", "By using a loop node, you can perform the same operation on multiple sets of data. The source of these sets can be either multiple records from a query node or multiple associated records of a single record. Loop node can also be used for iterating a certain number of times or for looping through each character in a string. However, excessive looping may cause performance issues, so use with caution.": "使用循环节点可以对多条数据进行同样的操作,多条数据的来源可以是查询节点的多条结果,或者一条数据的多条关系数据。也可以用于一定次数的循环,或者对字符串中每一个字符的循环处理。循环次数过高可能引起性能问题,请谨慎使用。", - "A single number will be treated as a loop count, a single string will be treated as an array of characters, and other non-array values will be converted to arrays. The loop node ends when the loop count is reached, or when the array loop is completed. You can also add condition nodes to the loop to terminate it.": "单一数字值将被视为循环次数,单一字符串值将被视为字符数组,其他非数组值将被转换为数组。达到循环次数,或者将数组循环完成后,循环节点结束。你也可以在循环中添加条件节点,以终止循环。" + "A single number will be treated as a loop count, a single string will be treated as an array of characters, and other non-array values will be converted to arrays. The loop node ends when the loop count is reached, or when the array loop is completed. You can also add condition nodes to the loop to terminate it.": "单一数字值将被视为循环次数,单一字符串值将被视为字符数组,其他非数组值将被转换为数组。达到循环次数,或者将数组循环完成后,循环节点结束。你也可以在循环中添加条件节点,以终止循环。", + "Enable loop condition": "启用循环条件", + "Loop condition on each item": "循环每一项的条件", + "When to check": "检查时机", + "Before each starts": "每一轮开始之前", + "After each ends": "每一轮完成之后", + "When condition is not met on item": "当项不满足条件时", + "Exit loop": "退出循环", + "Continue on next item": "继续下一项", + "Condition": "条件", + "When node inside loop failed": "当循环内节点执行失败时", + "Continue loop on next item": "继续循环下一项", + "Exit loop and continue workflow": "退出循环并继续工作流", + "Exit workflow": "退出工作流" } diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/src/server/LoopInstruction.ts b/packages/plugins/@nocobase/plugin-workflow-loop/src/server/LoopInstruction.ts index b40e949d9..43d0ee527 100644 --- a/packages/plugins/@nocobase/plugin-workflow-loop/src/server/LoopInstruction.ts +++ b/packages/plugins/@nocobase/plugin-workflow-loop/src/server/LoopInstruction.ts @@ -7,7 +7,22 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { Processor, Instruction, JOB_STATUS, FlowNodeModel, JobModel } from '@nocobase/plugin-workflow'; +import evaluators from '@nocobase/evaluators'; +import { Processor, Instruction, JOB_STATUS, FlowNodeModel, JobModel, logicCalculate } from '@nocobase/plugin-workflow'; +import { EXIT } from '../constants'; + +export type LoopInstructionConfig = { + target: any; + condition?: + | { + checkpoint?: number; + continueOnFalse?: boolean; + calculation?: any; + expression?: string; + } + | false; + exit?: number; +}; function getTargetLength(target) { let length = 0; @@ -17,39 +32,57 @@ function getTargetLength(target) { } length = Math.floor(target); } else { - const targets = (Array.isArray(target) ? target : [target]).filter((t) => t != null); + const targets = Array.isArray(target) ? target : [target].filter((t) => t != null); length = targets.length; } return length; } +function calculateCondition(node: FlowNodeModel, processor: Processor) { + const { engine, calculation, expression } = node.config.condition ?? {}; + const evaluator = evaluators.get(engine); + + return evaluator + ? evaluator(expression, processor.getScope(node.id, true)) + : logicCalculate(processor.getParsedValue(calculation, node.id, { includeSelfScope: true })); +} + export default class extends Instruction { async run(node: FlowNodeModel, prevJob: JobModel, processor: Processor) { const [branch] = processor.getBranches(node); const target = processor.getParsedValue(node.config.target, node.id); const length = getTargetLength(target); + const looped = 0; if (!branch || !length) { return { status: JOB_STATUS.RESOLVED, - result: 0, + result: { looped }, }; } + // NOTE: save job for condition calculation const job = await processor.saveJob({ status: JOB_STATUS.PENDING, - // save loop index - result: 0, + result: { looped }, nodeId: node.id, nodeKey: node.key, upstreamId: prevJob?.id ?? null, }); - // TODO: add loop scope to stack - // processor.stack.push({ - // label: node.title, - // value: node.id - // }); + if (node.config.condition) { + const { checkpoint, calculation, expression, continueOnFalse } = node.config.condition ?? {}; + if ((calculation || expression) && !checkpoint) { + const condition = calculateCondition(node, processor); + if (!condition && !continueOnFalse) { + job.set({ + status: JOB_STATUS.RESOLVED, + result: { looped, broken: true }, + }); + return job; + } + } + } await processor.run(branch, job); @@ -62,43 +95,74 @@ export default class extends Instruction { const [branch] = processor.getBranches(node); const { result, status } = job; - // if loop has been done (resolved / rejected), do not care newly executed branch jobs. + // NOTE: if loop has been done (resolved / rejected), do not care newly executed branch jobs. if (status !== JOB_STATUS.PENDING) { return processor.exit(); } - const nextIndex = result + 1; + const nextIndex = result.looped + 1; const target = processor.getParsedValue(loop.config.target, node.id); - // branchJob.status === JOB_STATUS.RESOLVED means branchJob is done, try next loop or exit as resolved - if (branchJob.status > JOB_STATUS.PENDING) { - job.set({ result: nextIndex }); + // NOTE: branchJob.status === JOB_STATUS.RESOLVED means branchJob is done, try next loop or exit as resolved + if ( + branchJob.status === JOB_STATUS.RESOLVED || + (branchJob.status < JOB_STATUS.PENDING && loop.config.exit === EXIT.CONTINUE) + ) { + job.set({ result: { looped: nextIndex } }); + await processor.saveJob(job); + + if (loop.config.condition) { + const { calculation, expression, continueOnFalse } = loop.config.condition ?? {}; + if (calculation || expression) { + const condition = calculateCondition(loop, processor); + if (!condition && !continueOnFalse) { + job.set({ + status: JOB_STATUS.RESOLVED, + result: { looped: nextIndex, broken: true }, + }); + return job; + } + } + } const length = getTargetLength(target); if (nextIndex < length) { await processor.saveJob(job); await processor.run(branch, job); - return null; + return processor.exit(); + } else { + job.set({ + status: JOB_STATUS.RESOLVED, + }); } + } else { + // NOTE: branchJob.status < JOB_STATUS.PENDING means branchJob is rejected, any rejection should cause loop rejected + job.set( + loop.config.exit + ? { + result: { looped: result.looped, broken: true }, + status: JOB_STATUS.RESOLVED, + } + : { + status: branchJob.status, + }, + ); } - // branchJob.status < JOB_STATUS.PENDING means branchJob is rejected, any rejection should cause loop rejected - job.set({ - status: branchJob.status, - }); - return job; } - getScope(node, index, processor) { + getScope(node, { looped }, processor) { const target = processor.getParsedValue(node.config.target, node.id); const targets = (Array.isArray(target) ? target : [target]).filter((t) => t != null); const length = getTargetLength(target); - const item = typeof target === 'number' ? index : targets[index]; + const index = looped; + const item = typeof target === 'number' ? index : targets[looped]; const result = { item, index, + sequence: index + 1, length, }; diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/src/server/__tests__/instruction.test.ts b/packages/plugins/@nocobase/plugin-workflow-loop/src/server/__tests__/instruction.test.ts index a9a72e732..dd139f093 100644 --- a/packages/plugins/@nocobase/plugin-workflow-loop/src/server/__tests__/instruction.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-loop/src/server/__tests__/instruction.test.ts @@ -13,6 +13,7 @@ import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow'; import { getApp, sleep } from '@nocobase/plugin-workflow-test'; import Plugin from '..'; +import { EXIT } from '../../constants'; describe('workflow > instructions > loop', () => { let app: Application; @@ -66,7 +67,7 @@ describe('workflow > instructions > loop', () => { const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); expect(jobs.length).toBe(2); expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); - expect(jobs[0].result).toBe(0); + expect(jobs[0].result).toEqual({ looped: 0 }); }); it('should exit when branch meets error', async () => { @@ -99,209 +100,686 @@ describe('workflow > instructions > loop', () => { const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); expect(jobs.length).toBe(2); expect(jobs[0].status).toBe(JOB_STATUS.ERROR); - expect(jobs[0].result).toBe(0); + expect(jobs[0].result).toEqual({ looped: 0 }); expect(jobs[1].status).toBe(JOB_STATUS.ERROR); }); }); describe('config', () => { - it('no target just pass', async () => { - const n1 = await workflow.createNode({ - type: 'loop', + describe('target', () => { + it('no target just pass', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + expect(jobs.length).toBe(2); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 0 }); }); - const n2 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - branchIndex: 0, + it('null target just pass', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: null, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + expect(jobs.length).toBe(2); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 0 }); }); - const n3 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, + it('empty array just pass', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: [], + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + expect(jobs.length).toBe(2); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 0 }); }); - await n1.setDownstream(n3); + it('null value in array will not be passed', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: [null], + }, + }); - const post = await PostRepo.create({ values: { title: 't1' } }); + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); - await sleep(500); + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); - const [execution] = await workflow.getExecutions(); - expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); - const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); - expect(jobs.length).toBe(2); - expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); - expect(jobs[0].result).toBe(0); + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + expect(jobs.length).toBe(3); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 1 }); + }); + + it('target is number, cycle number times', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: 2.5, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(4); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 2 }); + }); + + it('target is no array, set as an array', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: {}, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(3); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 1 }); + }); + + it('multiple targets', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: [1, 2], + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(4); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 2 }); + expect(jobs.filter((j) => j.nodeId === n2.id).length).toBe(2); + }); }); - it('null target just pass', async () => { - const n1 = await workflow.createNode({ - type: 'loop', - config: { - target: null, - }, + describe.skip('startIndex', () => { + it('startIndex as 0', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: 2, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echoVariable', + config: { + variable: '{{$scopes.' + n1.key + '.item}}', + }, + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echoVariable', + config: { + variable: '{{$scopes.' + n1.key + '.index}}', + }, + upstreamId: n2.id, + }); + + await n2.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(5); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 2 }); + expect(jobs[1].result).toBe(0); + expect(jobs[2].result).toBe(0); + expect(jobs[3].result).toBe(1); + expect(jobs[4].result).toBe(1); }); - const n2 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - branchIndex: 0, + it('startIndex as 1', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: 2, + startIndex: 1, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echoVariable', + config: { + variable: `{{$scopes.${n1.key}.item}}`, + }, + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echoVariable', + config: { + variable: `{{$scopes.${n1.key}.index}}`, + }, + upstreamId: n2.id, + }); + + await n2.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(5); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 2 }); + expect(jobs[1].result).toBe(1); + expect(jobs[2].result).toBe(1); + expect(jobs[3].result).toBe(2); + expect(jobs[4].result).toBe(2); }); - - const n3 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - }); - - await n1.setDownstream(n3); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const [execution] = await workflow.getExecutions(); - expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); - const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); - expect(jobs.length).toBe(2); - expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); - expect(jobs[0].result).toBe(0); }); - it('empty array just pass', async () => { - const n1 = await workflow.createNode({ - type: 'loop', - config: { - target: [], - }, + describe('condition', () => { + it('empty condition', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: 2, + condition: { + calculation: {}, + }, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(3); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 2 }); }); - const n2 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - branchIndex: 0, + it('condition engine basic before each: true with loop variable', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + }); + await n1.update({ + config: { + target: 2, + condition: { + calculation: { + group: { + type: 'and', + calculations: [ + { + calculator: 'equal', + operands: [`{{$scopes.${n1.key}.item}}`, 0], + }, + { + calculator: 'equal', + operands: [`{{$scopes.${n1.key}.index}}`, 0], + }, + ], + }, + }, + }, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(2); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 1, broken: true }); }); - const n3 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, + it('condition engine basic after each: true with loop variable', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + }); + await n1.update({ + config: { + target: 2, + condition: { + checkpoint: 1, + calculation: { + group: { + type: 'and', + calculations: [ + { + calculator: 'equal', + operands: [`{{$scopes.${n1.key}.item}}`, 0], + }, + { + calculator: 'equal', + operands: [`{{$scopes.${n1.key}.index}}`, 0], + }, + ], + }, + }, + }, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(2); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 1, broken: true }); }); - await n1.setDownstream(n3); + it('condition engine basic before each: first false', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + }); + await n1.update({ + config: { + target: 2, + condition: { + calculation: { + group: { + type: 'and', + calculations: [ + { + calculator: 'equal', + operands: [1, 0], + }, + ], + }, + }, + }, + }, + }); - const post = await PostRepo.create({ values: { title: 't1' } }); + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); - await sleep(500); + const post = await PostRepo.create({ values: { title: 't1' } }); - const [execution] = await workflow.getExecutions(); - expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); - const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); - expect(jobs.length).toBe(2); - expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); - expect(jobs[0].result).toBe(0); + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(1); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 0, broken: true }); + }); + + it('condition engine basic after each: first false', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + }); + await n1.update({ + config: { + target: 2, + condition: { + checkpoint: 1, + calculation: { + group: { + type: 'and', + calculations: [ + { + calculator: 'equal', + operands: [1, 0], + }, + ], + }, + }, + }, + }, + }); + + const n2 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + branchIndex: 0, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(2); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 1, broken: true }); + }); }); - it('target is number, cycle number times', async () => { - const n1 = await workflow.createNode({ - type: 'loop', - config: { - target: 2.5, - }, + describe('exit', () => { + it('exit not configured (legacy)', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: 2, + }, + }); + + const n2 = await workflow.createNode({ + type: 'error', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.ERROR); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(2); + expect(jobs[0].status).toBe(JOB_STATUS.ERROR); + expect(jobs[0].result).toEqual({ looped: 0 }); }); - const n2 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - branchIndex: 0, + it('exit as failed', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: 2, + exit: EXIT.RETURN, + }, + }); + + const n2 = await workflow.createNode({ + type: 'error', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.ERROR); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(2); + expect(jobs[0].status).toBe(JOB_STATUS.ERROR); + expect(jobs[0].result).toEqual({ looped: 0 }); }); - const n3 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, + it('exit as break', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: 2, + exit: EXIT.BREAK, + }, + }); + + const n2 = await workflow.createNode({ + type: 'error', + upstreamId: n1.id, + branchIndex: 0, + }); + + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); + + await n1.setDownstream(n3); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(3); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 0, broken: true }); }); - await n1.setDownstream(n3); + it('exit as continue', async () => { + const n1 = await workflow.createNode({ + type: 'loop', + config: { + target: 2, + exit: EXIT.CONTINUE, + }, + }); - const post = await PostRepo.create({ values: { title: 't1' } }); + const n2 = await workflow.createNode({ + type: 'error', + upstreamId: n1.id, + branchIndex: 0, + }); - await sleep(500); + const n3 = await workflow.createNode({ + type: 'echo', + upstreamId: n1.id, + }); - const [execution] = await workflow.getExecutions(); - expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); - const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + await n1.setDownstream(n3); - expect(jobs.length).toBe(4); - expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); - expect(jobs[0].result).toBe(2); - }); + const post = await PostRepo.create({ values: { title: 't1' } }); - it('target is no array, set as an array', async () => { - const n1 = await workflow.createNode({ - type: 'loop', - config: { - target: {}, - }, + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); + + expect(jobs.length).toBe(4); + expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); + expect(jobs[0].result).toEqual({ looped: 2 }); }); - - const n2 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - branchIndex: 0, - }); - - const n3 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - }); - - await n1.setDownstream(n3); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const [execution] = await workflow.getExecutions(); - expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); - const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); - - expect(jobs.length).toBe(3); - expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); - expect(jobs[0].result).toBe(1); - }); - - it('multiple targets', async () => { - const n1 = await workflow.createNode({ - type: 'loop', - config: { - target: [1, 2], - }, - }); - - const n2 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - branchIndex: 0, - }); - - const n3 = await workflow.createNode({ - type: 'echo', - upstreamId: n1.id, - }); - - await n1.setDownstream(n3); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const [execution] = await workflow.getExecutions(); - expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); - const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); - - expect(jobs.length).toBe(4); - expect(jobs[0].status).toBe(JOB_STATUS.RESOLVED); - expect(jobs[0].result).toBe(2); - expect(jobs.filter((j) => j.nodeId === n2.id).length).toBe(2); }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/src/server/migrations/20240929212031-change-loop-result.ts b/packages/plugins/@nocobase/plugin-workflow-loop/src/server/migrations/20240929212031-change-loop-result.ts new file mode 100644 index 000000000..1b50d241b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-loop/src/server/migrations/20240929212031-change-loop-result.ts @@ -0,0 +1,45 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This program is offered under a commercial license. + * For more information, see + */ + +import { Migration } from '@nocobase/server'; + +export default class extends Migration { + async up() { + const { db, app } = this.context; + const JobRepo = db.getRepository('jobs'); + await db.sequelize.transaction(async (transaction) => { + const records = await JobRepo.find({ + filter: { + node: { + type: 'loop', + }, + }, + transaction, + }); + + app.logger.debug(`${records.length} records need to be migrated.`); + + for (const record of records) { + const { result } = record; + if (typeof result === 'number') { + await record.update({ result: { looped: result } }, { transaction }); + } + } + }); + } +} diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts index fcb1b9b67..3a04e1d05 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/actions.ts @@ -67,16 +67,18 @@ export async function submit(context: Context, next) { return context.throw(403); } const presetValues = processor.getParsedValue(actionItem.values ?? {}, userJob.nodeId, { - // @deprecated - currentUser: currentUser, - // @deprecated - currentRecord: values.result[formKey], - // @deprecated - currentTime: new Date(), - $user: currentUser, - $nForm: values.result[formKey], - $nDate: { - now: new Date(), + additionalScope: { + // @deprecated + currentUser: currentUser, + // @deprecated + currentRecord: values.result[formKey], + // @deprecated + currentTime: new Date(), + $user: currentUser, + $nForm: values.result[formKey], + $nDate: { + now: new Date(), + }, }, }); diff --git a/packages/plugins/@nocobase/plugin-workflow-request/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-request/src/locale/zh-CN.json index b397b3f05..a27dcc8b9 100644 --- a/packages/plugins/@nocobase/plugin-workflow-request/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-workflow-request/src/locale/zh-CN.json @@ -12,8 +12,8 @@ "Add key-value pairs": "添加键值对", "Format": "格式化", "Insert": "插入", - "Timeout config": "超时设置", - "ms": "毫秒", + "Timeout": "超时设置", + "Milliseconds": "毫秒", "Input request data": "输入请求数据", "Only support standard JSON data": "仅支持标准 JSON 数据", "\"Content-Type\" will be ignored from headers.": "请求头中配置的 \"Content-Type\" 将被忽略。", diff --git a/packages/plugins/@nocobase/plugin-workflow-test/src/server/instructions.ts b/packages/plugins/@nocobase/plugin-workflow-test/src/server/instructions.ts index 772bb68e1..50c8855db 100644 --- a/packages/plugins/@nocobase/plugin-workflow-test/src/server/instructions.ts +++ b/packages/plugins/@nocobase/plugin-workflow-test/src/server/instructions.ts @@ -19,6 +19,15 @@ export default { }, }, + echoVariable: { + run({ id, config = {} }: any, job, processor) { + return { + status: 1, + result: config.variable ? processor.getParsedValue(config.variable, id) : null, + }; + }, + }, + error: { run(node, input, processor) { throw new Error('definite error'); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/components/Calculation.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/components/Calculation.tsx new file mode 100644 index 000000000..627ebdd93 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/components/Calculation.tsx @@ -0,0 +1,351 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Registry } from '@nocobase/utils/client'; +import React, { createContext, useCallback, useContext } from 'react'; +import { lang, NAMESPACE } from '../locale'; +import { css, cx, useCompile, Variable } from '@nocobase/client'; +import { useWorkflowVariableOptions } from '../variable'; +import { Button, Select } from 'antd'; +import { Trans, useTranslation } from 'react-i18next'; +import { CloseCircleOutlined } from '@ant-design/icons'; + +interface Calculator { + name: string; + type: 'boolean' | 'number' | 'string' | 'date' | 'unknown' | 'null' | 'array'; + group: string; +} + +export const calculators = new Registry(); + +calculators.register('equal', { + name: '=', + type: 'boolean', + group: 'boolean', +}); +calculators.register('notEqual', { + name: '≠', + type: 'boolean', + group: 'boolean', +}); +calculators.register('gt', { + name: '>', + type: 'boolean', + group: 'boolean', +}); +calculators.register('gte', { + name: '≥', + type: 'boolean', + group: 'boolean', +}); +calculators.register('lt', { + name: '<', + type: 'boolean', + group: 'boolean', +}); +calculators.register('lte', { + name: '≤', + type: 'boolean', + group: 'boolean', +}); + +calculators.register('add', { + name: '+', + type: 'number', + group: 'number', +}); +calculators.register('minus', { + name: '-', + type: 'number', + group: 'number', +}); +calculators.register('multiple', { + name: '*', + type: 'number', + group: 'number', +}); +calculators.register('divide', { + name: '/', + type: 'number', + group: 'number', +}); +calculators.register('mod', { + name: '%', + type: 'number', + group: 'number', +}); + +calculators.register('includes', { + name: '{{t("contains")}}', + type: 'boolean', + group: 'string', +}); +calculators.register('notIncludes', { + name: '{{t("does not contain")}}', + type: 'boolean', + group: 'string', +}); +calculators.register('startsWith', { + name: '{{t("starts with")}}', + type: 'boolean', + group: 'string', +}); +calculators.register('notStartsWith', { + name: '{{t("not starts with")}}', + type: 'boolean', + group: 'string', +}); +calculators.register('endsWith', { + name: '{{t("ends with")}}', + type: 'boolean', + group: 'string', +}); +calculators.register('notEndsWith', { + name: '{{t("not ends with")}}', + type: 'boolean', + group: 'string', +}); +calculators.register('concat', { + name: `{{t("concat", { ns: "${NAMESPACE}" })}}`, + type: 'string', + group: 'string', +}); + +const calculatorGroups = [ + { + value: 'boolean', + title: '{{t("Comparision")}}', + }, + { + value: 'number', + title: `{{t("Arithmetic calculation", { ns: "${NAMESPACE}" })}}`, + }, + { + value: 'string', + title: `{{t("String operation", { ns: "${NAMESPACE}" })}}`, + }, + { + value: 'date', + title: `{{t("Date", { ns: "${NAMESPACE}" })}}`, + }, +]; + +function getGroupCalculators(group) { + return Array.from(calculators.getEntities()).filter(([key, value]) => value.group === group); +} + +function Calculation({ calculator, operands = [], onChange }) { + const compile = useCompile(); + const useVariableHook = useContext(VariableHookContext); + const leftOptions = useVariableHook(); + const rightOptions = useVariableHook(); + const leftOperandOnChange = useCallback( + (v) => onChange({ calculator, operands: [v, operands[1]] }), + [calculator, onChange, operands], + ); + const rightOperandOnChange = useCallback( + (v) => onChange({ calculator, operands: [operands[0], v] }), + [calculator, onChange, operands], + ); + const operatorOnChange = useCallback((v) => onChange({ operands, calculator: v }), [onChange, operands]); + + return ( +
+ + + +
+ ); +} + +function CalculationItem({ value, onChange, onRemove }) { + if (!value) { + return null; + } + + const { calculator, operands = [] } = value; + + return ( +
+ {value.group ? ( + onChange({ ...value, group })} /> + ) : ( + + )} +
+ ); +} + +function CalculationGroup({ value, onChange }) { + const { t } = useTranslation(); + const { type = 'and', calculations = [] } = value; + + const onAddSingle = useCallback(() => { + onChange({ + ...value, + calculations: [...calculations, { not: false, calculator: 'equal' }], + }); + }, [value, calculations, onChange]); + + const onAddGroup = useCallback(() => { + onChange({ + ...value, + calculations: [...calculations, { not: false, group: { type: 'and', calculations: [] } }], + }); + }, [value, calculations, onChange]); + + const onRemove = useCallback( + (i: number) => { + calculations.splice(i, 1); + onChange({ ...value, calculations: [...calculations] }); + }, + [value, calculations, onChange], + ); + + const onItemChange = useCallback( + (i: number, v) => { + calculations.splice(i, 1, v); + + onChange({ ...value, calculations: [...calculations] }); + }, + [value, calculations, onChange], + ); + + return ( +
+
+ + {'Meet '} + + {' conditions in the group'} + +
+
+ {calculations.map((calculation, i) => ( + onRemove(i)} + /> + ))} +
+
+ + +
+
+ ); +} + +const VariableHookContext = createContext(useWorkflowVariableOptions); + +export function CalculationConfig({ value, onChange, useVariableHook = useWorkflowVariableOptions }) { + const rule = value && Object.keys(value).length ? value : { group: { type: 'and', calculations: [] } }; + return ( + + onChange({ ...rule, group })} /> + + ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/components/index.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/components/index.ts index e2653ed99..1336bed8a 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/components/index.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/components/index.ts @@ -15,3 +15,5 @@ export * from './RadioWithTooltip'; export * from './CheckboxGroupWithTooltip'; export * from './ValueBlock'; export * from './SimpleDesigner'; +export * from './renderEngineReference'; +export * from './Calculation'; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/condition.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/condition.tsx index 1393e2865..a4c70cd44 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/condition.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/condition.tsx @@ -7,13 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { CloseCircleOutlined } from '@ant-design/icons'; -import { css, cx, useCompile, Variable } from '@nocobase/client'; import { evaluators } from '@nocobase/evaluators/client'; -import { Registry } from '@nocobase/utils/client'; -import { Button, Select } from 'antd'; -import React, { useCallback } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Instruction, NodeDefaultView } from '.'; import { Branch } from '../Branch'; import { RadioWithTooltip, RadioWithTooltipOption } from '../components/RadioWithTooltip'; @@ -22,333 +18,7 @@ import { useFlowContext } from '../FlowContext'; import { lang, NAMESPACE } from '../locale'; import useStyles from '../style'; import { useWorkflowVariableOptions, WorkflowVariableTextArea } from '../variable'; - -interface Calculator { - name: string; - type: 'boolean' | 'number' | 'string' | 'date' | 'unknown' | 'null' | 'array'; - group: string; -} - -export const calculators = new Registry(); - -calculators.register('equal', { - name: '=', - type: 'boolean', - group: 'boolean', -}); -calculators.register('notEqual', { - name: '≠', - type: 'boolean', - group: 'boolean', -}); -calculators.register('gt', { - name: '>', - type: 'boolean', - group: 'boolean', -}); -calculators.register('gte', { - name: '≥', - type: 'boolean', - group: 'boolean', -}); -calculators.register('lt', { - name: '<', - type: 'boolean', - group: 'boolean', -}); -calculators.register('lte', { - name: '≤', - type: 'boolean', - group: 'boolean', -}); - -calculators.register('add', { - name: '+', - type: 'number', - group: 'number', -}); -calculators.register('minus', { - name: '-', - type: 'number', - group: 'number', -}); -calculators.register('multiple', { - name: '*', - type: 'number', - group: 'number', -}); -calculators.register('divide', { - name: '/', - type: 'number', - group: 'number', -}); -calculators.register('mod', { - name: '%', - type: 'number', - group: 'number', -}); - -calculators.register('includes', { - name: '{{t("contains")}}', - type: 'boolean', - group: 'string', -}); -calculators.register('notIncludes', { - name: '{{t("does not contain")}}', - type: 'boolean', - group: 'string', -}); -calculators.register('startsWith', { - name: '{{t("starts with")}}', - type: 'boolean', - group: 'string', -}); -calculators.register('notStartsWith', { - name: '{{t("not starts with")}}', - type: 'boolean', - group: 'string', -}); -calculators.register('endsWith', { - name: '{{t("ends with")}}', - type: 'boolean', - group: 'string', -}); -calculators.register('notEndsWith', { - name: '{{t("not ends with")}}', - type: 'boolean', - group: 'string', -}); -calculators.register('concat', { - name: `{{t("concat", { ns: "${NAMESPACE}" })}}`, - type: 'string', - group: 'string', -}); - -const calculatorGroups = [ - { - value: 'boolean', - title: '{{t("Comparision")}}', - }, - { - value: 'number', - title: `{{t("Arithmetic calculation", { ns: "${NAMESPACE}" })}}`, - }, - { - value: 'string', - title: `{{t("String operation", { ns: "${NAMESPACE}" })}}`, - }, - { - value: 'date', - title: `{{t("Date", { ns: "${NAMESPACE}" })}}`, - }, -]; - -function getGroupCalculators(group) { - return Array.from(calculators.getEntities()).filter(([key, value]) => value.group === group); -} - -function Calculation({ calculator, operands = [], onChange }) { - const compile = useCompile(); - const leftOptions = useWorkflowVariableOptions(); - const rightOptions = useWorkflowVariableOptions(); - const leftOperandOnChange = useCallback( - (v) => onChange({ calculator, operands: [v, operands[1]] }), - [calculator, onChange, operands], - ); - const rightOperandOnChange = useCallback( - (v) => onChange({ calculator, operands: [operands[0], v] }), - [calculator, onChange, operands], - ); - const operatorOnChange = useCallback((v) => onChange({ operands, calculator: v }), [onChange, operands]); - - return ( -
- - - -
- ); -} - -function CalculationItem({ value, onChange, onRemove }) { - if (!value) { - return null; - } - - const { calculator, operands = [] } = value; - - return ( -
- {value.group ? ( - onChange({ ...value, group })} /> - ) : ( - - )} -
- ); -} - -function CalculationGroup({ value, onChange }) { - const { t } = useTranslation(); - const { type = 'and', calculations = [] } = value; - - const onAddSingle = useCallback(() => { - onChange({ - ...value, - calculations: [...calculations, { not: false, calculator: 'equal' }], - }); - }, [value, calculations, onChange]); - - const onAddGroup = useCallback(() => { - onChange({ - ...value, - calculations: [...calculations, { not: false, group: { type: 'and', calculations: [] } }], - }); - }, [value, calculations, onChange]); - - const onRemove = useCallback( - (i: number) => { - calculations.splice(i, 1); - onChange({ ...value, calculations: [...calculations] }); - }, - [value, calculations, onChange], - ); - - const onItemChange = useCallback( - (i: number, v) => { - calculations.splice(i, 1, v); - - onChange({ ...value, calculations: [...calculations] }); - }, - [value, calculations, onChange], - ); - - return ( -
-
- - {'Meet '} - - {' conditions in the group'} - -
-
- {calculations.map((calculation, i) => ( - onRemove(i)} - /> - ))} -
-
- - -
-
- ); -} - -function CalculationConfig({ value, onChange }) { - const rule = value && Object.keys(value).length ? value : { group: { type: 'and', calculations: [] } }; - return onChange({ ...rule, group })} />; -} +import { CalculationConfig } from '../components/Calculation'; export default class extends Instruction { title = `{{t("Condition", { ns: "${NAMESPACE}" })}}`; @@ -390,7 +60,7 @@ export default class extends Instruction { default: 'basic', }, calculation: { - type: 'string', + type: 'object', title: `{{t("Condition", { ns: "${NAMESPACE}" })}}`, 'x-decorator': 'FormItem', 'x-component': 'CalculationConfig', diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/index.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/index.tsx index f2c7acbd9..296dcc6d5 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/index.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/nodes/index.tsx @@ -184,19 +184,16 @@ export function RemoveButton() { const current = useNodeContext(); const { modal } = App.useApp(); - if (!workflow) { - return null; - } const resource = api.resource('flow_nodes'); - async function onRemove() { - async function onOk() { - await resource.destroy?.({ - filterByTk: current.id, - }); - refresh(); - } + const onOk = useCallback(async () => { + await resource.destroy?.({ + filterByTk: current.id, + }); + refresh(); + }, [current.id, refresh, resource]); + const onRemove = useCallback(async () => { const usingNodes = nodes.filter((node) => { if (node === current) { return false; @@ -230,6 +227,10 @@ export function RemoveButton() { content: message, onOk, }); + }, [current, modal, nodes, onOk, t]); + + if (!workflow) { + return null; } return workflow.executed ? null : ( diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/Processor.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/Processor.ts index 420f7e086..85909d06b 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/server/Processor.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/server/Processor.ts @@ -259,10 +259,8 @@ export default class Processor { // TODO(optimize) /** * @experimental - * @param {JobModel | Record} payload - * @returns {JobModel} */ - async saveJob(payload) { + async saveJob(payload: JobModel | Record): Promise { const { database } = this.execution.constructor; const { transaction } = this; const { model } = database.getCollection('jobs'); @@ -377,7 +375,7 @@ export default class Processor { /** * @experimental */ - public getScope(sourceNodeId: number) { + public getScope(sourceNodeId: number, includeSelfScope = false) { const node = this.nodesMap.get(sourceNodeId); const systemFns = {}; const scope = { @@ -389,9 +387,9 @@ export default class Processor { } const $scopes = {}; - for (let n = this.findBranchParentNode(node); n; n = this.findBranchParentNode(n)) { + for (let n = includeSelfScope ? node : this.findBranchParentNode(node); n; n = this.findBranchParentNode(n)) { const instruction = this.options.plugin.instructions.get(n.type); - if (typeof instruction.getScope === 'function') { + if (typeof instruction?.getScope === 'function') { $scopes[n.id] = $scopes[n.key] = instruction.getScope(n, this.jobsMapByNodeKey[n.key], this); } } @@ -407,9 +405,9 @@ export default class Processor { /** * @experimental */ - public getParsedValue(value, sourceNodeId: number, additionalScope?: object) { + public getParsedValue(value, sourceNodeId: number, { additionalScope = {}, includeSelfScope = false } = {}) { const template = parse(value); - const scope = Object.assign(this.getScope(sourceNodeId), additionalScope); + const scope = Object.assign(this.getScope(sourceNodeId, includeSelfScope), additionalScope); template.parameters.forEach(({ key }) => { appendArrayColumn(scope, key); }); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/index.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/index.ts index d7baac1bf..0e679da7f 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/server/index.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/server/index.ts @@ -11,6 +11,7 @@ export * from './utils'; export * from './constants'; export * from './instructions'; export * from './functions'; +export * from './logicCalculate'; export { Trigger } from './triggers'; export { default as Processor } from './Processor'; export { default } from './Plugin'; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/ConditionInstruction.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/ConditionInstruction.ts index ab4035237..f9eaecd84 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/ConditionInstruction.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/server/instructions/ConditionInstruction.ts @@ -8,13 +8,11 @@ */ import { evaluators } from '@nocobase/evaluators'; -import { Registry } from '@nocobase/utils'; import { Instruction } from '.'; import type Processor from '../Processor'; import { JOB_STATUS } from '../constants'; import type { FlowNodeModel, JobModel } from '../types'; - -type Comparer = (a: any, b: any) => boolean; +import { logicCalculate } from '../logicCalculate'; export const BRANCH_INDEX = { DEFAULT: null, @@ -22,115 +20,6 @@ export const BRANCH_INDEX = { ON_FALSE: 0, } as const; -export const calculators = new Registry(); - -// built-in functions -function equal(a, b) { - return a == b; -} - -function notEqual(a, b) { - return a != b; -} - -function gt(a, b) { - return a > b; -} - -function gte(a, b) { - return a >= b; -} - -function lt(a, b) { - return a < b; -} - -function lte(a, b) { - return a <= b; -} - -calculators.register('equal', equal); -calculators.register('notEqual', notEqual); -calculators.register('gt', gt); -calculators.register('gte', gte); -calculators.register('lt', lt); -calculators.register('lte', lte); - -calculators.register('==', equal); -calculators.register('!=', notEqual); -calculators.register('>', gt); -calculators.register('>=', gte); -calculators.register('<', lt); -calculators.register('<=', lte); - -function includes(a, b) { - return a.includes(b); -} - -function notIncludes(a, b) { - return !a.includes(b); -} - -function startsWith(a: string, b: string) { - return a.startsWith(b); -} - -function notStartsWith(a: string, b: string) { - return !a.startsWith(b); -} - -function endsWith(a: string, b: string) { - return a.endsWith(b); -} - -function notEndsWith(a: string, b: string) { - return !a.endsWith(b); -} - -calculators.register('includes', includes); -calculators.register('notIncludes', notIncludes); -calculators.register('startsWith', startsWith); -calculators.register('notStartsWith', notStartsWith); -calculators.register('endsWith', endsWith); -calculators.register('notEndsWith', notEndsWith); - -type CalculationItem = { - calculator?: string; - operands?: [any?, any?]; -}; - -type CalculationGroup = { - group: { - type: 'and' | 'or'; - calculations?: Calculation[]; - }; -}; - -type Calculation = CalculationItem | CalculationGroup; - -function calculate(calculation: CalculationItem = {}) { - type NewType = Comparer; - - let fn: NewType; - if (!(calculation.calculator && (fn = calculators.get(calculation.calculator)))) { - throw new Error(`no calculator function registered for "${calculation.calculator}"`); - } - return Boolean(fn(...(calculation.operands ?? []))); -} - -function logicCalculate(calculation?: Calculation) { - if (!calculation) { - return true; - } - - if (typeof calculation['group'] === 'object') { - const method = calculation['group'].type === 'and' ? 'every' : 'some'; - return (calculation['group'].calculations ?? [])[method]((item: Calculation) => logicCalculate(item)); - } - - return calculate(calculation as CalculationItem); -} - export class ConditionInstruction extends Instruction { async run(node: FlowNodeModel, prevJob, processor: Processor) { const { engine, calculation, expression, rejectOnFalse } = node.config || {}; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/logicCalculate.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/logicCalculate.ts new file mode 100644 index 000000000..737ed3359 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/server/logicCalculate.ts @@ -0,0 +1,127 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { Registry } from '@nocobase/utils'; + +type Comparer = (a: any, b: any) => boolean; + +export const calculators = new Registry(); + +// built-in functions +function equal(a, b) { + return a == b; +} + +function notEqual(a, b) { + return a != b; +} + +function gt(a, b) { + return a > b; +} + +function gte(a, b) { + return a >= b; +} + +function lt(a, b) { + return a < b; +} + +function lte(a, b) { + return a <= b; +} + +calculators.register('equal', equal); +calculators.register('notEqual', notEqual); +calculators.register('gt', gt); +calculators.register('gte', gte); +calculators.register('lt', lt); +calculators.register('lte', lte); + +calculators.register('==', equal); +calculators.register('!=', notEqual); +calculators.register('>', gt); +calculators.register('>=', gte); +calculators.register('<', lt); +calculators.register('<=', lte); + +function includes(a, b) { + return a.includes(b); +} + +function notIncludes(a, b) { + return !a.includes(b); +} + +function startsWith(a: string, b: string) { + return a.startsWith(b); +} + +function notStartsWith(a: string, b: string) { + return !a.startsWith(b); +} + +function endsWith(a: string, b: string) { + return a.endsWith(b); +} + +function notEndsWith(a: string, b: string) { + return !a.endsWith(b); +} + +calculators.register('includes', includes); +calculators.register('notIncludes', notIncludes); +calculators.register('startsWith', startsWith); +calculators.register('notStartsWith', notStartsWith); +calculators.register('endsWith', endsWith); +calculators.register('notEndsWith', notEndsWith); + +type CalculationItem = { + calculator?: string; + operands?: [any?, any?]; +}; + +type CalculationGroup = { + group: { + type: 'and' | 'or'; + calculations?: Calculation[]; + }; +}; + +type Calculation = CalculationItem | CalculationGroup; + +function calculate(calculation: CalculationItem = {}): boolean { + let fn: Comparer; + if (!calculation.calculator || !calculation.operands?.length) { + return true; + } + if (!(fn = calculators.get(calculation.calculator))) { + throw new Error(`no calculator function registered for "${calculation.calculator}"`); + } + return Boolean(fn(...(calculation.operands ?? []))); +} + +const GroupTypeMethodMap = { + and: 'every', + or: 'some', +}; + +export function logicCalculate(calculation?: Calculation) { + if (!calculation) { + return true; + } + + if (typeof calculation['group'] === 'object') { + const method = GroupTypeMethodMap[calculation['group'].type]; + return (calculation['group'].calculations ?? [])[method]((item: Calculation) => logicCalculate(item)); + } + + return calculate(calculation as CalculationItem); +}