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 () => {