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

View File

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

View File

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

View File

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

View File

@ -2,11 +2,11 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Input, Cascader, Tooltip, Button } from 'antd';
import { useForm } from '@formily/react';
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 }) {
// 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[]>();
for (const option of options) {
map.set(option.key, [option.label]);
map.set(option.value, [option.label]);
if (option.children) {
for (const [key, labels] of createOptionsKeyLabelMap(option.children)) {
map.set(`${option.key}.${key}`, [option.label, ...labels]);
for (const [value, labels] of createOptionsValueLabelMap(option.children)) {
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>`;
}
export function VariableTextArea(props) {
export function TextArea(props) {
const { value = '', scope, onChange, multiline = true, button } = props;
const { t } = useTranslation()
const inputRef = useRef<HTMLDivElement>(null);
const options = (typeof scope === 'function' ? scope() : scope) ?? [];
const form = useForm();
const keyLabelMap = useMemo(() => createOptionsKeyLabelMap(options), [scope]);
const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]);
const [changed, setChanged] = useState(false);
const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap));
// [startElementIndex, startOffset, endElementIndex, endOffset]
@ -213,7 +214,7 @@ export function VariableTextArea(props) {
contentEditable={!disabled}
dangerouslySetInnerHTML={{ __html: html }}
/>
<Tooltip title={lang('Use variable')}>
<Tooltip title={t('Use variable')}>
<Cascader
value={[]}
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
export * from './lib/client';
export { default } from './lib/client';

View File

@ -1,2 +1,3 @@
// @ts-nocheck
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',
tooltip: '{{t("Formula.js supports most Microsoft Excel formula 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',
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: 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 mathjs from './engines/mathjs';
@ -10,9 +14,34 @@ export interface Evaluator {
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('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;

View File

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

View File

@ -1,24 +1,16 @@
import { get } from "lodash";
import { Registry, RegistryOptions } from "@nocobase/utils";
import { Registry } from "@nocobase/utils";
import { evaluate, Evaluator } from '../utils';
import mathjs from "../utils/mathjs";
import formulajs from "../utils/formulajs";
export interface Evaluator {
(expression: string, scope?: { [key: string]: any }): any;
}
export { Evaluator } from '../utils';
export interface EvaluatorsOptions extends RegistryOptions {
empty?: boolean;
evaluators?: { [key: string]: Evaluator };
}
export const evaluators = new Registry<Evaluator>();
const evaluators = new Registry<Evaluator>();
evaluators.register('math.js', mathjs);
evaluators.register('formula.js', formulajs);
evaluators.register('math.js', evaluate.bind(mathjs));
evaluators.register('formula.js', evaluate.bind(formulajs));
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 fns = fnNames.map(key => functions[key]);
export default function(exp: string, scope = {}) {
const expression = exp.replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => v);
export default 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') {

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

View File

@ -61,7 +61,7 @@ export default {
group: 'advanced',
order: 1,
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,
default: {
type: 'formula',

View File

@ -1,242 +1,25 @@
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { css, cx } from '@emotion/css';
import { useForm } from '@formily/react';
import { Button, Cascader, Input, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
import { useCompile } from '@nocobase/client';
import React from 'react';
import { useCompile, Variable } 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) => {
const { value = '', supports, useCurrentFields, onChange } = props;
const { t } = useTranslation();
const compile = useCompile();
const fields = useCurrentFields().filter(field => supports.includes(field.interface));
const inputRef = useRef<HTMLDivElement>(null);
const options = fields.map(field => ({
label: compile(field.uiSchema.title),
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 (
<Input.Group compact className={css`
&.ant-input-group.ant-input-group-compact{
display: flex;
.ant-input{
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>
<Variable.TextArea
value={value}
onChange={onChange}
scope={options}
/>
);
};

View File

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

View File

@ -3,5 +3,6 @@ export default {
'Formula engine': '公式引擎',
'Expression': '表达式',
'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);
});
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', () => {

View File

@ -9,6 +9,7 @@
"@nocobase/actions": "0.9.0-alpha.2",
"@nocobase/client": "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/server": "0.9.0-alpha.2",
"@nocobase/utils": "0.9.0-alpha.2",
@ -16,9 +17,7 @@
"axios": "^0.27.2",
"classnames": "^2.3.1",
"cron-parser": "4.4.0",
"@formulajs/formulajs": "4.2.0",
"json-templates": "^4.2.0",
"mathjs": "^10.6.0",
"moment": "^2.29.2",
"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 { css } from "@emotion/css";
import { CollectionField, CollectionProvider, SchemaComponent, useCollectionManager, useCompile } from "@nocobase/client";
import { VariableInput } from "./VariableInput";
import { CollectionField, CollectionProvider, SchemaComponent, Variable, useCollectionManager, useCompile } from "@nocobase/client";
import { lang } from "../locale";
import { useWorkflowVariableOptions } from "../variable";
@ -40,7 +39,8 @@ export default observer(({ value, disabled, onChange }: any) => {
.filter(field => (
!field.hidden
&& (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));
@ -77,7 +77,7 @@ export default observer(({ value, disabled, onChange }: any) => {
display: flex;
}
`}>
<VariableInput
<Variable.Input
scope={['hasMany', 'belongsToMany'].includes(field.type) ? [] : scope}
value={value[field.name]}
onChange={(next) => {
@ -94,7 +94,7 @@ export default observer(({ value, disabled, onChange }: any) => {
}
}}
/>
</VariableInput>
</Variable.Input>
{!mergedDisabled
? (
<Button

View File

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

View File

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

View File

@ -3,13 +3,12 @@ import { css } from '@emotion/css';
import parse from 'json-templates';
import { SchemaInitializer, SchemaInitializerItemOptions } from '@nocobase/client';
import evaluators, { renderReference, Evaluator } from '@nocobase/evaluators/client';
import { useFlowContext } from '../../FlowContext';
import { lang, NAMESPACE } from '../../locale';
import { VariableTextArea } from '../../components/VariableTextArea';
import { TypeSets, useWorkflowVariableOptions } from '../../variable';
import { calculationEngines, renderReference } from './engines';
import { RadioWithTooltip } from '../../components/RadioWithTooltip';
import { useFlowContext } from '../FlowContext';
import { lang, NAMESPACE } from '../locale';
import { TypeSets, useWorkflowVariableOptions } from '../variable';
import { RadioWithTooltip } from '../components/RadioWithTooltip';
@ -25,7 +24,7 @@ export default {
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'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,
default: 'math.js'
@ -35,14 +34,14 @@ export default {
title: `{{t("Calculation expression", { ns: "${NAMESPACE}" })}}`,
name: 'config.expression',
'x-decorator': 'FormItem',
'x-component': 'VariableTextArea',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope: '{{useWorkflowVariableOptions}}'
},
['x-validator'](value, rules, { form }) {
const { values } = form;
const { evaluate } = calculationEngines.get(values.config.engine);
const exp = value.trim().replace(/\{\{([^{}]+)\}\}/g, '1');
const { evaluate } = evaluators.get(values.config.engine) as Evaluator;
const exp = value.trim().replace(/{{([^{}]+)}}/g, '1');
try {
evaluate(exp);
return '';
@ -86,8 +85,7 @@ export default {
</pre>
);
},
RadioWithTooltip,
VariableTextArea
RadioWithTooltip
},
getOptions(config, types) {
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 { Registry } from "@nocobase/utils/client";
import { Variable, useCompile } from "@nocobase/client";
import evaluators, { renderReference } from "@nocobase/evaluators/client";
import { NodeDefaultView } from ".";
import { Branch } from "../Branch";
@ -12,11 +14,7 @@ import { useFlowContext } from '../FlowContext';
import { branchBlockClass, nodeSubtreeClass } from "../style";
import { lang, NAMESPACE } from "../locale";
import { useWorkflowVariableOptions } from "../variable";
import { VariableTextArea } from "../components/VariableTextArea";
import { VariableInput } from "../components/VariableInput";
import { RadioWithTooltip, RadioWithTooltipOption } from "../components/RadioWithTooltip";
import { calculationEngines, renderReference } from "./calculation/engines";
import { useCompile } from "@nocobase/client";
interface Calculator {
name: string;
@ -152,7 +150,7 @@ export function Calculation({ calculator, operands = [], onChange }) {
align-items: center;
flex-wrap: wrap;
`}>
<VariableInput
<Variable.Input
value={operands[0]}
onChange={(v => onChange({ calculator, operands: [v, operands[1]] }))}
scope={options}
@ -170,7 +168,7 @@ export function Calculation({ calculator, operands = [], onChange }) {
</Select.OptGroup>
))}
</Select>
<VariableInput
<Variable.Input
value={operands[1]}
onChange={(v => onChange({ calculator, operands: [operands[0], v] }))}
scope={options}
@ -335,7 +333,7 @@ export default {
'x-component-props': {
options: [
['basic', { label: `{{t("Basic", { ns: "${NAMESPACE}" })}}` }],
...Array.from(calculationEngines.getEntities())
...Array.from(evaluators.getEntities())
].reduce((result: RadioWithTooltipOption[], [value, options]: any) => result.concat({ value, ...options }), []),
},
required: true,
@ -362,14 +360,14 @@ export default {
title: `{{t("Condition expression", { ns: "${NAMESPACE}" })}}`,
name: 'config.expression',
'x-decorator': 'FormItem',
'x-component': 'VariableTextArea',
'x-component': 'Variable.TextArea',
'x-component-props': {
scope: '{{useWorkflowVariableOptions}}'
},
['x-validator'](value, rules, { form }) {
const { values } = form;
const { evaluate } = calculationEngines.get(values.config.engine);
const exp = value.trim().replace(/\{\{([^{}]+)\}\}/g, '1');
const { evaluate } = evaluators.get(values.config.engine);
const exp = value.trim().replace(/{{([^{}]+)}}/g, '1');
try {
evaluate(exp);
return '';
@ -450,7 +448,6 @@ export default {
},
components: {
CalculationConfig,
VariableTextArea,
RadioWithTooltip
}
};

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import { MockServer, mockServer } from '@nocobase/test';
import path from 'path';
import { ApplicationOptions } from '@nocobase/server';
import { MockServer, mockServer } from '@nocobase/test';
import Plugin from '..';
import calculators from '../calculators';
import { JOB_STATUS } from '../constants';
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 './calculators';
// export * from './instructions';
export { Trigger } from './triggers';
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 FlowNodeModel from "../models/FlowNode";
import { Instruction } from ".";
@ -8,14 +8,14 @@ import { Instruction } from ".";
interface CalculationConfig {
engine: string;
expression: string;
engine?: string;
expression?: string;
}
export default {
async run(node: FlowNodeModel, prevJob, processor: Processor) {
const { engine, expression = '' } = <CalculationConfig>node.config || {};
const evaluator = <Evaluator | undefined>processor.options.plugin.calculators.get(engine);
const { engine = 'math.js', expression = '' } = <CalculationConfig>node.config || {};
const evaluator = <Evaluator | undefined>evaluators.get(engine);
const scope = processor.getScope();
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 FlowNodeModel from "../models/FlowNode";
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) {
return true;
}
if (typeof calculation === 'object' && calculation.group) {
const method = calculation.group.type === 'and' ? 'every' : 'some';
return calculation.group.calculations[method](item => logicCalculate(item, evaluator, scope));
if (typeof calculation['group'] === 'object') {
const method = calculation['group'].type === 'and' ? 'every' : 'some';
return (calculation['group'].calculations ?? [])[method]((item: Calculation) => logicCalculate(item));
}
return evaluator(calculation, scope);
return calculate(calculation as CalculationItem);
}
export default {
async run(node: FlowNodeModel, prevJob, processor: Processor) {
// TODO(optimize): loading of jobs could be reduced and turned into incrementally in processor
// const jobs = await processor.getJobs();
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 { engine, calculation, expression, rejectOnFalse } = node.config || {};
const evaluator = evaluators.get(engine);
const scope = processor.getScope();
let result = true;
try {
result = logicCalculate(engine === 'basic' ? processor.getParsedValue(calculation) : expression, evaluator, scope);
result = evaluator
? evaluator(expression, processor.getScope())
: logicCalculate(processor.getParsedValue(calculation));
} catch (e) {
return {
result: e.toString(),

View File

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