diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json
index 465dd102e..ed7efd901 100644
--- a/packages/core/client/src/locale/zh-CN.json
+++ b/packages/core/client/src/locale/zh-CN.json
@@ -262,11 +262,11 @@
"String": "字符串",
"Password": "密码",
"Advanced type": "高级类型",
- "Formula": "公式",
- "Formula description": "基于同一条记录中的其他字段计算出一个值。",
"Syntax references": "语法参考",
- "Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types": "Math.js 包含大量内置函数和常量,并提供了集成的解决方案来处理不同的数据类型。",
+ "Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types.": "Math.js 包含大量内置函数和常量,并提供了集成的解决方案来处理不同的数据类型。",
"Formula.js supports most Microsoft Excel formula functions.": "Formula.js 支持大部分 Mircrosoft Excel 公式。",
+ "String template": "字符串模板",
+ "Simple string replacement, can be used to interpolate variables in a string.": "简单的字符串替换,可以用于在字符串中插入变量。",
"Choices": "选择类型",
"Checkbox": "勾选",
"Display when unchecked": "未勾选时显示 ",
diff --git a/packages/core/evaluators/src/client/engines/formulajs.ts b/packages/core/evaluators/src/client/engines/formulajs.ts
index 64fecc84b..2a96a1262 100644
--- a/packages/core/evaluators/src/client/engines/formulajs.ts
+++ b/packages/core/evaluators/src/client/engines/formulajs.ts
@@ -1,9 +1,8 @@
-import { evaluate } from '../../utils';
import formulajs from '../../utils/formulajs';
export default {
label: 'Formula.js',
tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}',
link: 'https://formulajs.info/functions/',
- evaluate: evaluate.bind(formulajs),
+ evaluate: formulajs,
};
diff --git a/packages/core/evaluators/src/client/engines/mathjs.ts b/packages/core/evaluators/src/client/engines/mathjs.ts
index b5d93845f..6a9930055 100644
--- a/packages/core/evaluators/src/client/engines/mathjs.ts
+++ b/packages/core/evaluators/src/client/engines/mathjs.ts
@@ -1,9 +1,8 @@
-import { evaluate } from '../../utils';
import mathjs from '../../utils/mathjs';
export default {
label: 'Math.js',
- tooltip: `{{t('Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types')}}`,
+ tooltip: `{{t('Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types.')}}`,
link: 'https://mathjs.org/',
- evaluate: evaluate.bind(mathjs),
+ evaluate: mathjs,
};
diff --git a/packages/core/evaluators/src/client/engines/string.ts b/packages/core/evaluators/src/client/engines/string.ts
new file mode 100644
index 000000000..6936eaca4
--- /dev/null
+++ b/packages/core/evaluators/src/client/engines/string.ts
@@ -0,0 +1,7 @@
+import string from '../../utils/string';
+
+export default {
+ label: `{{t('String template')}}`,
+ tooltip: `{{t('Simple string replacement, can be used to interpolate variables in a string.')}}`,
+ evaluate: string,
+};
diff --git a/packages/core/evaluators/src/client/index.tsx b/packages/core/evaluators/src/client/index.tsx
index 6bd459103..882ae2440 100644
--- a/packages/core/evaluators/src/client/index.tsx
+++ b/packages/core/evaluators/src/client/index.tsx
@@ -2,6 +2,7 @@ import { Registry } from '@nocobase/utils/client';
import mathjs from './engines/mathjs';
import formulajs from './engines/formulajs';
+import string from './engines/string';
export interface Evaluator {
label: string;
@@ -14,6 +15,7 @@ export const evaluators = new Registry();
evaluators.register('math.js', mathjs);
evaluators.register('formula.js', formulajs);
+evaluators.register('string', string);
export function getOptions() {
return Array.from((evaluators as Registry).getEntities()).reduce(
diff --git a/packages/core/evaluators/src/server/__tests__/index.test.ts b/packages/core/evaluators/src/server/__tests__/index.test.ts
index 056aab05f..d849c78b4 100644
--- a/packages/core/evaluators/src/server/__tests__/index.test.ts
+++ b/packages/core/evaluators/src/server/__tests__/index.test.ts
@@ -1,89 +1,197 @@
+import { evaluate } from '../../utils';
import evaluators from '..';
-const mathEval = evaluators.get('math.js');
-const formulaEval = evaluators.get('formula.js');
-
describe('evaluate', () => {
- it('reference null or undefined', () => {
- const result = formulaEval('{{a.b}}', { a: null });
- expect(result).toBeNull();
- });
-
- it('function result string with quote', () => {
- const result = formulaEval('{{a}}', {
- a() {
- return "I'm done.";
- },
+ describe('pre-process', () => {
+ const preProcess = evaluate.bind((expression, scope) => ({ expression, scope }), {});
+ const preProcessReplaceValue = evaluate.bind((expression, scope) => ({ expression, scope }), {
+ replaceValue: true,
});
- expect(result).toBe("I'm done.");
- });
- it('function result null', () => {
- const result = formulaEval('{{a}}', {
- a() {
- return null;
- },
+ it('only variable as null', () => {
+ const { expression, scope } = preProcess('{{a}}', { a: null });
+ expect(expression).toBe(`$$0`);
+ expect(scope.$$0).toBeNull();
});
- expect(result).toBe(null);
- });
- it('function result number', () => {
- const result = formulaEval('{{a}}', {
- a() {
- return 1;
- },
+ it('string containing variable', () => {
+ const { expression, scope } = preProcess('a{{a}}b', { a: 1 });
+ expect(expression).toBe(`a$$0b`);
+ expect(scope.$$0).toBe(1);
});
- expect(result).toBe(1);
- });
- it('function result Date', () => {
- const now = new Date();
- const result = formulaEval('{{a}}', {
- a() {
- return now;
- },
+ it('duplicated variable name in expression should be unique in new scope', () => {
+ const { expression, scope } = preProcess('{{a}} {{a}}', { a: 1 });
+ expect(expression).toBe(`$$0 $$0`);
+ expect(Object.keys(scope).length).toBe(1);
+ expect(scope.$$0).toBe(1);
+ });
+
+ it('number path to array item 0', () => {
+ const { expression, scope } = preProcess('{{a.0}}', { a: [1, 2, 3] });
+ expect(expression).toBe(`$$0`);
+ expect(scope.$$0).toBe(1);
+ });
+
+ it('number path to array item 1', () => {
+ const { expression, scope } = preProcess('{{a.1}}', { a: [1, 2, 3] });
+ expect(expression).toBe(`$$0`);
+ expect(scope.$$0).toBe(2);
+ });
+
+ it('deep array path', () => {
+ const { expression, scope } = preProcess('{{a.b}}', { a: [{ b: 1 }, { b: 2 }] });
+ expect(expression).toBe(`$$0`);
+ expect(scope.$$0).toEqual([1, 2]);
+ });
+
+ it('pre-process replace value', () => {
+ const { expression, scope } = preProcessReplaceValue('a{{a}}b', { a: 1 });
+ expect(expression).toBe(`a1b`);
+ expect(scope.$$0).toBe(1);
});
- expect(result).toBeInstanceOf(Date);
- expect(result).toBe(now);
});
- it('deep array', () => {
- const result = formulaEval('{{a.b}}', { a: [{ b: 1 }, { b: 2 }] });
- expect(result).toEqual([1, 2]);
+ describe('math.js', () => {
+ const mathEval = evaluators.get('math.js');
+
+ it('number path to array item 0 (math.js)', () => {
+ expect(() => mathEval('{{a}}[0]', { a: [1, 2, 3] })).toThrow();
+ });
+
+ it('number path to array item 1 (math.js)', () => {
+ const result = mathEval('{{a}}[1]', { a: [1, 2, 3] });
+ expect(result).toBe(1);
+ });
+
+ it('number path to array item 0 (math.js)', () => {
+ const result = mathEval('{{a.0}}', { a: [1, 2, 3] });
+ expect(result).toBe(1);
+ });
+
+ it('number path to object member 0 (math.js)', () => {
+ const result = mathEval('{{a.1}}', { a: { 1: 1 } });
+ expect(result).toBe(1);
+ });
+
+ it('string expression with space', () => {
+ const result = mathEval('{{a}} + 1', { a: 1 });
+ expect(result).toBe(2);
+ });
});
- it('key with dash', () => {
- const result = formulaEval('{{a-b}}', { 'a-b': 1 });
- expect(result).toBe(1);
+ describe('formula.js', () => {
+ const formulaEval = evaluators.get('formula.js');
+
+ it('reference null or undefined', () => {
+ const result = formulaEval('{{a.b}}', { a: null });
+ expect(result).toBeNull();
+ });
+
+ it('function result string with quote', () => {
+ const result = formulaEval('{{a}}', {
+ a() {
+ return "I'm done.";
+ },
+ });
+ expect(result).toBe("I'm done.");
+ });
+
+ it('function result null', () => {
+ const result = formulaEval('{{a}}', {
+ a() {
+ return null;
+ },
+ });
+ expect(result).toBe(null);
+ });
+
+ it('function result number', () => {
+ const result = formulaEval('{{a}}', {
+ a() {
+ return 1;
+ },
+ });
+ expect(result).toBe(1);
+ });
+
+ it('function result Date', () => {
+ const now = new Date();
+ const result = formulaEval('{{a}}', {
+ a() {
+ return now;
+ },
+ });
+ expect(result).toBeInstanceOf(Date);
+ expect(result).toBe(now);
+ });
+
+ it('deep array', () => {
+ const result = formulaEval('{{a.b}}', { a: [{ b: 1 }, { b: 2 }] });
+ expect(result).toEqual([1, 2]);
+ });
+
+ it('key with dash', () => {
+ const result = formulaEval('{{a-b}}', { 'a-b': 1 });
+ expect(result).toBe(1);
+ });
+
+ it('deep key with dash', () => {
+ const result = formulaEval('{{a.b-c}}', { a: { 'b-c': 1 } });
+ expect(result).toBe(1);
+ });
+
+ it('number lead string path to object member (formula.js)', () => {
+ const result = formulaEval('{{a.1a}}', { a: { '1a': 1 } });
+ expect(result).toBe(1);
+ });
});
- it('deep key with dash', () => {
- const result = formulaEval('{{a.b-c}}', { a: { 'b-c': 1 } });
- expect(result).toBe(1);
- });
+ describe('string', () => {
+ const stringReplace = evaluators.get('string');
- it('number path to array item 1 (math.js)', () => {
- const result = mathEval('{{a}}[1]', { a: [1, 2, 3] });
- expect(result).toBe(1);
- });
+ it('only variable', () => {
+ const result = stringReplace('{{a}}', { a: 1 });
+ expect(result).toBe('1');
+ });
- // NOTE: This case is skipped because `a.` is not able to be configured from UI.
- it.skip('number path to array item 0 (math.js)', () => {
- expect(() => mathEval('{{a.0}}', { a: [1, 2, 3] })).toThrow();
- });
+ it('string containing undefined variable', () => {
+ const result = stringReplace('a{{a}}b', {});
+ expect(result).toBe('ab');
+ });
- it.skip('number path to array item 1 (math.js)', () => {
- const result = mathEval('{{a.1}}', { a: [1, 2, 3] });
- expect(result).toBe(1);
- });
+ it('string containing null variable', () => {
+ const result = stringReplace('a{{a}}b', { a: null });
+ expect(result).toBe('ab');
+ });
- it('number path to object member 0 (math.js)', () => {
- const result = mathEval('{{a.1}}', { a: { 1: 1 } });
- expect(result).toBe(1);
- });
+ it('string containing boolean variable', () => {
+ const result = stringReplace('a{{a}}b', { a: false });
+ expect(result).toBe('afalseb');
+ });
- it('number lead string path to object member (formula.js)', () => {
- const result = formulaEval('{{a.1a}}', { a: { '1a': 1 } });
- expect(result).toBe(1);
+ it('string containing number variable', () => {
+ const result = stringReplace('a{{a}}b', { a: 1 });
+ expect(result).toBe('a1b');
+ });
+
+ it('string containing NaN variable', () => {
+ const result = stringReplace('a{{a}}b', { a: Number.NaN });
+ expect(result).toBe('ab');
+ });
+
+ it('string containing infinite variable', () => {
+ const result = stringReplace('a{{a}}b', { a: Number.POSITIVE_INFINITY });
+ expect(result).toBe('ab');
+ });
+
+ it('string containing function variable', () => {
+ const result = stringReplace('a{{a}}b', {
+ a() {
+ return 1;
+ },
+ });
+ expect(result).toBe('a1b');
+ });
});
});
diff --git a/packages/core/evaluators/src/server/index.ts b/packages/core/evaluators/src/server/index.ts
index 4d143d98a..b89e089b0 100644
--- a/packages/core/evaluators/src/server/index.ts
+++ b/packages/core/evaluators/src/server/index.ts
@@ -1,14 +1,16 @@
import { Registry } from '@nocobase/utils';
-import { evaluate, Evaluator } from '../utils';
+import { Evaluator } from '../utils';
import mathjs from '../utils/mathjs';
import formulajs from '../utils/formulajs';
+import string from '../utils/string';
export { Evaluator, evaluate, appendArrayColumn } from '../utils';
export const evaluators = new Registry();
-evaluators.register('math.js', evaluate.bind(mathjs));
-evaluators.register('formula.js', evaluate.bind(formulajs));
+evaluators.register('math.js', mathjs);
+evaluators.register('formula.js', formulajs);
+evaluators.register('string', string);
export default evaluators;
diff --git a/packages/core/evaluators/src/utils/formulajs.ts b/packages/core/evaluators/src/utils/formulajs.ts
index f81196912..9f470824b 100644
--- a/packages/core/evaluators/src/utils/formulajs.ts
+++ b/packages/core/evaluators/src/utils/formulajs.ts
@@ -1,9 +1,11 @@
import * as functions from '@formulajs/formulajs';
+import { evaluate } from '.';
+
const fnNames = Object.keys(functions).filter((key) => key !== 'default');
const fns = fnNames.map((key) => functions[key]);
-export default function (expression: string, scope = {}) {
+export default evaluate.bind(function (expression: string, scope = {}) {
const fn = new Function(...fnNames, ...Object.keys(scope), `return ${expression}`);
const result = fn(...fns, ...Object.values(scope));
if (typeof result === 'number') {
@@ -13,4 +15,4 @@ export default function (expression: string, scope = {}) {
return functions.ROUND(result, 9);
}
return result;
-}
+}, {});
diff --git a/packages/core/evaluators/src/utils/index.ts b/packages/core/evaluators/src/utils/index.ts
index 609387b75..38fadad7d 100644
--- a/packages/core/evaluators/src/utils/index.ts
+++ b/packages/core/evaluators/src/utils/index.ts
@@ -17,24 +17,34 @@ export function appendArrayColumn(scope, key) {
}
}
-export function evaluate(this: Evaluator, expression: string, scope: Scope = {}) {
+interface EvaluatorOptions {
+ replaceValue?: boolean;
+}
+
+export function evaluate(this: Evaluator, options: EvaluatorOptions = {}, expression: string, scope: Scope = {}) {
const context = cloneDeep(scope);
const newContext = {};
- const exp = expression.trim().replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => {
+ const keyMap = {};
+ let index = 0;
+ const exp = expression.trim().replace(/{{\s*([\w$.-]+)\s*}}/g, (_, v) => {
appendArrayColumn(context, v);
- let item = get(context, v);
+ let item = get(context, v) ?? null;
if (typeof item === 'function') {
item = item();
}
- const randomKey = `$$${Math.random().toString(36).slice(2, 10).padEnd(8, '0')}`;
- if (item == null) {
- return 'null';
+ let key = keyMap[v];
+ if (!key) {
+ key = `$$${index++}`;
+ keyMap[v] = key;
+
+ newContext[key] = item;
}
- newContext[randomKey] = item;
- return ` ${randomKey} `;
+ return options.replaceValue
+ ? `${item == null || (typeof item === 'number' && (Number.isNaN(item) || !Number.isFinite(item))) ? '' : item}`
+ : key;
});
return this(exp, newContext);
diff --git a/packages/core/evaluators/src/utils/mathjs.ts b/packages/core/evaluators/src/utils/mathjs.ts
index d55a378e6..b2679a479 100644
--- a/packages/core/evaluators/src/utils/mathjs.ts
+++ b/packages/core/evaluators/src/utils/mathjs.ts
@@ -1,12 +1,17 @@
import * as math from 'mathjs';
-export default function (expression: string, scope = {}) {
- const result = math.evaluate(expression, scope);
- if (typeof result === 'number') {
- if (Number.isNaN(result) || !Number.isFinite(result)) {
- return null;
+import { evaluate } from '.';
+
+export default evaluate.bind(
+ function (expression: string, scope = {}) {
+ const result = math.evaluate(expression, scope);
+ if (typeof result === 'number') {
+ if (Number.isNaN(result) || !Number.isFinite(result)) {
+ return null;
+ }
+ return math.round(result, 9);
}
- return math.round(result, 9);
- }
- return result;
-}
+ return result;
+ },
+ { replaceKey: true },
+);
diff --git a/packages/core/evaluators/src/utils/string.ts b/packages/core/evaluators/src/utils/string.ts
new file mode 100644
index 000000000..f9ff96b48
--- /dev/null
+++ b/packages/core/evaluators/src/utils/string.ts
@@ -0,0 +1,8 @@
+import { evaluate } from '.';
+
+export default evaluate.bind(
+ function (expression: string, scope = {}) {
+ return expression;
+ },
+ { replaceValue: true },
+);
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json
index ecd3ab68f..4a074ac09 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json
+++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json
@@ -94,8 +94,8 @@
"Scope variables": "局域变量",
"Operator": "运算符",
- "Calculate an expression based on a calculation engine and obtain a value as the result. Variables in the upstream nodes can be used in the expression. The expression can be static or dynamic one from an expression collections.":
- "基于计算引擎对一个表达式进行计算,并获得一个值作为结果。表达式中可以使用上游节点里的变量。表达式可以是静态的,也可以是表达式表中的动态表达式。",
+ "Calculate an expression based on a calculation engine and obtain a value as the result. Variables in the upstream nodes can be used in the expression.":
+ "基于计算引擎对一个表达式进行计算,并获得一个值作为结果。表达式中可以使用上游节点里的变量。",
"String operation": "字符串",
"System variables": "系统变量",
"System time": "系统时间",
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/__tests__/instructions/calculation.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/__tests__/instructions/calculation.test.ts
index 27674dca6..e9ed0c5f9 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/server/__tests__/instructions/calculation.test.ts
+++ b/packages/plugins/@nocobase/plugin-workflow/src/server/__tests__/instructions/calculation.test.ts
@@ -166,8 +166,7 @@ describe('workflow > instructions > calculation', () => {
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
- expect((job.result as string).startsWith('a $$')).toBe(true);
- expect((job.result as string).endsWith(' ')).toBe(true);
+ expect(job.result).toBe('a$$0');
});
it('text', async () => {