refactor(plugin-workflow): migrate evaluators (#1485)

* fix(plugin-formula): fix locale

* refactor(client): migrate variable component

* refactor(plugin-workflow): use core evaluators

* refactor(plugin-workflow): migrate calculation engines to evaluators
This commit is contained in:
Junyi 2023-02-22 23:45:03 +08:00 committed by GitHub
parent 85ff054db4
commit 86d9eaf2fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 318 additions and 574 deletions

View File

@ -158,6 +158,7 @@ export default {
"Long text": "多行文本", "Long text": "多行文本",
"Phone": "手机号码", "Phone": "手机号码",
"Email": "电子邮箱", "Email": "电子邮箱",
'Null': '空值',
"Boolean": "逻辑值", "Boolean": "逻辑值",
"Number": "数字", "Number": "数字",
"Integer": "整数", "Integer": "整数",
@ -640,4 +641,10 @@ export default {
'Display page title': '显示页面标题', 'Display page title': '显示页面标题',
'Edit page title': '编辑页面标题', 'Edit page title': '编辑页面标题',
'Enable page tabs': '启用页面选项卡', 'Enable page tabs': '启用页面选项卡',
'Constant': '常量',
'Use variable': '使用变量',
'True': '真',
'False': '假',
'Prettify': '格式化'
} }

View File

@ -36,4 +36,5 @@ export * from './tabs';
export * from './time-picker'; export * from './time-picker';
export * from './tree-select'; export * from './tree-select';
export * from './upload'; export * from './upload';
export * from './variable';
import './index.less'; import './index.less';

View File

@ -1,20 +1,18 @@
import React from "react"; import React from "react";
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { Cascader, Input, Button, Tag, InputNumber, Select, DatePicker } from "antd"; import { Cascader, Input as AntInput, Button, Tag, InputNumber, Select, DatePicker } from "antd";
import { CloseCircleFilled } from "@ant-design/icons"; import { CloseCircleFilled } from "@ant-design/icons";
import { cx, css } from "@emotion/css"; import { cx, css } from "@emotion/css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import moment from "moment"; import moment from "moment";
import { useCompile } from "@nocobase/client"; import { useCompile } from '../../hooks/useCompile';
import { lang, NAMESPACE } from "../locale";
const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/; const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/;
export function parseValue(value: any): string | string[] { function parseValue(value: any): string | string[] {
if (value == null) { if (value == null) {
return 'null'; return 'null';
} }
@ -36,11 +34,11 @@ export function parseValue(value: any): string | string[] {
const ConstantTypes = { const ConstantTypes = {
string: { string: {
label: `{{t("String", { ns: "${NAMESPACE}" })}}`, label: `{{t("String")}}`,
value: 'string', value: 'string',
component({ onChange, value }) { component({ onChange, value }) {
return ( return (
<Input <AntInput
value={value} value={value}
onChange={ev => onChange(ev.target.value)} onChange={ev => onChange(ev.target.value)}
/> />
@ -62,7 +60,7 @@ const ConstantTypes = {
default: 0 default: 0
}, },
boolean: { boolean: {
label: `{{t("Boolean", { ns: "${NAMESPACE}" })}}`, label: `{{t("Boolean")}}`,
value: 'boolean', value: 'boolean',
component({ onChange, value }) { component({ onChange, value }) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -72,8 +70,8 @@ const ConstantTypes = {
onChange={onChange} onChange={onChange}
placeholder={t('Select')} placeholder={t('Select')}
options={[ options={[
{ value: true, label: lang('True') }, { value: true, label: t('True') },
{ value: false, label: lang('False') }, { value: false, label: t('False') },
]} ]}
/> />
); );
@ -81,7 +79,7 @@ const ConstantTypes = {
default: false default: false
}, },
date: { date: {
label: '日期', label: '{{t("Date")}}',
value: 'date', value: 'date',
component({ onChange, value }) { component({ onChange, value }) {
return ( return (
@ -99,11 +97,12 @@ const ConstantTypes = {
})(), })(),
}, },
null: { null: {
label: `{{t("Null", { ns: "${NAMESPACE}" })}}`, label: `{{t("Null")}}`,
value: 'null', value: 'null',
component() { component() {
const { t } = useTranslation();
return ( return (
<Input readOnly placeholder={lang('Null')} /> <AntInput readOnly placeholder={t('Null')} className="null-value" />
); );
}, },
default: null, default: null,
@ -116,19 +115,21 @@ type VariableOptions = {
children?: VariableOptions[]; children?: VariableOptions[];
} }
export function VariableInput(props) { export function Input(props) {
const { value = '', scope, onChange, children, button } = props; const { value = '', scope, onChange, children, button } = props;
const parsed = parseValue(value); const parsed = parseValue(value);
const isConstant = typeof parsed === 'string'; const isConstant = typeof parsed === 'string';
const type = isConstant ? parsed : 'string'; const type = isConstant ? parsed : '';
const variable = isConstant ? null : parsed; const variable = isConstant ? null : parsed;
const ConstantComponent = ConstantTypes[type]?.component; const ConstantComponent = ConstantTypes[type]?.component;
const constantOptions = Object.values(ConstantTypes); const constantOptions = Object.values(ConstantTypes);
const compile = useCompile(); const compile = useCompile();
const { t } = useTranslation();
const options: VariableOptions[] = compile([ const options: VariableOptions[] = compile([
{ value: '', label: lang('Constant'), children: children ? null : constantOptions }, { value: '', label: t('Constant'), children: children ? null : constantOptions },
...(typeof scope === 'function' ? scope() : (scope ?? [])) ...(typeof scope === 'function' ? scope() : (scope ?? []))
]); ]);
const form = useForm(); const form = useForm();
function onSwitch(next) { function onSwitch(next) {
@ -155,7 +156,7 @@ export function VariableInput(props) {
const disabled = props.disabled || form.disabled; const disabled = props.disabled || form.disabled;
return ( return (
<Input.Group compact className={css` <AntInput.Group compact className={css`
width: auto; width: auto;
.ant-input-disabled{ .ant-input-disabled{
.ant-tag{ .ant-tag{
@ -163,6 +164,10 @@ export function VariableInput(props) {
border-color: #d9d9d9; border-color: #d9d9d9;
} }
} }
.ant-input.null-value{
width: 4em;
min-width: 4em;
}
`} `}
> >
{variable {variable
@ -202,6 +207,7 @@ export function VariableInput(props) {
}} }}
className={cx('ant-input', { 'ant-input-disabled': disabled })} className={cx('ant-input', { 'ant-input-disabled': disabled })}
contentEditable={!disabled} contentEditable={!disabled}
suppressContentEditableWarning
> >
<Tag contentEditable={false} color="blue">{variableText}</Tag> <Tag contentEditable={false} color="blue">{variableText}</Tag>
</div> </div>
@ -247,6 +253,6 @@ export function VariableInput(props) {
) )
: null : null
} }
</Input.Group> </AntInput.Group>
); );
} }

View File

@ -2,9 +2,8 @@ import React, { useRef } from 'react';
import { Button, Cascader } from 'antd'; import { Button, Cascader } from 'antd';
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { Input } from "@nocobase/client"; import { Input } from "../input";
import { useTranslation } from 'react-i18next';
import { lang } from '../locale';
@ -17,9 +16,10 @@ function setNativeInputValue(input, value) {
})); }));
} }
export function VariableJSONInput(props) { export function JSONInput(props) {
const inputRef = useRef(null); const inputRef = useRef(null);
const { value, space = 2, scope } = props; const { value, space = 2, scope } = props;
const { t } = useTranslation()
const options = typeof scope === 'function' ? scope() : (scope ?? []); const options = typeof scope === 'function' ? scope() : (scope ?? []);
function onFormat() { function onFormat() {
@ -69,7 +69,7 @@ export function VariableJSONInput(props) {
} }
`} `}
> >
<Button onClick={onFormat}>{lang('Format')}</Button> <Button onClick={onFormat}>{t('Prettify')}</Button>
<Cascader <Cascader
value={[]} value={[]}
options={options} options={options}

View File

@ -2,11 +2,11 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Input, Cascader, Tooltip, Button } from 'antd'; import { Input, Cascader, Tooltip, Button } from 'antd';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { cx, css } from "@emotion/css"; import { cx, css } from "@emotion/css";
import { lang } from '../locale'; import { useTranslation } from 'react-i18next';
const VARIABLE_RE = /\{\{\s*([^{}]+)\s*\}\}/g; const VARIABLE_RE = /{{\s*([^{}]+)\s*}}/g;
function pasteHtml(container, html, { selectPastedContent = false, range: indexes }) { function pasteHtml(container, html, { selectPastedContent = false, range: indexes }) {
// IE9 and non-IE // IE9 and non-IE
@ -85,13 +85,13 @@ function renderHTML(exp: string, keyLabelMap) {
}); });
} }
function createOptionsKeyLabelMap(options: any[]) { function createOptionsValueLabelMap(options: any[]) {
const map = new Map<string, string[]>(); const map = new Map<string, string[]>();
for (const option of options) { for (const option of options) {
map.set(option.key, [option.label]); map.set(option.value, [option.label]);
if (option.children) { if (option.children) {
for (const [key, labels] of createOptionsKeyLabelMap(option.children)) { for (const [value, labels] of createOptionsValueLabelMap(option.children)) {
map.set(`${option.key}.${key}`, [option.label, ...labels]); map.set(`${option.value}.${value}`, [option.label, ...labels]);
} }
} }
} }
@ -103,12 +103,13 @@ function createVariableTagHTML(variable, keyLabelMap) {
return `<span class="ant-tag ant-tag-blue" contentEditable="false" data-key="${variable}">${labels?.join(' / ')}</span>`; return `<span class="ant-tag ant-tag-blue" contentEditable="false" data-key="${variable}">${labels?.join(' / ')}</span>`;
} }
export function VariableTextArea(props) { export function TextArea(props) {
const { value = '', scope, onChange, multiline = true, button } = props; const { value = '', scope, onChange, multiline = true, button } = props;
const { t } = useTranslation()
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
const options = (typeof scope === 'function' ? scope() : scope) ?? []; const options = (typeof scope === 'function' ? scope() : scope) ?? [];
const form = useForm(); const form = useForm();
const keyLabelMap = useMemo(() => createOptionsKeyLabelMap(options), [scope]); const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]);
const [changed, setChanged] = useState(false); const [changed, setChanged] = useState(false);
const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap)); const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap));
// [startElementIndex, startOffset, endElementIndex, endOffset] // [startElementIndex, startOffset, endElementIndex, endOffset]
@ -213,7 +214,7 @@ export function VariableTextArea(props) {
contentEditable={!disabled} contentEditable={!disabled}
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
<Tooltip title={lang('Use variable')}> <Tooltip title={t('Use variable')}>
<Cascader <Cascader
value={[]} value={[]}
options={options} options={options}

View File

@ -0,0 +1,19 @@
import { connect } from '@formily/react';
import { Input } from "./Input";
import { TextArea } from "./TextArea";
import { JSONInput } from "./JSONInput";
export function Variable() {
return null;
}
Variable.Input = connect(Input);
Variable.TextArea = connect(TextArea);
Variable.JSON = connect(JSONInput);
export default Variable;

View File

@ -0,0 +1 @@
export * from './Variable';

View File

@ -1,2 +1,3 @@
// @ts-nocheck // @ts-nocheck
export * from './lib/client'; export * from './lib/client';
export { default } from './lib/client';

View File

@ -1,2 +1,3 @@
// @ts-nocheck // @ts-nocheck
export * from './lib/server'; export * from './lib/server';
export { default } from './lib/server';

View File

@ -1,4 +1,5 @@
import evaluate from '../../utils/formulajs'; import { evaluate } from '../../utils';
import formulajs from '../../utils/formulajs';
@ -6,5 +7,5 @@ export default {
label: 'Formula.js', label: 'Formula.js',
tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}', tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}',
link: 'https://formulajs.info/functions/', link: 'https://formulajs.info/functions/',
evaluate evaluate: evaluate.bind(formulajs)
}; };

View File

@ -1,4 +1,5 @@
import evaluate from "../../utils/mathjs"; import { evaluate } from "../../utils";
import mathjs from "../../utils/mathjs";
@ -6,5 +7,5 @@ export default {
label: 'Math.js', 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/", link: "https://mathjs.org/",
evaluate evaluate: evaluate.bind(mathjs)
}; };

View File

@ -1,3 +1,7 @@
import React from 'react';
import { css } from '@emotion/css';
import { i18n } from '@nocobase/client';
import { Registry } from '@nocobase/utils/client'; import { Registry } from '@nocobase/utils/client';
import mathjs from './engines/mathjs'; import mathjs from './engines/mathjs';
@ -10,9 +14,34 @@ export interface Evaluator {
evaluate(exp: string, scope?: { [key: string]: any }): any; evaluate(exp: string, scope?: { [key: string]: any }): any;
} }
const evaluators = new Registry<Evaluator>(); export const evaluators = new Registry<Evaluator>();
evaluators.register('math.js', mathjs); evaluators.register('math.js', mathjs);
evaluators.register('formula.js', formulajs); evaluators.register('formula.js', formulajs);
export const renderReference = (key: string) => {
const engine = evaluators.get(key);
if (!engine) {
return null;
}
return engine.link
? (
<>
<span className={css`
&:after {
content: ':';
}
& + a {
margin-left: .25em;
}
`}>
{i18n.t('Syntax references')}
</span>
<a href={engine.link} target="_blank">{engine.label}</a>
</>
)
: null
};
export default evaluators; export default evaluators;

View File

@ -1 +1,2 @@
export { default } from './server'; export { default } from './server';
export * from './server';

View File

@ -1,24 +1,16 @@
import { get } from "lodash"; import { Registry } from "@nocobase/utils";
import { Registry, RegistryOptions } from "@nocobase/utils";
import { evaluate, Evaluator } from '../utils';
import mathjs from "../utils/mathjs"; import mathjs from "../utils/mathjs";
import formulajs from "../utils/formulajs"; import formulajs from "../utils/formulajs";
export interface Evaluator { export { Evaluator } from '../utils';
(expression: string, scope?: { [key: string]: any }): any;
}
export interface EvaluatorsOptions extends RegistryOptions { export const evaluators = new Registry<Evaluator>();
empty?: boolean;
evaluators?: { [key: string]: Evaluator };
}
const evaluators = new Registry<Evaluator>(); evaluators.register('math.js', evaluate.bind(mathjs));
evaluators.register('formula.js', evaluate.bind(formulajs));
evaluators.register('math.js', mathjs);
evaluators.register('formula.js', formulajs);
export default evaluators; export default evaluators;

View File

@ -5,8 +5,7 @@ import * as functions from '@formulajs/formulajs';
const fnNames = Object.keys(functions).filter(key => key !== 'default'); const fnNames = Object.keys(functions).filter(key => key !== 'default');
const fns = fnNames.map(key => functions[key]); const fns = fnNames.map(key => functions[key]);
export default function(exp: string, scope = {}) { export default function(expression: string, scope = {}) {
const expression = exp.replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => v);
const fn = new Function(...fnNames, ...Object.keys(scope), `return ${expression}`); const fn = new Function(...fnNames, ...Object.keys(scope), `return ${expression}`);
const result = fn(...fns, ...Object.values(scope)); const result = fn(...fns, ...Object.values(scope));
if (typeof result === 'number') { if (typeof result === 'number') {

View File

@ -0,0 +1,16 @@
import { get } from "lodash";
export type Scope = { [key: string]: any };
export type Evaluator = (expression: string, scope?: Scope) => any;
export function evaluate(this: Evaluator, expression: string, scope: Scope = {}) {
const exp = expression.trim().replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => {
const item = get(scope, v);
const key = v.replace(/\.(\d+)/g, '["$1"]');
return ` ${typeof item === 'function' ? item() : key} `;
});
return this(exp, scope);
}

View File

@ -1,8 +1,7 @@
import * as math from 'mathjs'; import * as math from 'mathjs';
export default function (expression: string, scope = {}) { export default function (expression: string, scope = {}) {
const exp = expression.trim().replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => v); const result = math.evaluate(expression, scope);
const result = math.evaluate(exp, scope);
if (typeof result === 'number') { if (typeof result === 'number') {
if (Number.isNaN(result) || !Number.isFinite(result)) { if (Number.isNaN(result) || !Number.isFinite(result)) {
return null; return null;

View File

@ -61,7 +61,7 @@ export default {
group: 'advanced', group: 'advanced',
order: 1, order: 1,
title: `{{t("Formula", { ns: "${NAMESPACE}" })}}`, title: `{{t("Formula", { ns: "${NAMESPACE}" })}}`,
description: '{{t("Compute a value based on the other fields using mathjs")}}', description: `{{t("Compute a value based on the other fields", { ns: "${NAMESPACE}" })}}`,
sortable: true, sortable: true,
default: { default: {
type: 'formula', type: 'formula',

View File

@ -1,242 +1,25 @@
import React, { useEffect, useRef, useState, useMemo } from 'react'; import React from 'react';
import { css, cx } from '@emotion/css'; import { useCompile, Variable } from '@nocobase/client';
import { useForm } from '@formily/react';
import { Button, Cascader, Input, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { useCompile } from '@nocobase/client';
const VARIABLE_RE = /\{\{\s*([^{}]+)\s*\}\}/g;
function pasteHtml(container, html, { selectPastedContent = false, range: indexes }) {
// IE9 and non-IE
const sel = window.getSelection?.();
if (!sel?.getRangeAt || !sel.rangeCount) {
return;
}
const range = sel.getRangeAt(0);
if (!range) {
return;
}
const children = Array.from(container.childNodes) as HTMLElement[];
if (indexes[0] === -1) {
if (indexes[1]) {
range.setStartAfter(children[indexes[1] - 1]);
}
} else {
range.setStart(children[indexes[0]], indexes[1]);
}
if (indexes[2] === -1) {
if (indexes[3]) {
range.setEndAfter(children[indexes[3] - 1]);
}
} else {
range.setEnd(children[indexes[2]], indexes[3]);
}
range.deleteContents();
// Range.createContextualFragment() would be useful here but is
// only relatively recently standardized and is not supported in
// some browsers (IE9, for one)
const el = document.createElement('div');
el.innerHTML = html;
const frag = document.createDocumentFragment();
let lastNode;
while (el.firstChild) {
lastNode = frag.appendChild(el.firstChild);
}
const { firstChild } = frag;
range.insertNode(frag);
// Preserve the selection
if (lastNode) {
const next = range.cloneRange();
next.setStartAfter(lastNode);
if (selectPastedContent) {
if (firstChild) {
next.setStartBefore(firstChild);
}
} else {
next.collapse(true);
}
sel.removeAllRanges();
sel.addRange(next);
}
}
function getValue(el) {
const values: any[] = [];
for (const node of el.childNodes) {
if (node.nodeName === 'SPAN') {
values.push(`{{${node['dataset']['key']}}}`);
} else {
values.push(node.textContent?.trim?.());
}
}
return values.join(' ').replace(/\s+/g, ' ').trim();
}
function renderHTML(exp: string, keyLabelMap) {
return exp.replace(VARIABLE_RE, (_, i) => {
const key = i.trim();
return createVariableTagHTML(key, keyLabelMap) ?? '';
});
}
function createOptionsKeyLabelMap(options: any[]) {
const map = new Map<string, string[]>();
for (const option of options) {
map.set(option.value, [option.label]);
if (option.children) {
for (const [value, labels] of createOptionsKeyLabelMap(option.children)) {
map.set(`${option.value}.${value}`, [option.label, ...labels]);
}
}
}
return map;
}
function createVariableTagHTML(variable, keyLabelMap) {
const labels = keyLabelMap.get(variable);
return `<span class="ant-tag ant-tag-blue" contentEditable="false" data-key="${variable}">${labels?.join(' / ')}</span>`;
}
export const Expression = (props) => { export const Expression = (props) => {
const { value = '', supports, useCurrentFields, onChange } = props; const { value = '', supports, useCurrentFields, onChange } = props;
const { t } = useTranslation();
const compile = useCompile(); const compile = useCompile();
const fields = useCurrentFields().filter(field => supports.includes(field.interface)); const fields = useCurrentFields().filter(field => supports.includes(field.interface));
const inputRef = useRef<HTMLDivElement>(null);
const options = fields.map(field => ({ const options = fields.map(field => ({
label: compile(field.uiSchema.title), label: compile(field.uiSchema.title),
value: field.name value: field.name
})); }));
const form = useForm();
const keyLabelMap = useMemo(() => createOptionsKeyLabelMap(options), []);
const [changed, setChanged] = useState(false);
const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap));
// [startElementIndex, startOffset, endElementIndex, endOffset]
const [range, setRange] = useState<[number, number, number, number]>([-1, 0, -1, 0]);
useEffect(() => {
if (changed) {
return;
}
setHtml(renderHTML(value ?? '', keyLabelMap));
}, [value]);
useEffect(() => {
const { current } = inputRef;
if (!current || changed) {
return;
}
const nextRange = new Range();
const { lastChild } = current;
if (lastChild) {
nextRange.setStartAfter(lastChild);
nextRange.setEndAfter(lastChild);
const nodes = Array.from(current.childNodes);
const startElementIndex = nextRange.startContainer === current ? -1 : nodes.indexOf(lastChild);
const endElementIndex = nextRange.startContainer === current ? -1 : nodes.indexOf(lastChild);
setRange([startElementIndex, nextRange.startOffset, endElementIndex, nextRange.endOffset]);
}
}, [html]);
function onInsert(keyPath) {
const variable: string[] = keyPath.filter(key => Boolean(key.trim()));
const { current } = inputRef;
if (!current || !variable) {
return;
}
current.focus();
pasteHtml(current, createVariableTagHTML(variable.join('.'), keyLabelMap), {
range,
});
setChanged(true);
onChange(getValue(current));
}
function onInput({ currentTarget }) {
setChanged(true);
onChange(getValue(currentTarget));
}
function onBlur({ currentTarget }) {
const sel = window.getSelection?.();
if (!sel?.getRangeAt || !sel.rangeCount) {
return;
}
const r = sel.getRangeAt(0);
const nodes = Array.from(currentTarget.childNodes);
const startElementIndex = nodes.indexOf(r.startContainer);
const endElementIndex = nodes.indexOf(r.endContainer);
setRange([startElementIndex, r.startOffset, endElementIndex, r.endOffset]);
}
const disabled = props.disabled || form.disabled;
return ( return (
<Input.Group compact className={css` <Variable.TextArea
&.ant-input-group.ant-input-group-compact{ value={value}
display: flex; onChange={onChange}
.ant-input{ scope={options}
flex-grow: 1;
}
.ant-input-disabled{
.ant-tag{
color: #bfbfbf;
border-color: #d9d9d9;
}
}
}
`}>
<div
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
}
}}
onBlur={onBlur}
onInput={onInput}
className={cx('ant-input', { 'ant-input-disabled': disabled }, css`
overflow: auto;
white-space: nowrap;
.ant-tag{
display: inline;
line-height: 19px;
margin: 0 .5em;
padding: 2px 7px;
border-radius: 10px;
}
`)}
ref={inputRef as any}
contentEditable={!disabled}
dangerouslySetInnerHTML={{ __html: html }}
/> />
<Tooltip title={t('Use variable')}>
<Cascader
value={[]}
options={options}
onChange={onInsert}
>
<Button
className={css`
font-style: italic;
font-family: "New York", "Times New Roman", Times, serif;
`}
>
x
</Button>
</Cascader>
</Tooltip>
</Input.Group>
); );
}; };

View File

@ -5,7 +5,7 @@ import { css } from '@emotion/css';
import { CollectionManagerContext, registerField, SchemaComponentOptions } from '@nocobase/client'; import { CollectionManagerContext, registerField, SchemaComponentOptions } from '@nocobase/client';
import evaluators, { Evaluator } from '@nocobase/evaluators/client'; import evaluators, { Evaluator } from '@nocobase/evaluators/client';
import Formula from './formula'; import { Formula } from './formula';
import field from './field'; import field from './field';
import { NAMESPACE } from './locale'; import { NAMESPACE } from './locale';
import { Registry } from '@nocobase/utils/client'; import { Registry } from '@nocobase/utils/client';
@ -36,6 +36,8 @@ function renderExpressionDescription(key: string) {
: null : null
} }
export { Formula } from './formula';
export default React.memo((props) => { export default React.memo((props) => {
const ctx = useContext(CollectionManagerContext); const ctx = useContext(CollectionManagerContext);
return ( return (

View File

@ -3,5 +3,6 @@ export default {
'Formula engine': '公式引擎', 'Formula engine': '公式引擎',
'Expression': '表达式', 'Expression': '表达式',
'Expression syntax error': '表达式语法错误', 'Expression syntax error': '表达式语法错误',
'Syntax references': '语法参考' 'Syntax references': '语法参考',
'Compute a value based on the other fields': '基于其他字段进行计算'
} }

View File

@ -89,6 +89,25 @@ describe('formula field', () => {
expect(test.get('sum')).toEqual(3.22); expect(test.get('sum')).toEqual(3.22);
}); });
it('scope with number key', async () => {
const expression = '{{a.1}}+1';
const Test = db.collection({
name: 'tests',
fields: [
{ type: 'json', name: 'a' },
{ name: 'sum', type: 'formula', expression, engine: 'math.js' },
],
});
await db.sync();
const test = await Test.model.create<any>({
a: { '1': 1 },
});
expect(test.get('sum')).toEqual(2);
});
}); });
describe('formula.js', () => { describe('formula.js', () => {

View File

@ -9,6 +9,7 @@
"@nocobase/actions": "0.9.0-alpha.2", "@nocobase/actions": "0.9.0-alpha.2",
"@nocobase/client": "0.9.0-alpha.2", "@nocobase/client": "0.9.0-alpha.2",
"@nocobase/database": "0.9.0-alpha.2", "@nocobase/database": "0.9.0-alpha.2",
"@nocobase/evaluators": "0.9.0-alpha.2",
"@nocobase/resourcer": "0.9.0-alpha.2", "@nocobase/resourcer": "0.9.0-alpha.2",
"@nocobase/server": "0.9.0-alpha.2", "@nocobase/server": "0.9.0-alpha.2",
"@nocobase/utils": "0.9.0-alpha.2", "@nocobase/utils": "0.9.0-alpha.2",
@ -16,9 +17,7 @@
"axios": "^0.27.2", "axios": "^0.27.2",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"cron-parser": "4.4.0", "cron-parser": "4.4.0",
"@formulajs/formulajs": "4.2.0",
"json-templates": "^4.2.0", "json-templates": "^4.2.0",
"mathjs": "^10.6.0",
"moment": "^2.29.2", "moment": "^2.29.2",
"react-js-cron": "^1.4.0" "react-js-cron": "^1.4.0"
}, },

View File

@ -5,8 +5,7 @@ import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { css } from "@emotion/css"; import { css } from "@emotion/css";
import { CollectionField, CollectionProvider, SchemaComponent, useCollectionManager, useCompile } from "@nocobase/client"; import { CollectionField, CollectionProvider, SchemaComponent, Variable, useCollectionManager, useCompile } from "@nocobase/client";
import { VariableInput } from "./VariableInput";
import { lang } from "../locale"; import { lang } from "../locale";
import { useWorkflowVariableOptions } from "../variable"; import { useWorkflowVariableOptions } from "../variable";
@ -40,7 +39,8 @@ export default observer(({ value, disabled, onChange }: any) => {
.filter(field => ( .filter(field => (
!field.hidden !field.hidden
&& (field.uiSchema ? !field.uiSchema['x-read-pretty'] : false) && (field.uiSchema ? !field.uiSchema['x-read-pretty'] : false)
// && (!['linkTo', 'hasMany', 'hasOne', 'belongsToMany'].includes(field.type)) // TODO: should use some field option but not type to control this
&& (!['formula'].includes(field.type))
)); ));
const unassignedFields = fields.filter(field => !(field.name in value)); const unassignedFields = fields.filter(field => !(field.name in value));
@ -77,7 +77,7 @@ export default observer(({ value, disabled, onChange }: any) => {
display: flex; display: flex;
} }
`}> `}>
<VariableInput <Variable.Input
scope={['hasMany', 'belongsToMany'].includes(field.type) ? [] : scope} scope={['hasMany', 'belongsToMany'].includes(field.type) ? [] : scope}
value={value[field.name]} value={value[field.name]}
onChange={(next) => { onChange={(next) => {
@ -94,7 +94,7 @@ export default observer(({ value, disabled, onChange }: any) => {
} }
}} }}
/> />
</VariableInput> </Variable.Input>
{!mergedDisabled {!mergedDisabled
? ( ? (
<Button <Button

View File

@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import { Variable } from "@nocobase/client";
import { useWorkflowVariableOptions } from "../variable"; import { useWorkflowVariableOptions } from "../variable";
import { VariableInput } from "./VariableInput";
@ -9,12 +10,11 @@ export function FilterDynamicComponent({ value, onChange, renderSchemaComponent
const scope = useWorkflowVariableOptions(); const scope = useWorkflowVariableOptions();
return ( return (
<VariableInput <Variable.Input
value={value} value={value}
onChange={onChange} onChange={onChange}
scope={scope} scope={scope}
> >
{renderSchemaComponent()} </Variable.Input>
</VariableInput>
); );
} }

View File

@ -54,11 +54,6 @@ export default {
'Advanced': '高级模式', 'Advanced': '高级模式',
'End': '结束', 'End': '结束',
'Node result': '节点数据', 'Node result': '节点数据',
'Constant': '常量',
'Null': '空值',
'Boolean': '逻辑值',
'String': '字符串',
'Date': '日期',
'Calculator': '运算', 'Calculator': '运算',
'Arithmetic calculation': '算术运算', 'Arithmetic calculation': '算术运算',
'String operation': '字符串', 'String operation': '字符串',

View File

@ -3,13 +3,12 @@ import { css } from '@emotion/css';
import parse from 'json-templates'; import parse from 'json-templates';
import { SchemaInitializer, SchemaInitializerItemOptions } from '@nocobase/client'; import { SchemaInitializer, SchemaInitializerItemOptions } from '@nocobase/client';
import evaluators, { renderReference, Evaluator } from '@nocobase/evaluators/client';
import { useFlowContext } from '../../FlowContext'; import { useFlowContext } from '../FlowContext';
import { lang, NAMESPACE } from '../../locale'; import { lang, NAMESPACE } from '../locale';
import { VariableTextArea } from '../../components/VariableTextArea'; import { TypeSets, useWorkflowVariableOptions } from '../variable';
import { TypeSets, useWorkflowVariableOptions } from '../../variable'; import { RadioWithTooltip } from '../components/RadioWithTooltip';
import { calculationEngines, renderReference } from './engines';
import { RadioWithTooltip } from '../../components/RadioWithTooltip';
@ -25,7 +24,7 @@ export default {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip', 'x-component': 'RadioWithTooltip',
'x-component-props': { 'x-component-props': {
options: Array.from(calculationEngines.getEntities()).reduce((result: any[], [value, options]) => result.concat({ value, ...options }), []) options: Array.from(evaluators.getEntities()).reduce((result: any[], [value, options]) => result.concat({ value, ...options }), [])
}, },
required: true, required: true,
default: 'math.js' default: 'math.js'
@ -35,14 +34,14 @@ export default {
title: `{{t("Calculation expression", { ns: "${NAMESPACE}" })}}`, title: `{{t("Calculation expression", { ns: "${NAMESPACE}" })}}`,
name: 'config.expression', name: 'config.expression',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'VariableTextArea', 'x-component': 'Variable.TextArea',
'x-component-props': { 'x-component-props': {
scope: '{{useWorkflowVariableOptions}}' scope: '{{useWorkflowVariableOptions}}'
}, },
['x-validator'](value, rules, { form }) { ['x-validator'](value, rules, { form }) {
const { values } = form; const { values } = form;
const { evaluate } = calculationEngines.get(values.config.engine); const { evaluate } = evaluators.get(values.config.engine) as Evaluator;
const exp = value.trim().replace(/\{\{([^{}]+)\}\}/g, '1'); const exp = value.trim().replace(/{{([^{}]+)}}/g, '1');
try { try {
evaluate(exp); evaluate(exp);
return ''; return '';
@ -86,8 +85,7 @@ export default {
</pre> </pre>
); );
}, },
RadioWithTooltip, RadioWithTooltip
VariableTextArea
}, },
getOptions(config, types) { getOptions(config, types) {
if (types && !types.some(type => type in TypeSets || Object.values(TypeSets).some(set => set.has(type)))) { if (types && !types.some(type => type in TypeSets || Object.values(TypeSets).some(set => set.has(type)))) {

View File

@ -1,13 +0,0 @@
import * as formulajs from '@formulajs/formulajs';
export default {
label: 'Formula.js',
tooltip: '{{t("Formula.js supports most Microsoft Excel formula functions.")}}',
link: 'https://formulajs.info/functions/',
evaluate(exp: string) {
const fn = new Function(...Object.keys(formulajs), `return ${exp}`);
return fn(...Object.values(formulajs));
}
};

View File

@ -1,45 +0,0 @@
import React from 'react';
import { css } from '@emotion/css';
import { Registry } from '@nocobase/utils/client';
import { i18n } from '@nocobase/client';
import mathjs from './mathjs';
import formulajs from './formulajs';
export interface CalculationEngine {
label: string;
tooltip?: string;
link?: string;
evaluate(exp: string): any;
}
export const calculationEngines = new Registry<CalculationEngine>();
calculationEngines.register('math.js', mathjs);
calculationEngines.register('formula.js', formulajs);
export const renderReference = (key: string) => {
const engine = calculationEngines.get(key);
if (!engine) {
return null;
}
return engine.link
? (
<>
<span className={css`
&:after {
content: ':';
}
& + a {
margin-left: .25em;
}
`}>
{i18n.t('Syntax references')}
</span>
<a href={engine.link} target="_blank">{engine.label}</a>
</>
)
: null
};

View File

@ -1,10 +0,0 @@
import { evaluate } from "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')}}`,
link: "https://mathjs.org/",
evaluate
};

View File

@ -5,6 +5,8 @@ import { CloseCircleOutlined } from '@ant-design/icons';
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Registry } from "@nocobase/utils/client"; import { Registry } from "@nocobase/utils/client";
import { Variable, useCompile } from "@nocobase/client";
import evaluators, { renderReference } from "@nocobase/evaluators/client";
import { NodeDefaultView } from "."; import { NodeDefaultView } from ".";
import { Branch } from "../Branch"; import { Branch } from "../Branch";
@ -12,11 +14,7 @@ import { useFlowContext } from '../FlowContext';
import { branchBlockClass, nodeSubtreeClass } from "../style"; import { branchBlockClass, nodeSubtreeClass } from "../style";
import { lang, NAMESPACE } from "../locale"; import { lang, NAMESPACE } from "../locale";
import { useWorkflowVariableOptions } from "../variable"; import { useWorkflowVariableOptions } from "../variable";
import { VariableTextArea } from "../components/VariableTextArea";
import { VariableInput } from "../components/VariableInput";
import { RadioWithTooltip, RadioWithTooltipOption } from "../components/RadioWithTooltip"; import { RadioWithTooltip, RadioWithTooltipOption } from "../components/RadioWithTooltip";
import { calculationEngines, renderReference } from "./calculation/engines";
import { useCompile } from "@nocobase/client";
interface Calculator { interface Calculator {
name: string; name: string;
@ -152,7 +150,7 @@ export function Calculation({ calculator, operands = [], onChange }) {
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
`}> `}>
<VariableInput <Variable.Input
value={operands[0]} value={operands[0]}
onChange={(v => onChange({ calculator, operands: [v, operands[1]] }))} onChange={(v => onChange({ calculator, operands: [v, operands[1]] }))}
scope={options} scope={options}
@ -170,7 +168,7 @@ export function Calculation({ calculator, operands = [], onChange }) {
</Select.OptGroup> </Select.OptGroup>
))} ))}
</Select> </Select>
<VariableInput <Variable.Input
value={operands[1]} value={operands[1]}
onChange={(v => onChange({ calculator, operands: [operands[0], v] }))} onChange={(v => onChange({ calculator, operands: [operands[0], v] }))}
scope={options} scope={options}
@ -335,7 +333,7 @@ export default {
'x-component-props': { 'x-component-props': {
options: [ options: [
['basic', { label: `{{t("Basic", { ns: "${NAMESPACE}" })}}` }], ['basic', { label: `{{t("Basic", { ns: "${NAMESPACE}" })}}` }],
...Array.from(calculationEngines.getEntities()) ...Array.from(evaluators.getEntities())
].reduce((result: RadioWithTooltipOption[], [value, options]: any) => result.concat({ value, ...options }), []), ].reduce((result: RadioWithTooltipOption[], [value, options]: any) => result.concat({ value, ...options }), []),
}, },
required: true, required: true,
@ -362,14 +360,14 @@ export default {
title: `{{t("Condition expression", { ns: "${NAMESPACE}" })}}`, title: `{{t("Condition expression", { ns: "${NAMESPACE}" })}}`,
name: 'config.expression', name: 'config.expression',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'VariableTextArea', 'x-component': 'Variable.TextArea',
'x-component-props': { 'x-component-props': {
scope: '{{useWorkflowVariableOptions}}' scope: '{{useWorkflowVariableOptions}}'
}, },
['x-validator'](value, rules, { form }) { ['x-validator'](value, rules, { form }) {
const { values } = form; const { values } = form;
const { evaluate } = calculationEngines.get(values.config.engine); const { evaluate } = evaluators.get(values.config.engine);
const exp = value.trim().replace(/\{\{([^{}]+)\}\}/g, '1'); const exp = value.trim().replace(/{{([^{}]+)}}/g, '1');
try { try {
evaluate(exp); evaluate(exp);
return ''; return '';
@ -450,7 +448,6 @@ export default {
}, },
components: { components: {
CalculationConfig, CalculationConfig,
VariableTextArea,
RadioWithTooltip RadioWithTooltip
} }
}; };

View File

@ -1,15 +1,16 @@
import { RemoteSelect } from '@nocobase/client'; import { RemoteSelect } from '@nocobase/client';
import React from 'react'; import React from 'react';
import { VariableInput } from '../../components/VariableInput'; import { Variable } from '@nocobase/client';
import { useWorkflowVariableOptions } from '../../variable'; import { useWorkflowVariableOptions } from '../../variable';
export function AssigneesSelect({ multiple = false, value = [], onChange }) { export function AssigneesSelect({ multiple = false, value = [], onChange }) {
const scope = useWorkflowVariableOptions(); const scope = useWorkflowVariableOptions();
console.log(value);
return ( return (
<VariableInput <Variable.Input
scope={scope} scope={scope}
types={[{ type: 'reference', options: { collection: 'users' } }]} types={[{ type: 'reference', options: { collection: 'users' } }]}
value={value[0]} value={value[0]}
@ -30,6 +31,6 @@ export function AssigneesSelect({ multiple = false, value = [], onChange }) {
onChange([v]); onChange([v]);
}} }}
/> />
</VariableInput> </Variable.Input>
); );
} }

View File

@ -3,8 +3,6 @@ import { css } from '@emotion/css';
import { NAMESPACE } from '../locale'; import { NAMESPACE } from '../locale';
import { useWorkflowVariableOptions } from '../variable'; import { useWorkflowVariableOptions } from '../variable';
import { VariableJSONInput } from '../components/VariableJSONInput';
import { VariableInput } from '../components/VariableInput';
@ -79,7 +77,7 @@ export default {
value: { value: {
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'VariableInput', 'x-component': 'Variable.Input',
'x-component-props': { 'x-component-props': {
scope: useWorkflowVariableOptions scope: useWorkflowVariableOptions
} }
@ -125,7 +123,7 @@ export default {
value: { value: {
type: 'string', type: 'string',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'VariableInput', 'x-component': 'Variable.Input',
'x-component-props': { 'x-component-props': {
scope: useWorkflowVariableOptions scope: useWorkflowVariableOptions
} }
@ -153,7 +151,7 @@ export default {
title: `{{t("Body", { ns: "${NAMESPACE}" })}}`, title: `{{t("Body", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-decorator-props': {}, 'x-decorator-props': {},
'x-component': 'VariableJSONInput', 'x-component': 'Variable.JSON',
'x-component-props': { 'x-component-props': {
scope: useWorkflowVariableOptions, scope: useWorkflowVariableOptions,
autoSize: { autoSize: {
@ -193,7 +191,5 @@ export default {
scope: {}, scope: {},
components: { components: {
ArrayItems, ArrayItems,
VariableInput,
VariableJSONInput
}, },
}; };

View File

@ -5,8 +5,6 @@ import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils'; import { Registry } from '@nocobase/utils';
import initActions from './actions'; import initActions from './actions';
import initCalculationEngines from './calculators';
import type { Evaluator } from './calculators';
import { EXECUTION_STATUS } from './constants'; import { EXECUTION_STATUS } from './constants';
import initInstructions, { Instruction } from './instructions'; import initInstructions, { Instruction } from './instructions';
import ExecutionModel from './models/Execution'; import ExecutionModel from './models/Execution';
@ -20,7 +18,6 @@ type Pending = [ExecutionModel, JobModel?];
export default class WorkflowPlugin extends Plugin { export default class WorkflowPlugin extends Plugin {
instructions: Registry<Instruction> = new Registry(); instructions: Registry<Instruction> = new Registry();
triggers: Registry<Trigger> = new Registry(); triggers: Registry<Trigger> = new Registry();
calculators: Registry<Evaluator> = new Registry();
functions: Registry<Function> = new Registry(); functions: Registry<Function> = new Registry();
executing: ExecutionModel | null = null; executing: ExecutionModel | null = null;
pending: Pending[] = []; pending: Pending[] = [];
@ -78,7 +75,6 @@ export default class WorkflowPlugin extends Plugin {
initActions(this); initActions(this);
initTriggers(this, options.triggers); initTriggers(this, options.triggers);
initInstructions(this, options.instructions); initInstructions(this, options.instructions);
initCalculationEngines(this);
initFunctions(this, options.functions); initFunctions(this, options.functions);

View File

@ -1,9 +1,9 @@
import { MockServer, mockServer } from '@nocobase/test';
import path from 'path'; import path from 'path';
import { ApplicationOptions } from '@nocobase/server'; import { ApplicationOptions } from '@nocobase/server';
import { MockServer, mockServer } from '@nocobase/test';
import Plugin from '..'; import Plugin from '..';
import calculators from '../calculators';
import { JOB_STATUS } from '../constants'; import { JOB_STATUS } from '../constants';
export function sleep(ms: number) { export function sleep(ms: number) {

View File

@ -1,84 +0,0 @@
import { Registry } from "@nocobase/utils";
export const calculators = new Registry<Function>();
// 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);
export default function(calculation, scope) {
const fn = calculators.get(calculation.calculator);
if (!fn) {
throw new Error(`no calculator function registered for "${calculation.calculator}"`);
}
return Boolean(fn(...calculation.operands));
}

View File

@ -1,17 +0,0 @@
import { default as fns } from '@formulajs/formulajs';
import { parseExpression, Scope } from '..';
export default function(expression: string, scope?: Scope) {
const exp = parseExpression(expression, scope);
const fn = new Function(...Object.keys(fns), ...Object.keys(scope), `return ${exp}`);
const result = fn(...Object.values(fns), ...Object.values(scope));
if (typeof result === 'number') {
if (Number.isNaN(result) || !Number.isFinite(result)) {
return null;
}
return fns.ROUND(result, 9);
}
return result;
}

View File

@ -1,24 +0,0 @@
import { get } from 'lodash';
import Plugin from "..";
import basic from './basic';
import mathjs from "./mathjs";
import formulajs from "./formulajs";
export type Scope = { [key: string]: any };
export type Evaluator = (expression: string, scope?: Scope) => any;
export function parseExpression(exp: string, scope: Scope = {}) {
return exp.trim().replace(/\s*{{\s*([^{}]+)\s*}}\s*/g, (_, v) => {
const item = get(scope, v);
const key = v.replace(/\.(\d+)/g, '["$1"]');
return ` ${typeof item === 'function' ? item() : key} `;
});
}
export default function(plugin: Plugin) {
plugin.calculators.register('basic', basic);
plugin.calculators.register('math.js', mathjs);
plugin.calculators.register('formula.js', formulajs);
};

View File

@ -1,16 +0,0 @@
import * as math from 'mathjs';
import { parseExpression, Scope } from '..';
export default function (expression: string, scope: Scope = {}) {
const exp = parseExpression(expression, scope);
const result = math.evaluate(exp, scope);
if (typeof result === 'number') {
if (Number.isNaN(result) || !Number.isFinite(result)) {
return null;
}
return math.round(result, 9);
}
return result;
}

View File

@ -1,5 +1,4 @@
export * from './constants'; export * from './constants';
export * from './calculators';
// export * from './instructions'; // export * from './instructions';
export { Trigger } from './triggers'; export { Trigger } from './triggers';
export { default as Processor } from './Processor'; export { default as Processor } from './Processor';

View File

@ -1,6 +1,6 @@
import { get } from "lodash"; import evaluators, { Evaluator } from '@nocobase/evaluators';
import { Evaluator, Processor } from '..'; import { Processor } from '..';
import { JOB_STATUS } from "../constants"; import { JOB_STATUS } from "../constants";
import FlowNodeModel from "../models/FlowNode"; import FlowNodeModel from "../models/FlowNode";
import { Instruction } from "."; import { Instruction } from ".";
@ -8,14 +8,14 @@ import { Instruction } from ".";
interface CalculationConfig { interface CalculationConfig {
engine: string; engine?: string;
expression: string; expression?: string;
} }
export default { export default {
async run(node: FlowNodeModel, prevJob, processor: Processor) { async run(node: FlowNodeModel, prevJob, processor: Processor) {
const { engine, expression = '' } = <CalculationConfig>node.config || {}; const { engine = 'math.js', expression = '' } = <CalculationConfig>node.config || {};
const evaluator = <Evaluator | undefined>processor.options.plugin.calculators.get(engine); const evaluator = <Evaluator | undefined>evaluators.get(engine);
const scope = processor.getScope(); const scope = processor.getScope();
try { try {

View File

@ -1,40 +1,132 @@
import { Evaluator, Processor } from '..'; import { Registry } from "@nocobase/utils";
import evaluators from '@nocobase/evaluators';
import { Processor } from '..';
import { JOB_STATUS } from "../constants"; import { JOB_STATUS } from "../constants";
import FlowNodeModel from "../models/FlowNode"; import FlowNodeModel from "../models/FlowNode";
import { Instruction } from "."; import { Instruction } from ".";
function logicCalculate(calculation, evaluator, scope) { export const calculators = new Registry<Function>();
// 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 = {}) {
let fn: Function;
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) { if (!calculation) {
return true; return true;
} }
if (typeof calculation === 'object' && calculation.group) { if (typeof calculation['group'] === 'object') {
const method = calculation.group.type === 'and' ? 'every' : 'some'; const method = calculation['group'].type === 'and' ? 'every' : 'some';
return calculation.group.calculations[method](item => logicCalculate(item, evaluator, scope)); return (calculation['group'].calculations ?? [])[method]((item: Calculation) => logicCalculate(item));
} }
return evaluator(calculation, scope); return calculate(calculation as CalculationItem);
} }
export default { export default {
async run(node: FlowNodeModel, prevJob, processor: Processor) { async run(node: FlowNodeModel, prevJob, processor: Processor) {
// TODO(optimize): loading of jobs could be reduced and turned into incrementally in processor const { engine, calculation, expression, rejectOnFalse } = node.config || {};
// const jobs = await processor.getJobs(); const evaluator = evaluators.get(engine);
const { engine = 'basic', calculation, expression, rejectOnFalse } = node.config || {};
const evaluator = <Evaluator | undefined>processor.options.plugin.calculators.get(engine);
if (!evaluator) {
return {
status: JOB_STATUS.ERROR,
result: new Error('no calculator engine configured')
}
}
const scope = processor.getScope();
let result = true; let result = true;
try { try {
result = logicCalculate(engine === 'basic' ? processor.getParsedValue(calculation) : expression, evaluator, scope); result = evaluator
? evaluator(expression, processor.getScope())
: logicCalculate(processor.getParsedValue(calculation));
} catch (e) { } catch (e) {
return { return {
result: e.toString(), result: e.toString(),

View File

@ -4236,7 +4236,7 @@
dependencies: dependencies:
"@formily/shared" "2.0.20" "@formily/shared" "2.0.20"
"@formulajs/formulajs@^4.2.0": "@formulajs/formulajs@4.2.0", "@formulajs/formulajs@^4.2.0":
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/@formulajs/formulajs/-/formulajs-4.2.0.tgz#e5c6a98fa5863442cb68f93b8b9b28d75070abc4" resolved "https://registry.yarnpkg.com/@formulajs/formulajs/-/formulajs-4.2.0.tgz#e5c6a98fa5863442cb68f93b8b9b28d75070abc4"
integrity sha512-egxyvwj08iwOznFgxv7dvjgHUC7C8jdtznAs+15uThIti7TwDGhB3wsbJt1dlfhSHKvlRAiW4MDYxNkvgmyjyg== integrity sha512-egxyvwj08iwOznFgxv7dvjgHUC7C8jdtznAs+15uThIti7TwDGhB3wsbJt1dlfhSHKvlRAiW4MDYxNkvgmyjyg==