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
This commit is contained in:
Junyi 2024-10-16 21:15:35 +08:00 committed by GitHub
parent d30644cdab
commit 05b9703101
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1631 additions and 724 deletions

View File

@ -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);

View File

@ -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"

View File

@ -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 (
<>
<Checkbox
checked={Boolean(value)}
onChange={(ev) => {
onChange(
ev.target.checked
? { checkpoint: 0, continueOnFalse: false, calculation: { group: { type: 'and', calculations: [] } } }
: false,
);
}}
>
{t('Enable loop condition', { ns: NAMESPACE })}
</Checkbox>
{value ? (
<Card>
<FormLayout layout="vertical">
<FormItem label={t('Condition', { ns: NAMESPACE })}>
<CalculationConfig
value={value.calculation}
onChange={onCalculationChange}
useVariableHook={useVariableHook}
/>
</FormItem>
<FormItem label={t('When to check', { ns: NAMESPACE })}>
<RadioWithTooltip
value={value.checkpoint}
onChange={onCheckpointChange}
options={[
{
label: t('Before each starts', { ns: NAMESPACE }),
value: 0,
},
{
label: t('After each ends', { ns: NAMESPACE }),
value: 1,
},
]}
/>
</FormItem>
<FormItem label={t('When condition is not met on item', { ns: NAMESPACE })}>
<RadioWithTooltip
value={value.continueOnFalse}
onChange={onContinueOnFalseChange}
options={[
{
label: t('Exit loop', { ns: NAMESPACE }),
value: false,
},
{
label: t('Continue on next item', { ns: NAMESPACE }),
value: true,
},
]}
/>
</FormItem>
</FormLayout>
</Card>
) : 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<any>();
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 <Variable.TextArea scope={scope} {...props} />;
}
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 {
</NodeDefaultView>
);
}
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;
}

View File

@ -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,
};

View File

@ -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": "退出工作流"
}

View File

@ -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,
};

View File

@ -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);
});
});

View File

@ -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 <https://www.nocobase.com/agreement>
*/
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 });
}
}
});
}
}

View File

@ -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(),
},
},
});

View File

@ -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\" 将被忽略。",

View File

@ -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');

View File

@ -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<Calculator>();
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 (
<fieldset
className={css`
display: flex;
gap: 0.5em;
align-items: center;
flex-wrap: wrap;
`}
>
<Variable.Input
changeOnSelect
value={operands[0]}
onChange={leftOperandOnChange}
scope={leftOptions}
useTypedConstant
/>
<Select
// @ts-ignore
role="button"
aria-label="select-operator-calc"
value={calculator}
onChange={operatorOnChange}
placeholder={lang('Operator')}
popupMatchSelectWidth={false}
className="auto-width"
>
{calculatorGroups
.filter((group) => Boolean(getGroupCalculators(group.value).length))
.map((group) => (
<Select.OptGroup key={group.value} label={compile(group.title)}>
{getGroupCalculators(group.value).map(([value, { name }]) => (
<Select.Option key={value} value={value}>
{compile(name)}
</Select.Option>
))}
</Select.OptGroup>
))}
</Select>
<Variable.Input
changeOnSelect
value={operands[1]}
onChange={rightOperandOnChange}
scope={rightOptions}
useTypedConstant
/>
</fieldset>
);
}
function CalculationItem({ value, onChange, onRemove }) {
if (!value) {
return null;
}
const { calculator, operands = [] } = value;
return (
<div
className={css`
display: flex;
position: relative;
margin: 0.5em 0;
`}
>
{value.group ? (
<CalculationGroup value={value.group} onChange={(group) => onChange({ ...value, group })} />
) : (
<Calculation operands={operands} calculator={calculator} onChange={onChange} />
)}
<Button aria-label="icon-close" onClick={onRemove} type="link" icon={<CloseCircleOutlined />} />
</div>
);
}
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 (
<div
className={cx(
'node-type-condition-group',
css`
position: relative;
width: 100%;
.node-type-condition-group {
padding: 0.5em 1em;
border: 1px dashed #ddd;
}
+ button {
position: absolute;
right: 0;
}
`,
)}
>
<div
className={css`
display: flex;
align-items: center;
gap: 0.5em;
.ant-select {
width: auto;
min-width: 6em;
}
`}
>
<Trans>
{'Meet '}
<Select
// @ts-ignore
role="button"
data-testid="filter-select-all-or-any"
value={type}
onChange={(t) => onChange({ ...value, type: t })}
>
<Select.Option value="and">All</Select.Option>
<Select.Option value="or">Any</Select.Option>
</Select>
{' conditions in the group'}
</Trans>
</div>
<div className="calculation-items">
{calculations.map((calculation, i) => (
<CalculationItem
key={`${calculation.calculator}_${i}`}
value={calculation}
onChange={onItemChange.bind(this, i)}
onRemove={() => onRemove(i)}
/>
))}
</div>
<div
className={css`
button {
padding: 0;
&:not(:last-child) {
margin-right: 1em;
}
}
`}
>
<Button type="link" onClick={onAddSingle}>
{t('Add condition')}
</Button>
<Button type="link" onClick={onAddGroup}>
{t('Add condition group')}
</Button>
</div>
</div>
);
}
const VariableHookContext = createContext(useWorkflowVariableOptions);
export function CalculationConfig({ value, onChange, useVariableHook = useWorkflowVariableOptions }) {
const rule = value && Object.keys(value).length ? value : { group: { type: 'and', calculations: [] } };
return (
<VariableHookContext.Provider value={useVariableHook}>
<CalculationGroup value={rule.group} onChange={(group) => onChange({ ...rule, group })} />
</VariableHookContext.Provider>
);
}

View File

@ -15,3 +15,5 @@ export * from './RadioWithTooltip';
export * from './CheckboxGroupWithTooltip';
export * from './ValueBlock';
export * from './SimpleDesigner';
export * from './renderEngineReference';
export * from './Calculation';

View File

@ -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<Calculator>();
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 (
<fieldset
className={css`
display: flex;
gap: 0.5em;
align-items: center;
flex-wrap: wrap;
`}
>
<Variable.Input
changeOnSelect
value={operands[0]}
onChange={leftOperandOnChange}
scope={leftOptions}
useTypedConstant
/>
<Select
// @ts-ignore
role="button"
aria-label="select-operator-calc"
value={calculator}
onChange={operatorOnChange}
placeholder={lang('Operator')}
popupMatchSelectWidth={false}
className="auto-width"
>
{calculatorGroups
.filter((group) => Boolean(getGroupCalculators(group.value).length))
.map((group) => (
<Select.OptGroup key={group.value} label={compile(group.title)}>
{getGroupCalculators(group.value).map(([value, { name }]) => (
<Select.Option key={value} value={value}>
{compile(name)}
</Select.Option>
))}
</Select.OptGroup>
))}
</Select>
<Variable.Input
changeOnSelect
value={operands[1]}
onChange={rightOperandOnChange}
scope={rightOptions}
useTypedConstant
/>
</fieldset>
);
}
function CalculationItem({ value, onChange, onRemove }) {
if (!value) {
return null;
}
const { calculator, operands = [] } = value;
return (
<div
className={css`
display: flex;
position: relative;
margin: 0.5em 0;
`}
>
{value.group ? (
<CalculationGroup value={value.group} onChange={(group) => onChange({ ...value, group })} />
) : (
<Calculation operands={operands} calculator={calculator} onChange={onChange} />
)}
<Button aria-label="icon-close" onClick={onRemove} type="link" icon={<CloseCircleOutlined />} />
</div>
);
}
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 (
<div
className={cx(
'node-type-condition-group',
css`
position: relative;
width: 100%;
.node-type-condition-group {
padding: 0.5em 1em;
border: 1px dashed #ddd;
}
+ button {
position: absolute;
right: 0;
}
`,
)}
>
<div
className={css`
display: flex;
align-items: center;
gap: 0.5em;
.ant-select {
width: auto;
min-width: 6em;
}
`}
>
<Trans>
{'Meet '}
<Select
// @ts-ignore
role="button"
data-testid="filter-select-all-or-any"
value={type}
onChange={(t) => onChange({ ...value, type: t })}
>
<Select.Option value="and">All</Select.Option>
<Select.Option value="or">Any</Select.Option>
</Select>
{' conditions in the group'}
</Trans>
</div>
<div className="calculation-items">
{calculations.map((calculation, i) => (
<CalculationItem
key={`${calculation.calculator}_${i}`}
value={calculation}
onChange={onItemChange.bind(this, i)}
onRemove={() => onRemove(i)}
/>
))}
</div>
<div
className={css`
button {
padding: 0;
&:not(:last-child) {
margin-right: 1em;
}
}
`}
>
<Button type="link" onClick={onAddSingle}>
{t('Add condition')}
</Button>
<Button type="link" onClick={onAddGroup}>
{t('Add condition group')}
</Button>
</div>
</div>
);
}
function CalculationConfig({ value, onChange }) {
const rule = value && Object.keys(value).length ? value : { group: { type: 'and', calculations: [] } };
return <CalculationGroup value={rule.group} onChange={(group) => 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',

View File

@ -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 : (

View File

@ -259,10 +259,8 @@ export default class Processor {
// TODO(optimize)
/**
* @experimental
* @param {JobModel | Record<string, any>} payload
* @returns {JobModel}
*/
async saveJob(payload) {
async saveJob(payload: JobModel | Record<string, any>): Promise<JobModel> {
const { database } = <typeof ExecutionModel>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);
});

View File

@ -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';

View File

@ -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<Comparer>();
// 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 || {};

View File

@ -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<Comparer>();
// 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);
}