feat: (plugin-workflow) dynamic expression (#1560)

* feat(plugin-formula): add dynamic expression field

* feat(plugin-workflow): add dynamic expression for calculation

* refactor(client): allow select part of paths in variable component

* fix(client): fix types

* feat(plugin-formula): add dynamic expression config

* feat(plugin-workflow): add dynamic calculation

* refactor(plugin-formula): move expression field type to workflow plugin

* fix(plugin-workflow): fix types

* fix(plugin-workflow): fix register field in client

* fix(plugin-workflow): fix expression result value default

* fix(plugin-workflow): fix dynamic expression field error when switch collection

* fix(plugin-workflow): test component value change

* test(plugin-workflow): test component linkages

* refactor(plugin-workflow): change to expression collection template

* fix(client): fix hooks of Variable.TextArea

* fix(client): fix to import evaluators in client

* fix(evaluators): move renderReference method to plugin

* fix(plugin-workflow): fix missed component

* fix(plugin-workflow): fix dynamic expression test case

* refactor(client): change popover to double click to choose entire object

* refactor(plugin-workflow): make variable options and filter more sensible

* fix(plugin-workflow): fix form effect

* fix(plugin-workflow): fix variable filtering in collection trigger

* fix(plugin-workflow): fix types

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2023-04-08 09:52:31 +07:00 committed by GitHub
parent b8776fe2d0
commit 52329df140
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 890 additions and 228 deletions

View File

@ -13,6 +13,7 @@
"@formily/antd": "2.0.20",
"@formily/core": "2.0.20",
"@formily/react": "2.0.20",
"@nocobase/evaluators": "0.9.1-alpha.2",
"@nocobase/sdk": "0.9.1-alpha.2",
"@nocobase/utils": "0.9.1-alpha.2",
"ahooks": "^3.7.2",

View File

@ -3,12 +3,13 @@ import { action } from '@formily/reactive';
import { useCollectionManager } from ".";
import { useCompile } from "../../schema-component";
export function useCollectionDataSource() {
export function useCollectionDataSource(filter?: Function) {
return (field: any) => {
const compile = useCompile();
const { collections = [] } = useCollectionManager();
action.bound((data: any) => {
field.dataSource = data.map(item => ({
const filtered = typeof filter === 'function' ? data.filter(filter) : data;
field.dataSource = filtered.map(item => ({
label: compile(item.title),
value: item.name
}));

View File

@ -21,7 +21,7 @@ export const id: IField = {
'x-read-pretty': true,
},
},
availableTypes:['bigInt','integer'],
availableTypes:['bigInt','integer','string'],
properties: {
'uiSchema.title': {
type: 'string',

View File

@ -0,0 +1,58 @@
import { getOptions } from '@nocobase/evaluators/client';
import { getConfigurableProperties } from './properties';
import { ICollectionTemplate } from './types';
export const expression: ICollectionTemplate = {
name: 'expression',
title: '{{t("Expression collection")}}',
order: 4,
color: 'orange',
default: {
createdBy: true,
updatedBy: true,
createdAt: true,
updatedAt: true,
sortable: true,
fields: [
{
name: 'engine',
type: 'string',
interface: 'radioGroup',
uiSchema: {
type: 'string',
title: '{{t("Calculation engine")}}',
'x-component': 'Radio.Group',
enum: getOptions(),
default: 'formula.js',
},
},
{
name: 'sourceCollection',
type: 'string',
interface: 'select',
uiSchema: {
type: 'string',
title: '{{t("Collection")}}',
'x-component': 'CollectionSelect',
'x-component-props': {
multiple: true
},
}
},
{
name: 'expression',
type: 'text',
interface: 'expression',
uiSchema: {
type: 'string',
title: '{{t("Expression")}}',
'x-component': 'DynamicExpression',
}
}
],
},
availableFieldInterfaces: {
include: [],
},
configurableProperties: getConfigurableProperties('title', 'name', 'inherits','category'),
};

View File

@ -1,5 +1,6 @@
export * from './calendar';
export * from './general';
export * from './tree';
export * from './expression';
export * from './view';

View File

@ -78,7 +78,9 @@ export default {
"Properties":"属性",
"Add linkage rule":"添加联动规则",
"Add property":"添加属性",
"Calculation engine": "计算引擎",
"Expression":"表达式",
"Expression collection": "表达式表",
"Sort":"排序",
"Categories":"数据表类别",
"Category name":"分类名称",
@ -164,7 +166,6 @@ export default {
"Connect data blocks": "连接数据区块",
"Action type": "操作类型",
"Actions": "操作",
"Insert": "新增",
"Update": "更新",
"View": "查看",
"View record": "查看数据",
@ -712,9 +713,11 @@ export default {
'Column width': '列宽',
'Sortable': '可排序的',
'Constant': '常量',
'Select a variable': '选择变量',
"Insert": "插入",
'System variables': '系统变量',
'Date variables': '日期变量',
'Use variable': '使用变量',
'Double click to choose entire object': '双击选择整个对象',
'True': '真',
'False': '假',
'Prettify': '格式化',

View File

@ -39,12 +39,12 @@ export const PinnedPluginList = () => {
`}
style={{ display: 'inline-block' }}
>
{Object.values<any>(ctx.items)
.sort((a, b) => a.order - b.order)
.filter((v) => getSnippetsAllow(v.snippet))
.map((item) => {
const Action = get(components, item.component);
return Action ? <Action /> : null;
{Object.keys(ctx.items)
.sort((a, b) => ctx.items[a].order - ctx.items[b].order)
.filter((key) => getSnippetsAllow(ctx.items[key].snippet))
.map((key) => {
const Action = get(components, ctx.items[key].component);
return Action ? <Action key={key} /> : null;
})}
</div>
);

View File

@ -0,0 +1,54 @@
import { connect, mapReadPretty, observer } from "@formily/react";
import { Select, SelectProps, Tag } from "antd";
import React from "react";
import { useTranslation } from "react-i18next";
import { useCollectionManager } from "../../../collection-manager/hooks";
import { useCompile } from "../../hooks";
export type CollectionSelectProps = SelectProps<any, any> & {
filter?: (item: any, index: number, array: any[]) => boolean
};
function useOptions({ filter }: CollectionSelectProps) {
const compile = useCompile();
const { collections = [] } = useCollectionManager();
const filtered = typeof filter === 'function' ? collections.filter(filter) : collections;
return filtered.map(item => ({
label: compile(item.title),
value: item.name,
color: item.category?.color,
}));
}
export const CollectionSelect = connect((props: CollectionSelectProps) => {
const { filter, ...others } = props;
const options = useOptions(props);
const { t } = useTranslation();
return (
<Select
placeholder={t('Select collection')}
{...others}
options={options}
/>
);
}, mapReadPretty(observer((props: CollectionSelectProps) => {
const { mode } = props;
const compile = useCompile();
const options = useOptions(props).filter(option => {
if (mode === 'multiple') {
return (props.value ?? []).includes(option.value);
}
return props.value === option.value;
});
return (
<div>
{options.map((option) => (
<Tag key={option.value} color={option.color}>
{compile(option.label)}
</Tag>
))}
</div>
);
})));

View File

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

View File

@ -5,6 +5,7 @@ export * from './calendar';
export * from './card-item';
export * from './cascader';
export * from './checkbox';
export * from './collection-select';
export * from './color-select';
export * from './cron';
export * from './date-picker';

View File

@ -7,12 +7,12 @@ import { ReadPretty } from './ReadPretty';
import { Json, JSONTextAreaProps } from './Json';
type ComposedInput = React.FC<InputProps> & {
TextArea?: React.FC<TextAreaProps>;
URL?: React.FC<InputProps>;
JSON?: React.FC<JSONTextAreaProps>;
TextArea: React.FC<TextAreaProps>;
URL: React.FC<InputProps>;
JSON: React.FC<JSONTextAreaProps>;
};
export const Input: ComposedInput = connect(
export const Input: ComposedInput = Object.assign(connect(
AntdInput,
mapProps((props, field) => {
return {
@ -21,9 +21,8 @@ export const Input: ComposedInput = connect(
};
}),
mapReadPretty(ReadPretty.Input),
);
Input.TextArea = connect(
), {
TextArea: connect(
AntdInput.TextArea,
mapProps((props, field) => {
return {
@ -35,13 +34,9 @@ Input.TextArea = connect(
};
}),
mapReadPretty(ReadPretty.TextArea),
);
Input.URL = connect(AntdInput, mapReadPretty(ReadPretty.URL));
Input.JSON = connect(
Json,
mapReadPretty(ReadPretty.JSON),
);
),
URL: connect(AntdInput, mapReadPretty(ReadPretty.URL)),
JSON: connect(Json, mapReadPretty(ReadPretty.JSON))
});
export default Input;

View File

@ -4,9 +4,9 @@ import { useField } from '@formily/react';
import { Input } from 'antd';
import { TextAreaProps } from 'antd/lib/input';
export type JSONTextAreaProps = TextAreaProps & { ref: Ref<any>, value: any, space: number };
export type JSONTextAreaProps = TextAreaProps & { value?: string, space?: number };
export const Json = React.forwardRef<Ref<any>>(({ value, onChange, space = 2, ...props }: JSONTextAreaProps, ref: Ref<any>) => {
export const Json = React.forwardRef<typeof Input.TextArea, JSONTextAreaProps>(({ value, onChange, space = 2, ...props }: JSONTextAreaProps, ref: Ref<any>) => {
const field = useField<Field>();
return (
<Input.TextArea
@ -17,7 +17,7 @@ export const Json = React.forwardRef<Ref<any>>(({ value, onChange, space = 2, ..
try {
const v = ev.target.value.trim() !== '' ? JSON.parse(ev.target.value) : null;
field.setFeedback({});
onChange(v);
onChange?.(v);
} catch (err) {
field.setFeedback({
type: 'error',

View File

@ -13,8 +13,8 @@ type Composed = {
TextArea: React.FC<
TextAreaProps & { ellipsis?: any; text?: any; addonBefore?: any; suffix?: any; addonAfter?: any; autop?: boolean }
>;
Html?: any;
JSON?: React.FC<TextAreaProps & { space: number }>;
Html: any;
JSON: React.FC<TextAreaProps & { space: number }>;
};
export const ReadPretty: Composed = () => null;
@ -63,10 +63,9 @@ ReadPretty.TextArea = (props) => {
};
function convertToText(html: string) {
let temp = document.createElement('div');
const temp = document.createElement('div');
temp.innerHTML = html;
const text = temp.innerText;
temp = null;
return text.replace(/[\n\r]/g, '');
}

View File

@ -7,6 +7,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCompile } from '../..';
import { XButton } from './XButton';
const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/;
@ -95,6 +96,19 @@ const ConstantTypes = {
},
};
function getTypedConstantOption(type) {
const { t } = useTranslation();
return {
value: '',
label: t('Constant'),
children: Object.values(ConstantTypes),
component: ConstantTypes[type]?.component
};
}
type VariableOptions = {
value: string;
label?: string;
@ -102,17 +116,28 @@ type VariableOptions = {
};
export function Input(props) {
const { value = '', scope, onChange, children, button } = props;
const { value = '', scope, onChange, children, button, useTypedConstant } = props;
const parsed = parseValue(value);
const isConstant = typeof 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 { component: ConstantComponent, ...constantOption }: VariableOptions & { component: React.FC<any> } = children
? {
value: '',
label: '{{t("Constant")}}',
component: () => children
}
: (useTypedConstant
? getTypedConstantOption(type)
: {
value: '',
label: '{{t("Null")}}',
component: ConstantTypes.null.component
}
);
const options: VariableOptions[] = compile([
{ value: '', label: t('Constant'), children: children ? null : constantOptions },
constantOption,
...(typeof scope === 'function' ? scope() : scope ?? []),
]);
@ -223,20 +248,17 @@ export function Input(props) {
) : null}
</div>
) : (
children ?? <ConstantComponent value={value} onChange={onChange} />
<ConstantComponent value={value} onChange={onChange} />
)}
{options.length > 1 ? (
<Cascader value={variable ?? ['', ...(children ? [] : [type])]} options={options} onChange={onSwitch}>
{button ?? (
<Button
type={variable ? 'primary' : 'default'}
className={css`
font-style: italic;
font-family: 'New York', 'Times New Roman', Times, serif;
`}
<Cascader
options={options}
value={variable ?? ['', ...(children || !constantOption.children?.length ? [] : [type])]}
onChange={onSwitch}
changeOnSelect
>
x
</Button>
{button ?? (
<XButton type={variable ? 'primary' : 'default'} />
)}
</Cascader>
) : null}

View File

@ -1,25 +1,27 @@
import React, { useRef } from 'react';
import { Button, Cascader } from 'antd';
import React, { useRef, useState } from 'react';
import { Button, Cascader, Popover, Input as AntInput } from 'antd';
import { css } from "@emotion/css";
import { Input } from "../input";
import { useTranslation } from 'react-i18next';
import { XButton } from './XButton';
// NOTE: https://stackoverflow.com/questions/23892547/what-is-the-best-way-to-trigger-onchange-event-in-react-js/46012210#46012210
function setNativeInputValue(input, value) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value').set;
nativeInputValueSetter.call(input, value);
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value')?.set;
nativeInputValueSetter?.call(input, value);
input.dispatchEvent(new Event('input', {
bubbles: true,
}));
}
export function JSONInput(props) {
const inputRef = useRef(null);
const inputRef = useRef<any>(null);
const { value, space = 2, scope } = props;
const { t } = useTranslation()
const { t } = useTranslation();
const [selectedVar, setSelectedVar] = useState<string[]>([]);
const options = typeof scope === 'function' ? scope() : (scope ?? []);
function onFormat() {
@ -70,20 +72,24 @@ export function JSONInput(props) {
`}
>
<Button onClick={onFormat}>{t('Prettify')}</Button>
<Popover
content={(
<AntInput.Group compact>
<Cascader
value={[]}
placeholder={t('Select a variable')}
value={selectedVar}
options={options}
onChange={onInsert}
onChange={(keyPaths) => setSelectedVar(keyPaths as string[])}
changeOnSelect
/>
<Button onClick={onInsert}>{t('Insert')}</Button>
</AntInput.Group>
)}
trigger="click"
placement="topRight"
>
<Button
className={css`
font-style: italic;
font-family: "New York", "Times New Roman", Times, serif;
`}
>
x
</Button>
</Cascader>
<XButton />
</Popover>
</Button.Group>
</div>
);

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Input, Cascader, Tooltip, Button } from 'antd';
import { Input, Cascader, Button } from 'antd';
import { useForm } from '@formily/react';
import { cx, css } from '@emotion/css';
import { useTranslation } from 'react-i18next';
@ -24,6 +24,8 @@ function pasteHTML(container: HTMLElement, html: string, { selectPastedContent =
if (indexes[0] === -1) {
if (indexes[1]) {
range.setStartAfter(children[indexes[1] - 1]);
} else {
range.setStart(container, 0);
}
} else {
range.setStart(children[indexes[0]], indexes[1]);
@ -32,6 +34,8 @@ function pasteHTML(container: HTMLElement, html: string, { selectPastedContent =
if (indexes[2] === -1) {
if (indexes[3]) {
range.setEndAfter(children[indexes[3] - 1]);
} else {
range.setEnd(container, 0);
}
} else {
range.setEnd(children[indexes[2]], indexes[3]);
@ -173,7 +177,7 @@ function getCurrentRange(element: HTMLElement): RangeIndexes {
}
export function TextArea(props) {
const { value = '', scope, onChange, multiline = true, button } = props;
const { value = '', scope, onChange, multiline = true } = props;
const compile = useCompile();
const { t } = useTranslation();
const inputRef = useRef<HTMLDivElement>(null);
@ -185,9 +189,17 @@ export function TextArea(props) {
const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap));
// NOTE: e.g. [startElementIndex, startOffset, endElementIndex, endOffset]
const [range, setRange] = useState<[number, number, number, number]>([-1, 0, -1, 0]);
const [selectedVar, setSelectedVar] = useState<string[]>([]);
useEffect(() => {
setSelectedVar([]);
}, [scope]);
useEffect(() => {
setHtml(renderHTML(value ?? '', keyLabelMap));
if (!changed) {
setRange([-1, 0, -1, 0]);
}
}, [value]);
useEffect(() => {
@ -197,6 +209,7 @@ export function TextArea(props) {
}
const nextRange = new Range();
if (changed) {
setChanged(false);
if (range.join() === '-1,0,-1,0') {
return;
}
@ -234,8 +247,8 @@ export function TextArea(props) {
}
}, [html]);
function onInsert(keyPath) {
const variable: string[] = keyPath.filter((key) => Boolean(key.trim()));
function onInsert(paths: string[]) {
const variable: string[] = paths.filter((key) => Boolean(key.trim()));
const { current } = inputRef;
if (!current || !variable) {
return;
@ -243,7 +256,8 @@ export function TextArea(props) {
current.focus();
pasteHTML(current, createVariableTagHTML(variable.join('.'), keyLabelMap), {
const content = createVariableTagHTML(variable.join('.'), keyLabelMap);
pasteHTML(current, content, {
range,
});
@ -326,6 +340,19 @@ export function TextArea(props) {
}
}
}
.x-button{
.ant-select.ant-cascader{
position: absolute;
top: -1px;
left: -1px;
min-width: auto;
width: calc(100% + 2px);
height: calc(100% + 2px);
overflow: hidden;
opacity: 0;
}
}
`}
>
<div
@ -353,20 +380,61 @@ export function TextArea(props) {
contentEditable={!disabled}
dangerouslySetInnerHTML={{ __html: html }}
/>
<Tooltip title={t('Use variable')}>
<Cascader value={[]} options={options} onChange={onInsert}>
{button ?? (
<Button
<Button className={cx('x-button', css`
position: relative;
`)}>
<span
className={css`
font-style: italic;
font-family: 'New York', 'Times New Roman', Times, serif;
font-family: "New York", "Times New Roman", Times, serif;
`}
>x</span>
<Cascader
placeholder={t('Select a variable')}
value={[]}
options={options}
onChange={(keyPaths = [], selectedOptions = []) => {
setSelectedVar(keyPaths as string[]);
if (!keyPaths.length) {
return;
}
const option = selectedOptions[selectedOptions.length - 1];
if (!option?.children?.length) {
onInsert(keyPaths);
}
}}
changeOnSelect
onClick={(e: any) => {
if (e.detail !== 2) {
return;
}
for (let n = e.target; n && n !== e.currentTarget; n = n.parentNode) {
if (Array.from(n.classList ?? []).includes('ant-cascader-menu-item')) {
onInsert(selectedVar);
}
}
}}
dropdownClassName={css`
.ant-cascader-menu{
margin-bottom: 0;
}
`}
dropdownRender={(menu) => (
<>
{menu}
<div
className={css`
padding: .5em;
border-top: 1px solid rgba(0, 0, 0, .06);
color: rgba(0, 0, 0, .45);
`}
>
x
</Button>
{t('Double click to choose entire object')}
</div>
</>
)}
</Cascader>
</Tooltip>
/>
</Button>
</Input.Group>
);
}

View File

@ -0,0 +1,18 @@
import React, { forwardRef } from "react";
import { Button, ButtonProps } from "antd";
import { css } from "@emotion/css";
export const XButton = forwardRef((props: ButtonProps, ref: any) => (
<Button
ref={ref}
className={css`
font-style: italic;
font-family: "New York", "Times New Roman", Times, serif;
`}
{...props}
>
x
</Button>
));

View File

@ -1,7 +1,3 @@
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';
@ -19,29 +15,8 @@ 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 function getOptions() {
return Array.from((evaluators as Registry<Evaluator>).getEntities()).reduce((result: any[], [value, options]) => result.concat({ value, ...options }), []);
}
export default evaluators;

View File

@ -157,7 +157,7 @@ export const createSchema = () => {
'x-component': 'Select',
'x-read-pretty': true,
enum: [
{ label: "{{ t('Insert') }}", value: 'create', color: 'green' },
{ label: "{{ t('Add new') }}", value: 'create', color: 'green' },
{ label: "{{ t('Update') }}", value: 'update', color: 'blue' },
{ label: "{{ t('Delete') }}", value: 'destroy', color: 'red' },
],

View File

@ -3,11 +3,11 @@ import { onFormValuesChange } from '@formily/core';
import { useFieldSchema, useFormEffects } from '@formily/react';
import cloneDeep from 'lodash/cloneDeep';
import { evaluators, Evaluator } from '@nocobase/evaluators/client';
import evaluators, { Evaluator } from '@nocobase/evaluators/client';
import { Registry, toFixedByStep } from '@nocobase/utils/client';
import { Checkbox, DatePicker, Input as InputString, InputNumber, useCollection } from '@nocobase/client';
import { toDbType } from '../../utils';
import { toDbType } from '../../../utils';
const TypedComponents = {
boolean: Checkbox,

View File

@ -1,4 +1,4 @@
import { connect, mapReadPretty } from '@formily/react';
import { connect } from '@formily/react';
import Expression from './Expression';
import Result from './Result';

View File

@ -0,0 +1 @@
export { Formula } from './Formula';

View File

@ -5,14 +5,14 @@ import { css } from '@emotion/css';
import { CollectionManagerContext, registerField, SchemaComponentOptions } from '@nocobase/client';
import { evaluators, Evaluator } from '@nocobase/evaluators/client';
import { Formula } from './formula';
import field from './field';
import { Formula } from './components';
import formulaField from './interfaces/formula';
import { NAMESPACE } from './locale';
import { Registry } from '@nocobase/utils/client';
registerField(field.group, 'formula', field);
registerField(formulaField.group, 'formula', formulaField);
function renderExpressionDescription(key: string) {
const engine = (evaluators as Registry<Evaluator>).get(key);
@ -36,20 +36,28 @@ function renderExpressionDescription(key: string) {
: null
}
export { Formula } from './formula';
export default React.memo((props) => {
const ctx = useContext(CollectionManagerContext);
return (
<SchemaComponentOptions
components={{
Formula
Formula,
// DynamicExpression
}}
scope={{
renderExpressionDescription
}}
>
<CollectionManagerContext.Provider value={{ ...ctx, interfaces: { ...ctx.interfaces, formula: field } }}>
<CollectionManagerContext.Provider
value={{
...ctx,
interfaces: {
...ctx.interfaces,
formula: formulaField,
// expression: expressionField
}
}}
>
{props.children}
</CollectionManagerContext.Provider>
</SchemaComponentOptions>

View File

@ -1,9 +1,9 @@
import { cloneDeep } from 'lodash';
import { i18n, IField, interfacesProperties } from '@nocobase/client';
import { evaluators, Evaluator } from '@nocobase/evaluators/client';
import evaluators, { Evaluator } from '@nocobase/evaluators/client';
import { Registry } from '@nocobase/utils/client';
import { NAMESPACE } from './locale';
import { NAMESPACE } from '../locale';
@ -125,7 +125,7 @@ export default {
...datetimeProperties,
engine: {
type: 'string',
title: `{{t("Formula engine", { ns: "${NAMESPACE}" })}}`,
title: `{{t("Calculation engine", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
enum: Array.from((evaluators as Registry<Evaluator>).getEntities()).reduce((result: any[], [value, options]) => result.concat({ value, ...options }), []),

View File

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

View File

@ -2,6 +2,8 @@ import { InstallOptions, Plugin } from '@nocobase/server';
import { resolve } from 'path';
import { FormulaField } from './formula-field';
export class FormulaFieldPlugin extends Plugin {
afterAdd() {}

View File

@ -22,6 +22,7 @@
"react-js-cron": "^3.1.0"
},
"devDependencies": {
"@nocobase/plugin-formula-field": "0.9.1-alpha.2",
"@nocobase/test": "0.9.1-alpha.2",
"@types/ejs": "^3.1.1"
},

View File

@ -1,6 +1,7 @@
import React, { useContext } from 'react';
import { Card } from 'antd';
import { PluginManagerContext, RouteSwitchContext, SchemaComponent, SchemaComponentOptions, SettingsCenterProvider } from '@nocobase/client';
import { CollectionManagerContext, PluginManagerContext, RouteSwitchContext, SchemaComponent, SchemaComponentOptions, SettingsCenterProvider, registerField, useCollectionDataSource } from '@nocobase/client';
import { WorkflowPage } from './WorkflowPage';
import { ExecutionPage } from './ExecutionPage';
import { triggers } from './triggers';
@ -13,6 +14,12 @@ import { ExecutionLink } from './ExecutionLink';
import OpenDrawer from './components/OpenDrawer';
import { WorkflowTodo } from './nodes/manual/WorkflowTodo';
import { WorkflowTodoBlockInitializer } from './nodes/manual/WorkflowTodoBlockInitializer';
import { DynamicExpression } from './components/DynamicExpression';
import expressionField from './interfaces/expression';
// registerField(expressionField.group, 'expression', expressionField);
export const WorkflowContext = React.createContext({});
@ -37,7 +44,8 @@ function WorkflowPane() {
};
export const WorkflowProvider = (props) => {
const ctx = useContext(PluginManagerContext);
const pmCtx = useContext(PluginManagerContext);
const cmCtx = useContext(CollectionManagerContext);
const { routes, components, ...others } = useContext(RouteSwitchContext);
routes[1].routes.unshift(
{
@ -71,16 +79,35 @@ export const WorkflowProvider = (props) => {
<PluginManagerContext.Provider
value={{
components: {
...ctx?.components,
...pmCtx?.components,
// WorkflowShortcut,
},
}}
>
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage, ExecutionPage }, ...others, routes }}>
<SchemaComponentOptions components={{ WorkflowTodo, WorkflowTodoBlockInitializer }}>
<SchemaComponentOptions
components={{
WorkflowTodo,
WorkflowTodoBlockInitializer,
DynamicExpression
}}
scope={{
useCollectionDataSource
}}
>
<CollectionManagerContext.Provider
value={{
...cmCtx,
interfaces: {
...cmCtx.interfaces,
expression: expressionField
}
}}
>
<WorkflowContext.Provider value={{ triggers, instructions }}>
{props.children}
</WorkflowContext.Provider>
</CollectionManagerContext.Provider>
</SchemaComponentOptions>
</RouteSwitchContext.Provider>
</PluginManagerContext.Provider>

View File

@ -0,0 +1,43 @@
import React, { useState } from "react";
import { onFieldInputValueChange } from '@formily/core';
import { observer, connect, mapReadPretty, useFormEffects } from '@formily/react';
import { Tag } from 'antd';
import { useTranslation } from 'react-i18next';
import { Variable } from "@nocobase/client";
import { NAMESPACE } from "../locale";
import { useCollectionFieldOptions } from "../variable";
const InternalExpression = observer((props: any) => {
const { value, onChange } = props;
const [collection, setCollection] = useState(null);
useFormEffects(() => {
onFieldInputValueChange('sourceCollection', (f) => {
setCollection(f.value);
onChange(null);
});
});
const options = useCollectionFieldOptions({ collection });
return (
<Variable.TextArea
value={value}
onChange={onChange}
scope={options}
/>
);
});
function Result({ value }) {
const { t } = useTranslation();
return value
? <Tag color="purple">{t('Expression')}</Tag>
: <Tag>{t('Unconfigured', { ns: NAMESPACE })}</Tag>;
}
export const DynamicExpression = connect(InternalExpression, mapReadPretty(Result));

View File

@ -15,6 +15,7 @@ export function FilterDynamicComponent({ value, onChange, renderSchemaComponent
onChange={onChange}
scope={scope}
>
{renderSchemaComponent()}
</Variable.Input>
);
}

View File

@ -0,0 +1,32 @@
import React from "react";
import { css } from '@emotion/css';
import { i18n } from "@nocobase/client";
import evaluators from "@nocobase/evaluators/client";
export const renderEngineReference = (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
};

View File

@ -0,0 +1,24 @@
import { IField, interfacesProperties } from "@nocobase/client";
import { NAMESPACE } from "../locale";
const { defaultProps } = interfacesProperties;
export default {
name: 'expression',
type: 'string',
group: 'advanced',
order: 1,
title: `{{t("Expression", { ns: "${NAMESPACE}" })}}`,
description: `{{t("An expression for calculation in each rows", { ns: "${NAMESPACE}" })}}`,
sortable: true,
default: {
type: 'text',
uiSchema: {
'x-component': 'DynamicExpression',
},
},
properties: {
...defaultProps,
},
} as IField;

View File

@ -60,8 +60,8 @@ export default {
'Arithmetic calculation': '算术运算',
'String operation': '字符串',
'System variables': '系统变量',
'System time': '系统时间',
'Date variables': '日期变量',
'Current time': '当前时间',
'Executed at': '执行于',
'Queueing': '队列中',
@ -86,6 +86,11 @@ export default {
'Extended types': '扩展类型',
'Node type': '节点类型',
'Calculation': '运算',
'Expression type': '表达式类型',
'Static': '静态',
'Dynamic': '动态',
'Select dynamic expression': '选择动态表达式',
'Variable datasource': '变量数据源',
'Calculation engine': '运算引擎',
'Basic': '基础',
'Calculation expression': '运算表达式',
@ -165,4 +170,8 @@ export default {
'Workflow todos': '工作流待办',
'Task': '任务',
'Dynamic expression': '动态表达式',
'An expression for calculation in each rows': '每行数据计算规则不同时使用',
'Unconfigured': '未配置'
};

View File

@ -1,32 +1,90 @@
import React from 'react';
import { observer } from '@formily/react';
import { FormLayout, FormItem } from '@formily/antd';
import { css } from '@emotion/css';
import parse from 'json-templates';
import { useTranslation } from 'react-i18next';
import { Radio } from 'antd';
import { SchemaInitializer, SchemaInitializerItemOptions } from '@nocobase/client';
import { evaluators, renderReference, Evaluator } from '@nocobase/evaluators/client';
import { SchemaInitializer, SchemaInitializerItemOptions, useCollectionManager, Variable } from '@nocobase/client';
import { evaluators, Evaluator, getOptions } from '@nocobase/evaluators/client';
import { useFlowContext } from '../FlowContext';
import { lang, NAMESPACE } from '../locale';
import { TypeSets, useWorkflowVariableOptions } from '../variable';
import { BaseTypeSets, useWorkflowVariableOptions } from '../variable';
import { RadioWithTooltip } from '../components/RadioWithTooltip';
import { renderEngineReference } from '../components/renderEngineReference';
function matchDynamicExpressionCollectionField(field): boolean {
const { getCollectionFields, getCollection } = useCollectionManager();
if (field.type !== 'belongsTo') {
return false;
}
const fields = getCollectionFields(field.target);
return fields.some(f => f.interface === 'expression');
}
const DynamicConfig = ({ value, onChange }) => {
const { t } = useTranslation();
const scope = useWorkflowVariableOptions([
matchDynamicExpressionCollectionField
]);
return (
<FormLayout layout="vertical">
<FormItem label={t('Expression type', { ns: NAMESPACE })}>
<Radio.Group value={value === false ? false : (value || null)} onChange={(ev) => {
onChange(ev.target.value);
}}>
<Radio value={false}>{t("Static", { ns: NAMESPACE })}</Radio>
<Radio value={value || null}>{t("Dynamic", { ns: NAMESPACE })}</Radio>
</Radio.Group>
</FormItem>
{value !== false ? (
<FormItem label={t('Select dynamic expression', { ns: NAMESPACE })}>
<Variable.Input value={value || null} onChange={(v) => onChange(v)} scope={scope} />
</FormItem>
) : null}
</FormLayout>
)
};
function useWorkflowVariableEntityOptions() {
return useWorkflowVariableOptions([{ type: "reference", options: { collection: "*", entity: true } }]);
}
export default {
title: `{{t("Calculation", { ns: "${NAMESPACE}" })}}`,
type: 'calculation',
group: 'control',
fieldset: {
dynamic: {
type: 'string',
'x-component': 'DynamicConfig',
default: false,
},
engine: {
type: 'string',
title: `{{t("Calculation engine", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RadioWithTooltip',
'x-component-props': {
options: Array.from(evaluators.getEntities()).reduce((result: any[], [value, options]) => result.concat({ value, ...options }), [])
options: getOptions()
},
required: true,
default: 'math.js'
default: 'math.js',
'x-reactions': {
dependencies: ['dynamic'],
fulfill: {
state: {
visible: '{{$deps[0] === false}}',
}
}
}
},
expression: {
type: 'string',
@ -47,15 +105,42 @@ export default {
return lang('Expression syntax error');
}
},
'x-reactions': {
dependencies: ['engine'],
'x-reactions': [
{
dependencies: ['dynamic'],
fulfill: {
schema: {
description: '{{renderReference($deps[0])}}',
state: {
visible: '{{$deps[0] === false}}',
}
}
},
{
dependencies: ['engine'],
fulfill: {
schema: {
description: '{{renderEngineReference($deps[0])}}',
}
}
},
],
required: true
},
scope: {
type: 'string',
title: `{{t("Variable datasource", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Variable.Input',
'x-component-props': {
scope: '{{useWorkflowVariableEntityOptions}}'
},
'x-reactions': {
dependencies: ['dynamic'],
fulfill: {
state: {
visible: '{{$deps[0] !== false}}',
}
}
}
}
},
view: {
@ -63,7 +148,8 @@ export default {
},
scope: {
useWorkflowVariableOptions,
renderReference
useWorkflowVariableEntityOptions,
renderEngineReference
},
components: {
CalculationResult({ dataSource }) {
@ -83,10 +169,11 @@ export default {
</pre>
);
},
RadioWithTooltip
RadioWithTooltip,
DynamicConfig
},
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 BaseTypeSets || Object.values(BaseTypeSets).some(set => set.has(type)))) {
return null;
}
return [

View File

@ -6,7 +6,7 @@ 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 { evaluators } from "@nocobase/evaluators/client";
import { NodeDefaultView } from ".";
import { Branch } from "../Branch";
@ -15,6 +15,7 @@ import { branchBlockClass, nodeSubtreeClass } from "../style";
import { lang, NAMESPACE } from "../locale";
import { useWorkflowVariableOptions } from "../variable";
import { RadioWithTooltip, RadioWithTooltipOption } from "../components/RadioWithTooltip";
import { renderEngineReference } from "../components/renderEngineReference";
interface Calculator {
name: string;
@ -154,6 +155,7 @@ export function Calculation({ calculator, operands = [], onChange }) {
value={operands[0]}
onChange={(v => onChange({ calculator, operands: [v, operands[1]] }))}
scope={options}
useTypedConstant
/>
<Select
value={calculator}
@ -172,6 +174,7 @@ export function Calculation({ calculator, operands = [], onChange }) {
value={operands[1]}
onChange={(v => onChange({ calculator, operands: [operands[0], v] }))}
scope={options}
useTypedConstant
/>
</fieldset>
);
@ -378,7 +381,7 @@ export default {
visible: '{{$deps[0] !== "basic"}}'
},
schema: {
description: '{{renderReference($deps[0])}}',
description: '{{renderEngineReference($deps[0])}}',
}
}
},
@ -439,7 +442,7 @@ export default {
)
},
scope: {
renderReference,
renderEngineReference,
useWorkflowVariableOptions
},
components: {

View File

@ -45,7 +45,7 @@ export default {
FieldsSelect
},
getOptions(config, types) {
return useCollectionFieldOptions({ collection: config.collection, types });
return useCollectionFieldOptions({ collection: config.collection, types, depth: config.params?.appends?.length ? 1 : 0 });
},
useInitializers(node): SchemaInitializerItemOptions | null {
if (!node.config.collection) {

View File

@ -30,7 +30,7 @@ import destroy from './destroy';
import { JobStatusOptions, JobStatusOptionsMap } from '../constants';
import { lang, NAMESPACE } from '../locale';
import request from "./request";
import { VariableOption } from '../variable';
import { VariableOptions } from '../variable';
export interface Instruction {
title: string;
@ -43,7 +43,7 @@ export interface Instruction {
components?: { [key: string]: any };
render?(props): React.ReactNode;
endding?: boolean;
getOptions?(config, types?): VariableOption[] | null;
getOptions?(config, types?): VariableOptions;
useInitializers?(node): SchemaInitializerItemOptions | null;
initializers?: { [key: string]: any };
};

View File

@ -6,12 +6,11 @@ import { useWorkflowVariableOptions } from '../../variable';
export function AssigneesSelect({ multiple = false, value = [], onChange }) {
const scope = useWorkflowVariableOptions();
const scope = useWorkflowVariableOptions([{ type: 'reference', options: { collection: 'users' } }]);
return (
<Variable.Input
scope={scope}
types={[{ type: 'reference', options: { collection: 'users' } }]}
value={value[0]}
onChange={(next) => {
onChange([next]);

View File

@ -50,7 +50,7 @@ export default {
FieldsSelect
},
getOptions(config, types) {
return useCollectionFieldOptions({ collection: config.collection, types });
return useCollectionFieldOptions({ collection: config.collection, types, depth: config.params?.appends?.length ? 1 : 0 });
},
useInitializers(node): SchemaInitializerItemOptions | null {
if (!node.config.collection) {

View File

@ -96,7 +96,7 @@ export default {
dependencies: ['collection', 'mode'],
fulfill: {
state: {
visible: `{{$deps[0] && $deps[1] & ${COLLECTION_TRIGGER_MODE.UPDATED}}}`,
visible: `{{!!$deps[0] && ($deps[1] & ${COLLECTION_TRIGGER_MODE.UPDATED})}}`,
},
}
},
@ -139,10 +139,18 @@ export default {
},
getOptions(config, types) {
const { t } = useWorkflowTranslation();
const fieldOptions = useCollectionFieldOptions({ collection: config.collection, types });
const options: any[] = [
...(fieldOptions?.length ? [{ label: t('Trigger data'), key: 'data', value: 'data', children: fieldOptions }] : []),
const rootFields = [
{
collectionName: config.collection,
name: 'data',
type: 'hasOne',
target: config.collection,
uiSchema: {
title: t('Trigger data')
}
}
];
const options = useCollectionFieldOptions({ fields: rootFields, types, depth: config.appends?.length ? 2 : 1 });
return options;
},
useInitializers(config): SchemaInitializerItemOptions | null {

View File

@ -13,6 +13,7 @@ import { useFlowContext } from "../FlowContext";
import collection from './collection';
import schedule from "./schedule/";
import { lang, NAMESPACE } from "../locale";
import { VariableOptions } from "../variable";
function useUpdateConfigAction() {
@ -44,7 +45,7 @@ export interface Trigger {
title: string;
type: string;
// group: string;
getOptions?(config: any, types: any[]): { label: string; value: any; key: string }[];
getOptions?(config: any, types: any[]): VariableOptions;
fieldset: { [key: string]: ISchema };
view?: ISchema;
scope?: { [key: string]: any };

View File

@ -34,7 +34,7 @@ export function OnField({ value, onChange }) {
{dir
? (
<>
<InputNumber value={Math.abs(value.offset)} onChange={(v) => onChange({ ...value, offset: v * dir })}/>
<InputNumber value={Math.abs(value.offset)} onChange={(v) => onChange({ ...value, offset: (v ?? 0) * dir })}/>
<Select value={value.unit || 86400000} onChange={unit => onChange({ ...value, unit })}>
<Select.Option value={86400000}>{localT('Days')}</Select.Option>
<Select.Option value={3600000}>{localT('Hours')}</Select.Option>

View File

@ -5,12 +5,14 @@ import { instructions, useAvailableUpstreams, useNodeContext } from './nodes';
import { triggers } from './triggers';
export type VariableOption = {
key: string;
key?: string;
value: string;
label: string;
children?: VariableOption[] | null;
children?: VariableOptions;
};
export type VariableOptions = VariableOption[] | null;
const VariableTypes = [
{
title: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
@ -46,85 +48,162 @@ const VariableTypes = [
{
title: `{{t("System variables", { ns: "${NAMESPACE}" })}}`,
value: '$system',
options: [
options(types) {
return [
...(!types || types.includes('date') ? [
{
key: 'now',
value: 'now',
label: `{{t("Now")}}`,
},
],
label: `{{t("System time")}}`,
}
] : [])
];
}
},
];
export const TypeSets = {
boolean: new Set(['boolean']),
number: new Set(['integer', 'bigInt', 'float', 'double', 'real', 'decimal']),
string: new Set(['string', 'text', 'password']),
date: new Set(['date', 'time']),
export const BaseTypeSets = {
boolean: new Set(['checkbox']),
number: new Set(['number', 'percent']),
string: new Set(['input', 'password', 'email', 'phone', 'select', 'radioGroup', 'text', 'markdown', 'richText', 'expression', 'time']),
date: new Set(['date', 'createdAt', 'updatedAt']),
};
// { type: 'reference', options: { collection: 'users', multiple: false } }
// { type: 'reference', options: { collection: 'attachments', multiple: false } }
// { type: 'reference', options: { collection: 'myExpressions', entity: false } }
function matchFieldType(field, type): Boolean {
if (typeof type === 'string') {
return Boolean(TypeSets[type]?.has(field.type));
const inputType = typeof type;
if (inputType === 'string') {
return Boolean(BaseTypeSets[type]?.has(field.interface));
}
if (typeof type === 'object' && type.type === 'reference') {
if (inputType === 'object' && type.type === 'reference') {
if (isAssociationField(field)) {
return type.options?.entity && (field.collectionName === type.options?.collection || type.options?.collection === '*');
} else if (field.isForeignKey) {
return (
(field.collectionName === type.options?.collection && field.name === 'id') ||
(field.type === 'belongsTo' && field.target === type.options?.collection)
(field.target === type.options?.collection)
);
} else {
return false;
}
}
if (inputType === 'function') {
return type(field);
}
return false;
}
export function filterTypedFields(fields, types) {
return types ? fields.filter((field) => types.some((type) => matchFieldType(field, type))) : fields;
function isAssociationField(field): boolean {
return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type);
}
export function useWorkflowVariableOptions() {
export function filterTypedFields(fields, types, depth = 1) {
if (!types) {
return fields;
}
return fields.filter((field) => {
if (isAssociationField(field) && depth && filterTypedFields(useNormallizedFields(field.target), types, depth - 1).length) {
return true;
}
return types.some((type) => matchFieldType(field, type));
});
}
export function useWorkflowVariableOptions(types?) {
const compile = useCompile();
const options = VariableTypes.map((item: any) => {
const options = typeof item.options === 'function' ? item.options().filter(Boolean) : item.options;
const opts = typeof item.options === 'function' ? item.options(types).filter(Boolean) : item.options;
return {
label: compile(item.title),
value: item.value,
key: item.value,
children: compile(options),
disabled: options && !options.length,
children: compile(opts),
disabled: opts && !opts.length,
};
});
return options;
}
function useCollectionNormalFields(collection) {
const { getCollectionFields } = useCollectionManager();
const fields = getCollectionFields(collection);
return fields.filter(field => field.interface);
}
export function useCollectionFieldOptions(options, depth = 1): VariableOption[] {
const { fields, collection, types } = options;
function useNormallizedFields(collection) {
const compile = useCompile();
const result: VariableOption[] = [];
filterTypedFields((fields ?? useCollectionNormalFields(collection)), types)
.forEach(field => {
const label = compile(field.uiSchema?.title || field.name);
const { getCollection } = useCollectionManager();
if (!collection) {
return [];
}
const { fields } = getCollection(collection);
const foreignKeyFields: any[] = [];
const otherFields: any[] = [];
fields.forEach(field => {
if (field.isForeignKey) {
foreignKeyFields.push(field);
} else {
otherFields.push(field);
}
});
for (let i = otherFields.length - 1; i >= 0; i--) {
const field = otherFields[i];
if (field.type === 'belongsTo') {
result.push({
label: `${label} ID`,
key: field.foreignKey,
value: field.foreignKey,
const foreignKeyField = foreignKeyFields.find(f => f.name === field.foreignKey);
if (foreignKeyField) {
otherFields.splice(i, 0, {
...foreignKeyField,
uiSchema: {
...field.uiSchema,
title: field.uiSchema?.title ? `${compile(field.uiSchema?.title)} ID` : foreignKeyField.name,
}
});
} else {
otherFields.splice(i, 0, {
...field,
name: field.foreignKey,
type: 'bigInt',
isForeignKey: true,
interface: field.interface,
uiSchema: {
...field.uiSchema,
title: field.uiSchema?.title ? `${compile(field.uiSchema?.title)} ID` : field.name,
}
});
}
result.push({
} else if (field.type === 'context' && field.collectionName === 'users') {
const belongsToField = otherFields.find(f => f.type === 'belongsTo' && f.target === 'users' && f.foreignKey === field.name) ?? {};
otherFields.splice(i, 0, {
...field,
type: field.dataType,
interface: belongsToField.interface,
uiSchema: {
...belongsToField.uiSchema,
title: belongsToField.uiSchema?.title ? `${compile(belongsToField.uiSchema?.title)} ID` : field.name
}
});
}
}
return otherFields.filter(field => field.interface && !field.hidden);
}
export function useCollectionFieldOptions(options): VariableOption[] {
const { fields, collection, types, depth = 1 } = options;
const compile = useCompile();
const normalizedFields = fields ?? useNormallizedFields(collection);
const result: VariableOption[] = filterTypedFields(normalizedFields, types, depth)
.filter(field => !isAssociationField(field) || depth)
.map(field => {
const label = compile(field.uiSchema?.title || field.name);
return {
label,
key: field.name,
value: field.name,
children: ['linkTo', 'belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type) && depth > 0
? useCollectionFieldOptions({ collection: field.target, types }, depth - 1)
children: isAssociationField(field) && depth
? useCollectionFieldOptions({ collection: field.target, types, depth: depth - 1 })
: null
});
};
});
return result;

View File

@ -4,6 +4,7 @@ import { Op } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils';
import initFields from './fields';
import initActions from './actions';
import { EXECUTION_STATUS } from './constants';
import initInstructions, { Instruction } from './instructions';
@ -72,12 +73,12 @@ export default class WorkflowPlugin extends Plugin {
async load() {
const { db, options } = this;
initFields(this);
initActions(this);
initTriggers(this, options.triggers);
initInstructions(this, options.instructions);
initFunctions(this, options.functions);
this.app.acl.registerSnippet({
name: `pm.${this.name}.workflows`,
actions: [

View File

@ -6,6 +6,18 @@ export default {
{
type: 'string',
name: 'title',
},
{
type: 'string',
name: 'engine'
},
{
type: 'string',
name: 'collection'
},
{
type: 'text',
name: 'expression'
}
],
} as CollectionOptions;

View File

@ -28,6 +28,15 @@ export default {
{
type: 'belongsToMany',
name: 'tags'
},
{
type: 'integer',
name: 'read',
defaultValue: 0
},
{
type: 'expression',
name: 'dexp'
}
]
} as CollectionOptions;

View File

@ -9,6 +9,7 @@ describe('workflow > instructions > calculation', () => {
let app: Application;
let db: Database;
let PostRepo;
let CategoryRepo;
let WorkflowModel;
let workflow;
@ -18,6 +19,7 @@ describe('workflow > instructions > calculation', () => {
db = app.db;
WorkflowModel = db.getCollection('workflows').model;
PostRepo = db.getCollection('posts').repository;
CategoryRepo = db.getCollection('categories').repository;
workflow = await WorkflowModel.create({
title: 'test workflow',
@ -169,4 +171,81 @@ describe('workflow > instructions > calculation', () => {
expect(job.result).toBe('at1');
});
});
describe('dynamic expression', () => {
it('dynamic expression field in current table', async () => {
const n1 = await workflow.createNode({
type: 'calculation',
config: {
dynamic: '{{$context.data.category}}',
scope: '{{$context.data}}',
}
});
const post = await PostRepo.create({
values: {
title: 't1',
category: {
engine: 'math.js',
expression: '1 + {{read}}',
}
}
});
await sleep(500);
const [execution] = await workflow.getExecutions();
const [job] = await execution.getJobs();
expect(job.result).toBe(1);
});
it('dynamic expression field in association table', async () => {
const n1 = await workflow.createNode({
type: 'query',
config: {
collection: 'categories',
params: {
filter: {
$and: [
{ id: '{{$context.data.categoryId}}' },
]
}
}
}
});
const n2 = await workflow.createNode({
type: 'calculation',
config: {
dynamic: `{{$jobsMapByNodeId.${n1.id}}}`,
scope: '{{$context.data}}',
},
upstreamId: n1.id
});
await n1.setDownstream(n2);
const category = await CategoryRepo.create({
values: {
title: 'c1',
engine: 'math.js',
expression: '1 + {{read}}',
}
});
const post = await PostRepo.create({
values: {
title: 't1',
categoryId: category.id
}
});
await sleep(500);
const [execution] = await workflow.getExecutions();
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
expect(jobs.length).toBe(2);
expect(jobs[1].result).toBe(1);
});
});
});

View File

@ -0,0 +1,15 @@
import { DataTypes } from 'sequelize';
import { BaseFieldOptions, Field } from '@nocobase/database';
export interface ExpressionFieldOptions extends BaseFieldOptions {
type: 'expression',
}
export class ExpressionField extends Field {
get dataType() {
return DataTypes.TEXT;
}
}

View File

@ -0,0 +1,7 @@
import { ExpressionField } from "./expression-field";
export default function (plugin) {
plugin.db.registerFieldTypes({
expression: ExpressionField
});
}

View File

@ -1,3 +1,5 @@
import parse from 'json-templates';
import evaluators, { Evaluator } from '@nocobase/evaluators';
import { Processor } from '..';
@ -8,15 +10,24 @@ import { Instruction } from ".";
interface CalculationConfig {
dynamic?: boolean | string;
engine?: string;
expression?: string;
}
export default {
async run(node: FlowNodeModel, prevJob, processor: Processor) {
const { engine = 'math.js', expression = '' } = <CalculationConfig>node.config || {};
const { dynamic = false } = <CalculationConfig>node.config || {};
let { engine = 'math.js', expression = '' } = node.config;
let scope = processor.getScope();
if (dynamic) {
const parsed = parse(dynamic)(scope) ?? {};
engine = parsed.engine;
expression = parsed.expression;
scope = parse(node.config.scope ?? '')(scope) ?? {};
}
const evaluator = <Evaluator | undefined>evaluators.get(engine);
const scope = processor.getScope();
try {
const result = evaluator && expression