Feat(plugin-workflow) manual instruction (#1339)
* feat(plugin-workflow): add prompt node * feat(plugin-workflow): useValueGetter for all instructions and triggers * feat(plugin-workflow): add workflow block initializer * refactor(plugin-workflow): change prompt node type to manual * feat(plugin-workflow): add ModeConfig component for mode * feat(plugin-workflow): add todo drawer * feat(plugin-workflow): add block value provider * feat(plugin-workflow): improve todo block and drawer * fix(plugin-workflow): fix instruction name in test cases * fix(plugin-workflow): fix test cases * refactor(plugin-workflow): change param type of collection field initializer * feat(plugin-workflow): add filter types for getters * fix(plugin-workflow): fix assignees variable * fix(plugin-workflow): filter todo by exist workflow * fix(plugin-workflow): fix duplicated save action in manual config * fix(plugin-workflow): fix transaction * feat(plugin-workflow): destroy workflow will be cascaded * fix(plugin-workflow): fix merge * fix(plugin-workflow): fix locale * fix(plugin-workflow): allow open ui view when executed * fix(plugin-workflow): change todo table filter * feat(plugin-workflow): use formula for calculation * fix(plugin-workflow): fix variable template regexp * fix(plugin-workflow): fix sub-options logic with types * refactor(plugin-workflow): drop useless component * fix(plugin-workflow): fix manual node action button * feat(plugin-workflow): add new variable input component * refactor(plugin-workflow): change all variable to new component * fix(plugin-workflow): fix type * fix(plugin-workflow): fix functions init * fix(plugin-workflow): change jsonb to json for stable order * fix(plugin-workflow): fix duplicated field name when initialize * fix(plugin-workflow): fix manual result in manual block * test(plugin-workflow): log field initializer props * fix(plugin-workflow): fix nullable arguments * test(plugin-workflow): test initializer fields schema * fix: observer * fix(plugin-workflow): adjust hints * fix(plugin-workflow): fix locale and cursor in variable input * refactor(plugin-workflow): change status keys * fix(plugin-workflow): fix parallel instruction * fix(plugin-workflow): fix calculation migration * fix(plugin-workflow): move tasks native filter to server * fix(plugin-workflow): fix manual options for variable * fix(plugin-workflow): fix conflict * fix(plugin-workflow): fix some bugs * fix(plugin-workflow): fix todo list filter and locale * fix(plugin-workflow): fix update action of workflow * refactor(plugin-workflow): add legacy condition calculation as basic engine * fix(plugin-workflow): fix type * fix(plugin-workflow): fix condition basic calculation * fix(plugin-workflow): fix type * fix(plugin-workflow): fix migration * fix(plugin-workflow): fix evaluators and scope * fix(plugin-workflow): remove disabled type select in schema config * fix(plugin-workflow): fix manual form schema designer --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
651200f3ab
commit
4fbad75ea9
@ -1,5 +1,6 @@
|
|||||||
const { pathsToModuleNameMapper } = require('ts-jest/utils');
|
const { pathsToModuleNameMapper } = require('ts-jest/utils');
|
||||||
const { compilerOptions } = require('./tsconfig.json');
|
const { compilerOptions } = require('./tsconfig.json');
|
||||||
|
const { defaults } = require('jest-config');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rootDir: process.cwd(),
|
rootDir: process.cwd(),
|
||||||
@ -21,6 +22,8 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
modulePathIgnorePatterns: ['/esm/', '/es/', '/dist/', '/lib/'],
|
modulePathIgnorePatterns: ['/esm/', '/es/', '/dist/', '/lib/'],
|
||||||
|
// add .mjs .cjs for formula.js
|
||||||
|
moduleFileExtensions: [...defaults.moduleFileExtensions, 'mjs', 'cjs'],
|
||||||
coveragePathIgnorePatterns: [
|
coveragePathIgnorePatterns: [
|
||||||
'/node_modules/',
|
'/node_modules/',
|
||||||
'/__tests__/',
|
'/__tests__/',
|
||||||
|
@ -1 +1 @@
|
|||||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
{"name":"","short_name":"","icons":[{"src":"./android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
|
@ -21,7 +21,7 @@ import { SharedFilterProvider } from './SharedFilterProvider';
|
|||||||
|
|
||||||
export const BlockResourceContext = createContext(null);
|
export const BlockResourceContext = createContext(null);
|
||||||
export const BlockAssociationContext = createContext(null);
|
export const BlockAssociationContext = createContext(null);
|
||||||
export const BlockRequestContext = createContext<any>(null);
|
export const BlockRequestContext = createContext<any>({});
|
||||||
|
|
||||||
export const useBlockResource = () => {
|
export const useBlockResource = () => {
|
||||||
return useContext(BlockResourceContext);
|
return useContext(BlockResourceContext);
|
||||||
|
@ -164,6 +164,9 @@ export default {
|
|||||||
"Advanced type": "高级类型",
|
"Advanced type": "高级类型",
|
||||||
"Formula": "公式",
|
"Formula": "公式",
|
||||||
"Formula description": "基于同一条记录中的其他字段计算出一个值。",
|
"Formula description": "基于同一条记录中的其他字段计算出一个值。",
|
||||||
|
"Syntax references": "语法参考",
|
||||||
|
"Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types": "Math.js 包含大量内置函数和常量,并提供了集成的解决方案来处理不同的数据类型。",
|
||||||
|
"Formula.js supports most Microsoft Excel formula functions.": "Formula.js 支持大部分 Mircrosoft Excel 公式。",
|
||||||
"Choices": "选择类型",
|
"Choices": "选择类型",
|
||||||
"Checkbox": "勾选",
|
"Checkbox": "勾选",
|
||||||
"Single select": "下拉菜单(单选)",
|
"Single select": "下拉菜单(单选)",
|
||||||
|
@ -43,7 +43,7 @@ export const ActionDesigner = (props) => {
|
|||||||
const isUpdateModePopupAction = ['customize:bulkUpdate', 'customize:bulkEdit'].includes(fieldSchema['x-action']);
|
const isUpdateModePopupAction = ['customize:bulkUpdate', 'customize:bulkEdit'].includes(fieldSchema['x-action']);
|
||||||
const context = useActionContext();
|
const context = useActionContext();
|
||||||
const [initialSchema, setInitialSchema] = useState<ISchema>();
|
const [initialSchema, setInitialSchema] = useState<ISchema>();
|
||||||
const actionType = fieldSchema['x-action'] || '';
|
const actionType = fieldSchema['x-action'] ?? '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const schemaUid = uid();
|
const schemaUid = uid();
|
||||||
|
@ -69,7 +69,7 @@ export const Action: ComposedAction = observer((props: any) => {
|
|||||||
const {
|
const {
|
||||||
popover,
|
popover,
|
||||||
confirm,
|
confirm,
|
||||||
// openMode,
|
openMode: om,
|
||||||
containerRefKey,
|
containerRefKey,
|
||||||
component,
|
component,
|
||||||
useAction = useA,
|
useAction = useA,
|
||||||
|
@ -53,13 +53,14 @@ const VariableCascader = connect((props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const DynamicComponent = (props) => {
|
export const DynamicComponent = (props) => {
|
||||||
const { dynamicComponent } = useContext(FilterContext);
|
const { dynamicComponent, disabled } = useContext(FilterContext);
|
||||||
const component = useComponent(dynamicComponent);
|
const component = useComponent(dynamicComponent);
|
||||||
const form = useMemo(() => {
|
const form = useMemo(() => {
|
||||||
return createForm({
|
return createForm({
|
||||||
values: {
|
values: {
|
||||||
value: props.value,
|
value: props.value,
|
||||||
},
|
},
|
||||||
|
disabled,
|
||||||
effects() {
|
effects() {
|
||||||
onFieldValueChange('value', (field) => {
|
onFieldValueChange('value', (field) => {
|
||||||
props?.onChange?.(field.value);
|
props?.onChange?.(field.value);
|
||||||
|
@ -27,7 +27,7 @@ export const Filter: any = observer((props: any) => {
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<FilterContext.Provider
|
<FilterContext.Provider
|
||||||
value={{ field, fieldSchema, dynamicComponent, options: options || field.dataSource || [] }}
|
value={{ field, fieldSchema, dynamicComponent, options: options || field.dataSource || [], disabled: props.disabled }}
|
||||||
>
|
>
|
||||||
<FilterGroup {...props} bordered={false} />
|
<FilterGroup {...props} bordered={false} />
|
||||||
{/* <pre>{JSON.stringify(field.value, null, 2)}</pre> */}
|
{/* <pre>{JSON.stringify(field.value, null, 2)}</pre> */}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { CloseCircleOutlined } from '@ant-design/icons';
|
import { CloseCircleOutlined } from '@ant-design/icons';
|
||||||
import { ObjectField as ObjectFieldModel } from '@formily/core';
|
import { ObjectField as ObjectFieldModel } from '@formily/core';
|
||||||
import { ArrayField, connect, useField } from '@formily/react';
|
import { ArrayField, connect, useField } from '@formily/react';
|
||||||
import { ConfigProvider, Select, Space } from 'antd';
|
import { Select, Space } from 'antd';
|
||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { FilterLogicContext, RemoveConditionContext } from './context';
|
import { FilterLogicContext, RemoveConditionContext } from './context';
|
||||||
import { FilterItems } from './FilterItems';
|
import { FilterItems } from './FilterItems';
|
||||||
|
|
||||||
export const FilterGroup = connect((props) => {
|
export const FilterGroup = connect((props) => {
|
||||||
const { bordered = true } = props;
|
const { bordered = true, disabled } = props;
|
||||||
const field = useField<ObjectFieldModel>();
|
const field = useField<ObjectFieldModel>();
|
||||||
const remove = useContext(RemoveConditionContext);
|
const remove = useContext(RemoveConditionContext);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -20,89 +20,87 @@ export const FilterGroup = connect((props) => {
|
|||||||
[value]: [...(obj[logic] || [])],
|
[value]: [...(obj[logic] || [])],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const mergedDisabled = field.disabled;
|
const mergedDisabled = disabled || field.disabled;
|
||||||
return (
|
return (
|
||||||
<ConfigProvider componentDisabled={mergedDisabled}>
|
<FilterLogicContext.Provider value={logic}>
|
||||||
<FilterLogicContext.Provider value={logic}>
|
<div
|
||||||
<div
|
style={
|
||||||
style={
|
bordered
|
||||||
bordered
|
? {
|
||||||
? {
|
position: 'relative',
|
||||||
position: 'relative',
|
border: '1px dashed #dedede',
|
||||||
border: '1px dashed #dedede',
|
padding: 14,
|
||||||
padding: 14,
|
marginBottom: 8,
|
||||||
marginBottom: 8,
|
}
|
||||||
}
|
: {
|
||||||
: {
|
position: 'relative',
|
||||||
position: 'relative',
|
marginBottom: 8,
|
||||||
marginBottom: 8,
|
}
|
||||||
}
|
}
|
||||||
}
|
>
|
||||||
>
|
{remove && !mergedDisabled && (
|
||||||
{remove && !mergedDisabled && (
|
<a>
|
||||||
<a>
|
<CloseCircleOutlined
|
||||||
<CloseCircleOutlined
|
style={{
|
||||||
style={{
|
position: 'absolute',
|
||||||
position: 'absolute',
|
right: 10,
|
||||||
right: 10,
|
top: 10,
|
||||||
top: 10,
|
color: '#bfbfbf',
|
||||||
color: '#bfbfbf',
|
}}
|
||||||
}}
|
onClick={() => remove()}
|
||||||
onClick={() => remove()}
|
/>
|
||||||
/>
|
</a>
|
||||||
</a>
|
)}
|
||||||
)}
|
<div style={{ marginBottom: 8 }}>
|
||||||
<div style={{ marginBottom: 8 }}>
|
<Trans>
|
||||||
<Trans>
|
{'Meet '}
|
||||||
{'Meet '}
|
<Select
|
||||||
<Select
|
style={{ width: 'auto' }}
|
||||||
style={{ width: 'auto' }}
|
value={logic}
|
||||||
value={logic}
|
onChange={(value) => {
|
||||||
onChange={(value) => {
|
setLogic(value);
|
||||||
setLogic(value);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Select.Option value={'$and'}>All</Select.Option>
|
||||||
<Select.Option value={'$and'}>All</Select.Option>
|
<Select.Option value={'$or'}>Any</Select.Option>
|
||||||
<Select.Option value={'$or'}>Any</Select.Option>
|
</Select>
|
||||||
</Select>
|
{' conditions in the group'}
|
||||||
{' conditions in the group'}
|
</Trans>
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<ArrayField name={logic} component={[FilterItems]} disabled={mergedDisabled} />
|
|
||||||
</div>
|
|
||||||
{!mergedDisabled && (
|
|
||||||
<Space size={16} style={{ marginTop: 8, marginBottom: 8 }}>
|
|
||||||
<a
|
|
||||||
onClick={() => {
|
|
||||||
const value = field.value || {};
|
|
||||||
const items = value[logic] || [];
|
|
||||||
items.push({});
|
|
||||||
field.value = {
|
|
||||||
[logic]: items,
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('Add condition')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
onClick={() => {
|
|
||||||
const value = field.value || {};
|
|
||||||
const items = value[logic] || [];
|
|
||||||
items.push({
|
|
||||||
$and: [{}],
|
|
||||||
});
|
|
||||||
field.value = {
|
|
||||||
[logic]: items,
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('Add condition group')}
|
|
||||||
</a>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</FilterLogicContext.Provider>
|
<div>
|
||||||
</ConfigProvider>
|
<ArrayField name={logic} component={[FilterItems]} disabled={mergedDisabled} />
|
||||||
|
</div>
|
||||||
|
{!mergedDisabled && (
|
||||||
|
<Space size={16} style={{ marginTop: 8, marginBottom: 8 }}>
|
||||||
|
<a
|
||||||
|
onClick={() => {
|
||||||
|
const value = field.value || {};
|
||||||
|
const items = value[logic] || [];
|
||||||
|
items.push({});
|
||||||
|
field.value = {
|
||||||
|
[logic]: items,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Add condition')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
onClick={() => {
|
||||||
|
const value = field.value || {};
|
||||||
|
const items = value[logic] || [];
|
||||||
|
items.push({
|
||||||
|
$and: [{}],
|
||||||
|
});
|
||||||
|
field.value = {
|
||||||
|
[logic]: items,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Add condition group')}
|
||||||
|
</a>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FilterLogicContext.Provider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -7,6 +7,7 @@ export interface FilterContextProps {
|
|||||||
fieldSchema?: Schema;
|
fieldSchema?: Schema;
|
||||||
dynamicComponent?: any;
|
dynamicComponent?: any;
|
||||||
options?: any[];
|
options?: any[];
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RemoveConditionContext = createContext(null);
|
export const RemoveConditionContext = createContext(null);
|
||||||
|
@ -29,6 +29,7 @@ export const useValues = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
const value2data = () => {
|
const value2data = () => {
|
||||||
|
field.data = field.data || {};
|
||||||
const values = flat(field.value);
|
const values = flat(field.value);
|
||||||
const path = Object.keys(values).shift() || '';
|
const path = Object.keys(values).shift() || '';
|
||||||
if (!path) {
|
if (!path) {
|
||||||
@ -40,16 +41,13 @@ export const useValues = () => {
|
|||||||
const option = findOption(dataIndex, options);
|
const option = findOption(dataIndex, options);
|
||||||
const operators = option?.operators;
|
const operators = option?.operators;
|
||||||
const operator = operators?.find?.((item) => item.value === `$${operatorValue}`);
|
const operator = operators?.find?.((item) => item.value === `$${operatorValue}`);
|
||||||
field.data = field.data || {};
|
|
||||||
field.data.dataIndex = dataIndex;
|
field.data.dataIndex = dataIndex;
|
||||||
field.data.operators = operators;
|
field.data.operators = operators;
|
||||||
field.data.operator = operator;
|
field.data.operator = operator;
|
||||||
field.data.schema = merge(option?.schema, operator?.schema);
|
field.data.schema = merge(option?.schema, operator?.schema);
|
||||||
field.data.value = get(field.value, `${fieldPath}.$${operatorValue}`);
|
field.data.value = get(field.value, `${fieldPath}.$${operatorValue}`);
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(value2data, [logic]);
|
||||||
value2data();
|
|
||||||
}, [logic]);
|
|
||||||
return {
|
return {
|
||||||
fields: options,
|
fields: options,
|
||||||
...field.data,
|
...field.data,
|
||||||
@ -63,7 +61,6 @@ export const useValues = () => {
|
|||||||
field.data.dataIndex = dataIndex;
|
field.data.dataIndex = dataIndex;
|
||||||
field.data.value = null;
|
field.data.value = null;
|
||||||
data2value();
|
data2value();
|
||||||
console.log('setDataIndex', field.data);
|
|
||||||
},
|
},
|
||||||
setOperator(operatorValue) {
|
setOperator(operatorValue) {
|
||||||
const operator = field.data?.operators?.find?.((item) => item.value === operatorValue);
|
const operator = field.data?.operators?.find?.((item) => item.value === operatorValue);
|
||||||
@ -71,12 +68,10 @@ export const useValues = () => {
|
|||||||
field.data.schema = merge(field.data.schema, operator.schema);
|
field.data.schema = merge(field.data.schema, operator.schema);
|
||||||
field.data.value = operator.noValue ? operator.default || true : null;
|
field.data.value = operator.noValue ? operator.default || true : null;
|
||||||
data2value();
|
data2value();
|
||||||
console.log('setOperator', field.data);
|
|
||||||
},
|
},
|
||||||
setValue(value) {
|
setValue(value) {
|
||||||
field.data.value = value;
|
field.data.value = value;
|
||||||
data2value();
|
data2value();
|
||||||
console.log('setValue', field.data);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { ArrayCollapse, FormItem as Item, FormLayout } from '@formily/antd';
|
import { ArrayCollapse, FormItem as Item, FormLayout } from '@formily/antd';
|
||||||
import { Field } from '@formily/core';
|
import { Field } from '@formily/core';
|
||||||
import { ISchema, useField, useFieldSchema } from '@formily/react';
|
import { ISchema, observer, useField, useFieldSchema } from '@formily/react';
|
||||||
import { uid } from '@formily/shared';
|
import { uid } from '@formily/shared';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import React, { useContext, useEffect } from 'react';
|
import React, { useContext, useEffect } from 'react';
|
||||||
@ -24,7 +24,7 @@ const divWrap = (schema: ISchema) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FormItem: any = (props) => {
|
export const FormItem: any = observer((props) => {
|
||||||
const field = useField();
|
const field = useField();
|
||||||
const ctx = useContext(BlockRequestContext);
|
const ctx = useContext(BlockRequestContext);
|
||||||
const schema = useFieldSchema();
|
const schema = useFieldSchema();
|
||||||
@ -60,7 +60,7 @@ export const FormItem: any = (props) => {
|
|||||||
</BlockItem>
|
</BlockItem>
|
||||||
</ACLCollectionFieldProvider>
|
</ACLCollectionFieldProvider>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
FormItem.Designer = (props) => {
|
FormItem.Designer = (props) => {
|
||||||
const { getCollectionFields, getCollection, getInterface, getCollectionJoinField } = useCollectionManager();
|
const { getCollectionFields, getCollection, getInterface, getCollectionJoinField } = useCollectionManager();
|
||||||
|
@ -28,6 +28,7 @@ function IconField(props: any) {
|
|||||||
<div style={{ width: '26em', maxHeight: '20em', overflowY: 'auto' }}>
|
<div style={{ width: '26em', maxHeight: '20em', overflowY: 'auto' }}>
|
||||||
{[...icons.keys()].map((key) => (
|
{[...icons.keys()].map((key) => (
|
||||||
<span
|
<span
|
||||||
|
key={key}
|
||||||
style={{ fontSize: 18, marginRight: 10, cursor: 'pointer' }}
|
style={{ fontSize: 18, marginRight: 10, cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(key);
|
onChange(key);
|
||||||
|
@ -59,7 +59,7 @@ export const useFixedBlockDesignerSetting = () => {
|
|||||||
return (
|
return (
|
||||||
<SchemaSettings.SwitchItem
|
<SchemaSettings.SwitchItem
|
||||||
title={t('Fix block')}
|
title={t('Fix block')}
|
||||||
checked={fieldSchema['x-decorator-props']['fixedBlock']}
|
checked={fieldSchema['x-decorator-props']?.fixedBlock}
|
||||||
onChange={async (fixedBlock) => {
|
onChange={async (fixedBlock) => {
|
||||||
const decoratorProps = {
|
const decoratorProps = {
|
||||||
...fieldSchema['x-decorator-props'],
|
...fieldSchema['x-decorator-props'],
|
||||||
|
@ -12,6 +12,7 @@ export const TabsDesigner = () => {
|
|||||||
return (
|
return (
|
||||||
<GeneralSchemaDesigner disableInitializer>
|
<GeneralSchemaDesigner disableInitializer>
|
||||||
<SchemaSettings.ModalItem
|
<SchemaSettings.ModalItem
|
||||||
|
key="edit"
|
||||||
title={t('Edit')}
|
title={t('Edit')}
|
||||||
schema={
|
schema={
|
||||||
{
|
{
|
||||||
|
@ -161,7 +161,6 @@ SchemaInitializer.Item = (props: SchemaInitializerItemProps) => {
|
|||||||
return <Menu.Divider key={`divider-${indexA}`} />;
|
return <Menu.Divider key={`divider-${indexA}`} />;
|
||||||
}
|
}
|
||||||
if (item.type === 'itemGroup') {
|
if (item.type === 'itemGroup') {
|
||||||
console.log(item.children);
|
|
||||||
return (
|
return (
|
||||||
<Menu.ItemGroup
|
<Menu.ItemGroup
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -3,3 +3,4 @@ export * from './SchemaInitializerProvider';
|
|||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './items';
|
export * from './items';
|
||||||
export { gridRowColWrap, useRecordCollectionDataSourceItems } from './utils';
|
export { gridRowColWrap, useRecordCollectionDataSourceItems } from './utils';
|
||||||
|
export * from './buttons';
|
||||||
|
@ -9,13 +9,16 @@
|
|||||||
"@nocobase/actions": "0.9.0-alpha.2",
|
"@nocobase/actions": "0.9.0-alpha.2",
|
||||||
"@nocobase/client": "0.9.0-alpha.2",
|
"@nocobase/client": "0.9.0-alpha.2",
|
||||||
"@nocobase/database": "0.9.0-alpha.2",
|
"@nocobase/database": "0.9.0-alpha.2",
|
||||||
|
"@nocobase/resourcer": "0.9.0-alpha.2",
|
||||||
"@nocobase/server": "0.9.0-alpha.2",
|
"@nocobase/server": "0.9.0-alpha.2",
|
||||||
"@nocobase/utils": "0.9.0-alpha.2",
|
"@nocobase/utils": "0.9.0-alpha.2",
|
||||||
"antd": "4.22.8",
|
"antd": "4.22.8",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"cron-parser": "4.4.0",
|
"cron-parser": "4.4.0",
|
||||||
|
"@formulajs/formulajs": "4.2.0",
|
||||||
"json-templates": "^4.2.0",
|
"json-templates": "^4.2.0",
|
||||||
|
"mathjs": "^10.6.0",
|
||||||
"moment": "^2.29.2",
|
"moment": "^2.29.2",
|
||||||
"react-js-cron": "^1.4.0"
|
"react-js-cron": "^1.4.0"
|
||||||
},
|
},
|
||||||
|
@ -7,14 +7,14 @@ import {
|
|||||||
useCompile
|
useCompile
|
||||||
} from '@nocobase/client';
|
} from '@nocobase/client';
|
||||||
import { useFlowContext } from './FlowContext';
|
import { useFlowContext } from './FlowContext';
|
||||||
import { Instruction, instructions, Node } from './nodes';
|
import { Instruction, instructions } from './nodes';
|
||||||
import { addButtonClass } from './style';
|
import { addButtonClass } from './style';
|
||||||
import { NAMESPACE } from './locale';
|
import { NAMESPACE } from './locale';
|
||||||
|
|
||||||
|
|
||||||
interface AddButtonProps {
|
interface AddButtonProps {
|
||||||
upstream;
|
upstream;
|
||||||
branchIndex?: number;
|
branchIndex?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
||||||
@ -31,11 +31,11 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
|||||||
const config = {};
|
const config = {};
|
||||||
const [optionKey] = keyPath;
|
const [optionKey] = keyPath;
|
||||||
if (optionKey) {
|
if (optionKey) {
|
||||||
const { value } = instructions.get(type).options.find(item => item.key === optionKey);
|
const { value } = instructions.get(type)?.options?.find(item => item.key === optionKey) ?? {};
|
||||||
Object.assign(config, value);
|
Object.assign(config, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: { data: node } } = await resource.create({
|
const { data: { data: node } } = await resource.create!({
|
||||||
values: {
|
values: {
|
||||||
type,
|
type,
|
||||||
upstreamId: upstream?.id ?? null,
|
upstreamId: upstream?.id ?? null,
|
||||||
@ -50,6 +50,7 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
|||||||
const groups = [
|
const groups = [
|
||||||
{ value: 'control', name: `{{t("Control", { ns: "${NAMESPACE}" })}}` },
|
{ value: 'control', name: `{{t("Control", { ns: "${NAMESPACE}" })}}` },
|
||||||
{ value: 'collection', name: `{{t("Collection operations", { ns: "${NAMESPACE}" })}}` },
|
{ value: 'collection', name: `{{t("Collection operations", { ns: "${NAMESPACE}" })}}` },
|
||||||
|
{ value: 'manual', name: `{{t("Manual", { ns: "${NAMESPACE}" })}}` },
|
||||||
{ value: 'extended', name: `{{t("Extended types", { ns: "${NAMESPACE}" })}}` },
|
{ value: 'extended', name: `{{t("Extended types", { ns: "${NAMESPACE}" })}}` },
|
||||||
];
|
];
|
||||||
const instructionList = (Array.from(instructions.getValues()) as Instruction[]);
|
const instructionList = (Array.from(instructions.getValues()) as Instruction[]);
|
||||||
|
@ -9,8 +9,13 @@ export function Branch({
|
|||||||
entry = null,
|
entry = null,
|
||||||
branchIndex = null,
|
branchIndex = null,
|
||||||
controller = null
|
controller = null
|
||||||
|
}: {
|
||||||
|
from?: any;
|
||||||
|
entry?: any;
|
||||||
|
branchIndex?: number | null;
|
||||||
|
controller?: any
|
||||||
}) {
|
}) {
|
||||||
const list = [];
|
const list: any[] = [];
|
||||||
for (let node = entry; node; node = node.downstream) {
|
for (let node = entry; node; node = node.downstream) {
|
||||||
list.push(node);
|
list.push(node);
|
||||||
}
|
}
|
||||||
|
@ -16,24 +16,17 @@ import { TriggerConfig } from './triggers';
|
|||||||
import { Branch } from './Branch';
|
import { Branch } from './Branch';
|
||||||
import { ExecutionStatusOptionsMap } from './constants';
|
import { ExecutionStatusOptionsMap } from './constants';
|
||||||
import { lang } from './locale';
|
import { lang } from './locale';
|
||||||
|
import { linkNodes } from './utils';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function makeNodes(nodes, jobs: any[] = []): void {
|
function attachJobs(nodes, jobs: any[] = []): void {
|
||||||
const nodesMap = new Map();
|
const nodesMap = new Map();
|
||||||
nodes.forEach(item => nodesMap.set(item.id, item));
|
nodes.forEach(item => nodesMap.set(item.id, item));
|
||||||
const jobsMap = new Map();
|
const jobsMap = new Map();
|
||||||
jobs.forEach(item => jobsMap.set(item.nodeId, item));
|
jobs.forEach(item => jobsMap.set(item.nodeId, item));
|
||||||
for (let node of nodesMap.values()) {
|
for (let node of nodesMap.values()) {
|
||||||
if (node.upstreamId) {
|
|
||||||
node.upstream = nodesMap.get(node.upstreamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.downstreamId) {
|
|
||||||
node.downstream = nodesMap.get(node.downstreamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobsMap.has(node.id)) {
|
if (jobsMap.has(node.id)) {
|
||||||
node.job = jobsMap.get(node.id);
|
node.job = jobsMap.get(node.id);
|
||||||
}
|
}
|
||||||
@ -63,7 +56,8 @@ export function ExecutionCanvas() {
|
|||||||
...execution
|
...execution
|
||||||
} = data?.data ?? {};
|
} = data?.data ?? {};
|
||||||
|
|
||||||
makeNodes(nodes, jobs);
|
linkNodes(nodes);
|
||||||
|
attachJobs(nodes, jobs);
|
||||||
|
|
||||||
const entry = nodes.find(item => !item.upstream);
|
const entry = nodes.find(item => !item.upstream);
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ import { Branch } from './Branch';
|
|||||||
import { executionSchema } from './schemas/executions';
|
import { executionSchema } from './schemas/executions';
|
||||||
import { ExecutionLink } from './ExecutionLink';
|
import { ExecutionLink } from './ExecutionLink';
|
||||||
import { lang } from './locale';
|
import { lang } from './locale';
|
||||||
|
import { linkNodes } from './utils';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -47,25 +48,12 @@ function ExecutionResourceProvider({ request, filter = {}, ...others }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function makeNodes(nodes): void {
|
|
||||||
const nodesMap = new Map();
|
|
||||||
nodes.forEach(item => nodesMap.set(item.id, item));
|
|
||||||
for (let node of nodesMap.values()) {
|
|
||||||
if (node.upstreamId) {
|
|
||||||
node.upstream = nodesMap.get(node.upstreamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.downstreamId) {
|
|
||||||
node.downstream = nodesMap.get(node.downstreamId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowCanvas() {
|
export function WorkflowCanvas() {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data, refresh, loading } = useResourceActionContext();
|
const { data, refresh, loading } = useResourceActionContext();
|
||||||
const { resource, targetKey } = useResourceContext();
|
const { resource } = useResourceContext();
|
||||||
const { setTitle } = useDocumentTitle();
|
const { setTitle } = useDocumentTitle();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -79,7 +67,7 @@ export function WorkflowCanvas() {
|
|||||||
|
|
||||||
const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
|
const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
|
||||||
|
|
||||||
makeNodes(nodes);
|
linkNodes(nodes);
|
||||||
|
|
||||||
const entry = nodes.find(item => !item.upstream);
|
const entry = nodes.find(item => !item.upstream);
|
||||||
|
|
||||||
@ -91,11 +79,9 @@ export function WorkflowCanvas() {
|
|||||||
|
|
||||||
async function onToggle(value) {
|
async function onToggle(value) {
|
||||||
await resource.update({
|
await resource.update({
|
||||||
filterByTk: workflow[targetKey],
|
filterByTk: workflow.id,
|
||||||
values: {
|
values: {
|
||||||
enabled: value,
|
enabled: value
|
||||||
// NOTE: keep `key` field to adapt for backend
|
|
||||||
key: workflow.key
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
refresh();
|
refresh();
|
||||||
@ -103,7 +89,7 @@ export function WorkflowCanvas() {
|
|||||||
|
|
||||||
async function onRevision() {
|
async function onRevision() {
|
||||||
const { data: { data: revision } } = await resource.revision({
|
const { data: { data: revision } } = await resource.revision({
|
||||||
filterByTk: workflow[targetKey],
|
filterByTk: workflow.id,
|
||||||
filter: {
|
filter: {
|
||||||
key: workflow.key
|
key: workflow.key
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import { Card } from 'antd';
|
import { Card } from 'antd';
|
||||||
import { PluginManagerContext, RouteSwitchContext, SchemaComponent, SettingsCenterProvider } from '@nocobase/client';
|
import { PluginManagerContext, RouteSwitchContext, SchemaComponent, SchemaComponentOptions, SettingsCenterProvider } from '@nocobase/client';
|
||||||
import { WorkflowPage } from './WorkflowPage';
|
import { WorkflowPage } from './WorkflowPage';
|
||||||
import { ExecutionPage } from './ExecutionPage';
|
import { ExecutionPage } from './ExecutionPage';
|
||||||
import { triggers } from './triggers';
|
import { triggers } from './triggers';
|
||||||
@ -11,6 +11,8 @@ import { WorkflowLink } from './WorkflowLink';
|
|||||||
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
|
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
|
||||||
import { ExecutionLink } from './ExecutionLink';
|
import { ExecutionLink } from './ExecutionLink';
|
||||||
import OpenDrawer from './components/OpenDrawer';
|
import OpenDrawer from './components/OpenDrawer';
|
||||||
|
import { WorkflowTodo } from './nodes/manual/WorkflowTodo';
|
||||||
|
import { WorkflowTodoBlockInitializer } from './nodes/manual/WorkflowTodoBlockInitializer';
|
||||||
|
|
||||||
export const WorkflowContext = React.createContext({});
|
export const WorkflowContext = React.createContext({});
|
||||||
|
|
||||||
@ -74,10 +76,12 @@ export const WorkflowProvider = (props) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RouteSwitchContext.Provider
|
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage, ExecutionPage }, ...others, routes }}>
|
||||||
value={{ components: { ...components, WorkflowPage, ExecutionPage }, ...others, routes }}
|
<SchemaComponentOptions components={{ WorkflowTodo, WorkflowTodoBlockInitializer }}>
|
||||||
>
|
<WorkflowContext.Provider value={{ triggers, instructions }}>
|
||||||
<WorkflowContext.Provider value={{ triggers, instructions }}>{props.children}</WorkflowContext.Provider>
|
{props.children}
|
||||||
|
</WorkflowContext.Provider>
|
||||||
|
</SchemaComponentOptions>
|
||||||
</RouteSwitchContext.Provider>
|
</RouteSwitchContext.Provider>
|
||||||
</PluginManagerContext.Provider>
|
</PluginManagerContext.Provider>
|
||||||
</SettingsCenterProvider>
|
</SettingsCenterProvider>
|
||||||
|
@ -1,480 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Cascader, Input, InputNumber, Select } from "antd";
|
|
||||||
import { css } from "@emotion/css";
|
|
||||||
|
|
||||||
import { useCompile } from "@nocobase/client";
|
|
||||||
|
|
||||||
import { instructions, useNodeContext } from "./nodes";
|
|
||||||
import { useFlowContext } from "./FlowContext";
|
|
||||||
import { triggers } from "./triggers";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Registry } from "@nocobase/utils/client";
|
|
||||||
import { lang, NAMESPACE, useWorkflowTranslation } from "./locale";
|
|
||||||
|
|
||||||
function NullRender() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Calculator {
|
|
||||||
name: string;
|
|
||||||
type: 'boolean' | 'number' | 'string' | 'date' | 'unknown' | 'null' | 'array';
|
|
||||||
group: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const calculators = new Registry<Calculator>();
|
|
||||||
|
|
||||||
calculators.register('equal', {
|
|
||||||
name: '=',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'boolean',
|
|
||||||
});
|
|
||||||
calculators.register('notEqual', {
|
|
||||||
name: '≠',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'boolean',
|
|
||||||
});
|
|
||||||
calculators.register('gt', {
|
|
||||||
name: '>',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'boolean',
|
|
||||||
});
|
|
||||||
calculators.register('gte', {
|
|
||||||
name: '≥',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'boolean',
|
|
||||||
});
|
|
||||||
calculators.register('lt', {
|
|
||||||
name: '<',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'boolean',
|
|
||||||
});
|
|
||||||
calculators.register('lte', {
|
|
||||||
name: '≤',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'boolean',
|
|
||||||
});
|
|
||||||
|
|
||||||
calculators.register('add', {
|
|
||||||
name: '+',
|
|
||||||
type: 'number',
|
|
||||||
group: 'number',
|
|
||||||
});
|
|
||||||
calculators.register('minus', {
|
|
||||||
name: '-',
|
|
||||||
type: 'number',
|
|
||||||
group: 'number',
|
|
||||||
});
|
|
||||||
calculators.register('multiple', {
|
|
||||||
name: '*',
|
|
||||||
type: 'number',
|
|
||||||
group: 'number',
|
|
||||||
});
|
|
||||||
calculators.register('divide', {
|
|
||||||
name: '/',
|
|
||||||
type: 'number',
|
|
||||||
group: 'number',
|
|
||||||
});
|
|
||||||
calculators.register('mod', {
|
|
||||||
name: '%',
|
|
||||||
type: 'number',
|
|
||||||
group: 'number',
|
|
||||||
});
|
|
||||||
|
|
||||||
calculators.register('includes', {
|
|
||||||
name: '{{t("contains")}}',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'string'
|
|
||||||
});
|
|
||||||
calculators.register('notIncludes', {
|
|
||||||
name: '{{t("does not contain")}}',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'string'
|
|
||||||
});
|
|
||||||
calculators.register('startsWith', {
|
|
||||||
name: '{{t("starts with")}}',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'string'
|
|
||||||
});
|
|
||||||
calculators.register('notStartsWith', {
|
|
||||||
name: '{{t("not starts with")}}',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'string'
|
|
||||||
});
|
|
||||||
calculators.register('endsWith', {
|
|
||||||
name: '{{t("ends with")}}',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'string'
|
|
||||||
});
|
|
||||||
calculators.register('notEndsWith', {
|
|
||||||
name: '{{t("not ends with")}}',
|
|
||||||
type: 'boolean',
|
|
||||||
group: 'string'
|
|
||||||
});
|
|
||||||
calculators.register('concat', {
|
|
||||||
name: `{{t("concat", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
type: 'string',
|
|
||||||
group: 'string'
|
|
||||||
});
|
|
||||||
|
|
||||||
const calculatorGroups = [
|
|
||||||
{
|
|
||||||
value: 'boolean',
|
|
||||||
title: '{{t("Comparision")}}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'number',
|
|
||||||
title: `{{t("Arithmetic calculation", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'string',
|
|
||||||
title: `{{t("String operation", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'date',
|
|
||||||
title: `{{t("Date", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
function getGroupCalculators(group) {
|
|
||||||
return Array.from(calculators.getEntities()).filter(([key, value]) => value.group === group);
|
|
||||||
}
|
|
||||||
|
|
||||||
const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/;
|
|
||||||
|
|
||||||
function getType(value) {
|
|
||||||
if (value == null) {
|
|
||||||
return 'null';
|
|
||||||
}
|
|
||||||
const type = typeof value;
|
|
||||||
switch (type) {
|
|
||||||
case 'object':
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// 'boolean'
|
|
||||||
// 'number'
|
|
||||||
// 'bigint'
|
|
||||||
// 'string'
|
|
||||||
// 'symbol'
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
if (value instanceof Date) {
|
|
||||||
return 'date';
|
|
||||||
}
|
|
||||||
return 'object';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseValue(value: any, Types): {
|
|
||||||
type: string;
|
|
||||||
value?: any;
|
|
||||||
options?: any;
|
|
||||||
} {
|
|
||||||
const valueType = getType(value);
|
|
||||||
if (valueType !== 'string') {
|
|
||||||
return { type: 'constant', value, options: { type: valueType } }
|
|
||||||
}
|
|
||||||
|
|
||||||
const matcher = value.match(JT_VALUE_RE);
|
|
||||||
if (!matcher) {
|
|
||||||
return { type: 'constant', value, options: { type: 'string' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [type, ...paths] = matcher[1].split('.');
|
|
||||||
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
options: paths.length ? (Types || VariableTypes)[type]?.parse(paths) : {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BaseTypeSet = new Set(['boolean', 'number', 'string', 'date']);
|
|
||||||
|
|
||||||
const ConstantTypes = {
|
|
||||||
null: {
|
|
||||||
title: `{{t("Null", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: 'null',
|
|
||||||
default: null,
|
|
||||||
component: NullRender,
|
|
||||||
},
|
|
||||||
string: {
|
|
||||||
title: `{{t("String", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: 'string',
|
|
||||||
component({ onChange, value }) {
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={ev => onChange(ev.target.value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
number: {
|
|
||||||
title: '{{t("Number")}}',
|
|
||||||
value: 'number',
|
|
||||||
component({ onChange, value }) {
|
|
||||||
return (
|
|
||||||
<InputNumber
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
boolean: {
|
|
||||||
title: `{{t("Boolean", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: 'boolean',
|
|
||||||
component({ onChange, value }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={t('Select')}
|
|
||||||
>
|
|
||||||
<Select.Option value={true}>{lang('True')}</Select.Option>
|
|
||||||
<Select.Option value={false}>{lang('False')}</Select.Option>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// date: {
|
|
||||||
// title: '日期',
|
|
||||||
// value: 'date',
|
|
||||||
// component({ onChange, type, options, value }) {
|
|
||||||
// return <DatePicker value={value} onChange={v => onChange({ value: v, type, options })}/>;
|
|
||||||
// },
|
|
||||||
// default: new Date()
|
|
||||||
// }
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VariableTypes = {
|
|
||||||
constant: {
|
|
||||||
title: `{{t("Constant", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: 'constant',
|
|
||||||
options: Object.values(ConstantTypes).map(item => ({
|
|
||||||
value: item.value,
|
|
||||||
label: item.title
|
|
||||||
})),
|
|
||||||
component(props) {
|
|
||||||
const { options = { type: 'string' } } = useOperandContext();
|
|
||||||
return ConstantTypes[options.type]?.component(props);
|
|
||||||
},
|
|
||||||
appendTypeValue({ options = { type: 'string' } }) {
|
|
||||||
return options?.type ? [options.type] : [];
|
|
||||||
},
|
|
||||||
onTypeChange([type, optionsType], onChange) {
|
|
||||||
const { default: value } = ConstantTypes[optionsType];
|
|
||||||
onChange(value);
|
|
||||||
},
|
|
||||||
parse(path) {
|
|
||||||
return { path };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
$jobsMapByNodeId: {
|
|
||||||
title: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: '$jobsMapByNodeId',
|
|
||||||
options() {
|
|
||||||
const node = useNodeContext();
|
|
||||||
const stack = [];
|
|
||||||
for (let current = node.upstream; current; current = current.upstream) {
|
|
||||||
const { getter } = instructions.get(current.type);
|
|
||||||
// Note: consider `getter` as the key of a value available node
|
|
||||||
if (getter) {
|
|
||||||
stack.push({
|
|
||||||
value: current.id,
|
|
||||||
label: current.title ?? `#${current.id}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stack;
|
|
||||||
},
|
|
||||||
component(props) {
|
|
||||||
const { nodes } = useFlowContext();
|
|
||||||
const { options } = useOperandContext();
|
|
||||||
if (!options?.nodeId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const node = nodes.find(n => n.id == options.nodeId);
|
|
||||||
if (!node) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const instruction = instructions.get(node.type);
|
|
||||||
return instruction?.getter(props);
|
|
||||||
},
|
|
||||||
appendTypeValue({ options = {} }: { type: string, options: any }) {
|
|
||||||
return options.nodeId ? [Number.parseInt(options.nodeId, 10)] : [];
|
|
||||||
},
|
|
||||||
onTypeChange([type, nodeId], onChange) {
|
|
||||||
onChange(`{{${type}.${nodeId}}}`);
|
|
||||||
},
|
|
||||||
parse([nodeId, ...path]) {
|
|
||||||
return { nodeId, path: path.join('.') };
|
|
||||||
},
|
|
||||||
stringify(next) {
|
|
||||||
return `{{${next.join('.')}}}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
$context: {
|
|
||||||
title: `{{t("Trigger variables", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: '$context',
|
|
||||||
options() {
|
|
||||||
const { workflow } = useFlowContext();
|
|
||||||
const trigger = triggers.get(workflow.type);
|
|
||||||
return trigger?.getOptions?.(workflow.config) ?? null;
|
|
||||||
},
|
|
||||||
component(props) {
|
|
||||||
const { workflow } = useFlowContext();
|
|
||||||
const trigger = triggers.get(workflow.type);
|
|
||||||
return trigger?.getter(props);
|
|
||||||
},
|
|
||||||
appendTypeValue({ options }) {
|
|
||||||
return options.type ? [options.type] : [];
|
|
||||||
},
|
|
||||||
onTypeChange([type, optionType], onChange) {
|
|
||||||
onChange(`{{${type}.${optionType}}}`);
|
|
||||||
},
|
|
||||||
parse([type, ...path]) {
|
|
||||||
return { type, ...( path?.length ? { path: path.join('.') } : {}) };
|
|
||||||
},
|
|
||||||
stringify(next) {
|
|
||||||
return `{{${next.join('.')}}}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// calculation: Calculation
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VariableTypesContext = React.createContext({});
|
|
||||||
|
|
||||||
export function useVariableTypes() {
|
|
||||||
return React.useContext(VariableTypesContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OperandProps {
|
|
||||||
value: any;
|
|
||||||
onChange(v: any): void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OperandContext = React.createContext(null);
|
|
||||||
|
|
||||||
export function useOperandContext() {
|
|
||||||
return React.useContext(OperandContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Operand({
|
|
||||||
value = null,
|
|
||||||
onChange,
|
|
||||||
children
|
|
||||||
}: OperandProps) {
|
|
||||||
const compile = useCompile();
|
|
||||||
const Types = useVariableTypes();
|
|
||||||
|
|
||||||
const operand = parseValue(value, Types);
|
|
||||||
|
|
||||||
const { type } = operand;
|
|
||||||
|
|
||||||
const { component: Variable = NullRender, appendTypeValue } = Types[type] || {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={css`
|
|
||||||
display: flex;
|
|
||||||
gap: .5em;
|
|
||||||
align-items: center;
|
|
||||||
`}>
|
|
||||||
<Cascader
|
|
||||||
allowClear={false}
|
|
||||||
value={[Types[type] ? type : '', ...(appendTypeValue ? appendTypeValue(operand) : [])]}
|
|
||||||
options={Object.values(Types).map((item: any) => {
|
|
||||||
const options = typeof item.options === 'function' ? item.options() : item.options;
|
|
||||||
return {
|
|
||||||
label: compile(item.title),
|
|
||||||
value: item.value,
|
|
||||||
children: compile(options),
|
|
||||||
disabled: options && !options.length,
|
|
||||||
isLeaf: !options
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
onChange={(next: string[]) => {
|
|
||||||
// 类型变化,包括主类型和子类型
|
|
||||||
const { onTypeChange, stringify } = Types[next[0]];
|
|
||||||
// 自定义处理
|
|
||||||
if (typeof onTypeChange === 'function') {
|
|
||||||
return onTypeChange(next, onChange);
|
|
||||||
}
|
|
||||||
// 主类型变化
|
|
||||||
if (next[0] !== type) {
|
|
||||||
if (next[0] === 'constant') {
|
|
||||||
return onChange(null);
|
|
||||||
} else if (stringify) {
|
|
||||||
return onChange(stringify(next));
|
|
||||||
}
|
|
||||||
return onChange({ type: next[0], value: null });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<OperandContext.Provider value={operand}>
|
|
||||||
{children ?? <Variable value={operand.value} onChange={onChange} />}
|
|
||||||
</OperandContext.Provider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Calculation({ calculator, operands = [null], onChange }) {
|
|
||||||
const { t } = useWorkflowTranslation();
|
|
||||||
const compile = useCompile();
|
|
||||||
return (
|
|
||||||
<VariableTypesContext.Provider value={VariableTypes}>
|
|
||||||
<div className={css`
|
|
||||||
display: flex;
|
|
||||||
gap: .5em;
|
|
||||||
align-items: center;
|
|
||||||
`}>
|
|
||||||
<Operand value={operands[0]} onChange={(v => onChange({ calculator, operands: [v, operands[1]] }))} />
|
|
||||||
{typeof operands[0] !== 'undefined'
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
value={calculator}
|
|
||||||
onChange={v => onChange({ operands, calculator: v })}
|
|
||||||
placeholder={t('Calculator')}
|
|
||||||
>
|
|
||||||
{calculatorGroups.filter(group => Boolean(getGroupCalculators(group.value).length)).map(group => (
|
|
||||||
<Select.OptGroup key={group.value} label={compile(group.title)}>
|
|
||||||
{getGroupCalculators(group.value).map(([value, { name }]) => (
|
|
||||||
<Select.Option key={value} value={value}>{compile(name)}</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select.OptGroup>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Operand value={operands[1]} onChange={(v => onChange({ calculator, operands: [operands[0], v] }))} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</VariableTypesContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VariableComponent({ value, onChange, renderSchemaComponent }) {
|
|
||||||
const VTypes = {
|
|
||||||
...VariableTypes,
|
|
||||||
constant: {
|
|
||||||
title: `{{t("Constant", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: 'constant',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { type } = parseValue(value, VTypes);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VariableTypesContext.Provider value={VTypes}>
|
|
||||||
<Operand value={value} onChange={onChange}>
|
|
||||||
{type === 'constant' ? renderSchemaComponent() : null}
|
|
||||||
</Operand>
|
|
||||||
</VariableTypesContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { SchemaInitializer, useCollectionManager } from '@nocobase/client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function CollectionBlockInitializer({ insert, collection, dataSource, ...props }) {
|
||||||
|
const { getCollection } = useCollectionManager();
|
||||||
|
const resovledCollection = getCollection(collection);
|
||||||
|
return (
|
||||||
|
<SchemaInitializer.Item
|
||||||
|
{...props}
|
||||||
|
onClick={() => {
|
||||||
|
insert({
|
||||||
|
type: 'void',
|
||||||
|
name: resovledCollection.name,
|
||||||
|
title: resovledCollection.title,
|
||||||
|
'x-decorator': 'CollectionProvider',
|
||||||
|
'x-decorator-props': {
|
||||||
|
collection
|
||||||
|
},
|
||||||
|
'x-component': 'CardItem',
|
||||||
|
'x-component-props': {
|
||||||
|
// title: props.title
|
||||||
|
},
|
||||||
|
'x-designer': 'SimpleDesigner',
|
||||||
|
properties: {
|
||||||
|
grid: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-decorator-props': {
|
||||||
|
useValues: '{{ useFlowRecordFromBlock }}'
|
||||||
|
},
|
||||||
|
'x-component': 'Grid',
|
||||||
|
'x-initializer': 'CollectionFieldInitializers',
|
||||||
|
'x-context-datasource': dataSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { SchemaInitializer, useCollection, InitializerWithSwitch, gridRowColWrap } from '@nocobase/client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function CollectionFieldInitializer({ field, ...props }) {
|
||||||
|
const uiSchema = cloneDeep(field.uiSchema);
|
||||||
|
delete uiSchema['x-uid'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InitializerWithSwitch
|
||||||
|
{...props}
|
||||||
|
schema={{
|
||||||
|
...uiSchema,
|
||||||
|
name: field.name,
|
||||||
|
title: uiSchema.title ?? field.name,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
'x-designer': 'FormItem.Designer',
|
||||||
|
'x-collection-field': field.name,
|
||||||
|
}}
|
||||||
|
type="x-collection-field"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollectionFieldInitializers(props) {
|
||||||
|
const { fields } = useCollection();
|
||||||
|
const items = fields
|
||||||
|
.filter(field => !['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type) && field.uiSchema)
|
||||||
|
.map(field => ({
|
||||||
|
key: field.name,
|
||||||
|
type: 'item',
|
||||||
|
title: field.uiSchema.title,
|
||||||
|
component: CollectionFieldInitializer,
|
||||||
|
field
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchemaInitializer.Button
|
||||||
|
{...props}
|
||||||
|
items={items}
|
||||||
|
title="{{t('Configure fields')}}"
|
||||||
|
wrap={gridRowColWrap}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -1,62 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Select, Cascader } from 'antd';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import { useCollectionManager, useCollectionFilterOptions, useCompile } from "@nocobase/client";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function (props) {
|
|
||||||
const { collection, value, onChange } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const compile = useCompile();
|
|
||||||
const { getCollectionFields } = useCollectionManager();
|
|
||||||
const fields = getCollectionFields(collection)
|
|
||||||
.filter(field => field.interface && (!field.target || field.type === 'belongsTo'))
|
|
||||||
.map(field => field.type === 'belongsTo'
|
|
||||||
? {
|
|
||||||
title: `${compile(field.uiSchema?.title || field.name)} ID`,
|
|
||||||
name: field.foreignKey
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
title: compile(field.uiSchema?.title || field.name),
|
|
||||||
name: field.name
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
placeholder={t('Fields')}
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
>
|
|
||||||
{fields
|
|
||||||
.map(field => (
|
|
||||||
<Select.Option key={field.name} value={field.name}>{field.title}</Select.Option>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function SelectWithAssociations(props) {
|
|
||||||
const { collection, value, onChange } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const compile = useCompile();
|
|
||||||
const fields = useCollectionFilterOptions(collection);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Cascader
|
|
||||||
fieldNames={{
|
|
||||||
label: 'title',
|
|
||||||
value: 'name',
|
|
||||||
children: 'children',
|
|
||||||
}}
|
|
||||||
changeOnSelect={false}
|
|
||||||
value={Array.isArray(value) ? value : value?.split('.')}
|
|
||||||
options={compile(fields)}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={t('Select Field')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -6,8 +6,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { css } from "@emotion/css";
|
import { css } from "@emotion/css";
|
||||||
|
|
||||||
import { CollectionField, CollectionProvider, SchemaComponent, useCollectionManager, useCompile } from "@nocobase/client";
|
import { CollectionField, CollectionProvider, SchemaComponent, useCollectionManager, useCompile } from "@nocobase/client";
|
||||||
import { Operand, parseValue, VariableTypes, VariableTypesContext } from "../calculators";
|
import { VariableInput } from "./VariableInput";
|
||||||
import { lang, NAMESPACE } from "../locale";
|
import { lang } from "../locale";
|
||||||
|
import { useWorkflowVariableOptions } from "../variable";
|
||||||
|
|
||||||
function AssociationInput(props) {
|
function AssociationInput(props) {
|
||||||
const { getCollectionFields } = useCollectionManager();
|
const { getCollectionFields } = useCollectionManager();
|
||||||
@ -28,9 +29,10 @@ function AssociationInput(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: observer for watching useProps
|
// NOTE: observer for watching useProps
|
||||||
export default observer(({ value, onChange }: any) => {
|
export default observer(({ value, disabled, onChange }: any) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
|
const form = useForm();
|
||||||
const { getCollection, getCollectionFields } = useCollectionManager();
|
const { getCollection, getCollectionFields } = useCollectionManager();
|
||||||
const { values: data } = useForm();
|
const { values: data } = useForm();
|
||||||
const collectionName = data?.config?.collection;
|
const collectionName = data?.config?.collection;
|
||||||
@ -42,6 +44,8 @@ export default observer(({ value, onChange }: any) => {
|
|||||||
));
|
));
|
||||||
|
|
||||||
const unassignedFields = fields.filter(field => !(field.name in value));
|
const unassignedFields = fields.filter(field => !(field.name in value));
|
||||||
|
const scope = useWorkflowVariableOptions();
|
||||||
|
const mergedDisabled = disabled || form.disabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset className={css`
|
<fieldset className={css`
|
||||||
@ -61,18 +65,11 @@ export default observer(({ value, onChange }: any) => {
|
|||||||
{fields
|
{fields
|
||||||
.filter(field => field.name in value)
|
.filter(field => field.name in value)
|
||||||
.map(field => {
|
.map(field => {
|
||||||
const VTypes = {
|
|
||||||
...(['linkTo', 'hasMany', 'belongsToMany'].includes(field.type) ? {} : VariableTypes),
|
|
||||||
constant: {
|
|
||||||
title: `{{t("Constant", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
value: 'constant',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const operand = parseValue(value[field.name], VTypes);
|
|
||||||
// constant for associations to use Input, others to use CollectionField
|
// constant for associations to use Input, others to use CollectionField
|
||||||
// dynamic values only support belongsTo/hasOne association, other association type should disable
|
// dynamic values only support belongsTo/hasOne association, other association type should disable
|
||||||
|
const ConstantCompoent = ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type)
|
||||||
|
? AssociationInput
|
||||||
|
: CollectionField;
|
||||||
// TODO: try to use <ObjectField> to replace this map
|
// TODO: try to use <ObjectField> to replace this map
|
||||||
return (
|
return (
|
||||||
<Form.Item key={field.name} label={compile(field.uiSchema?.title ?? field.name)} labelAlign="left" className={css`
|
<Form.Item key={field.name} label={compile(field.uiSchema?.title ?? field.name)} labelAlign="left" className={css`
|
||||||
@ -80,45 +77,36 @@ export default observer(({ value, onChange }: any) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
`}>
|
`}>
|
||||||
<VariableTypesContext.Provider value={VTypes}>
|
<VariableInput
|
||||||
<Operand
|
scope={['hasMany', 'belongsToMany'].includes(field.type) ? [] : scope}
|
||||||
value={value[field.name]}
|
value={value[field.name]}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
onChange({ ...value, [field.name]: next });
|
onChange({ ...value, [field.name]: next });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{operand.type === 'constant'
|
<SchemaComponent
|
||||||
? (
|
schema={{
|
||||||
<SchemaComponent
|
type: 'void',
|
||||||
schema={{
|
properties: {
|
||||||
type: 'void',
|
[field.name]: {
|
||||||
properties: {
|
'x-component': ConstantCompoent
|
||||||
[field.name]: {
|
}
|
||||||
'x-component': ['linkTo', 'belongsTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.type)
|
}
|
||||||
? 'AssociationInput'
|
|
||||||
: 'CollectionField'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
CollectionField,
|
|
||||||
AssociationInput
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
// ? <SchemaComponent schema={{ ...field.uiSchema, name: field.name }} />
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
</Operand>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<CloseCircleOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
const { [field.name]: _, ...rest } = value;
|
|
||||||
onChange(rest);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</VariableTypesContext.Provider>
|
</VariableInput>
|
||||||
|
{!mergedDisabled
|
||||||
|
? (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<CloseCircleOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const { [field.name]: _, ...rest } = value;
|
||||||
|
onChange(rest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { useWorkflowVariableOptions } from "../variable";
|
||||||
|
import { VariableInput } from "./VariableInput";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function FilterDynamicComponent({ value, onChange, renderSchemaComponent }) {
|
||||||
|
const scope = useWorkflowVariableOptions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VariableInput
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
scope={scope}
|
||||||
|
>
|
||||||
|
{renderSchemaComponent()}
|
||||||
|
</VariableInput>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
export function NullRender() {
|
||||||
|
return null;
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Radio, Tooltip } from 'antd';
|
||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { css } from "@emotion/css";
|
||||||
|
|
||||||
|
import { useCompile } from '@nocobase/client';
|
||||||
|
|
||||||
|
export interface RadioWithTooltipOption {
|
||||||
|
value: any;
|
||||||
|
label: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioWithTooltip(props) {
|
||||||
|
const { options = [], ...other } = props;
|
||||||
|
const compile = useCompile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Radio.Group {...other}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Radio key={option.value} value={option.value}>
|
||||||
|
<span className={css`
|
||||||
|
& + .anticon {
|
||||||
|
margin-left: .25em;
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{compile(option.label)}
|
||||||
|
</span>
|
||||||
|
{option.tooltip && (
|
||||||
|
<Tooltip title={compile(option.tooltip)}>
|
||||||
|
<QuestionCircleOutlined style={{ color: '#666' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</Radio.Group>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,252 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useForm } from '@formily/react';
|
||||||
|
import { Cascader, Input, 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";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/;
|
||||||
|
|
||||||
|
export function parseValue(value: any): string | string[] {
|
||||||
|
if (value == null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
const type = typeof value;
|
||||||
|
if (type === 'string') {
|
||||||
|
const matched = value.match(JT_VALUE_RE);
|
||||||
|
if (matched) {
|
||||||
|
return matched[1].split('.');
|
||||||
|
}
|
||||||
|
// const ts = Date.parse(value);
|
||||||
|
// if (value.match(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{0,3})Z$/) && !Number.isNaN(Date.parse(value))) {
|
||||||
|
// return {
|
||||||
|
// type: 'date',
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
return type === 'object' && value instanceof Date ? 'date' : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConstantTypes = {
|
||||||
|
string: {
|
||||||
|
label: `{{t("String", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
value: 'string',
|
||||||
|
component({ onChange, value }) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={ev => onChange(ev.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
number: {
|
||||||
|
label: '{{t("Number")}}',
|
||||||
|
value: 'number',
|
||||||
|
component({ onChange, value }) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
boolean: {
|
||||||
|
label: `{{t("Boolean", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
value: 'boolean',
|
||||||
|
component({ onChange, value }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={t('Select')}
|
||||||
|
options={[
|
||||||
|
{ value: true, label: lang('True') },
|
||||||
|
{ value: false, label: lang('False') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
label: '日期',
|
||||||
|
value: 'date',
|
||||||
|
component({ onChange, value }) {
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
value={moment(value)}
|
||||||
|
onChange={(d) => d ? onChange(d.toDate()) : null}
|
||||||
|
allowClear={false}
|
||||||
|
showTime
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
default: (() => {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||||
|
})(),
|
||||||
|
},
|
||||||
|
null: {
|
||||||
|
label: `{{t("Null", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
value: 'null',
|
||||||
|
component() {
|
||||||
|
return (
|
||||||
|
<Input readOnly placeholder={lang('Null')} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type VariableOptions = {
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
children?: VariableOptions[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariableInput(props) {
|
||||||
|
const { value = '', scope, onChange, children, button } = props;
|
||||||
|
const parsed = parseValue(value);
|
||||||
|
const isConstant = typeof parsed === 'string';
|
||||||
|
const type = isConstant ? parsed : 'string';
|
||||||
|
const variable = isConstant ? null : parsed;
|
||||||
|
const ConstantComponent = ConstantTypes[type]?.component;
|
||||||
|
const constantOptions = Object.values(ConstantTypes);
|
||||||
|
const compile = useCompile();
|
||||||
|
const options: VariableOptions[] = compile([
|
||||||
|
{ value: '', label: lang('Constant'), children: children ? null : constantOptions },
|
||||||
|
...(typeof scope === 'function' ? scope() : (scope ?? []))
|
||||||
|
]);
|
||||||
|
const form = useForm();
|
||||||
|
|
||||||
|
function onSwitch(next) {
|
||||||
|
if (next[0] === '') {
|
||||||
|
if (next[1]) {
|
||||||
|
if (next[1] !== type) {
|
||||||
|
onChange(ConstantTypes[next[1]]?.default ?? null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (variable) {
|
||||||
|
onChange(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(`{{${next.join('.')}}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const variableText = variable?.reduce((opts, key, i) => {
|
||||||
|
const option = (i ? (opts[i - 1] as VariableOptions).children : options)?.find(item => item.value === key);
|
||||||
|
return option ? opts.concat(option) : opts;
|
||||||
|
}, [] as VariableOptions[]).map(item => item.label).join(' / ');
|
||||||
|
|
||||||
|
const disabled = props.disabled || form.disabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Group compact className={css`
|
||||||
|
width: auto;
|
||||||
|
.ant-input-disabled{
|
||||||
|
.ant-tag{
|
||||||
|
color: #bfbfbf;
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{variable
|
||||||
|
? (
|
||||||
|
<div className={css`
|
||||||
|
position: relative;
|
||||||
|
line-height: 0;
|
||||||
|
|
||||||
|
&:hover{
|
||||||
|
.ant-select-clear{
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input{
|
||||||
|
overflow: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
${disabled ? '' : 'padding-right: 28px;'}
|
||||||
|
|
||||||
|
.ant-tag{
|
||||||
|
display: inline;
|
||||||
|
line-height: 19px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
<div
|
||||||
|
onInput={e => e.preventDefault()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(null);
|
||||||
|
}}
|
||||||
|
className={cx('ant-input', { 'ant-input-disabled': disabled })}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
>
|
||||||
|
<Tag contentEditable={false} color="blue">{variableText}</Tag>
|
||||||
|
</div>
|
||||||
|
{!disabled
|
||||||
|
? (
|
||||||
|
<span
|
||||||
|
className={cx('ant-select-clear', css`
|
||||||
|
user-select: 'none'
|
||||||
|
`)}
|
||||||
|
unselectable="on"
|
||||||
|
aria-hidden
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
>
|
||||||
|
<CloseCircleFilled />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
children ?? <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;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Cascader>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</Input.Group>
|
||||||
|
);
|
||||||
|
}
|
@ -1,10 +1,9 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Popover, Button } from 'antd';
|
import { Button, Cascader } from 'antd';
|
||||||
import { css } from "@emotion/css";
|
import { css } from "@emotion/css";
|
||||||
|
|
||||||
import { Input } from "@nocobase/client";
|
import { Input } from "@nocobase/client";
|
||||||
|
|
||||||
import { Operand, VariableTypes, VariableTypesContext } from '../calculators';
|
|
||||||
import { lang } from '../locale';
|
import { lang } from '../locale';
|
||||||
|
|
||||||
|
|
||||||
@ -19,9 +18,9 @@ function setNativeInputValue(input, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VariableJSONInput(props) {
|
export function VariableJSONInput(props) {
|
||||||
const [variable, setVariable] = useState<string>('');
|
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
const { value, space = 2 } = props;
|
const { value, space = 2, scope } = props;
|
||||||
|
const options = typeof scope === 'function' ? scope() : (scope ?? []);
|
||||||
|
|
||||||
function onFormat() {
|
function onFormat() {
|
||||||
if (!inputRef.current) {
|
if (!inputRef.current) {
|
||||||
@ -38,17 +37,16 @@ export function VariableJSONInput(props) {
|
|||||||
textArea.focus();
|
textArea.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInsert() {
|
function onInsert(selected) {
|
||||||
if (!inputRef.current) {
|
if (!inputRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!variable) {
|
|
||||||
return;
|
const variable = `"{{${selected.join('.')}}}"`;
|
||||||
}
|
|
||||||
|
|
||||||
const { textArea } = inputRef.current.resizableTextArea;
|
const { textArea } = inputRef.current.resizableTextArea;
|
||||||
const nextValue = textArea.value.slice(0, textArea.selectionStart) + `"${variable}"` + textArea.value.slice(textArea.selectionEnd);
|
const nextValue = textArea.value.slice(0, textArea.selectionStart) + variable + textArea.value.slice(textArea.selectionEnd);
|
||||||
const nextPos = [textArea.selectionStart, textArea.selectionStart + variable.length + 2];
|
const nextPos = [textArea.selectionStart, textArea.selectionStart + variable.length];
|
||||||
setNativeInputValue(textArea, nextValue);
|
setNativeInputValue(textArea, nextValue);
|
||||||
textArea.setSelectionRange(...nextPos);
|
textArea.setSelectionRange(...nextPos);
|
||||||
textArea.focus();
|
textArea.focus();
|
||||||
@ -56,39 +54,36 @@ export function VariableJSONInput(props) {
|
|||||||
return (
|
return (
|
||||||
<div className={css`
|
<div className={css`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
.ant-input{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
`}>
|
`}>
|
||||||
<Input.JSON {...props} ref={inputRef} />
|
<Input.JSON {...props} ref={inputRef} />
|
||||||
<Button.Group
|
<Button.Group
|
||||||
className={css`
|
className={css`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 2px;
|
right: 0;
|
||||||
top: 2px;
|
top: 0;
|
||||||
.ant-btn-sm{
|
.ant-btn-sm{
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Button size="small" onClick={onFormat}>{lang('Format')}</Button>
|
|
||||||
<Popover
|
|
||||||
trigger="click"
|
|
||||||
placement="topRight"
|
|
||||||
content={
|
|
||||||
<div className={css`
|
|
||||||
display: flex;
|
|
||||||
gap: .5em;
|
|
||||||
`}>
|
|
||||||
<VariableTypesContext.Provider value={{
|
|
||||||
$jobsMapByNodeId: VariableTypes.$jobsMapByNodeId,
|
|
||||||
$context: VariableTypes.$context,
|
|
||||||
}}>
|
|
||||||
<Operand value={variable} onChange={setVariable} />
|
|
||||||
<Button onClick={onInsert} disabled={!variable}>{lang('Insert')}</Button>
|
|
||||||
</VariableTypesContext.Provider>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Button onClick={onFormat}>{lang('Format')}</Button>
|
||||||
|
<Cascader
|
||||||
|
value={[]}
|
||||||
|
options={options}
|
||||||
|
onChange={onInsert}
|
||||||
>
|
>
|
||||||
<Button size="small">{lang('Use variables')}</Button>
|
<Button
|
||||||
</Popover>
|
className={css`
|
||||||
|
font-style: italic;
|
||||||
|
font-family: "New York", "Times New Roman", Times, serif;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</Button>
|
||||||
|
</Cascader>
|
||||||
</Button.Group>
|
</Button.Group>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -0,0 +1,236 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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.key, [option.label]);
|
||||||
|
if (option.children) {
|
||||||
|
for (const [key, labels] of createOptionsKeyLabelMap(option.children)) {
|
||||||
|
map.set(`${option.key}.${key}`, [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 function VariableTextArea(props) {
|
||||||
|
const { value = '', scope, onChange, multiline = true, button } = props;
|
||||||
|
const inputRef = useRef<HTMLDivElement>(null);
|
||||||
|
const options = (typeof scope === 'function' ? scope() : scope) ?? [];
|
||||||
|
const form = useForm();
|
||||||
|
const keyLabelMap = useMemo(() => createOptionsKeyLabelMap(options), [scope]);
|
||||||
|
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
|
||||||
|
onInput={onInput}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cx('ant-input', { 'ant-input-disabled': disabled }, css`
|
||||||
|
overflow: auto;
|
||||||
|
white-space: ${multiline ? 'normal': 'nowrap'};
|
||||||
|
|
||||||
|
.ant-tag{
|
||||||
|
display: inline;
|
||||||
|
line-height: 19px;
|
||||||
|
margin: 0 .5em;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
`)}
|
||||||
|
ref={inputRef}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
<Tooltip title={lang('Use variable')}>
|
||||||
|
<Cascader
|
||||||
|
value={[]}
|
||||||
|
options={options}
|
||||||
|
onChange={onInsert}
|
||||||
|
>
|
||||||
|
{button ?? (
|
||||||
|
<Button
|
||||||
|
className={css`
|
||||||
|
font-style: italic;
|
||||||
|
font-family: "New York", "Times New Roman", Times, serif;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Cascader>
|
||||||
|
</Tooltip>
|
||||||
|
</Input.Group>
|
||||||
|
);
|
||||||
|
}
|
@ -3,6 +3,7 @@ import {
|
|||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
|
MinusOutlined,
|
||||||
ExclamationOutlined,
|
ExclamationOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { NAMESPACE } from './locale';
|
import { NAMESPACE } from './locale';
|
||||||
@ -10,33 +11,45 @@ import { NAMESPACE } from './locale';
|
|||||||
export const EXECUTION_STATUS = {
|
export const EXECUTION_STATUS = {
|
||||||
QUEUEING: null,
|
QUEUEING: null,
|
||||||
STARTED: 0,
|
STARTED: 0,
|
||||||
SUCCEEDED: 1,
|
RESOLVED: 1,
|
||||||
FAILED: -1,
|
FAILED: -1,
|
||||||
CANCELED: -2
|
ERROR: -2,
|
||||||
|
ABORTED: -3,
|
||||||
|
CANCELED: -4,
|
||||||
|
REJECTED: -5
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ExecutionStatusOptions = [
|
export const ExecutionStatusOptions = [
|
||||||
{ value: EXECUTION_STATUS.QUEUEING, label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`, color: 'blue' },
|
{ value: EXECUTION_STATUS.QUEUEING, label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`, color: 'blue' },
|
||||||
{ value: EXECUTION_STATUS.STARTED, label: `{{t("On going", { ns: "${NAMESPACE}" })}}`, color: 'gold' },
|
{ value: EXECUTION_STATUS.STARTED, label: `{{t("On going", { ns: "${NAMESPACE}" })}}`, color: 'gold' },
|
||||||
{ value: EXECUTION_STATUS.SUCCEEDED, label: `{{t("Succeeded", { ns: "${NAMESPACE}" })}}`, color: 'green' },
|
{ value: EXECUTION_STATUS.RESOLVED, label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`, color: 'green' },
|
||||||
{ value: EXECUTION_STATUS.FAILED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: 'red' },
|
{ value: EXECUTION_STATUS.FAILED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: 'red' },
|
||||||
{ value: EXECUTION_STATUS.CANCELED, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}` },
|
{ value: EXECUTION_STATUS.ERROR, label: `{{t("Error", { ns: "${NAMESPACE}" })}}`, color: 'red' },
|
||||||
|
{ value: EXECUTION_STATUS.ABORTED, label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`, color: 'red' },
|
||||||
|
{ value: EXECUTION_STATUS.CANCELED, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`, color: 'volcano' },
|
||||||
|
{ value: EXECUTION_STATUS.REJECTED, label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`, color: 'volcano' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ExecutionStatusOptionsMap = ExecutionStatusOptions.reduce((map, option) => Object.assign(map, { [option.value]: option }), {});
|
export const ExecutionStatusOptionsMap = ExecutionStatusOptions.reduce((map, option) => Object.assign(map, { [option.value as number]: option }), {});
|
||||||
|
|
||||||
export const JOB_STATUS = {
|
export const JOB_STATUS = {
|
||||||
PENDING: 0,
|
PENDING: 0,
|
||||||
RESOLVED: 1,
|
RESOLVED: 1,
|
||||||
REJECTED: -1,
|
FAILED: -1,
|
||||||
CANCELED: -2
|
ERROR: -2,
|
||||||
|
ABORTED: -3,
|
||||||
|
CANCELED: -4,
|
||||||
|
REJECTED: -5
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JobStatusOptions = [
|
export const JobStatusOptions = [
|
||||||
{ value: JOB_STATUS.PENDING, label: `{{t("Pending", { ns: "${NAMESPACE}" })}}`, color: '#d4c306', icon: <ClockCircleOutlined /> },
|
{ value: JOB_STATUS.PENDING, label: `{{t("Pending", { ns: "${NAMESPACE}" })}}`, color: 'gold', icon: <ClockCircleOutlined /> },
|
||||||
{ value: JOB_STATUS.RESOLVED, label: `{{t("Succeeded", { ns: "${NAMESPACE}" })}}`, color: '#67c068', icon: <CheckOutlined /> },
|
{ value: JOB_STATUS.RESOLVED, label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`, color: 'green', icon: <CheckOutlined /> },
|
||||||
{ value: JOB_STATUS.REJECTED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: '#f40', icon: <ExclamationOutlined /> },
|
{ value: JOB_STATUS.FAILED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: 'red', icon: <ExclamationOutlined /> },
|
||||||
{ value: JOB_STATUS.CANCELED, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`, color: '#f40', icon: <CloseOutlined /> }
|
{ value: JOB_STATUS.ERROR, label: `{{t("Error", { ns: "${NAMESPACE}" })}}`, color: 'red', icon: <CloseOutlined /> },
|
||||||
|
{ value: JOB_STATUS.ABORTED, label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`, color: 'red', icon: <MinusOutlined rotate={90} /> },
|
||||||
|
{ value: JOB_STATUS.CANCELED, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`, color: 'volcano', icon: <MinusOutlined rotate={45} /> },
|
||||||
|
{ value: JOB_STATUS.REJECTED, label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`, color: 'volcano', icon: <MinusOutlined /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const JobStatusOptionsMap = JobStatusOptions.reduce((map, option) => Object.assign(map, { [option.value]: option }), {});
|
export const JobStatusOptionsMap = JobStatusOptions.reduce((map, option) => Object.assign(map, { [option.value]: option }), {});
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
export { triggers } from './triggers';
|
export { triggers } from './triggers';
|
||||||
export * from './nodes';
|
export * from './nodes';
|
||||||
export { calculators } from './calculators';
|
|
||||||
export * from './FlowContext';
|
export * from './FlowContext';
|
||||||
export { WorkflowProvider as default } from './WorkflowProvider';
|
export { WorkflowProvider as default } from './WorkflowProvider';
|
||||||
|
@ -118,11 +118,11 @@ export default {
|
|||||||
'Parameters': 'Parameters',
|
'Parameters': 'Parameters',
|
||||||
'Add parameter': 'Add parameter',
|
'Add parameter': 'Add parameter',
|
||||||
'Body': 'Body',
|
'Body': 'Body',
|
||||||
'Use variables': '使用变量',
|
'Use variable': 'Use variable',
|
||||||
'Format': '格式化',
|
'Format': 'Format',
|
||||||
'Insert': '插入',
|
'Insert': 'Insert',
|
||||||
'Timeout config': '超时设置',
|
'Timeout config': 'Timeout config',
|
||||||
'ms': '毫秒',
|
'ms': 'ms',
|
||||||
'Input request data': 'Input request data',
|
'Input request data': 'Input request data',
|
||||||
'Only support standard JSON data': 'Only support standard JSON data',
|
'Only support standard JSON data': 'Only support standard JSON data',
|
||||||
'"Content-Type" only support "application/json", and no need to specify': '"Content-Type" only support "application/json", and no need to specify',
|
'"Content-Type" only support "application/json", and no need to specify': '"Content-Type" only support "application/json", and no need to specify',
|
||||||
|
@ -58,23 +58,41 @@ export default {
|
|||||||
'Null': '空值',
|
'Null': '空值',
|
||||||
'Boolean': '逻辑值',
|
'Boolean': '逻辑值',
|
||||||
'String': '字符串',
|
'String': '字符串',
|
||||||
|
'Date': '日期',
|
||||||
'Calculator': '运算',
|
'Calculator': '运算',
|
||||||
'Arithmetic calculation': '算术运算',
|
'Arithmetic calculation': '算术运算',
|
||||||
'String operation': '字符串',
|
'String operation': '字符串',
|
||||||
|
'System variables': '系统变量',
|
||||||
|
'Current time': '当前时间',
|
||||||
|
|
||||||
'Executed at': '执行于',
|
'Executed at': '执行于',
|
||||||
'Queueing': '队列中',
|
'Queueing': '队列中',
|
||||||
'On going': '进行中',
|
'On going': '进行中',
|
||||||
'Succeeded': '成功',
|
'Resolved': '完成',
|
||||||
|
'Pending': '待处理',
|
||||||
'Failed': '失败',
|
'Failed': '失败',
|
||||||
'Pending': '等待处理',
|
'Error': '出错',
|
||||||
|
'Aborted': '已终止',
|
||||||
'Canceled': '已取消',
|
'Canceled': '已取消',
|
||||||
|
'Rejected': '已拒绝',
|
||||||
|
|
||||||
|
'Continue the process': '继续流程',
|
||||||
|
'Terminate the process': '终止流程',
|
||||||
|
'Save temporarily': '暂存',
|
||||||
|
|
||||||
|
'Operations': '操作',
|
||||||
'This node contains branches, deleting will also be preformed to them, are you sure?': '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',
|
'This node contains branches, deleting will also be preformed to them, are you sure?': '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',
|
||||||
'Control': '流程控制',
|
'Control': '流程控制',
|
||||||
'Collection operations': '数据表操作',
|
'Collection operations': '数据表操作',
|
||||||
|
'Manual': '人工处理',
|
||||||
'Extended types': '扩展类型',
|
'Extended types': '扩展类型',
|
||||||
'Node type': '节点类型',
|
'Node type': '节点类型',
|
||||||
'Calculation': '运算',
|
'Calculation': '运算',
|
||||||
'Configure calculation': '配置运算',
|
'Calculation engine': '运算引擎',
|
||||||
|
'Basic': '基础',
|
||||||
|
'Calculation expression': '运算表达式',
|
||||||
|
'Expression syntax error': '表达式语法错误',
|
||||||
|
'Syntax references: ': '语法参考:',
|
||||||
'Calculation result': '运算结果',
|
'Calculation result': '运算结果',
|
||||||
'True': '真',
|
'True': '真',
|
||||||
'False': '假',
|
'False': '假',
|
||||||
@ -83,7 +101,7 @@ export default {
|
|||||||
'Mode': '模式',
|
'Mode': '模式',
|
||||||
'Continue when "Yes"': '“是”则继续',
|
'Continue when "Yes"': '“是”则继续',
|
||||||
'Branch into "Yes" and "No"': '“是”和“否”分别继续',
|
'Branch into "Yes" and "No"': '“是”和“否”分别继续',
|
||||||
'Conditions': '条件配置',
|
'Condition expression': '条件表达式',
|
||||||
'Parallel branch': '分支',
|
'Parallel branch': '分支',
|
||||||
'Add branch': '增加分支',
|
'Add branch': '增加分支',
|
||||||
'All succeeded': '全部成功',
|
'All succeeded': '全部成功',
|
||||||
@ -98,6 +116,23 @@ export default {
|
|||||||
'Select status': '选择状态',
|
'Select status': '选择状态',
|
||||||
'Succeed and continue': '通过并继续',
|
'Succeed and continue': '通过并继续',
|
||||||
'Fail and exit': '失败并退出',
|
'Fail and exit': '失败并退出',
|
||||||
|
|
||||||
|
'Assignee': '负责人',
|
||||||
|
'Assignees': '负责人',
|
||||||
|
'User interface': '操作界面',
|
||||||
|
'Configure user interface': '配置界面',
|
||||||
|
'View user interface': '查看界面',
|
||||||
|
'Separately': '分别处理',
|
||||||
|
'Each user has own task': '每个人处理各自的任务',
|
||||||
|
'Collaboratively': '协作处理',
|
||||||
|
'Everyone shares one task': '所有人共享同一个任务',
|
||||||
|
'Negotiation': '协商机制',
|
||||||
|
'All pass': '全部通过',
|
||||||
|
'Everyone should pass': '每个人通过才通过',
|
||||||
|
'Any pass': '任意通过',
|
||||||
|
'Anyone pass': '任何一人通过即通过',
|
||||||
|
'Field name existed in form': '表单中已有对应标识的字段',
|
||||||
|
|
||||||
'Create record': '新增数据',
|
'Create record': '新增数据',
|
||||||
'Update record': '更新数据',
|
'Update record': '更新数据',
|
||||||
'Query record': '查询数据',
|
'Query record': '查询数据',
|
||||||
@ -118,7 +153,7 @@ export default {
|
|||||||
'Parameters': '参数',
|
'Parameters': '参数',
|
||||||
'Add parameter': '添加参数',
|
'Add parameter': '添加参数',
|
||||||
'Body': '请求体',
|
'Body': '请求体',
|
||||||
'Use variables': '使用变量',
|
'Use variable': '使用变量',
|
||||||
'Format': '格式化',
|
'Format': '格式化',
|
||||||
'Insert': '插入',
|
'Insert': '插入',
|
||||||
'Timeout config': '超时设置',
|
'Timeout config': '超时设置',
|
||||||
@ -127,4 +162,7 @@ export default {
|
|||||||
'Only support standard JSON data': '仅支持标准 JSON 数据',
|
'Only support standard JSON data': '仅支持标准 JSON 数据',
|
||||||
'"Content-Type" only support "application/json", and no need to specify': '"Content-Type" 请求头仅支持 "application/json",无需填写',
|
'"Content-Type" only support "application/json", and no need to specify': '"Content-Type" 请求头仅支持 "application/json",无需填写',
|
||||||
'Ignore fail request and continue workflow': '忽略失败的请求并继续工作流',
|
'Ignore fail request and continue workflow': '忽略失败的请求并继续工作流',
|
||||||
|
|
||||||
|
'Workflow todos': '工作流待办',
|
||||||
|
'Task': '任务',
|
||||||
};
|
};
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { css } from '@emotion/css';
|
|
||||||
|
|
||||||
import { Calculation } from '../calculators';
|
|
||||||
import { NAMESPACE, useWorkflowTranslation } from '../locale';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: `{{t("Calculation", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
type: 'calculation',
|
|
||||||
group: 'control',
|
|
||||||
fieldset: {
|
|
||||||
'config.calculation': {
|
|
||||||
type: 'object',
|
|
||||||
title: `{{t("Configure calculation", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
name: 'config.calculation',
|
|
||||||
required: true,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'CalculationConfig',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
view: {
|
|
||||||
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
CalculationConfig({ value, onChange }) {
|
|
||||||
return (
|
|
||||||
<Calculation {...value} onChange={onChange} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getter() {
|
|
||||||
const { t } = useWorkflowTranslation();
|
|
||||||
return <div className={css`flex-shrink: 0`}>{t('Calculation result')}</div>;
|
|
||||||
}
|
|
||||||
};
|
|
@ -0,0 +1,13 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
|
};
|
@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
};
|
138
packages/plugins/workflow/src/client/nodes/calculation/index.tsx
Normal file
138
packages/plugins/workflow/src/client/nodes/calculation/index.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import parse from 'json-templates';
|
||||||
|
|
||||||
|
import { SchemaInitializer, SchemaInitializerItemOptions } from '@nocobase/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';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: `{{t("Calculation", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
type: 'calculation',
|
||||||
|
group: 'control',
|
||||||
|
fieldset: {
|
||||||
|
'config.engine': {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Calculation engine", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
name: 'config.engine',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'RadioWithTooltip',
|
||||||
|
'x-component-props': {
|
||||||
|
options: Array.from(calculationEngines.getEntities()).reduce((result: any[], [value, options]) => result.concat({ value, ...options }), [])
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
default: 'math.js'
|
||||||
|
},
|
||||||
|
'config.expression': {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Calculation expression", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
name: 'config.expression',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'VariableTextArea',
|
||||||
|
'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');
|
||||||
|
try {
|
||||||
|
evaluate(exp);
|
||||||
|
return '';
|
||||||
|
} catch (e) {
|
||||||
|
return lang('Expression syntax error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['config.engine'],
|
||||||
|
fulfill: {
|
||||||
|
schema: {
|
||||||
|
description: '{{renderReference($deps[0])}}',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
|
||||||
|
},
|
||||||
|
scope: {
|
||||||
|
useWorkflowVariableOptions,
|
||||||
|
renderReference
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
CalculationResult({ dataSource }) {
|
||||||
|
const { execution } = useFlowContext();
|
||||||
|
if (!execution) {
|
||||||
|
return lang('Calculation result');
|
||||||
|
}
|
||||||
|
const result = parse(dataSource)({
|
||||||
|
$jobsMapByNodeId: (execution.jobs ?? []).reduce((map, job) => Object.assign(map, { [job.nodeId]: job.result }), {})
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre className={css`
|
||||||
|
margin: 0;
|
||||||
|
`}>
|
||||||
|
{JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
RadioWithTooltip,
|
||||||
|
VariableTextArea
|
||||||
|
},
|
||||||
|
getOptions(config, types) {
|
||||||
|
if (types && !types.some(type => type in TypeSets || Object.values(TypeSets).some(set => set.has(type)))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
// { key: '', value: '', label: lang('Calculation result') }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
useInitializers(node): SchemaInitializerItemOptions {
|
||||||
|
return {
|
||||||
|
type: 'item',
|
||||||
|
title: node.title ?? `#${node.id}`,
|
||||||
|
component: CalculationInitializer,
|
||||||
|
node
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function CalculationInitializer({ node, insert, ...props }) {
|
||||||
|
return (
|
||||||
|
<SchemaInitializer.Item
|
||||||
|
{...props}
|
||||||
|
onClick={() => {
|
||||||
|
insert({
|
||||||
|
type: 'void',
|
||||||
|
name: node.id,
|
||||||
|
title: node.title,
|
||||||
|
'x-component': 'CardItem',
|
||||||
|
'x-component-props': {
|
||||||
|
title: node.title ?? `#${node.id}`
|
||||||
|
},
|
||||||
|
'x-designer': 'SimpleDesigner',
|
||||||
|
properties: {
|
||||||
|
result: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'CalculationResult',
|
||||||
|
'x-component-props': {
|
||||||
|
// NOTE: as same format as other reference for migration of revision
|
||||||
|
dataSource: `{{$jobsMapByNodeId.${node.id}}}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -4,13 +4,180 @@ import { Button, Select } from "antd";
|
|||||||
import { CloseCircleOutlined } from '@ant-design/icons';
|
import { CloseCircleOutlined } from '@ant-design/icons';
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Registry } from "@nocobase/utils/client";
|
||||||
|
|
||||||
import { NodeDefaultView } from ".";
|
import { NodeDefaultView } from ".";
|
||||||
import { Branch } from "../Branch";
|
import { Branch } from "../Branch";
|
||||||
import { useFlowContext } from '../FlowContext';
|
import { useFlowContext } from '../FlowContext';
|
||||||
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
||||||
import { Calculation } from "../calculators";
|
|
||||||
import { lang, NAMESPACE } from "../locale";
|
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;
|
||||||
|
type: 'boolean' | 'number' | 'string' | 'date' | 'unknown' | 'null' | 'array';
|
||||||
|
group: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculators = new Registry<Calculator>();
|
||||||
|
|
||||||
|
calculators.register('equal', {
|
||||||
|
name: '=',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'boolean',
|
||||||
|
});
|
||||||
|
calculators.register('notEqual', {
|
||||||
|
name: '≠',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'boolean',
|
||||||
|
});
|
||||||
|
calculators.register('gt', {
|
||||||
|
name: '>',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'boolean',
|
||||||
|
});
|
||||||
|
calculators.register('gte', {
|
||||||
|
name: '≥',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'boolean',
|
||||||
|
});
|
||||||
|
calculators.register('lt', {
|
||||||
|
name: '<',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'boolean',
|
||||||
|
});
|
||||||
|
calculators.register('lte', {
|
||||||
|
name: '≤',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'boolean',
|
||||||
|
});
|
||||||
|
|
||||||
|
calculators.register('add', {
|
||||||
|
name: '+',
|
||||||
|
type: 'number',
|
||||||
|
group: 'number',
|
||||||
|
});
|
||||||
|
calculators.register('minus', {
|
||||||
|
name: '-',
|
||||||
|
type: 'number',
|
||||||
|
group: 'number',
|
||||||
|
});
|
||||||
|
calculators.register('multiple', {
|
||||||
|
name: '*',
|
||||||
|
type: 'number',
|
||||||
|
group: 'number',
|
||||||
|
});
|
||||||
|
calculators.register('divide', {
|
||||||
|
name: '/',
|
||||||
|
type: 'number',
|
||||||
|
group: 'number',
|
||||||
|
});
|
||||||
|
calculators.register('mod', {
|
||||||
|
name: '%',
|
||||||
|
type: 'number',
|
||||||
|
group: 'number',
|
||||||
|
});
|
||||||
|
|
||||||
|
calculators.register('includes', {
|
||||||
|
name: '{{t("contains")}}',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'string'
|
||||||
|
});
|
||||||
|
calculators.register('notIncludes', {
|
||||||
|
name: '{{t("does not contain")}}',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'string'
|
||||||
|
});
|
||||||
|
calculators.register('startsWith', {
|
||||||
|
name: '{{t("starts with")}}',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'string'
|
||||||
|
});
|
||||||
|
calculators.register('notStartsWith', {
|
||||||
|
name: '{{t("not starts with")}}',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'string'
|
||||||
|
});
|
||||||
|
calculators.register('endsWith', {
|
||||||
|
name: '{{t("ends with")}}',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'string'
|
||||||
|
});
|
||||||
|
calculators.register('notEndsWith', {
|
||||||
|
name: '{{t("not ends with")}}',
|
||||||
|
type: 'boolean',
|
||||||
|
group: 'string'
|
||||||
|
});
|
||||||
|
calculators.register('concat', {
|
||||||
|
name: `{{t("concat", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
type: 'string',
|
||||||
|
group: 'string'
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculatorGroups = [
|
||||||
|
{
|
||||||
|
value: 'boolean',
|
||||||
|
title: '{{t("Comparision")}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'number',
|
||||||
|
title: `{{t("Arithmetic calculation", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'string',
|
||||||
|
title: `{{t("String operation", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'date',
|
||||||
|
title: `{{t("Date", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function getGroupCalculators(group) {
|
||||||
|
return Array.from(calculators.getEntities()).filter(([key, value]) => value.group === group);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Calculation({ calculator, operands = [], onChange }) {
|
||||||
|
const compile = useCompile();
|
||||||
|
const options = useWorkflowVariableOptions();
|
||||||
|
return (
|
||||||
|
<fieldset className={css`
|
||||||
|
display: flex;
|
||||||
|
gap: .5em;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`}>
|
||||||
|
<VariableInput
|
||||||
|
value={operands[0]}
|
||||||
|
onChange={(v => onChange({ calculator, operands: [v, operands[1]] }))}
|
||||||
|
scope={options}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={calculator}
|
||||||
|
onChange={v => onChange({ operands, calculator: v })}
|
||||||
|
placeholder={lang('Calculator')}
|
||||||
|
>
|
||||||
|
{calculatorGroups.filter(group => Boolean(getGroupCalculators(group.value).length)).map(group => (
|
||||||
|
<Select.OptGroup key={group.value} label={compile(group.title)}>
|
||||||
|
{getGroupCalculators(group.value).map(([value, { name }]) => (
|
||||||
|
<Select.Option key={value} value={value}>{compile(name)}</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select.OptGroup>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<VariableInput
|
||||||
|
value={operands[1]}
|
||||||
|
onChange={(v => onChange({ calculator, operands: [operands[0], v] }))}
|
||||||
|
scope={options}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function CalculationItem({ value, onChange, onRemove }) {
|
function CalculationItem({ value, onChange, onRemove }) {
|
||||||
@ -18,7 +185,7 @@ function CalculationItem({ value, onChange, onRemove }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { calculator, operands = [null] } = value;
|
const { calculator, operands = [] } = value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css`
|
<div className={css`
|
||||||
@ -73,12 +240,10 @@ function CalculationGroup({ value, onChange }) {
|
|||||||
<div className={cx('node-type-condition-group', css`
|
<div className={cx('node-type-condition-group', css`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.node-type-condition-group{
|
.node-type-condition-group{
|
||||||
padding: .5em 1em;
|
padding: .5em 1em;
|
||||||
border: 1px dashed #ddd;
|
border: 1px dashed #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
+ button{
|
+ button{
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
@ -88,7 +253,6 @@ function CalculationGroup({ value, onChange }) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
|
|
||||||
.ant-select{
|
.ant-select{
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 6em;
|
min-width: 6em;
|
||||||
@ -116,7 +280,6 @@ function CalculationGroup({ value, onChange }) {
|
|||||||
<div className={css`
|
<div className={css`
|
||||||
button{
|
button{
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
&:not(:last-child){
|
&:not(:last-child){
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
}
|
}
|
||||||
@ -155,28 +318,85 @@ export default {
|
|||||||
enum: [
|
enum: [
|
||||||
{
|
{
|
||||||
value: true,
|
value: true,
|
||||||
label: lang('Continue when "Yes"')
|
label: `{{t('Continue when "Yes"', { ns: "${NAMESPACE}" })}}`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: false,
|
value: false,
|
||||||
label: lang('Branch into "Yes" and "No"')
|
label: `{{t('Branch into "Yes" and "No"', { ns: "${NAMESPACE}" })}}`
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
'config.engine': {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Calculation engine", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
name: 'config.engine',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'RadioWithTooltip',
|
||||||
|
'x-component-props': {
|
||||||
|
options: [
|
||||||
|
['basic', { label: `{{t("Basic", { ns: "${NAMESPACE}" })}}` }],
|
||||||
|
...Array.from(calculationEngines.getEntities())
|
||||||
|
].reduce((result: RadioWithTooltipOption[], [value, options]: any) => result.concat({ value, ...options }), []),
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
default: 'basic',
|
||||||
|
},
|
||||||
'config.calculation': {
|
'config.calculation': {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
name: 'config.calculation',
|
name: 'config.calculation',
|
||||||
title: `{{t("Conditions", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("Condition", { ns: "${NAMESPACE}" })}}`,
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'CalculationConfig',
|
'x-component': 'CalculationConfig',
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['config.engine'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{$deps[0] === "basic"}}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
'config.expression': {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Condition expression", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
name: 'config.expression',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'VariableTextArea',
|
||||||
|
'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');
|
||||||
|
try {
|
||||||
|
evaluate(exp);
|
||||||
|
return '';
|
||||||
|
} catch (e) {
|
||||||
|
return lang('Expression syntax error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['config.engine'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{$deps[0] !== "basic"}}'
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: '{{renderReference($deps[0])}}',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
|
|
||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
{ label: lang('Continue when "Yes"'), key: 'rejectOnFalse', value: { rejectOnFalse: true } },
|
{ label: `{{t('Continue when "Yes"', { ns: "${NAMESPACE}" })}}`, key: 'rejectOnFalse', value: { rejectOnFalse: true } },
|
||||||
{ label: lang('Branch into "Yes" and "No"'), key: 'branch', value: { rejectOnFalse: false } }
|
{ label: `{{t('Branch into "Yes" and "No"', { ns: "${NAMESPACE}" })}}`, key: 'branch', value: { rejectOnFalse: false } }
|
||||||
],
|
],
|
||||||
render(data) {
|
render(data) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -224,7 +444,13 @@ export default {
|
|||||||
</NodeDefaultView>
|
</NodeDefaultView>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
scope: {
|
||||||
|
renderReference,
|
||||||
|
useWorkflowVariableOptions
|
||||||
|
},
|
||||||
components: {
|
components: {
|
||||||
CalculationConfig
|
CalculationConfig,
|
||||||
|
VariableTextArea,
|
||||||
|
RadioWithTooltip
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client';
|
||||||
import { useCollectionDataSource } from '@nocobase/client';
|
|
||||||
|
|
||||||
import { collection, values } from '../schemas/collection';
|
import { collection, values } from '../schemas/collection';
|
||||||
import { useFlowContext } from '../FlowContext';
|
|
||||||
import CollectionFieldSelect from '../components/CollectionFieldSelect';
|
|
||||||
import CollectionFieldset from '../components/CollectionFieldset';
|
import CollectionFieldset from '../components/CollectionFieldset';
|
||||||
import { NAMESPACE } from '../locale';
|
import { NAMESPACE } from '../locale';
|
||||||
import { useOperandContext } from '../calculators';
|
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
|
||||||
|
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
|
||||||
|
import { useCollectionFieldOptions } from '../variable';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -40,20 +39,23 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
CollectionFieldset
|
CollectionFieldset
|
||||||
},
|
},
|
||||||
getter({ onChange }) {
|
getOptions(config, types) {
|
||||||
const { options } = useOperandContext();
|
return useCollectionFieldOptions({ collection: config.collection, types });
|
||||||
const { nodes } = useFlowContext();
|
},
|
||||||
const { config } = nodes.find(n => n.id == options.nodeId);
|
useInitializers(node): SchemaInitializerItemOptions | null {
|
||||||
const value = options?.path;
|
if (!node.config.collection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<CollectionFieldSelect
|
type: 'item',
|
||||||
collection={config.collection}
|
title: node.title ?? `#${node.id}`,
|
||||||
value={value}
|
component: CollectionBlockInitializer,
|
||||||
onChange={(path) => {
|
collection: node.config.collection,
|
||||||
onChange(`{{$jobsMapByNodeId.${options.nodeId}.${path}}}`);
|
dataSource: `{{$jobsMapByNodeId.${node.id}}}`
|
||||||
}}
|
};
|
||||||
/>
|
},
|
||||||
);
|
initializers: {
|
||||||
|
CollectionFieldInitializers
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import Duration from "../components/Duration";
|
import Duration from "../components/Duration";
|
||||||
|
import { JOB_STATUS } from "../constants";
|
||||||
import { NAMESPACE } from "../locale";
|
import { NAMESPACE } from "../locale";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -12,7 +13,8 @@ export default {
|
|||||||
title: `{{t("Duration", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("Duration", { ns: "${NAMESPACE}" })}}`,
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Duration',
|
'x-component': 'Duration',
|
||||||
default: 60000
|
default: 60000,
|
||||||
|
required: true
|
||||||
},
|
},
|
||||||
'config.endStatus': {
|
'config.endStatus': {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@ -24,9 +26,10 @@ export default {
|
|||||||
placeholder: `{{t("Select status", { ns: "${NAMESPACE}" })}}`,
|
placeholder: `{{t("Select status", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
enum: [
|
enum: [
|
||||||
{ label: `{{t("Succeed and continue", { ns: "${NAMESPACE}" })}}`, value: 1 },
|
{ label: `{{t("Succeed and continue", { ns: "${NAMESPACE}" })}}`, value: JOB_STATUS.RESOLVED },
|
||||||
{ label: `{{t("Fail and exit", { ns: "${NAMESPACE}" })}}`, value: -1 },
|
{ label: `{{t("Fail and exit", { ns: "${NAMESPACE}" })}}`, value: JOB_STATUS.FAILED },
|
||||||
]
|
],
|
||||||
|
required: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useCollectionDataSource } from '@nocobase/client';
|
import { useCollectionDataSource } from '@nocobase/client';
|
||||||
|
|
||||||
import { VariableComponent } from '../calculators';
|
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
|
||||||
import { collection, filter } from '../schemas/collection';
|
import { collection, filter } from '../schemas/collection';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -26,6 +26,6 @@ export default {
|
|||||||
useCollectionDataSource
|
useCollectionDataSource
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
VariableComponent
|
FilterDynamicComponent
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import parse from 'json-templates';
|
import parse from 'json-templates';
|
||||||
|
|
||||||
import { Registry } from '@nocobase/utils/client';
|
import { Registry } from '@nocobase/utils/client';
|
||||||
import { SchemaComponent, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
|
import { SchemaComponent, SchemaInitializerItemOptions, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
|
||||||
|
|
||||||
import { nodeBlockClass, nodeCardClass, nodeClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from '../style';
|
import { nodeBlockClass, nodeCardClass, nodeClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from '../style';
|
||||||
import { AddButton } from '../AddButton';
|
import { AddButton } from '../AddButton';
|
||||||
@ -21,6 +21,8 @@ import condition from './condition';
|
|||||||
import parallel from './parallel';
|
import parallel from './parallel';
|
||||||
import delay from './delay';
|
import delay from './delay';
|
||||||
|
|
||||||
|
import manual from './manual';
|
||||||
|
|
||||||
import query from './query';
|
import query from './query';
|
||||||
import create from './create';
|
import create from './create';
|
||||||
import update from './update';
|
import update from './update';
|
||||||
@ -28,6 +30,7 @@ import destroy from './destroy';
|
|||||||
import { JobStatusOptions, JobStatusOptionsMap } from '../constants';
|
import { JobStatusOptions, JobStatusOptionsMap } from '../constants';
|
||||||
import { lang, NAMESPACE } from '../locale';
|
import { lang, NAMESPACE } from '../locale';
|
||||||
import request from "./request";
|
import request from "./request";
|
||||||
|
import { VariableOption } from '../variable';
|
||||||
|
|
||||||
export interface Instruction {
|
export interface Instruction {
|
||||||
title: string;
|
title: string;
|
||||||
@ -38,9 +41,11 @@ export interface Instruction {
|
|||||||
view?: ISchema;
|
view?: ISchema;
|
||||||
scope?: { [key: string]: any };
|
scope?: { [key: string]: any };
|
||||||
components?: { [key: string]: any };
|
components?: { [key: string]: any };
|
||||||
render?(props): React.ReactElement;
|
render?(props): React.ReactNode;
|
||||||
endding?: boolean;
|
endding?: boolean;
|
||||||
getter?(node: any): React.ReactElement;
|
getOptions?(config, types?): VariableOption[] | null;
|
||||||
|
useInitializers?(node): SchemaInitializerItemOptions | null;
|
||||||
|
initializers?: { [key: string]: any };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const instructions = new Registry<Instruction>();
|
export const instructions = new Registry<Instruction>();
|
||||||
@ -50,6 +55,8 @@ instructions.register('parallel', parallel);
|
|||||||
instructions.register('calculation', calculation);
|
instructions.register('calculation', calculation);
|
||||||
instructions.register('delay', delay);
|
instructions.register('delay', delay);
|
||||||
|
|
||||||
|
instructions.register('manual', manual);
|
||||||
|
|
||||||
instructions.register('query', query);
|
instructions.register('query', query);
|
||||||
instructions.register('create', create);
|
instructions.register('create', create);
|
||||||
instructions.register('update', update);
|
instructions.register('update', update);
|
||||||
@ -70,8 +77,12 @@ function useUpdateAction() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// TODO: how to do validation separately for each field? especially disabled for dynamic fields?
|
// TODO: how to do validation separately for each field? especially disabled for dynamic fields?
|
||||||
// await form.submit();
|
try {
|
||||||
await api.resource('flow_nodes', data.id).update({
|
await form.submit();
|
||||||
|
} catch (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.resource('flow_nodes', data.id).update?.({
|
||||||
filterByTk: data.id,
|
filterByTk: data.id,
|
||||||
values: {
|
values: {
|
||||||
title: form.values.title,
|
title: form.values.title,
|
||||||
@ -84,12 +95,21 @@ function useUpdateAction() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const NodeContext = React.createContext(null);
|
export const NodeContext = React.createContext<any>({});
|
||||||
|
|
||||||
export function useNodeContext() {
|
export function useNodeContext() {
|
||||||
return useContext(NodeContext);
|
return useContext(NodeContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAvailableUpstreams(node) {
|
||||||
|
const stack: any[] = [];
|
||||||
|
for (let current = node.upstream; current; current = current.upstream) {
|
||||||
|
stack.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
|
||||||
export function Node({ data }) {
|
export function Node({ data }) {
|
||||||
const instruction = instructions.get(data.type);
|
const instruction = instructions.get(data.type);
|
||||||
|
|
||||||
@ -142,7 +162,7 @@ export function RemoveButton() {
|
|||||||
|
|
||||||
async function onRemove() {
|
async function onRemove() {
|
||||||
async function onOk() {
|
async function onOk() {
|
||||||
const { data: { data: node } } = await resource.destroy({
|
const { data: { data: node } } = await resource.destroy?.({
|
||||||
filterByTk: current.id
|
filterByTk: current.id
|
||||||
});
|
});
|
||||||
onNodeRemoved(node);
|
onNodeRemoved(node);
|
||||||
@ -218,21 +238,24 @@ export function JobButton() {
|
|||||||
schema={{
|
schema={{
|
||||||
type: 'void',
|
type: 'void',
|
||||||
properties: {
|
properties: {
|
||||||
job: {
|
[`${job.id}-button`]: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-component': 'Action',
|
'x-component': 'Action',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
title: icon,
|
title: <Tag color={color}>{icon}</Tag>,
|
||||||
shape: 'circle',
|
shape: 'circle',
|
||||||
className: ['workflow-node-job-button', css`
|
className: ['workflow-node-job-button', css`
|
||||||
background-color: ${color};
|
.ant-tag{
|
||||||
&:hover,&:focus{
|
padding: 0;
|
||||||
background-color: ${color}
|
width: 100%;
|
||||||
|
line-height: 18px;
|
||||||
|
margin-right: 0;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
`]
|
`]
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
[job.id]: {
|
[`${job.id}-modal`]: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-decorator': 'Form',
|
'x-decorator': 'Form',
|
||||||
'x-decorator-props': {
|
'x-decorator-props': {
|
||||||
@ -319,7 +342,7 @@ export function NodeDefaultView(props) {
|
|||||||
schema={{
|
schema={{
|
||||||
type: 'void',
|
type: 'void',
|
||||||
properties: {
|
properties: {
|
||||||
view: instruction.view,
|
...(instruction.view ? { view: instruction.view } : {}),
|
||||||
config: {
|
config: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
title: detailText,
|
title: detailText,
|
||||||
@ -328,7 +351,7 @@ export function NodeDefaultView(props) {
|
|||||||
type: 'primary',
|
type: 'primary',
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
[instruction.type]: {
|
[`${instruction.type}_${data.id}`]: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
title: instruction.title,
|
title: instruction.title,
|
||||||
'x-component': 'Action.Drawer',
|
'x-component': 'Action.Drawer',
|
||||||
@ -371,6 +394,7 @@ export function NodeDefaultView(props) {
|
|||||||
'x-component': 'fieldset',
|
'x-component': 'fieldset',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
className: css`
|
className: css`
|
||||||
|
.ant-input,
|
||||||
.ant-select,
|
.ant-select,
|
||||||
.ant-cascader-picker,
|
.ant-cascader-picker,
|
||||||
.ant-picker,
|
.ant-picker,
|
||||||
@ -405,9 +429,9 @@ export function NodeDefaultView(props) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as ISchema
|
}
|
||||||
}
|
}
|
||||||
}
|
} as ISchema
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,35 @@
|
|||||||
|
import { RemoteSelect } from '@nocobase/client';
|
||||||
|
import React from 'react';
|
||||||
|
import { VariableInput } from '../../components/VariableInput';
|
||||||
|
import { useWorkflowVariableOptions } from '../../variable';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function AssigneesSelect({ multiple = false, value = [], onChange }) {
|
||||||
|
const scope = useWorkflowVariableOptions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VariableInput
|
||||||
|
scope={scope}
|
||||||
|
types={[{ type: 'reference', options: { collection: 'users' } }]}
|
||||||
|
value={value[0]}
|
||||||
|
onChange={(next) => {
|
||||||
|
onChange([next]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RemoteSelect
|
||||||
|
fieldNames={{
|
||||||
|
label: 'nickname',
|
||||||
|
value: 'id',
|
||||||
|
}}
|
||||||
|
service={{
|
||||||
|
resource: 'users'
|
||||||
|
}}
|
||||||
|
value={value[0]}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange([v]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VariableInput>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormLayout } from '@formily/antd';
|
||||||
|
import { Radio, Tooltip, Form } from 'antd';
|
||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
|
import { FormItem } from '@nocobase/client';
|
||||||
|
|
||||||
|
import { lang } from '../../locale';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function parseMode(v) {
|
||||||
|
if (!v) {
|
||||||
|
return 'single';
|
||||||
|
}
|
||||||
|
if (v >= 1) {
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
if (v <= -1) {
|
||||||
|
return 'any';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = Math.sign(v);
|
||||||
|
if (dir > 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModeConfig({ value, onChange }) {
|
||||||
|
const mode = parseMode(value);
|
||||||
|
return (
|
||||||
|
<fieldset className={css`
|
||||||
|
.ant-radio-group{
|
||||||
|
.anticon{
|
||||||
|
margin-left: .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
<Form.Item>
|
||||||
|
<Radio.Group value={Boolean(value)} onChange={({ target: { value: v } }) => {
|
||||||
|
console.log(v);
|
||||||
|
onChange(Number(v))
|
||||||
|
}}>
|
||||||
|
<Radio value={true}>
|
||||||
|
<Tooltip
|
||||||
|
title={lang('Each user has own task')}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<span>{lang('Separately')}</span>
|
||||||
|
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Radio>
|
||||||
|
<Radio value={false}>
|
||||||
|
<Tooltip
|
||||||
|
title={lang('Everyone shares one task')}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<span>{lang('Collaboratively')}</span>
|
||||||
|
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
{value
|
||||||
|
? (
|
||||||
|
<fieldset>
|
||||||
|
<FormLayout layout="vertical">
|
||||||
|
<FormItem label={lang('Negotiation')}>
|
||||||
|
<Radio.Group value={value} onChange={onChange}>
|
||||||
|
<Radio value={1}>
|
||||||
|
<Tooltip
|
||||||
|
title={lang('Everyone should pass')}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<span>{lang('All pass')}</span>
|
||||||
|
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Radio>
|
||||||
|
<Radio value={-1}>
|
||||||
|
<Tooltip
|
||||||
|
title={lang('Anyone pass')}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<span>{lang('Any pass')}</span>
|
||||||
|
<QuestionCircleOutlined style={{ color: '#999' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</FormItem>
|
||||||
|
</FormLayout>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,591 @@
|
|||||||
|
import React, { useState, useContext } from 'react';
|
||||||
|
import { Field } from '@formily/core';
|
||||||
|
import { useForm, Schema } from '@formily/react';
|
||||||
|
import { ArrayTable } from '@formily/antd';
|
||||||
|
import { cloneDeep, get, set } from 'lodash';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CollectionProvider,
|
||||||
|
SchemaComponent,
|
||||||
|
SchemaComponentContext,
|
||||||
|
SchemaInitializer,
|
||||||
|
SchemaInitializerItemOptions,
|
||||||
|
InitializerWithSwitch,
|
||||||
|
SchemaInitializerProvider,
|
||||||
|
useSchemaComponentContext,
|
||||||
|
gridRowColWrap,
|
||||||
|
useCollectionManager,
|
||||||
|
ActionContext,
|
||||||
|
CollectionContext,
|
||||||
|
GeneralSchemaDesigner,
|
||||||
|
SchemaSettings
|
||||||
|
} from '@nocobase/client';
|
||||||
|
import { merge, uid } from '@nocobase/utils/client';
|
||||||
|
import { useTrigger } from '../../triggers';
|
||||||
|
import { instructions, useAvailableUpstreams, useNodeContext } from '..';
|
||||||
|
import { useFlowContext } from '../../FlowContext';
|
||||||
|
import { lang, NAMESPACE } from '../../locale';
|
||||||
|
import { JOB_STATUS } from '../../constants';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function useTriggerInitializers(): SchemaInitializerItemOptions | null {
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
const trigger = useTrigger();
|
||||||
|
return trigger.useInitializers? trigger.useInitializers(workflow.config) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SimpleDesigner() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<GeneralSchemaDesigner title={t('Form')}>
|
||||||
|
<SchemaSettings.BlockTitleItem />
|
||||||
|
<SchemaSettings.Divider />
|
||||||
|
<SchemaSettings.Remove
|
||||||
|
removeParentsIfNoChildren
|
||||||
|
breakRemoveOn={{
|
||||||
|
'x-component': 'Grid',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</GeneralSchemaDesigner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormBlockInitializer({ insert, ...props }) {
|
||||||
|
return (
|
||||||
|
<SchemaInitializer.Item
|
||||||
|
{...props}
|
||||||
|
onClick={() => {
|
||||||
|
insert({
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'CardItem',
|
||||||
|
'x-designer': 'SimpleDesigner',
|
||||||
|
properties: {
|
||||||
|
grid: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid',
|
||||||
|
'x-initializer': 'AddFormField',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddBlockButton(props: any) {
|
||||||
|
const current = useNodeContext();
|
||||||
|
const nodes = useAvailableUpstreams(current);
|
||||||
|
const triggerInitializers = [useTriggerInitializers()].filter(Boolean);
|
||||||
|
const nodeBlockInitializers = nodes.map((node) => {
|
||||||
|
const instruction = instructions.get(node.type);
|
||||||
|
return instruction?.useInitializers?.(node);
|
||||||
|
}).filter(Boolean);
|
||||||
|
const dataBlockInitializers = [
|
||||||
|
...triggerInitializers,
|
||||||
|
...(nodeBlockInitializers.length ? [{
|
||||||
|
key: 'nodes',
|
||||||
|
type: 'subMenu',
|
||||||
|
title: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
children: nodeBlockInitializers
|
||||||
|
}] : []),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
...(dataBlockInitializers.length ? [{
|
||||||
|
type: 'itemGroup',
|
||||||
|
title: '{{t("Data blocks")}}',
|
||||||
|
children: dataBlockInitializers
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
type: 'itemGroup',
|
||||||
|
title: '{{t("Form")}}',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'form',
|
||||||
|
type: 'item',
|
||||||
|
title: '{{t("Form")}}',
|
||||||
|
component: FormBlockInitializer
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'itemGroup',
|
||||||
|
title: '{{t("Other blocks")}}',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
title: '{{t("Markdown")}}',
|
||||||
|
component: 'MarkdownBlockInitializer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as SchemaInitializerItemOptions[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchemaInitializer.Button
|
||||||
|
{...props}
|
||||||
|
wrap={gridRowColWrap}
|
||||||
|
items={items}
|
||||||
|
title="{{t('Add block')}}"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupLabels = {
|
||||||
|
basic: '{{t("Basic")}}',
|
||||||
|
choices: '{{t("Choices")}}',
|
||||||
|
media: '{{t("Media")}}',
|
||||||
|
datetime: '{{t("Date & Time")}}',
|
||||||
|
relation: '{{t("Relation")}}',
|
||||||
|
advanced: '{{t("Advanced type")}}',
|
||||||
|
systemInfo: '{{t("System info")}}',
|
||||||
|
others: '{{t("Others")}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getOptions(interfaces) {
|
||||||
|
const fields = {};
|
||||||
|
|
||||||
|
Object.keys(interfaces).forEach((type) => {
|
||||||
|
const schema = interfaces[type];
|
||||||
|
const { group = 'others' } = schema;
|
||||||
|
fields[group] = fields[group] || {};
|
||||||
|
set(fields, [group, type], schema);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(GroupLabels)
|
||||||
|
.filter(groupName => ['basic', 'choices', 'datetime', 'media'].includes(groupName))
|
||||||
|
.map((groupName) => ({
|
||||||
|
title: GroupLabels[groupName],
|
||||||
|
children: Object.keys(fields[groupName] || {})
|
||||||
|
.map((type) => {
|
||||||
|
const field = fields[groupName][type];
|
||||||
|
return {
|
||||||
|
value: type,
|
||||||
|
title: field.title,
|
||||||
|
name: type,
|
||||||
|
...fields[groupName][type],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.order - b.order),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCommonInterfaceInitializers(): SchemaInitializerItemOptions[] {
|
||||||
|
const { interfaces } = useCollectionManager();
|
||||||
|
const options = getOptions(interfaces);
|
||||||
|
|
||||||
|
return options.map(group => ({
|
||||||
|
key: group.title,
|
||||||
|
type: 'itemGroup',
|
||||||
|
title: group.title,
|
||||||
|
children: group.children.map(item => ({
|
||||||
|
key: item.name,
|
||||||
|
type: 'item',
|
||||||
|
title: item.title,
|
||||||
|
component: FormFieldInitializer,
|
||||||
|
fieldInterface: item.name,
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddFormFieldButtonContext = React.createContext<any>({});
|
||||||
|
|
||||||
|
function AddFormField(props) {
|
||||||
|
const { insertPosition = 'beforeEnd', component } = props;
|
||||||
|
const items = useCommonInterfaceInitializers();
|
||||||
|
const collection = useContext(CollectionContext);
|
||||||
|
const [interfaceOptions, setInterface] = useState<any>(null);
|
||||||
|
const [insert, setCallback] = useState<any>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddFormFieldButtonContext.Provider value={{
|
||||||
|
onAddField(item) {
|
||||||
|
const { properties: { unique, type, ...properties }, ...options } = cloneDeep(item);
|
||||||
|
delete properties.name['x-disabled'];
|
||||||
|
setInterface({
|
||||||
|
...options,
|
||||||
|
properties
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setCallback
|
||||||
|
}}>
|
||||||
|
<SchemaInitializer.Button
|
||||||
|
wrap={gridRowColWrap}
|
||||||
|
insertPosition={insertPosition}
|
||||||
|
items={items}
|
||||||
|
component={component}
|
||||||
|
title="{{t('Configure fields')}}"
|
||||||
|
/>
|
||||||
|
<ActionContext.Provider value={{ visible: Boolean(interfaceOptions) }}>
|
||||||
|
{interfaceOptions
|
||||||
|
? (
|
||||||
|
<SchemaComponent
|
||||||
|
schema={{
|
||||||
|
type: 'void',
|
||||||
|
name: 'drawer',
|
||||||
|
title: '{{t("Configure field")}}',
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-component': 'Action.Drawer',
|
||||||
|
properties: {
|
||||||
|
...interfaceOptions.properties,
|
||||||
|
footer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Action.Drawer.Footer',
|
||||||
|
properties: {
|
||||||
|
cancel: {
|
||||||
|
type: 'void',
|
||||||
|
title: '{{t("Cancel")}}',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
useAction() {
|
||||||
|
const form = useForm();
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
setCallback(null);
|
||||||
|
setInterface(null);
|
||||||
|
form.reset();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
type: 'void',
|
||||||
|
title: '{{t("Submit")}}',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
useAction() {
|
||||||
|
const { values, query } = useForm();
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
const { default: options } = interfaceOptions;
|
||||||
|
const defaultName = uid();
|
||||||
|
options.name = values.name ?? defaultName;
|
||||||
|
options.uiSchema.title = values.uiSchema?.title ?? defaultName;
|
||||||
|
options.interface = interfaceOptions.name;
|
||||||
|
const existed = collection.fields?.find(item => item.name === options.name);
|
||||||
|
if (existed) {
|
||||||
|
const field = query('name').take() as Field;
|
||||||
|
field.setFeedback({
|
||||||
|
type: 'error',
|
||||||
|
// code: 'FormulaError',
|
||||||
|
messages: [lang('Field name existed in form')],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
collection.fields?.push(merge(options, values) as any);
|
||||||
|
insert({
|
||||||
|
name: options.name,
|
||||||
|
type: options.uiSchema.type,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'CollectionField',
|
||||||
|
'x-component-props': {},
|
||||||
|
'x-collection-field': `${collection.name}.${options.name}`,
|
||||||
|
'x-designer': 'FormItem.Designer',
|
||||||
|
});
|
||||||
|
setCallback(null);
|
||||||
|
setInterface(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
ArrayTable
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
</ActionContext.Provider>
|
||||||
|
</AddFormFieldButtonContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormFieldInitializer(props) {
|
||||||
|
const { item, insert } = props;
|
||||||
|
const { onAddField, setCallback } = useContext(AddFormFieldButtonContext);
|
||||||
|
const { getInterface } = useCollectionManager();
|
||||||
|
|
||||||
|
const interfaceOptions = getInterface(item.fieldInterface);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchemaInitializer.Item
|
||||||
|
key={item.fieldInterface}
|
||||||
|
onClick={() => {
|
||||||
|
setCallback(() => insert);
|
||||||
|
onAddField(interfaceOptions);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function findFormFields(formSchema, fields) {
|
||||||
|
if (!formSchema) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formSchema.properties) {
|
||||||
|
if (formSchema['x-component'] === 'CollectionField') {
|
||||||
|
fields.push(formSchema);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(formSchema.properties).forEach(key => {
|
||||||
|
findFormFields(formSchema.properties[key], fields);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchemaComponentRefreshProvider(props) {
|
||||||
|
const ctx = useSchemaComponentContext();
|
||||||
|
return (
|
||||||
|
<SchemaComponentContext.Provider
|
||||||
|
value={{
|
||||||
|
...ctx,
|
||||||
|
refresh() {
|
||||||
|
ctx?.refresh?.();
|
||||||
|
props?.onRefresh?.();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</SchemaComponentContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function ActionInitializer({ action, actionProps, ...props }) {
|
||||||
|
return (
|
||||||
|
<InitializerWithSwitch
|
||||||
|
{...props}
|
||||||
|
schema={{
|
||||||
|
type: 'void',
|
||||||
|
title: props.title,
|
||||||
|
'x-decorator': 'ManualActionStatusProvider',
|
||||||
|
'x-decorator-props': {
|
||||||
|
value: action
|
||||||
|
},
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
...actionProps,
|
||||||
|
useAction: '{{ useSubmit }}',
|
||||||
|
},
|
||||||
|
'x-designer': 'Action.Designer',
|
||||||
|
'x-action': `${action}`,
|
||||||
|
}}
|
||||||
|
type="x-action"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddActionButton(props) {
|
||||||
|
return (
|
||||||
|
<SchemaInitializer.Button
|
||||||
|
{...props}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: JOB_STATUS.RESOLVED,
|
||||||
|
type: 'item',
|
||||||
|
title: `{{t("Continue the process", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
component: ActionInitializer,
|
||||||
|
action: JOB_STATUS.RESOLVED,
|
||||||
|
actionProps: {
|
||||||
|
type: 'primary',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: JOB_STATUS.REJECTED,
|
||||||
|
type: 'item',
|
||||||
|
title: `{{t("Terminate the process", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
component: ActionInitializer,
|
||||||
|
action: JOB_STATUS.REJECTED,
|
||||||
|
actionProps: {
|
||||||
|
type: 'danger',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: JOB_STATUS.PENDING,
|
||||||
|
type: 'item',
|
||||||
|
title: `{{t("Save temporarily", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
component: ActionInitializer,
|
||||||
|
action: JOB_STATUS.PENDING,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
title="{{t('Configure actions')}}"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: fake useAction for ui configuration
|
||||||
|
function useSubmit() {
|
||||||
|
return { run() {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFlowRecordFromBlock() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemaConfig({ value, onChange }) {
|
||||||
|
const ctx = useContext(SchemaComponentContext);
|
||||||
|
const trigger = useTrigger();
|
||||||
|
const node = useNodeContext();
|
||||||
|
const nodes = useAvailableUpstreams(node);
|
||||||
|
const form = useForm();
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
|
||||||
|
const { collection = {
|
||||||
|
name: uid(),
|
||||||
|
fields: []
|
||||||
|
}, blocks, actions } = value ?? {};
|
||||||
|
|
||||||
|
const nodeInitializers = {};
|
||||||
|
const nodeComponents = {};
|
||||||
|
nodes.forEach(item => {
|
||||||
|
const instruction = instructions.get(item.type);
|
||||||
|
Object.assign(nodeInitializers, instruction.initializers);
|
||||||
|
Object.assign(nodeComponents, instruction.components);
|
||||||
|
});
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
properties: {
|
||||||
|
drawer: {
|
||||||
|
type: 'void',
|
||||||
|
title: '{{t("Configure form")}}',
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-component': 'Action.Drawer',
|
||||||
|
'x-component-props': {
|
||||||
|
className: 'nb-action-popup',
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
tabs: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Tabs',
|
||||||
|
'x-component-props': {},
|
||||||
|
'x-initializer': 'TabPaneInitializers',
|
||||||
|
'x-initializer-props': {
|
||||||
|
gridInitializer: 'AddBlockButton'
|
||||||
|
},
|
||||||
|
properties: blocks ?? {
|
||||||
|
tab1: {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{t("Manual", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-component': 'Tabs.TabPane',
|
||||||
|
'x-designer': 'Tabs.Designer',
|
||||||
|
properties: {
|
||||||
|
grid: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Grid',
|
||||||
|
'x-initializer': 'AddBlockButton',
|
||||||
|
properties: {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Action.Drawer.Footer',
|
||||||
|
'x-component-props': {
|
||||||
|
style: {
|
||||||
|
background: '#fff'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
actions: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'ActionBar',
|
||||||
|
'x-initializer': 'AddActionButton',
|
||||||
|
properties: actions ?? {
|
||||||
|
resolve: {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{t("Continue the process", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'ManualActionStatusProvider',
|
||||||
|
'x-decorator-props': {
|
||||||
|
value: JOB_STATUS.RESOLVED
|
||||||
|
},
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
useAction: '{{ useSubmit }}',
|
||||||
|
},
|
||||||
|
'x-designer': 'Action.Designer',
|
||||||
|
'x-action': `${JOB_STATUS.RESOLVED}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchemaComponentContext.Provider value={{ ...ctx, designable: !workflow.executed }}>
|
||||||
|
<SchemaInitializerProvider initializers={{ AddBlockButton, AddFormField, AddActionButton, ...trigger.initializers, ...nodeInitializers }}>
|
||||||
|
<SchemaComponentRefreshProvider
|
||||||
|
onRefresh={() => {
|
||||||
|
const { tabs, footer } = get(schema.toJSON(), 'properties.drawer.properties');
|
||||||
|
const fields: any[] = [];
|
||||||
|
findFormFields(tabs, fields);
|
||||||
|
|
||||||
|
for(let i = collection.fields.length - 1; i >= 0; i--) {
|
||||||
|
if (!fields.find(field => field.name === collection.fields[i].name)) {
|
||||||
|
collection.fields.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionKeys = (Object.values(footer.properties.actions.properties ?? {}) as any[])
|
||||||
|
.reduce((actions: number[], { ['x-action']: status }) => actions.concat(Number.parseInt(status, 10)), []);
|
||||||
|
form.setValuesIn('config.actions', actionKeys);
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
collection,
|
||||||
|
blocks: tabs.properties,
|
||||||
|
actions: footer.properties.actions.properties
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CollectionProvider collection={collection}>
|
||||||
|
<SchemaComponent
|
||||||
|
schema={schema}
|
||||||
|
components={{
|
||||||
|
...nodeComponents,
|
||||||
|
// NOTE: fake provider component
|
||||||
|
ManualActionStatusProvider(props) {
|
||||||
|
return props.children;
|
||||||
|
},
|
||||||
|
SimpleDesigner
|
||||||
|
}}
|
||||||
|
scope={{
|
||||||
|
useSubmit,
|
||||||
|
useFlowRecordFromBlock
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CollectionProvider>
|
||||||
|
</SchemaComponentRefreshProvider>
|
||||||
|
</SchemaInitializerProvider>
|
||||||
|
</SchemaComponentContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemaConfigButton(props) {
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="ant-btn ant-btn-primary" onClick={() => setVisible(true)}>
|
||||||
|
{workflow.executed ? lang('View user interface') : lang('Configure user interface')}
|
||||||
|
</div>
|
||||||
|
<ActionContext.Provider value={{ visible, setVisible }}>
|
||||||
|
{props.children}
|
||||||
|
</ActionContext.Provider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,526 @@
|
|||||||
|
import React, { useContext, createContext, useMemo, useEffect, useState } from "react";
|
||||||
|
import { createForm } from '@formily/core';
|
||||||
|
import { observer, useForm, useField, useFieldSchema } from '@formily/react';
|
||||||
|
import { Tag } from 'antd';
|
||||||
|
import parse from 'json-templates';
|
||||||
|
import { css } from "@emotion/css";
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
import { CollectionManagerProvider, CollectionProvider, SchemaComponent, SchemaComponentContext, SchemaComponentOptions, TableBlockProvider, useActionContext, useAPIClient, useCollectionManager, useRecord, useRequest, useTableBlockContext } from "@nocobase/client";
|
||||||
|
import { uid } from "@nocobase/utils/client";
|
||||||
|
|
||||||
|
import { JobStatusOptions, JobStatusOptionsMap, JOB_STATUS } from "../../constants";
|
||||||
|
import { NAMESPACE } from "../../locale";
|
||||||
|
import { FlowContext, useFlowContext } from "../../FlowContext";
|
||||||
|
import { instructions, useAvailableUpstreams } from '..';
|
||||||
|
import { linkNodes } from "../../utils";
|
||||||
|
|
||||||
|
const nodeCollection = {
|
||||||
|
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
name: 'flow_nodes',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'bigInt',
|
||||||
|
name: 'id',
|
||||||
|
interface: 'm2o',
|
||||||
|
uiSchema: {
|
||||||
|
type: 'number',
|
||||||
|
title: 'ID',
|
||||||
|
'x-component': 'RemoteSelect',
|
||||||
|
'x-component-props': {
|
||||||
|
fieldNames: {
|
||||||
|
label: 'title',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
resource: 'flow_nodes',
|
||||||
|
params: {
|
||||||
|
filter: {
|
||||||
|
type: 'manual'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
interface: 'input',
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: '{{t("Title")}}',
|
||||||
|
'x-component': 'Input'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const workflowCollection = {
|
||||||
|
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
name: 'workflows',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
interface: 'input',
|
||||||
|
uiSchema: {
|
||||||
|
title: '{{t("Name")}}',
|
||||||
|
type: 'string',
|
||||||
|
'x-component': 'Input',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const todoCollection = {
|
||||||
|
title: `{{t("Workflow todos", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
name: 'users_jobs',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'belongsTo',
|
||||||
|
name: 'user',
|
||||||
|
target: 'users',
|
||||||
|
foreignKey: 'userId',
|
||||||
|
interface: 'm2o',
|
||||||
|
uiSchema: {
|
||||||
|
type: 'number',
|
||||||
|
title: '{{t("User")}}',
|
||||||
|
'x-component': 'RemoteSelect',
|
||||||
|
'x-component-props': {
|
||||||
|
fieldNames: {
|
||||||
|
label: 'nickname',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
resource: 'users'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'belongsTo',
|
||||||
|
name: 'node',
|
||||||
|
target: 'flow_nodes',
|
||||||
|
foreignKey: 'nodeId',
|
||||||
|
interface: 'm2o',
|
||||||
|
isAssociation: true,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'number',
|
||||||
|
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-component': 'RemoteSelect',
|
||||||
|
'x-component-props': {
|
||||||
|
fieldNames: {
|
||||||
|
label: 'title',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
resource: 'flow_nodes'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'belongsTo',
|
||||||
|
name: 'workflow',
|
||||||
|
target: 'workflows',
|
||||||
|
foreignKey: 'workflowId',
|
||||||
|
interface: 'm2o',
|
||||||
|
uiSchema: {
|
||||||
|
type: 'number',
|
||||||
|
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-component': 'RemoteSelect',
|
||||||
|
'x-component-props': {
|
||||||
|
fieldNames: {
|
||||||
|
label: 'title',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
resource: 'workflows'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'integer',
|
||||||
|
name: 'status',
|
||||||
|
interface: 'select',
|
||||||
|
uiSchema: {
|
||||||
|
type: 'number',
|
||||||
|
title: `{{t("Status", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-component': 'Select',
|
||||||
|
enum: JobStatusOptions
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
type: 'date',
|
||||||
|
interface: 'createdAt',
|
||||||
|
uiSchema: {
|
||||||
|
type: 'datetime',
|
||||||
|
title: '{{t("Created at")}}',
|
||||||
|
'x-component': 'DatePicker',
|
||||||
|
'x-component-props': {
|
||||||
|
showTime: true,
|
||||||
|
},
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeColumn = observer(() => {
|
||||||
|
const field = useField<any>();
|
||||||
|
return field?.value?.title ?? `#${field.value?.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorkflowColumn = observer(() => {
|
||||||
|
const field = useField<any>();
|
||||||
|
return field?.value?.title ?? `#${field.value?.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const UserColumn = observer(() => {
|
||||||
|
const field = useField<any>();
|
||||||
|
return field?.value?.nickname ?? field.value?.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
export function WorkflowTodo() {
|
||||||
|
return (
|
||||||
|
<SchemaComponent
|
||||||
|
components={{
|
||||||
|
NodeColumn,
|
||||||
|
WorkflowColumn,
|
||||||
|
UserColumn
|
||||||
|
}}
|
||||||
|
schema={{
|
||||||
|
type: 'void',
|
||||||
|
name: uid(),
|
||||||
|
'x-component': 'div',
|
||||||
|
properties: {
|
||||||
|
actions: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'ActionBar',
|
||||||
|
'x-component-props': {
|
||||||
|
style: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
filter: {
|
||||||
|
type: 'void',
|
||||||
|
title: '{{ t("Filter") }}',
|
||||||
|
'x-action': 'filter',
|
||||||
|
'x-designer': 'Filter.Action.Designer',
|
||||||
|
'x-component': 'Filter.Action',
|
||||||
|
'x-component-props': {
|
||||||
|
icon: 'FilterOutlined',
|
||||||
|
useProps: '{{ useFilterActionProps }}',
|
||||||
|
},
|
||||||
|
'x-align': 'left',
|
||||||
|
},
|
||||||
|
refresher: {
|
||||||
|
type: 'void',
|
||||||
|
title: '{{ t("Refresh") }}',
|
||||||
|
'x-action': 'refresh',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-designer': 'Action.Designer',
|
||||||
|
'x-component-props': {
|
||||||
|
icon: 'ReloadOutlined',
|
||||||
|
useProps: '{{ useRefreshActionProps }}',
|
||||||
|
},
|
||||||
|
'x-align': 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
type: 'array',
|
||||||
|
'x-component': 'TableV2',
|
||||||
|
'x-component-props': {
|
||||||
|
rowKey: 'id',
|
||||||
|
useProps: '{{ useTableBlockProps }}',
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
node: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'TableV2.Column.Decorator',
|
||||||
|
'x-component': 'TableV2.Column',
|
||||||
|
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
properties: {
|
||||||
|
node: {
|
||||||
|
'x-component': 'NodeColumn',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'TableV2.Column.Decorator',
|
||||||
|
'x-component': 'TableV2.Column',
|
||||||
|
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
properties: {
|
||||||
|
workflow: {
|
||||||
|
'x-component': 'WorkflowColumn',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'TableV2.Column.Decorator',
|
||||||
|
'x-component': 'TableV2.Column',
|
||||||
|
properties: {
|
||||||
|
createdAt: {
|
||||||
|
type: 'number',
|
||||||
|
'x-component': 'CollectionField',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'TableV2.Column.Decorator',
|
||||||
|
'x-component': 'TableV2.Column',
|
||||||
|
title: `{{t("Assignee", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
'x-component': 'UserColumn',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'TableV2.Column.Decorator',
|
||||||
|
'x-component': 'TableV2.Column',
|
||||||
|
properties: {
|
||||||
|
status: {
|
||||||
|
type: 'number',
|
||||||
|
'x-component': 'CollectionField',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'TableV2.Column.Decorator',
|
||||||
|
'x-component': 'TableV2.Column',
|
||||||
|
title: '{{t("Actions")}}',
|
||||||
|
properties: {
|
||||||
|
view: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Action.Link',
|
||||||
|
title: '{{t("View")}}',
|
||||||
|
properties: {
|
||||||
|
drawer: {
|
||||||
|
'x-component': 'WorkflowTodo.Drawer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ManualActionStatusContext = createContext<number | null>(null);
|
||||||
|
|
||||||
|
function useManualActionStatusContext() {
|
||||||
|
return useContext(ManualActionStatusContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManualActionStatusProvider({ value, children }) {
|
||||||
|
return (
|
||||||
|
<ManualActionStatusContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ManualActionStatusContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSubmit() {
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { setVisible } = useActionContext();
|
||||||
|
const { values, submit } = useForm();
|
||||||
|
const nextStatus = useManualActionStatusContext();
|
||||||
|
const { service } = useTableBlockContext();
|
||||||
|
const { id } = useRecord();
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
await submit();
|
||||||
|
await api.resource('users_jobs').submit({
|
||||||
|
filterByTk: id,
|
||||||
|
values: {
|
||||||
|
status: nextStatus,
|
||||||
|
result: values
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setVisible(false);
|
||||||
|
service.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFlowRecordFromBlock(opts) {
|
||||||
|
const { ['x-context-datasource']: dataSource } = useFieldSchema();
|
||||||
|
const { execution } = useFlowContext();
|
||||||
|
let result = parse(dataSource)({
|
||||||
|
$context: execution?.context,
|
||||||
|
$jobsMapByNodeId: (execution?.jobs ?? []).reduce((map, job) => Object.assign(map, { [job.nodeId]: job.result }),{})
|
||||||
|
});
|
||||||
|
|
||||||
|
return useRequest(() => {
|
||||||
|
return Promise.resolve({ data: result })
|
||||||
|
}, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowContextProvider(props) {
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { node, executionId } = useRecord();
|
||||||
|
const [flowContext, setFlowContext] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!executionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api.resource('executions').get?.({
|
||||||
|
filterByTk: executionId,
|
||||||
|
appends: ['workflow', 'workflow.nodes', 'jobs'],
|
||||||
|
})
|
||||||
|
.then(({ data }) => {
|
||||||
|
const {
|
||||||
|
workflow: { nodes = [], ...workflow } = {},
|
||||||
|
...execution
|
||||||
|
} = data?.data ?? {};
|
||||||
|
linkNodes(nodes);
|
||||||
|
setFlowContext({
|
||||||
|
workflow,
|
||||||
|
nodes,
|
||||||
|
execution
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [executionId]);
|
||||||
|
|
||||||
|
if (!flowContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = useAvailableUpstreams(flowContext.nodes.find(item => item.id === node.id));
|
||||||
|
const nodeComponents = nodes.reduce((components, { type }) => Object.assign(components, instructions.get(type).components), {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlowContext.Provider value={flowContext}>
|
||||||
|
<SchemaComponentOptions components={{ ...nodeComponents }}>
|
||||||
|
{props.children}
|
||||||
|
</SchemaComponentOptions>
|
||||||
|
</FlowContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowTodo.Drawer = function () {
|
||||||
|
const ctx = useContext(SchemaComponentContext);
|
||||||
|
const { id, node, workflow, status, result, updatedAt } = useRecord();
|
||||||
|
|
||||||
|
const form = useMemo(() => createForm({
|
||||||
|
readPretty: Boolean(status),
|
||||||
|
initialValues: result
|
||||||
|
}), [result]);
|
||||||
|
|
||||||
|
const { blocks, collection, actions } = node.config.schema ?? {};
|
||||||
|
|
||||||
|
const statusOption = JobStatusOptionsMap[status];
|
||||||
|
const actionSchema = status
|
||||||
|
? {
|
||||||
|
date: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'time',
|
||||||
|
'x-component-props': {
|
||||||
|
className: css`
|
||||||
|
margin-right: .5em;
|
||||||
|
`
|
||||||
|
},
|
||||||
|
'x-content': moment(updatedAt).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Tag',
|
||||||
|
'x-component-props': {
|
||||||
|
icon: statusOption.icon,
|
||||||
|
color: statusOption.color
|
||||||
|
},
|
||||||
|
'x-content': statusOption.label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: actions;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
|
||||||
|
<CollectionProvider collection={collection}>
|
||||||
|
<SchemaComponent
|
||||||
|
components={{
|
||||||
|
Tag,
|
||||||
|
ManualActionStatusProvider,
|
||||||
|
FlowContextProvider
|
||||||
|
}}
|
||||||
|
schema={{
|
||||||
|
type: 'void',
|
||||||
|
name: `drawer-${id}-${status}`,
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-decorator-props': {
|
||||||
|
form,
|
||||||
|
},
|
||||||
|
'x-component': 'Action.Drawer',
|
||||||
|
'x-component-props': {
|
||||||
|
className: 'nb-action-popup',
|
||||||
|
},
|
||||||
|
title: `${workflow.title} - ${node.title ?? `#${node.id}`}`,
|
||||||
|
properties: {
|
||||||
|
tabs: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'FlowContextProvider',
|
||||||
|
'x-component': 'Tabs',
|
||||||
|
properties: blocks,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Action.Drawer.Footer',
|
||||||
|
properties: actionSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
scope={{
|
||||||
|
useSubmit,
|
||||||
|
useFlowRecordFromBlock
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CollectionProvider>
|
||||||
|
</SchemaComponentContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowTodo.Decorator = function ({ children }) {
|
||||||
|
const { collections, ...cm } = useCollectionManager();
|
||||||
|
const blockProps = {
|
||||||
|
collection: 'users_jobs',
|
||||||
|
resource: 'users_jobs',
|
||||||
|
action: 'list',
|
||||||
|
params: {
|
||||||
|
pageSize: 20,
|
||||||
|
sort: ['-createdAt'],
|
||||||
|
appends: ['user', 'node', 'workflow'],
|
||||||
|
except: ['workflow.config'],
|
||||||
|
},
|
||||||
|
rowKey: 'id',
|
||||||
|
showIndex: true,
|
||||||
|
dragSort: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollectionManagerProvider {...cm} collections={[...collections, nodeCollection, workflowCollection, todoCollection]}>
|
||||||
|
<TableBlockProvider {...blockProps}>{children}</TableBlockProvider>
|
||||||
|
</CollectionManagerProvider>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TableOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { SchemaInitializer, useCollectionManager } from "@nocobase/client";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function WorkflowTodoBlockInitializer({ insert, ...props }) {
|
||||||
|
return (
|
||||||
|
<SchemaInitializer.Item
|
||||||
|
icon={<TableOutlined />}
|
||||||
|
{...props}
|
||||||
|
onClick={() => {
|
||||||
|
insert({
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'WorkflowTodo.Decorator',
|
||||||
|
'x-decorator-props': {
|
||||||
|
},
|
||||||
|
'x-component': 'CardItem',
|
||||||
|
'x-designer': 'TableBlockDesigner',
|
||||||
|
properties: {
|
||||||
|
todos: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'WorkflowTodo'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
124
packages/plugins/workflow/src/client/nodes/manual/index.tsx
Normal file
124
packages/plugins/workflow/src/client/nodes/manual/index.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { BlockInitializers, SchemaInitializerItemOptions } from '@nocobase/client';
|
||||||
|
|
||||||
|
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
|
||||||
|
import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers';
|
||||||
|
import { filterTypedFields } from '../../variable';
|
||||||
|
import { NAMESPACE } from '../../locale';
|
||||||
|
import { SchemaConfig, SchemaConfigButton } from './SchemaConfig';
|
||||||
|
import { ModeConfig } from './ModeConfig';
|
||||||
|
import { AssigneesSelect } from './AssigneesSelect';
|
||||||
|
|
||||||
|
|
||||||
|
const MULTIPLE_ASSIGNED_MODE = {
|
||||||
|
SINGLE: Symbol('single'),
|
||||||
|
ALL: Symbol('all'),
|
||||||
|
ANY: Symbol('any'),
|
||||||
|
ALL_PERCENTAGE: Symbol('all percentage'),
|
||||||
|
ANY_PERCENTAGE: Symbol('any percentage')
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO(optimize): change to register way
|
||||||
|
const initializerGroup = BlockInitializers.items.find(group => group.key ==='media');
|
||||||
|
if (!initializerGroup.children.find(item => item.key === 'workflowTodos')) {
|
||||||
|
initializerGroup.children.push({
|
||||||
|
key: 'workflowTodos',
|
||||||
|
type: 'item',
|
||||||
|
title: `{{t("Workflow todos", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
component: 'WorkflowTodoBlockInitializer',
|
||||||
|
icon: 'CheckSquareOutlined',
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: `{{t("Manual", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
type: 'manual',
|
||||||
|
group: 'manual',
|
||||||
|
fieldset: {
|
||||||
|
'config.assignees': {
|
||||||
|
type: 'array',
|
||||||
|
name: 'config.assignees',
|
||||||
|
title: `{{t("Assignees", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'AssigneesSelect',
|
||||||
|
'x-component-props': {
|
||||||
|
// multiple: true,
|
||||||
|
// fieldNames: {
|
||||||
|
// label: 'nickname',
|
||||||
|
// value: 'id',
|
||||||
|
// },
|
||||||
|
// service: {
|
||||||
|
// resource: 'users'
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
'config.mode': {
|
||||||
|
type: 'number',
|
||||||
|
name: 'config.mode',
|
||||||
|
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'ModeConfig',
|
||||||
|
default: 1,
|
||||||
|
'x-reactions': {
|
||||||
|
dependencies: ['config.assignees'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{$deps[0].length > 1}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'config.schema': {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'SchemaConfigButton',
|
||||||
|
properties: {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
'x-component': 'SchemaConfig',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
|
||||||
|
},
|
||||||
|
scope: {
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
SchemaConfigButton,
|
||||||
|
SchemaConfig,
|
||||||
|
ModeConfig,
|
||||||
|
AssigneesSelect
|
||||||
|
},
|
||||||
|
getOptions(config, types) {
|
||||||
|
const fields = (config.schema?.collection?.fields ?? []).map(field => ({
|
||||||
|
key: field.name,
|
||||||
|
value: field.name,
|
||||||
|
label: field.uiSchema.title,
|
||||||
|
title: field.uiSchema.title
|
||||||
|
}));
|
||||||
|
const filteredFields = filterTypedFields(fields, types);
|
||||||
|
return filteredFields.length ? filteredFields : null;
|
||||||
|
},
|
||||||
|
useInitializers(node): SchemaInitializerItemOptions | null {
|
||||||
|
if (!node.config.schema?.collection?.fields?.length
|
||||||
|
|| node.config.mode
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'item',
|
||||||
|
title: node.title ?? `#${node.id}`,
|
||||||
|
component: CollectionBlockInitializer,
|
||||||
|
collection: node.config.schema.collection,
|
||||||
|
dataSource: `{{$jobsMapByNodeId.${node.id}}}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initializers: {
|
||||||
|
CollectionFieldInitializers
|
||||||
|
}
|
||||||
|
};
|
@ -2,15 +2,13 @@ import React, { useState } from "react";
|
|||||||
import { css, cx } from "@emotion/css";
|
import { css, cx } from "@emotion/css";
|
||||||
import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import { Button, Tooltip } from "antd";
|
import { Button, Tooltip } from "antd";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { i18n } from "@nocobase/client";
|
|
||||||
|
|
||||||
import { NodeDefaultView } from ".";
|
import { NodeDefaultView } from ".";
|
||||||
import { Branch } from "../Branch";
|
import { Branch } from "../Branch";
|
||||||
import { useFlowContext } from '../FlowContext';
|
import { useFlowContext } from '../FlowContext';
|
||||||
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
||||||
import { lang, NAMESPACE } from "../locale";
|
import { lang, NAMESPACE } from "../locale";
|
||||||
|
import { RadioWithTooltip } from "../components/RadioWithTooltip";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -24,44 +22,26 @@ export default {
|
|||||||
name: 'config.mode',
|
name: 'config.mode',
|
||||||
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Radio.Group',
|
'x-component': 'RadioWithTooltip',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: 'all',
|
||||||
|
label: `{{t('All succeeded', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
tooltip: `{{t('Continue after all branches succeeded', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'any',
|
||||||
|
label: `{{t('Any succeeded', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
tooltip: `{{t('Continue after any branch succeeded', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'race',
|
||||||
|
label: `{{t('Any succeeded or failed', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
tooltip: `{{t('Continue after any branch succeeded, or exit after any branch failed', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
},
|
},
|
||||||
enum: [
|
|
||||||
{
|
|
||||||
value: 'all',
|
|
||||||
label: (
|
|
||||||
<Tooltip
|
|
||||||
title={lang('Continue after all branches succeeded')}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
{lang('All succeeded')} <QuestionCircleOutlined style={{ color: '#999' }} />
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'any',
|
|
||||||
label: (
|
|
||||||
<Tooltip
|
|
||||||
title={lang('Continue after any branch succeeded')}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
{lang('Any succeeded')} <QuestionCircleOutlined style={{ color: '#999' }} />
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'race',
|
|
||||||
label: (
|
|
||||||
<Tooltip
|
|
||||||
title={lang('Continue after any branch succeeded, or exit after any branch failed')}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
{lang('Any succeeded or failed')} <QuestionCircleOutlined style={{ color: '#999' }} />
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
],
|
|
||||||
default: 'all'
|
default: 'all'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -143,5 +123,8 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</NodeDefaultView>
|
</NodeDefaultView>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
RadioWithTooltip
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client';
|
||||||
|
|
||||||
import { useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client';
|
|
||||||
|
|
||||||
import { useFlowContext } from '../FlowContext';
|
|
||||||
import { useOperandContext, VariableComponent } from '../calculators';
|
|
||||||
import { collection, filter } from '../schemas/collection';
|
import { collection, filter } from '../schemas/collection';
|
||||||
import CollectionFieldSelect from '../components/CollectionFieldSelect';
|
|
||||||
import { NAMESPACE } from '../locale';
|
import { NAMESPACE } from '../locale';
|
||||||
|
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
|
||||||
|
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
|
||||||
|
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
|
||||||
|
import { useCollectionFieldOptions } from '../variable';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -16,16 +15,16 @@ export default {
|
|||||||
group: 'collection',
|
group: 'collection',
|
||||||
fieldset: {
|
fieldset: {
|
||||||
'config.collection': collection,
|
'config.collection': collection,
|
||||||
'config.multiple': {
|
// 'config.multiple': {
|
||||||
type: 'boolean',
|
// type: 'boolean',
|
||||||
title: `{{t("Multiple records", { ns: "${NAMESPACE}" })}}`,
|
// title: `{{t("Multiple records", { ns: "${NAMESPACE}" })}}`,
|
||||||
name: 'config.multiple',
|
// name: 'config.multiple',
|
||||||
'x-decorator': 'FormItem',
|
// 'x-decorator': 'FormItem',
|
||||||
'x-component': 'Checkbox',
|
// 'x-component': 'Checkbox',
|
||||||
'x-component-props': {
|
// 'x-component-props': {
|
||||||
disabled: true
|
// disabled: true
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
'config.params': {
|
'config.params': {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
name: 'config.params',
|
name: 'config.params',
|
||||||
@ -43,23 +42,25 @@ export default {
|
|||||||
useCollectionDataSource
|
useCollectionDataSource
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
VariableComponent
|
FilterDynamicComponent
|
||||||
},
|
},
|
||||||
getter(props) {
|
getOptions(config, types) {
|
||||||
const { onChange } = props;
|
return useCollectionFieldOptions({ collection: config.collection, types });
|
||||||
const { nodes } = useFlowContext();
|
},
|
||||||
const { options } = useOperandContext();
|
useInitializers(node): SchemaInitializerItemOptions | null {
|
||||||
const { config } = nodes.find(n => n.id == options.nodeId);
|
if (!node.config.collection) {
|
||||||
const value = options?.path;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<CollectionFieldSelect
|
type: 'item',
|
||||||
collection={config.collection}
|
title: node.title ?? `#${node.id}`,
|
||||||
value={value}
|
component: CollectionBlockInitializer,
|
||||||
onChange={(path) => {
|
collection: node.config.collection,
|
||||||
onChange(`{{$jobsMapByNodeId.${options.nodeId}.${path}}}`);
|
dataSource: `{{$jobsMapByNodeId.${node.id}}}`
|
||||||
}}
|
};
|
||||||
/>
|
},
|
||||||
);
|
initializers: {
|
||||||
|
CollectionFieldInitializers
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,21 +1,13 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ArrayItems } from '@formily/antd';
|
import { ArrayItems } from '@formily/antd';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
import { NAMESPACE } from '../locale';
|
import { NAMESPACE } from '../locale';
|
||||||
import { Operand, VariableTypes, VariableTypesContext } from '../calculators';
|
import { useWorkflowVariableOptions } from '../variable';
|
||||||
import { VariableJSONInput } from '../components/VariableJSONInput';
|
import { VariableJSONInput } from '../components/VariableJSONInput';
|
||||||
|
import { VariableInput } from '../components/VariableInput';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function VariableTypesContextProvider(props) {
|
|
||||||
return (
|
|
||||||
<VariableTypesContext.Provider value={VariableTypes}>
|
|
||||||
{props.children}
|
|
||||||
</VariableTypesContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: `{{t("HTTP request", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("HTTP request", { ns: "${NAMESPACE}" })}}`,
|
||||||
type: 'request',
|
type: 'request',
|
||||||
@ -29,7 +21,6 @@ export default {
|
|||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Select',
|
'x-component': 'Select',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
defaultValue: 'POST',
|
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
allowClear: false,
|
allowClear: false,
|
||||||
},
|
},
|
||||||
@ -40,6 +31,7 @@ export default {
|
|||||||
{ label: 'PATCH', value: 'PATCH' },
|
{ label: 'PATCH', value: 'PATCH' },
|
||||||
{ label: 'DELETE', value: 'DELETE' },
|
{ label: 'DELETE', value: 'DELETE' },
|
||||||
],
|
],
|
||||||
|
default: 'POST'
|
||||||
},
|
},
|
||||||
'config.url': {
|
'config.url': {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@ -71,7 +63,6 @@ export default {
|
|||||||
description: `{{t('"Content-Type" only support "application/json", and no need to specify', { ns: "${NAMESPACE}" })}}`,
|
description: `{{t('"Content-Type" only support "application/json", and no need to specify', { ns: "${NAMESPACE}" })}}`,
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
'x-decorator': 'VariableTypesContextProvider',
|
|
||||||
properties: {
|
properties: {
|
||||||
space: {
|
space: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
@ -88,7 +79,10 @@ export default {
|
|||||||
value: {
|
value: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Operand',
|
'x-component': 'VariableInput',
|
||||||
|
'x-component-props': {
|
||||||
|
scope: useWorkflowVariableOptions
|
||||||
|
}
|
||||||
},
|
},
|
||||||
remove: {
|
remove: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
@ -115,7 +109,6 @@ export default {
|
|||||||
title: `{{t("Parameters", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("Parameters", { ns: "${NAMESPACE}" })}}`,
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
'x-decorator': 'VariableTypesContextProvider',
|
|
||||||
properties: {
|
properties: {
|
||||||
space: {
|
space: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
@ -132,7 +125,10 @@ export default {
|
|||||||
value: {
|
value: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Operand',
|
'x-component': 'VariableInput',
|
||||||
|
'x-component-props': {
|
||||||
|
scope: useWorkflowVariableOptions
|
||||||
|
}
|
||||||
},
|
},
|
||||||
remove: {
|
remove: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
@ -159,6 +155,7 @@ export default {
|
|||||||
'x-decorator-props': {},
|
'x-decorator-props': {},
|
||||||
'x-component': 'VariableJSONInput',
|
'x-component': 'VariableJSONInput',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
|
scope: useWorkflowVariableOptions,
|
||||||
autoSize: {
|
autoSize: {
|
||||||
minRows: 10,
|
minRows: 10,
|
||||||
},
|
},
|
||||||
@ -196,8 +193,7 @@ export default {
|
|||||||
scope: {},
|
scope: {},
|
||||||
components: {
|
components: {
|
||||||
ArrayItems,
|
ArrayItems,
|
||||||
Operand,
|
VariableInput,
|
||||||
VariableTypesContextProvider,
|
|
||||||
VariableJSONInput
|
VariableJSONInput
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useCollectionDataSource } from '@nocobase/client';
|
import { useCollectionDataSource } from '@nocobase/client';
|
||||||
|
|
||||||
import { VariableComponent } from '../calculators';
|
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
|
||||||
import CollectionFieldset from '../components/CollectionFieldset';
|
import CollectionFieldset from '../components/CollectionFieldset';
|
||||||
import { NAMESPACE } from '../locale';
|
import { NAMESPACE } from '../locale';
|
||||||
import { collection, filter, values } from '../schemas/collection';
|
import { collection, filter, values } from '../schemas/collection';
|
||||||
@ -26,7 +26,7 @@ export default {
|
|||||||
useCollectionDataSource
|
useCollectionDataSource
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
VariableComponent,
|
FilterDynamicComponent,
|
||||||
CollectionFieldset
|
CollectionFieldset
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -55,6 +55,6 @@ export const filter = {
|
|||||||
`
|
`
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
dynamicComponent: 'VariableComponent'
|
dynamicComponent: 'FilterDynamicComponent'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -22,7 +22,7 @@ export const executionCollection = {
|
|||||||
} as ISchema,
|
} as ISchema,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interface: 'object',
|
interface: 'm2o',
|
||||||
type: 'belongsTo',
|
type: 'belongsTo',
|
||||||
name: 'workflowId',
|
name: 'workflowId',
|
||||||
uiSchema: {
|
uiSchema: {
|
||||||
|
@ -96,7 +96,7 @@ export const workflowSchema: ISchema = {
|
|||||||
filter: {
|
filter: {
|
||||||
current: true
|
current: true
|
||||||
},
|
},
|
||||||
sort: ['-enabled', '-createdAt'],
|
sort: ['-createdAt'],
|
||||||
except: ['config'],
|
except: ['config'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
export const workflowPageClass = css`
|
export const workflowPageClass = css`
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
.workflow-toolbar{
|
.workflow-toolbar{
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Select } from 'antd';
|
import { Select } from 'antd';
|
||||||
|
import { onFieldValueChange } from '@formily/core';
|
||||||
import { observer, useForm, useFormEffects } from '@formily/react';
|
import { observer, useForm, useFormEffects } from '@formily/react';
|
||||||
|
|
||||||
import { useCollectionDataSource, useCollectionManager, useCompile } from '@nocobase/client';
|
import {
|
||||||
|
SchemaInitializerItemOptions,
|
||||||
|
useCollectionDataSource,
|
||||||
|
useCollectionManager,
|
||||||
|
useCompile,
|
||||||
|
} from '@nocobase/client';
|
||||||
|
|
||||||
import { useFlowContext } from '../FlowContext';
|
|
||||||
import { collection, filter } from '../schemas/collection';
|
import { collection, filter } from '../schemas/collection';
|
||||||
import { css } from '@emotion/css';
|
import { useCollectionFieldOptions } from '../variable';
|
||||||
import { onFieldValueChange } from '@formily/core';
|
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
|
||||||
import CollectionFieldSelect from '../components/CollectionFieldSelect';
|
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
|
||||||
import { NAMESPACE, useWorkflowTranslation } from '../locale';
|
import { NAMESPACE, useWorkflowTranslation } from '../locale';
|
||||||
import { useOperandContext } from '../calculators';
|
|
||||||
|
|
||||||
const FieldsSelect = observer((props) => {
|
const FieldsSelect = observer((props) => {
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
@ -25,12 +29,7 @@ const FieldsSelect = observer((props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select {...props}>
|
||||||
{...props}
|
|
||||||
className={css`
|
|
||||||
min-width: 6em;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{fields
|
{fields
|
||||||
.filter(field => (
|
.filter(field => (
|
||||||
!field.hidden
|
!field.hidden
|
||||||
@ -58,8 +57,6 @@ const collectionModeOptions = [
|
|||||||
{ label: `{{t("After record deleted", { ns: "${NAMESPACE}" })}}`, value: COLLECTION_TRIGGER_MODE.DELETED },
|
{ label: `{{t("After record deleted", { ns: "${NAMESPACE}" })}}`, value: COLLECTION_TRIGGER_MODE.DELETED },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: `{{t("Collection event", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("Collection event", { ns: "${NAMESPACE}" })}}`,
|
||||||
type: 'collection',
|
type: 'collection',
|
||||||
@ -140,26 +137,28 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
FieldsSelect
|
FieldsSelect
|
||||||
},
|
},
|
||||||
getOptions(config) {
|
getOptions(config, types) {
|
||||||
const { t } = useWorkflowTranslation();
|
const { t } = useWorkflowTranslation();
|
||||||
|
const fieldOptions = useCollectionFieldOptions({ collection: config.collection, types });
|
||||||
const options: any[] = [
|
const options: any[] = [
|
||||||
{ value: 'data', label: t('Trigger data') },
|
...(fieldOptions?.length ? [{ label: t('Trigger data'), key: 'data', value: 'data', children: fieldOptions }] : []),
|
||||||
];
|
];
|
||||||
return options;
|
return options;
|
||||||
},
|
},
|
||||||
getter(props) {
|
useInitializers(config): SchemaInitializerItemOptions | null {
|
||||||
const { onChange } = props;
|
if (!config.collection) {
|
||||||
const { workflow } = useFlowContext();
|
return null;
|
||||||
const { options } = useOperandContext();
|
}
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<CollectionFieldSelect
|
type: 'item',
|
||||||
collection={workflow.config.collection}
|
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
|
||||||
value={options?.path}
|
component: CollectionBlockInitializer,
|
||||||
onChange={(path) => {
|
collection: config.collection,
|
||||||
onChange(`{{$context.data.${path}}}`);
|
dataSource: '{{$context.data}}'
|
||||||
}}
|
};
|
||||||
/>
|
},
|
||||||
);
|
initializers: {
|
||||||
|
CollectionFieldInitializers
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -6,7 +6,7 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { InfoOutlined } from '@ant-design/icons';
|
import { InfoOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { SchemaComponent, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
|
import { SchemaComponent, SchemaInitializerItemOptions, useActionContext, useAPIClient, useCompile, useRequest, useResourceActionContext } from '@nocobase/client';
|
||||||
|
|
||||||
import { nodeCardClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from "../style";
|
import { nodeCardClass, nodeHeaderClass, nodeMetaClass, nodeTitleClass } from "../style";
|
||||||
import { useFlowContext } from "../FlowContext";
|
import { useFlowContext } from "../FlowContext";
|
||||||
@ -43,13 +43,14 @@ export interface Trigger {
|
|||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
// group: string;
|
// group: string;
|
||||||
getOptions?(config: any): { label: string; value: any; key: string }[];
|
getOptions?(config: any, types: any[]): { label: string; value: any; key: string }[];
|
||||||
fieldset: { [key: string]: ISchema };
|
fieldset: { [key: string]: ISchema };
|
||||||
view?: ISchema;
|
view?: ISchema;
|
||||||
scope?: { [key: string]: any };
|
scope?: { [key: string]: any };
|
||||||
components?: { [key: string]: any };
|
components?: { [key: string]: any };
|
||||||
render?(props): React.ReactNode;
|
render?(props): React.ReactNode;
|
||||||
getter?(node: any): React.ReactNode;
|
useInitializers?(config): SchemaInitializerItemOptions | null;
|
||||||
|
initializers?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const triggers = new Registry<Trigger>();
|
export const triggers = new Registry<Trigger>();
|
||||||
@ -231,3 +232,8 @@ export const TriggerConfig = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTrigger() {
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
return triggers.get(workflow.type);
|
||||||
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import React from 'react';
|
import { useCollectionDataSource, SchemaInitializerItemOptions } from '@nocobase/client';
|
||||||
import { Cascader } from 'antd';
|
|
||||||
|
|
||||||
import { useCompile, useCollectionDataSource, useCollectionManager } from '@nocobase/client';
|
|
||||||
|
|
||||||
import { ScheduleConfig } from './ScheduleConfig';
|
import { ScheduleConfig } from './ScheduleConfig';
|
||||||
import { useFlowContext } from '../../FlowContext';
|
|
||||||
import { BaseTypeSet, useOperandContext } from '../../calculators';
|
|
||||||
import { SCHEDULE_MODE } from './constants';
|
import { SCHEDULE_MODE } from './constants';
|
||||||
import { NAMESPACE, useWorkflowTranslation } from '../../locale';
|
import { NAMESPACE, useWorkflowTranslation } from '../../locale';
|
||||||
|
import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers';
|
||||||
|
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
|
||||||
|
import { useCollectionFieldOptions } from '../../variable';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`,
|
||||||
@ -27,45 +27,40 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
ScheduleConfig
|
ScheduleConfig
|
||||||
},
|
},
|
||||||
getOptions(config) {
|
getOptions(config, types) {
|
||||||
const { t } = useWorkflowTranslation();
|
const { t } = useWorkflowTranslation();
|
||||||
const options: any[] = [
|
const options: any[] = [];
|
||||||
{ value: 'date', label: t('Trigger time') },
|
if (!types || types.includes('date')) {
|
||||||
];
|
options.push({ key: 'date', value: 'date', label: t('Trigger time') });
|
||||||
|
}
|
||||||
if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
|
if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
|
||||||
options.push({
|
const fieldOptions = useCollectionFieldOptions({ collection: config.collection });
|
||||||
value: 'data',
|
|
||||||
label: t('Trigger data')
|
if (fieldOptions.length) {
|
||||||
});
|
options.push({
|
||||||
|
key: 'data',
|
||||||
|
value: 'data',
|
||||||
|
label: t('Trigger data'),
|
||||||
|
children: fieldOptions
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
},
|
},
|
||||||
getter({ onChange }) {
|
useInitializers(config): SchemaInitializerItemOptions | null {
|
||||||
const { t } = useWorkflowTranslation();
|
if (!config.collection) {
|
||||||
const compile = useCompile();
|
|
||||||
const { collections = [] } = useCollectionManager();
|
|
||||||
const { workflow } = useFlowContext();
|
|
||||||
const { options } = useOperandContext();
|
|
||||||
const path = options?.path ? options.path.split('.') : [];
|
|
||||||
if (!options.type || options.type === 'date') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const collection = collections.find(item => item.name === workflow.config.collection) ?? { fields: [] };
|
|
||||||
return (
|
return {
|
||||||
<Cascader
|
type: 'item',
|
||||||
placeholder={t('Trigger data')}
|
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
|
||||||
value={path}
|
component: CollectionBlockInitializer,
|
||||||
options={collection.fields
|
collection: config.collection,
|
||||||
.filter(field => BaseTypeSet.has(field?.uiSchema?.type))
|
dataSource: '{{$context.data}}'
|
||||||
.map(field => ({
|
};
|
||||||
value: field.name,
|
},
|
||||||
label: compile(field.uiSchema?.title),
|
initializers: {
|
||||||
}))}
|
CollectionFieldInitializers
|
||||||
onChange={(next) => {
|
|
||||||
onChange(`{{$context.${next.join('.')}}}`);
|
|
||||||
}}
|
|
||||||
allowClear={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
13
packages/plugins/workflow/src/client/utils.ts
Normal file
13
packages/plugins/workflow/src/client/utils.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export function linkNodes(nodes): void {
|
||||||
|
const nodesMap = new Map();
|
||||||
|
nodes.forEach(item => nodesMap.set(item.id, item));
|
||||||
|
for (let node of nodesMap.values()) {
|
||||||
|
if (node.upstreamId) {
|
||||||
|
node.upstream = nodesMap.get(node.upstreamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.downstreamId) {
|
||||||
|
node.downstream = nodesMap.get(node.downstreamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
113
packages/plugins/workflow/src/client/variable.tsx
Normal file
113
packages/plugins/workflow/src/client/variable.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useCollectionManager, useCompile } from "@nocobase/client";
|
||||||
|
|
||||||
|
import { instructions, useAvailableUpstreams, useNodeContext } from "./nodes";
|
||||||
|
import { useFlowContext } from "./FlowContext";
|
||||||
|
import { triggers } from "./triggers";
|
||||||
|
import { NAMESPACE } from "./locale";
|
||||||
|
|
||||||
|
export type VariableOption = { key: string, value: string; label: string; children?: VariableOption[] };
|
||||||
|
|
||||||
|
const VariableTypes = [
|
||||||
|
{
|
||||||
|
title: `{{t("Node result", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
value: '$jobsMapByNodeId',
|
||||||
|
options(types) {
|
||||||
|
const current = useNodeContext();
|
||||||
|
const upstreams = useAvailableUpstreams(current);
|
||||||
|
const options: VariableOption[] = [];
|
||||||
|
upstreams.forEach((node) => {
|
||||||
|
const instruction = instructions.get(node.type);
|
||||||
|
const subOptions = instruction.getOptions?.(node.config, types);
|
||||||
|
if (subOptions) {
|
||||||
|
options.push({
|
||||||
|
key: node.id.toString(),
|
||||||
|
value: node.id.toString(),
|
||||||
|
label: node.title ?? `#${node.id}`,
|
||||||
|
children: subOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return options;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `{{t("Trigger variables", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
value: '$context',
|
||||||
|
options(types) {
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
const trigger = triggers.get(workflow.type);
|
||||||
|
return trigger?.getOptions?.(workflow.config, types) ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `{{t("System variables", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
value: '$system',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
key: 'now',
|
||||||
|
value: 'now',
|
||||||
|
label: `{{t("Current time", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
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'])
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchFieldType(field, type): Boolean {
|
||||||
|
if (typeof type === 'string') {
|
||||||
|
return Boolean(TypeSets[type]?.has(field.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof type === 'object' && type.type === 'reference') {
|
||||||
|
return (field.collectionName === type.options?.collection && field.name === 'id')
|
||||||
|
|| (field.type === 'belongsTo' && field.target === type.options?.collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterTypedFields(fields, types) {
|
||||||
|
return types
|
||||||
|
? fields.filter(field => types.some(type => matchFieldType(field, type)))
|
||||||
|
: fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkflowVariableOptions() {
|
||||||
|
const compile = useCompile();
|
||||||
|
const options = VariableTypes.map((item: any) => {
|
||||||
|
const options = typeof item.options === 'function' ? item.options().filter(Boolean) : item.options;
|
||||||
|
return {
|
||||||
|
label: compile(item.title),
|
||||||
|
value: item.value,
|
||||||
|
key: item.value,
|
||||||
|
children: compile(options),
|
||||||
|
disabled: options && !options.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCollectionFieldOptions(props) {
|
||||||
|
const { fields, collection, types } = props;
|
||||||
|
const compile = useCompile();
|
||||||
|
const { getCollectionFields } = useCollectionManager();
|
||||||
|
return filterTypedFields((fields ?? getCollectionFields(collection)), types)
|
||||||
|
.filter(field => field.interface && (!field.target || field.type === 'belongsTo'))
|
||||||
|
.map(field => field.type === 'belongsTo'
|
||||||
|
? {
|
||||||
|
label: `${compile(field.uiSchema?.title || field.name)} ID`,
|
||||||
|
key: field.foreignKey,
|
||||||
|
value: field.foreignKey,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: compile(field.uiSchema?.title || field.name),
|
||||||
|
key: field.name,
|
||||||
|
value: field.name,
|
||||||
|
});
|
||||||
|
}
|
@ -5,22 +5,23 @@ import { Plugin } from '@nocobase/server';
|
|||||||
import { Registry } from '@nocobase/utils';
|
import { Registry } from '@nocobase/utils';
|
||||||
|
|
||||||
import initActions from './actions';
|
import initActions from './actions';
|
||||||
import calculators from './calculators';
|
import initCalculationEngines from './calculators';
|
||||||
|
import type { Evaluator } from './calculators';
|
||||||
import { EXECUTION_STATUS } from './constants';
|
import { EXECUTION_STATUS } from './constants';
|
||||||
import extensions from './extensions';
|
|
||||||
import initInstructions, { Instruction } from './instructions';
|
import initInstructions, { Instruction } from './instructions';
|
||||||
import ExecutionModel from './models/Execution';
|
import ExecutionModel from './models/Execution';
|
||||||
import JobModel from './models/Job';
|
import JobModel from './models/Job';
|
||||||
import WorkflowModel from './models/Workflow';
|
import WorkflowModel from './models/Workflow';
|
||||||
import Processor from './Processor';
|
import Processor from './Processor';
|
||||||
import initTriggers, { Trigger } from './triggers';
|
import initTriggers, { Trigger } from './triggers';
|
||||||
|
import initFunctions from './functions';
|
||||||
|
|
||||||
type Pending = [ExecutionModel, JobModel?];
|
type Pending = [ExecutionModel, JobModel?];
|
||||||
export default class WorkflowPlugin extends Plugin {
|
export default class WorkflowPlugin extends Plugin {
|
||||||
instructions: Registry<Instruction> = new Registry();
|
instructions: Registry<Instruction> = new Registry();
|
||||||
triggers: Registry<Trigger> = new Registry();
|
triggers: Registry<Trigger> = new Registry();
|
||||||
calculators = calculators;
|
calculators: Registry<Evaluator> = new Registry();
|
||||||
extensions = extensions;
|
functions: Registry<Function> = new Registry();
|
||||||
executing: ExecutionModel | null = null;
|
executing: ExecutionModel | null = null;
|
||||||
pending: Pending[] = [];
|
pending: Pending[] = [];
|
||||||
events: [WorkflowModel, any, { context?: any }][] = [];
|
events: [WorkflowModel, any, { context?: any }][] = [];
|
||||||
@ -74,6 +75,13 @@ export default class WorkflowPlugin extends Plugin {
|
|||||||
async load() {
|
async load() {
|
||||||
const { db, options } = this;
|
const { db, options } = this;
|
||||||
|
|
||||||
|
initActions(this);
|
||||||
|
initTriggers(this, options.triggers);
|
||||||
|
initInstructions(this, options.instructions);
|
||||||
|
initCalculationEngines(this);
|
||||||
|
initFunctions(this, options.functions);
|
||||||
|
|
||||||
|
|
||||||
this.app.acl.registerSnippet({
|
this.app.acl.registerSnippet({
|
||||||
name: `pm.${this.name}.workflows`,
|
name: `pm.${this.name}.workflows`,
|
||||||
actions: [
|
actions: [
|
||||||
@ -100,18 +108,10 @@ export default class WorkflowPlugin extends Plugin {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
initActions(this);
|
|
||||||
initTriggers(this, options.triggers);
|
|
||||||
initInstructions(this, options.instructions);
|
|
||||||
|
|
||||||
db.on('workflows.beforeSave', this.onBeforeSave);
|
db.on('workflows.beforeSave', this.onBeforeSave);
|
||||||
db.on('workflows.afterSave', (model: WorkflowModel) => this.toggle(model));
|
db.on('workflows.afterSave', (model: WorkflowModel) => this.toggle(model));
|
||||||
db.on('workflows.afterDestroy', (model: WorkflowModel) => this.toggle(model, false));
|
db.on('workflows.afterDestroy', (model: WorkflowModel) => this.toggle(model, false));
|
||||||
|
|
||||||
this.app.on('afterLoad', async () => {
|
|
||||||
this.extensions.reduce((promise, extend) => promise.then(() => extend(this)), Promise.resolve());
|
|
||||||
});
|
|
||||||
|
|
||||||
// [Life Cycle]:
|
// [Life Cycle]:
|
||||||
// * load all workflows in db
|
// * load all workflows in db
|
||||||
// * add all hooks for enabled workflows
|
// * add all hooks for enabled workflows
|
||||||
@ -200,16 +200,13 @@ export default class WorkflowPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (valid) {
|
if (valid) {
|
||||||
const execution = await this.db.sequelize.transaction(async (transaction) => {
|
const execution = await this.db.sequelize.transaction(async transaction => {
|
||||||
const execution = await workflow.createExecution(
|
const execution = await workflow.createExecution({
|
||||||
{
|
context,
|
||||||
context,
|
key: workflow.key,
|
||||||
key: workflow.key,
|
status: EXECUTION_STATUS.QUEUEING,
|
||||||
status: EXECUTION_STATUS.CREATED,
|
useTransaction: workflow.useTransaction,
|
||||||
useTransaction: workflow.useTransaction,
|
}, { transaction });
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
|
|
||||||
const executed = await workflow.countExecutions({ transaction });
|
const executed = await workflow.countExecutions({ transaction });
|
||||||
|
|
||||||
@ -276,7 +273,7 @@ export default class WorkflowPlugin extends Plugin {
|
|||||||
} else {
|
} else {
|
||||||
const execution = (await this.db.getRepository('executions').findOne({
|
const execution = (await this.db.getRepository('executions').findOne({
|
||||||
filter: {
|
filter: {
|
||||||
status: EXECUTION_STATUS.CREATED,
|
status: EXECUTION_STATUS.QUEUEING
|
||||||
},
|
},
|
||||||
sort: 'createdAt',
|
sort: 'createdAt',
|
||||||
})) as ExecutionModel;
|
})) as ExecutionModel;
|
||||||
@ -292,13 +289,13 @@ export default class WorkflowPlugin extends Plugin {
|
|||||||
private async process(execution: ExecutionModel, job?: JobModel) {
|
private async process(execution: ExecutionModel, job?: JobModel) {
|
||||||
this.executing = execution;
|
this.executing = execution;
|
||||||
|
|
||||||
if (execution.status === EXECUTION_STATUS.CREATED) {
|
if (execution.status === EXECUTION_STATUS.QUEUEING) {
|
||||||
await execution.update({ status: EXECUTION_STATUS.STARTED });
|
await execution.update({ status: EXECUTION_STATUS.STARTED });
|
||||||
}
|
}
|
||||||
|
|
||||||
const processor = this.createProcessor(execution);
|
const processor = this.createProcessor(execution);
|
||||||
|
|
||||||
this.app.logger.info(`[Workflow] execution ${execution.id} ${job ? 'resuming' : 'starting'} ...`);
|
this.app.logger.info(`[Workflow] execution ${execution.id} ${job ? 'resuming' : 'starting'}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await (job ? processor.resume(job) : processor.start());
|
await (job ? processor.resume(job) : processor.start());
|
||||||
@ -311,7 +308,7 @@ export default class WorkflowPlugin extends Plugin {
|
|||||||
this.dispatch();
|
this.dispatch();
|
||||||
}
|
}
|
||||||
|
|
||||||
private createProcessor(execution: ExecutionModel, options = {}): Processor {
|
public createProcessor(execution: ExecutionModel, options = {}): Processor {
|
||||||
return new Processor(execution, { ...options, plugin: this });
|
return new Processor(execution, { ...options, plugin: this });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ import Plugin from '.';
|
|||||||
import ExecutionModel from './models/Execution';
|
import ExecutionModel from './models/Execution';
|
||||||
import JobModel from './models/Job';
|
import JobModel from './models/Job';
|
||||||
import FlowNodeModel from './models/FlowNode';
|
import FlowNodeModel from './models/FlowNode';
|
||||||
import calculators from './calculators';
|
|
||||||
import { EXECUTION_STATUS, JOB_STATUS } from './constants';
|
import { EXECUTION_STATUS, JOB_STATUS } from './constants';
|
||||||
|
|
||||||
|
|
||||||
@ -22,11 +21,14 @@ export default class Processor {
|
|||||||
static StatusMap = {
|
static StatusMap = {
|
||||||
[JOB_STATUS.PENDING]: EXECUTION_STATUS.STARTED,
|
[JOB_STATUS.PENDING]: EXECUTION_STATUS.STARTED,
|
||||||
[JOB_STATUS.RESOLVED]: EXECUTION_STATUS.RESOLVED,
|
[JOB_STATUS.RESOLVED]: EXECUTION_STATUS.RESOLVED,
|
||||||
[JOB_STATUS.REJECTED]: EXECUTION_STATUS.REJECTED,
|
[JOB_STATUS.FAILED]: EXECUTION_STATUS.FAILED,
|
||||||
|
[JOB_STATUS.ERROR]: EXECUTION_STATUS.ERROR,
|
||||||
|
[JOB_STATUS.ABORTED]: EXECUTION_STATUS.ABORTED,
|
||||||
[JOB_STATUS.CANCELED]: EXECUTION_STATUS.CANCELED,
|
[JOB_STATUS.CANCELED]: EXECUTION_STATUS.CANCELED,
|
||||||
|
[JOB_STATUS.REJECTED]: EXECUTION_STATUS.REJECTED,
|
||||||
};
|
};
|
||||||
|
|
||||||
transaction: Transaction;
|
transaction?: Transaction;
|
||||||
|
|
||||||
nodes: FlowNodeModel[] = [];
|
nodes: FlowNodeModel[] = [];
|
||||||
nodesMap = new Map<number, FlowNodeModel>();
|
nodesMap = new Map<number, FlowNodeModel>();
|
||||||
@ -37,7 +39,7 @@ export default class Processor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// make dual linked nodes list then cache
|
// make dual linked nodes list then cache
|
||||||
private makeNodes(nodes = []) {
|
private makeNodes(nodes: FlowNodeModel[] = []) {
|
||||||
this.nodes = nodes;
|
this.nodes = nodes;
|
||||||
|
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
@ -46,11 +48,11 @@ export default class Processor {
|
|||||||
|
|
||||||
nodes.forEach((node) => {
|
nodes.forEach((node) => {
|
||||||
if (node.upstreamId) {
|
if (node.upstreamId) {
|
||||||
node.upstream = this.nodesMap.get(node.upstreamId);
|
node.upstream = this.nodesMap.get(node.upstreamId) as FlowNodeModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.downstreamId) {
|
if (node.downstreamId) {
|
||||||
node.downstream = this.nodesMap.get(node.downstreamId);
|
node.downstream = this.nodesMap.get(node.downstreamId) as FlowNodeModel;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -76,7 +78,7 @@ export default class Processor {
|
|||||||
: await options.plugin.db.sequelize.transaction();
|
: await options.plugin.db.sequelize.transaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async prepare() {
|
public async prepare() {
|
||||||
const transaction = await this.getTransaction();
|
const transaction = await this.getTransaction();
|
||||||
this.transaction = transaction;
|
this.transaction = transaction;
|
||||||
|
|
||||||
@ -139,12 +141,12 @@ export default class Processor {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// for uncaught error, set to rejected
|
// for uncaught error, set to error
|
||||||
job = {
|
job = {
|
||||||
result: err instanceof Error
|
result: err instanceof Error
|
||||||
? { message: err.message, stack: process.env.NODE_ENV === 'production' ? [] : err.stack }
|
? { message: err.message, stack: process.env.NODE_ENV === 'production' ? [] : err.stack }
|
||||||
: err,
|
: err,
|
||||||
status: JOB_STATUS.REJECTED,
|
status: JOB_STATUS.ERROR,
|
||||||
};
|
};
|
||||||
// if previous job is from resuming
|
// if previous job is from resuming
|
||||||
if (prevJob && prevJob.nodeId === node.id) {
|
if (prevJob && prevJob.nodeId === node.id) {
|
||||||
@ -153,17 +155,11 @@ export default class Processor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let savedJob;
|
if (!(job instanceof Model)) {
|
||||||
if (job instanceof Model) {
|
job.upstreamId = prevJob instanceof Model ? prevJob.get('id') : null;
|
||||||
savedJob = (await job.save({ transaction: this.transaction })) as unknown as JobModel;
|
job.nodeId = node.id;
|
||||||
} else {
|
|
||||||
const upstreamId = prevJob instanceof Model ? prevJob.get('id') : null;
|
|
||||||
savedJob = await this.saveJob({
|
|
||||||
nodeId: node.id,
|
|
||||||
upstreamId,
|
|
||||||
...job,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const savedJob = await this.saveJob(job);
|
||||||
|
|
||||||
if (savedJob.status === JOB_STATUS.RESOLVED && node.downstream) {
|
if (savedJob.status === JOB_STATUS.RESOLVED && node.downstream) {
|
||||||
// run next node
|
// run next node
|
||||||
@ -244,7 +240,7 @@ export default class Processor {
|
|||||||
getBranches(node: FlowNodeModel): FlowNodeModel[] {
|
getBranches(node: FlowNodeModel): FlowNodeModel[] {
|
||||||
return this.nodes
|
return this.nodes
|
||||||
.filter(item => item.upstream === node && item.branchIndex !== null)
|
.filter(item => item.upstream === node && item.branchIndex !== null)
|
||||||
.sort((a, b) => a.branchIndex - b.branchIndex);
|
.sort((a, b) => Number(a.branchIndex) - Number(b.branchIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
// find the first node in current branch
|
// find the first node in current branch
|
||||||
@ -274,7 +270,7 @@ export default class Processor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findBranchParentJob(job: JobModel, node: FlowNodeModel): JobModel | null {
|
findBranchParentJob(job: JobModel, node: FlowNodeModel): JobModel | null {
|
||||||
for (let j = job; j; j = this.jobsMap.get(j.upstreamId)) {
|
for (let j: JobModel | undefined = job; j; j = this.jobsMap.get(j.upstreamId)) {
|
||||||
if (j.nodeId === node.id) {
|
if (j.nodeId === node.id) {
|
||||||
return j;
|
return j;
|
||||||
}
|
}
|
||||||
@ -282,20 +278,24 @@ export default class Processor {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getParsedValue(value, node?) {
|
public getScope(node?) {
|
||||||
const injectedFns = {};
|
const systemFns = {};
|
||||||
const scope = {
|
const scope = {
|
||||||
execution: this.execution,
|
execution: this.execution,
|
||||||
node
|
node
|
||||||
};
|
};
|
||||||
for (let [name, fn] of calculators.getEntities()) {
|
for (let [name, fn] of this.options.plugin.functions.getEntities()) {
|
||||||
injectedFns[name] = fn.bind(scope);
|
systemFns[name] = fn.bind(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parse(value)({
|
return {
|
||||||
$context: this.execution.context,
|
$context: this.execution.context,
|
||||||
$jobsMapByNodeId: this.jobsMapByNodeId,
|
$jobsMapByNodeId: this.jobsMapByNodeId,
|
||||||
$fn: injectedFns
|
$system: systemFns
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getParsedValue(value, node?) {
|
||||||
|
return parse(value)(this.getScope(node));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,6 @@ import { EXECUTION_STATUS } from '../constants';
|
|||||||
describe('workflow > Plugin', () => {
|
describe('workflow > Plugin', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
let db: Database;
|
let db: Database;
|
||||||
let PostModel;
|
|
||||||
let PostRepo;
|
let PostRepo;
|
||||||
let WorkflowModel;
|
let WorkflowModel;
|
||||||
|
|
||||||
@ -16,7 +15,6 @@ describe('workflow > Plugin', () => {
|
|||||||
app = await getApp();
|
app = await getApp();
|
||||||
db = app.db;
|
db = app.db;
|
||||||
WorkflowModel = db.getCollection('workflows').model;
|
WorkflowModel = db.getCollection('workflows').model;
|
||||||
PostModel = db.getCollection('posts').model;
|
|
||||||
PostRepo = db.getCollection('posts').repository;
|
PostRepo = db.getCollection('posts').repository;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -183,6 +181,56 @@ describe('workflow > Plugin', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('destroyed workflow will not be trigger any more', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'collection',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'update',
|
||||||
|
config: {
|
||||||
|
collection: 'posts',
|
||||||
|
params: {
|
||||||
|
filter: {
|
||||||
|
id: '{{$context.data.id}}'
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
title: 't2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const { model: JobModel } = db.getCollection('jobs');
|
||||||
|
|
||||||
|
const e1c = await workflow.countExecutions();
|
||||||
|
expect(e1c).toBe(1);
|
||||||
|
const j1c = await JobModel.count();
|
||||||
|
expect(j1c).toBe(1);
|
||||||
|
const p1 = await PostRepo.findOne();
|
||||||
|
expect(p1.title).toBe('t2');
|
||||||
|
|
||||||
|
await workflow.destroy();
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const p2c = await PostRepo.count({ filter: { title: 't1' } });
|
||||||
|
expect(p2c).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('cycling trigger', () => {
|
describe('cycling trigger', () => {
|
||||||
it('trigger should not be triggered more than once in same execution', async () => {
|
it('trigger should not be triggered more than once in same execution', async () => {
|
||||||
const workflow = await WorkflowModel.create({
|
const workflow = await WorkflowModel.create({
|
||||||
|
@ -121,12 +121,12 @@ describe('workflow > Processor', () => {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
const [execution] = await workflow.getExecutions();
|
||||||
expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
|
expect(execution.status).toEqual(EXECUTION_STATUS.ERROR);
|
||||||
|
|
||||||
const jobs = await execution.getJobs();
|
const jobs = await execution.getJobs();
|
||||||
expect(jobs.length).toEqual(1);
|
expect(jobs.length).toEqual(1);
|
||||||
const { status, result } = jobs[0].get();
|
const { status, result } = jobs[0].get();
|
||||||
expect(status).toEqual(JOB_STATUS.REJECTED);
|
expect(status).toEqual(JOB_STATUS.ERROR);
|
||||||
expect(result.message).toBe('definite error');
|
expect(result.message).toBe('definite error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -134,7 +134,7 @@ describe('workflow > Processor', () => {
|
|||||||
describe('manual nodes', () => {
|
describe('manual nodes', () => {
|
||||||
it('manual node should suspend execution, and could be manually resume', async () => {
|
it('manual node should suspend execution, and could be manually resume', async () => {
|
||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'prompt',
|
type: 'manual',
|
||||||
});
|
});
|
||||||
|
|
||||||
const n2 = await workflow.createNode({
|
const n2 = await workflow.createNode({
|
||||||
@ -154,7 +154,10 @@ describe('workflow > Processor', () => {
|
|||||||
expect(pending.status).toEqual(JOB_STATUS.PENDING);
|
expect(pending.status).toEqual(JOB_STATUS.PENDING);
|
||||||
expect(pending.result).toEqual(null);
|
expect(pending.result).toEqual(null);
|
||||||
|
|
||||||
pending.set('result', 123);
|
pending.set({
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: 123
|
||||||
|
});
|
||||||
pending.execution = execution;
|
pending.execution = execution;
|
||||||
await plugin.resume(pending);
|
await plugin.resume(pending);
|
||||||
|
|
||||||
@ -196,11 +199,11 @@ describe('workflow > Processor', () => {
|
|||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
|
expect(execution.status).toEqual(EXECUTION_STATUS.ERROR);
|
||||||
|
|
||||||
const jobs = await execution.getJobs();
|
const jobs = await execution.getJobs();
|
||||||
expect(jobs.length).toEqual(1);
|
expect(jobs.length).toEqual(1);
|
||||||
expect(jobs[0].status).toEqual(JOB_STATUS.REJECTED);
|
expect(jobs[0].status).toEqual(JOB_STATUS.ERROR);
|
||||||
expect(jobs[0].result.message).toEqual('input failed');
|
expect(jobs[0].result.message).toEqual('input failed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -245,7 +248,7 @@ describe('workflow > Processor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const n2 = await workflow.createNode({
|
const n2 = await workflow.createNode({
|
||||||
type: 'prompt',
|
type: 'manual',
|
||||||
branchIndex: BRANCH_INDEX.ON_TRUE,
|
branchIndex: BRANCH_INDEX.ON_TRUE,
|
||||||
upstreamId: n1.id
|
upstreamId: n1.id
|
||||||
});
|
});
|
||||||
@ -265,7 +268,10 @@ describe('workflow > Processor', () => {
|
|||||||
expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
|
expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
|
||||||
|
|
||||||
const [pending] = await execution.getJobs({ where: { nodeId: n2.id } });
|
const [pending] = await execution.getJobs({ where: { nodeId: n2.id } });
|
||||||
pending.set('result', 123);
|
pending.set({
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: 123
|
||||||
|
});
|
||||||
pending.execution = execution;
|
pending.execution = execution;
|
||||||
await plugin.resume(pending);
|
await plugin.resume(pending);
|
||||||
|
|
||||||
@ -275,7 +281,7 @@ describe('workflow > Processor', () => {
|
|||||||
expect(jobs.length).toEqual(3);
|
expect(jobs.length).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resume error downstream in condition branch, should reject', async () => {
|
it('resume error downstream in condition branch, should error', async () => {
|
||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
// no config means always true
|
// no config means always true
|
||||||
@ -308,7 +314,7 @@ describe('workflow > Processor', () => {
|
|||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
|
expect(execution.status).toEqual(EXECUTION_STATUS.ERROR);
|
||||||
|
|
||||||
const jobs = await execution.getJobs();
|
const jobs = await execution.getJobs();
|
||||||
expect(jobs.length).toEqual(2);
|
expect(jobs.length).toEqual(2);
|
||||||
@ -328,7 +334,7 @@ describe('workflow > Processor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const n3 = await workflow.createNode({
|
const n3 = await workflow.createNode({
|
||||||
type: 'prompt',
|
type: 'manual',
|
||||||
upstreamId: n2.id,
|
upstreamId: n2.id,
|
||||||
branchIndex: 0
|
branchIndex: 0
|
||||||
});
|
});
|
||||||
@ -359,7 +365,10 @@ describe('workflow > Processor', () => {
|
|||||||
expect(pendingJobs.length).toBe(4);
|
expect(pendingJobs.length).toBe(4);
|
||||||
|
|
||||||
const pending = pendingJobs.find(item => item.nodeId === n3.id );
|
const pending = pendingJobs.find(item => item.nodeId === n3.id );
|
||||||
pending.set('result', 123);
|
pending.set({
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: 123
|
||||||
|
});
|
||||||
pending.execution = execution;
|
pending.execution = execution;
|
||||||
await plugin.resume(pending);
|
await plugin.resume(pending);
|
||||||
|
|
||||||
@ -376,7 +385,7 @@ describe('workflow > Processor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const n2 = await workflow.createNode({
|
const n2 = await workflow.createNode({
|
||||||
type: 'prompt',
|
type: 'manual',
|
||||||
upstreamId: n1.id,
|
upstreamId: n1.id,
|
||||||
branchIndex: 0
|
branchIndex: 0
|
||||||
});
|
});
|
||||||
@ -413,7 +422,10 @@ describe('workflow > Processor', () => {
|
|||||||
expect(pendingJobs.length).toBe(4);
|
expect(pendingJobs.length).toBe(4);
|
||||||
|
|
||||||
const pending = pendingJobs.find(item => item.nodeId === n2.id );
|
const pending = pendingJobs.find(item => item.nodeId === n2.id );
|
||||||
pending.set('result', 123);
|
pending.set({
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: 123
|
||||||
|
});
|
||||||
pending.execution = e1;
|
pending.execution = e1;
|
||||||
await plugin.resume(pending);
|
await plugin.resume(pending);
|
||||||
|
|
||||||
|
@ -114,6 +114,74 @@ describe('workflow > actions > workflows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('cascading destroy all revisions, nodes, executions and jobs', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'collection',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'update',
|
||||||
|
config: {
|
||||||
|
collection: 'posts',
|
||||||
|
params: {
|
||||||
|
filter: {
|
||||||
|
id: '{{$context.data.id}}'
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
title: 't2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const { model: JobModel } = db.getCollection('jobs');
|
||||||
|
|
||||||
|
const e1c = await workflow.countExecutions();
|
||||||
|
expect(e1c).toBe(1);
|
||||||
|
const j1c = await JobModel.count();
|
||||||
|
expect(j1c).toBe(1);
|
||||||
|
const p1 = await PostRepo.findOne();
|
||||||
|
expect(p1.title).toBe('t2');
|
||||||
|
|
||||||
|
const { id, ...w1 } = workflow.get();
|
||||||
|
const w2 = await WorkflowModel.create(w1);
|
||||||
|
const { id: n1Id, ...n1Data } = n1.get();
|
||||||
|
const n2 = await w2.createNode(n1Data);
|
||||||
|
|
||||||
|
await agent.resource(`workflows`).destroy({
|
||||||
|
filterByTk: w2.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const w2c = await WorkflowModel.count();
|
||||||
|
expect(w2c).toBe(0);
|
||||||
|
const e2c = await workflow.countExecutions();
|
||||||
|
expect(e2c).toBe(0);
|
||||||
|
const n1c = await workflow.countNodes();
|
||||||
|
expect(n1c).toBe(0);
|
||||||
|
const n2c = await w2.countNodes();
|
||||||
|
expect(n2c).toBe(0);
|
||||||
|
const p2c = await PostRepo.count({ filter: { title: 't1' } });
|
||||||
|
expect(p2c).toBe(1);
|
||||||
|
|
||||||
|
const j2c = await JobModel.count();
|
||||||
|
expect(j2c).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('revision', () => {
|
describe('revision', () => {
|
||||||
it('create revision', async () => {
|
it('create revision', async () => {
|
||||||
const w1 = await WorkflowModel.create({
|
const w1 = await WorkflowModel.create({
|
||||||
@ -193,21 +261,8 @@ describe('workflow > actions > workflows', () => {
|
|||||||
const n2 = await w1.createNode({
|
const n2 = await w1.createNode({
|
||||||
type: 'calculation',
|
type: 'calculation',
|
||||||
config: {
|
config: {
|
||||||
calculation: {
|
engine: 'math.js',
|
||||||
calculator: 'add',
|
expression: `{{$jobsMapByNodeId.${n1.id}.data.read}} + {{$jobsMapByNodeId.${n1.id}.data.read}}`
|
||||||
operands: [
|
|
||||||
{
|
|
||||||
type: '$jobsMapByNodeId',
|
|
||||||
options: {
|
|
||||||
nodeId: n1.id,
|
|
||||||
path: 'data.read'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: `{{$jobsMapByNodeId.${n1.id}.data.read}}`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
upstreamId: n1.id
|
upstreamId: n1.id
|
||||||
});
|
});
|
||||||
@ -231,21 +286,8 @@ describe('workflow > actions > workflows', () => {
|
|||||||
expect(n1_2.type).toBe('echo');
|
expect(n1_2.type).toBe('echo');
|
||||||
expect(n2_2.type).toBe('calculation');
|
expect(n2_2.type).toBe('calculation');
|
||||||
expect(n2_2.config).toMatchObject({
|
expect(n2_2.config).toMatchObject({
|
||||||
calculation: {
|
engine: 'math.js',
|
||||||
calculator: 'add',
|
expression: `{{$jobsMapByNodeId.${n1_2.id}.data.read}} + {{$jobsMapByNodeId.${n1_2.id}.data.read}}`
|
||||||
operands: [
|
|
||||||
{
|
|
||||||
type: '$jobsMapByNodeId',
|
|
||||||
options: {
|
|
||||||
nodeId: n1_2.id,
|
|
||||||
path: 'data.read'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: `{{$jobsMapByNodeId.${n1_2.id}.data.read}}`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await w2.update({ enabled: true });
|
await w2.update({ enabled: true });
|
||||||
|
@ -45,17 +45,14 @@ export async function getApp({ manual, ...options }: MockAppOptions = {}): Promi
|
|||||||
},
|
},
|
||||||
resume(node, input, execution) {
|
resume(node, input, execution) {
|
||||||
throw new Error('input failed');
|
throw new Error('input failed');
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
functions: {
|
||||||
|
no1: () => 1
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!calculators.get('no1')) {
|
|
||||||
calculators.register('no1', () => 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
await app.db.clean({ drop: true });
|
|
||||||
|
|
||||||
await app.load();
|
await app.load();
|
||||||
|
|
||||||
await app.db.import({
|
await app.db.import({
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Application } from '@nocobase/server';
|
import { Application } from '@nocobase/server';
|
||||||
import Database from '@nocobase/database';
|
import Database from '@nocobase/database';
|
||||||
import { getApp, sleep } from '..';
|
import { getApp, sleep } from '..';
|
||||||
|
import { JOB_STATUS } from '../../constants';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -31,15 +32,32 @@ describe('workflow > instructions > calculation', () => {
|
|||||||
|
|
||||||
afterEach(() => db.close());
|
afterEach(() => db.close());
|
||||||
|
|
||||||
describe('operand types', () => {
|
describe('math.js', () => {
|
||||||
|
it('syntax error', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'calculation',
|
||||||
|
config: {
|
||||||
|
engine: 'math.js',
|
||||||
|
expression: '1 1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [execution] = await workflow.getExecutions();
|
||||||
|
const [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toBe(JOB_STATUS.ERROR);
|
||||||
|
expect(job.result.startsWith('SyntaxError: ')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('constant', async () => {
|
it('constant', async () => {
|
||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'calculation',
|
type: 'calculation',
|
||||||
config: {
|
config: {
|
||||||
calculation: {
|
engine: 'math.js',
|
||||||
calculator: 'add',
|
expression: ' 1 + 1 '
|
||||||
operands: [1, 1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -52,40 +70,12 @@ describe('workflow > instructions > calculation', () => {
|
|||||||
expect(job.result).toBe(2);
|
expect(job.result).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('constant (legacy)', async () => {
|
it('$context', async () => {
|
||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'calculation',
|
type: 'calculation',
|
||||||
config: {
|
config: {
|
||||||
calculation: {
|
engine: 'math.js',
|
||||||
calculator: 'add',
|
expression: '{{$context.data.read}} + 1',
|
||||||
operands: [
|
|
||||||
{ value: 1 },
|
|
||||||
{ value: 1 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
|
||||||
const [job] = await execution.getJobs();
|
|
||||||
expect(job.result).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('context (legacy)', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'calculation',
|
|
||||||
config: {
|
|
||||||
calculation: {
|
|
||||||
calculator: 'add',
|
|
||||||
operands: [
|
|
||||||
{ type: '$context', options: { type: 'data', path: 'read' } },
|
|
||||||
{ type: '$context', options: { path: 'data.read' } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -98,27 +88,7 @@ describe('workflow > instructions > calculation', () => {
|
|||||||
expect(job.result).toBe(2);
|
expect(job.result).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('context by json-template', async () => {
|
it('$jobsMapByNodeId', async () => {
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'calculation',
|
|
||||||
config: {
|
|
||||||
calculation: {
|
|
||||||
calculator: 'add',
|
|
||||||
operands: [1, '{{$context.data.read}}']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
|
||||||
const [job] = await execution.getJobs();
|
|
||||||
expect(job.result).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('job result (legacy)', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'echo'
|
type: 'echo'
|
||||||
});
|
});
|
||||||
@ -126,13 +96,8 @@ describe('workflow > instructions > calculation', () => {
|
|||||||
const n2 = await workflow.createNode({
|
const n2 = await workflow.createNode({
|
||||||
type: 'calculation',
|
type: 'calculation',
|
||||||
config: {
|
config: {
|
||||||
calculation: {
|
engine: 'math.js',
|
||||||
calculator: 'add',
|
expression: `{{$jobsMapByNodeId.${n1.id}.data.read}} + 1`,
|
||||||
operands: [
|
|
||||||
{ value: 1 },
|
|
||||||
{ type: '$jobsMapByNodeId', options: { nodeId: n1.id, path: 'data.read' } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
upstreamId: n1.id
|
upstreamId: n1.id
|
||||||
});
|
});
|
||||||
@ -148,44 +113,12 @@ describe('workflow > instructions > calculation', () => {
|
|||||||
expect(n2Job.result).toBe(1);
|
expect(n2Job.result).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('job result by json-template', async () => {
|
it('$system', async () => {
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'echo'
|
|
||||||
});
|
|
||||||
|
|
||||||
const n2 = await workflow.createNode({
|
|
||||||
type: 'calculation',
|
|
||||||
config: {
|
|
||||||
calculation: {
|
|
||||||
calculator: 'add',
|
|
||||||
operands: [1, `{{$jobsMapByNodeId.${n1.id}.data.read}}`]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
upstreamId: n1.id
|
|
||||||
});
|
|
||||||
|
|
||||||
await n1.setDownstream(n2);
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
|
||||||
const [n1Job, n2Job] = await execution.getJobs({ order: [['id', 'ASC']]});
|
|
||||||
expect(n2Job.result).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('function', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'calculation',
|
type: 'calculation',
|
||||||
config: {
|
config: {
|
||||||
calculation: {
|
engine: 'math.js',
|
||||||
calculator: 'add',
|
expression: '1 + {{$system.no1}}',
|
||||||
operands: [
|
|
||||||
{ value: 1 },
|
|
||||||
{ value: '{{$fn.no1}}' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -199,24 +132,13 @@ describe('workflow > instructions > calculation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('nested operands', () => {
|
describe('formula.js', () => {
|
||||||
it('1 + ( 0 - 2 )', async () => {
|
it('string variable with quote should be wrong result', async () => {
|
||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'calculation',
|
type: 'calculation',
|
||||||
config: {
|
config: {
|
||||||
calculation: {
|
engine: 'formula.js',
|
||||||
calculator: 'add',
|
expression: `CONCATENATE('a', '{{$context.data.title}}')`,
|
||||||
operands: [
|
|
||||||
{ value: 1 },
|
|
||||||
{
|
|
||||||
type: '$calculation',
|
|
||||||
options: {
|
|
||||||
calculator: 'minus',
|
|
||||||
operands: ['{{$context.data.read}}', 2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -226,7 +148,25 @@ describe('workflow > instructions > calculation', () => {
|
|||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
const [execution] = await workflow.getExecutions();
|
||||||
const [job] = await execution.getJobs();
|
const [job] = await execution.getJobs();
|
||||||
expect(job.result).toBe(-1);
|
expect(job.result).toBe('a $context.data.title ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'calculation',
|
||||||
|
config: {
|
||||||
|
engine: 'formula.js',
|
||||||
|
expression: `CONCATENATE('a', {{$context.data.title}})`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [execution] = await workflow.getExecutions();
|
||||||
|
const [job] = await execution.getJobs();
|
||||||
|
expect(job.result).toBe('at1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -43,11 +43,8 @@ describe('workflow > instructions > condition', () => {
|
|||||||
title: 'condition',
|
title: 'condition',
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
config: {
|
config: {
|
||||||
// (1 === 1): true
|
engine: 'math.js',
|
||||||
calculation: {
|
calculation: '1 == 1'
|
||||||
calculator: 'equal',
|
|
||||||
operands: [{ value: 1 }, { value: 1 }]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -82,11 +79,9 @@ describe('workflow > instructions > condition', () => {
|
|||||||
title: 'condition',
|
title: 'condition',
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
config: {
|
config: {
|
||||||
// (0 === 1): false
|
engine: 'math.js',
|
||||||
calculation: {
|
// false
|
||||||
calculator: 'equal',
|
calculation: '0 == 1'
|
||||||
operands: [{ value: 0 }, { value: 1 }]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -115,10 +110,6 @@ describe('workflow > instructions > condition', () => {
|
|||||||
expect(jobs.length).toEqual(2);
|
expect(jobs.length).toEqual(2);
|
||||||
expect(jobs[1].result).toEqual(false);
|
expect(jobs[1].result).toEqual(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('not', async () => {
|
|
||||||
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('group calculation', () => {
|
describe('group calculation', () => {
|
||||||
@ -126,18 +117,13 @@ describe('workflow > instructions > condition', () => {
|
|||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
config: {
|
config: {
|
||||||
|
engine: 'math.js',
|
||||||
calculation: {
|
calculation: {
|
||||||
group: {
|
group: {
|
||||||
type: 'and',
|
type: 'and',
|
||||||
calculations: [
|
calculations: [
|
||||||
{
|
'1 == 1',
|
||||||
calculator: 'equal',
|
'1 == 1',
|
||||||
operands: [{ value: 1 }, { value: 1 }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
calculator: 'equal',
|
|
||||||
operands: [{ value: 1 }, { value: 1 }]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,18 +143,13 @@ describe('workflow > instructions > condition', () => {
|
|||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
config: {
|
config: {
|
||||||
|
engine: 'math.js',
|
||||||
calculation: {
|
calculation: {
|
||||||
group: {
|
group: {
|
||||||
type: 'and',
|
type: 'and',
|
||||||
calculations: [
|
calculations: [
|
||||||
{
|
'1 == 1',
|
||||||
calculator: 'equal',
|
'0 == 1',
|
||||||
operands: [{ value: 1 }, { value: 1 }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
calculator: 'equal',
|
|
||||||
operands: [{ value: 0 }, { value: 1 }]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -188,18 +169,13 @@ describe('workflow > instructions > condition', () => {
|
|||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
config: {
|
config: {
|
||||||
|
engine: 'math.js',
|
||||||
calculation: {
|
calculation: {
|
||||||
group: {
|
group: {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
calculations: [
|
calculations: [
|
||||||
{
|
'1 == 1',
|
||||||
calculator: 'equal',
|
'0 == 1',
|
||||||
operands: [{ value: 1 }, { value: 1 }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
calculator: 'equal',
|
|
||||||
operands: [{ value: 0 }, { value: 1 }]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,18 +195,13 @@ describe('workflow > instructions > condition', () => {
|
|||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
config: {
|
config: {
|
||||||
|
engine: 'math.js',
|
||||||
calculation: {
|
calculation: {
|
||||||
group: {
|
group: {
|
||||||
type: 'and',
|
type: 'and',
|
||||||
calculations: [
|
calculations: [
|
||||||
{
|
'0 == 1',
|
||||||
calculator: 'equal',
|
'0 == 1',
|
||||||
operands: [{ value: 0 }, { value: 1 }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
calculator: 'equal',
|
|
||||||
operands: [{ value: 0 }, { value: 1 }]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -250,20 +221,18 @@ describe('workflow > instructions > condition', () => {
|
|||||||
const n1 = await workflow.createNode({
|
const n1 = await workflow.createNode({
|
||||||
type: 'condition',
|
type: 'condition',
|
||||||
config: {
|
config: {
|
||||||
|
engine: 'math.js',
|
||||||
calculation: {
|
calculation: {
|
||||||
group: {
|
group: {
|
||||||
type: 'and',
|
type: 'and',
|
||||||
calculations: [
|
calculations: [
|
||||||
{
|
'1 == 1',
|
||||||
calculator: 'equal',
|
|
||||||
operands: [{ value: 1 }, { value: 1 }]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
group: {
|
group: {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
calculations: [
|
calculations: [
|
||||||
{ calculator: 'equal', operands: [{ value: 0 }, { value: 1 }] },
|
'0 == 1',
|
||||||
{ calculator: 'equal', operands: [{ value: 0 }, { value: 1 }] }
|
'0 == 1',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -282,4 +251,95 @@ describe('workflow > instructions > condition', () => {
|
|||||||
expect(job.result).toBe(false);
|
expect(job.result).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('engines', () => {
|
||||||
|
it('default as basic', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
title: 'condition',
|
||||||
|
type: 'condition',
|
||||||
|
config: {
|
||||||
|
calculation: {
|
||||||
|
calculator: 'equal',
|
||||||
|
operands: [1, '{{$context.data.read}}']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { read: 1 } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [execution] = await workflow.getExecutions();
|
||||||
|
expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
|
||||||
|
|
||||||
|
const [job] = await execution.getJobs();
|
||||||
|
expect(job.result).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('basic engine', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
title: 'condition',
|
||||||
|
type: 'condition',
|
||||||
|
config: {
|
||||||
|
engine: 'basic',
|
||||||
|
calculation: {
|
||||||
|
calculator: 'equal',
|
||||||
|
operands: [1, '{{$context.data.read}}']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { read: 1 } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [execution] = await workflow.getExecutions();
|
||||||
|
expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
|
||||||
|
|
||||||
|
const [job] = await execution.getJobs();
|
||||||
|
expect(job.result).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('math.js', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
title: 'condition',
|
||||||
|
type: 'condition',
|
||||||
|
config: {
|
||||||
|
engine: 'math.js',
|
||||||
|
calculation: '1 == 1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [execution] = await workflow.getExecutions();
|
||||||
|
expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
|
||||||
|
|
||||||
|
const [job] = await execution.getJobs();
|
||||||
|
expect(job.result).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formula.js', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
title: 'condition',
|
||||||
|
type: 'condition',
|
||||||
|
config: {
|
||||||
|
engine: 'formula.js',
|
||||||
|
calculation: '1 == 1'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [execution] = await workflow.getExecutions();
|
||||||
|
expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
|
||||||
|
|
||||||
|
const [job] = await execution.getJobs();
|
||||||
|
expect(job.result).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -62,8 +62,8 @@ describe('workflow > instructions > delay', () => {
|
|||||||
type: 'delay',
|
type: 'delay',
|
||||||
config: {
|
config: {
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
endStatus: JOB_STATUS.REJECTED,
|
endStatus: JOB_STATUS.FAILED
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
@ -78,9 +78,9 @@ describe('workflow > instructions > delay', () => {
|
|||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
const [e2] = await workflow.getExecutions();
|
const [e2] = await workflow.getExecutions();
|
||||||
expect(e2.status).toEqual(EXECUTION_STATUS.REJECTED);
|
expect(e2.status).toEqual(EXECUTION_STATUS.FAILED);
|
||||||
const [j2] = await e2.getJobs();
|
const [j2] = await e2.getJobs();
|
||||||
expect(j2.status).toBe(JOB_STATUS.REJECTED);
|
expect(j2.status).toBe(JOB_STATUS.FAILED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delay to resolve and rollback in downstream node', async () => {
|
it('delay to resolve and rollback in downstream node', async () => {
|
||||||
@ -117,10 +117,10 @@ describe('workflow > instructions > delay', () => {
|
|||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
|
|
||||||
const [e2] = await workflow.getExecutions();
|
const [e2] = await workflow.getExecutions();
|
||||||
expect(e2.status).toEqual(EXECUTION_STATUS.REJECTED);
|
expect(e2.status).toEqual(EXECUTION_STATUS.ERROR);
|
||||||
const [j2, j3] = await e2.getJobs({ order: [['id', 'ASC']] });
|
const [j2, j3] = await e2.getJobs({ order: [['id', 'ASC']] });
|
||||||
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
||||||
expect(j3.status).toBe(JOB_STATUS.REJECTED);
|
expect(j3.status).toBe(JOB_STATUS.ERROR);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -0,0 +1,588 @@
|
|||||||
|
import Database from '@nocobase/database';
|
||||||
|
import UserPlugin from '@nocobase/plugin-users';
|
||||||
|
import { MockServer } from '@nocobase/test';
|
||||||
|
import { getApp, sleep } from '..';
|
||||||
|
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// NOTE: skipped because time is not stable on github ci, but should work in local
|
||||||
|
describe('workflow > instructions > manual', () => {
|
||||||
|
let app: MockServer;
|
||||||
|
let agent;
|
||||||
|
let userAgents;
|
||||||
|
let db: Database;
|
||||||
|
let PostRepo;
|
||||||
|
let WorkflowModel;
|
||||||
|
let workflow;
|
||||||
|
let UserModel;
|
||||||
|
let users;
|
||||||
|
let UserJobModel;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await getApp({
|
||||||
|
plugins: [
|
||||||
|
'users'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
agent = app.agent();
|
||||||
|
db = app.db;
|
||||||
|
WorkflowModel = db.getCollection('workflows').model;
|
||||||
|
PostRepo = db.getCollection('posts').repository;
|
||||||
|
UserModel = db.getCollection('users').model;
|
||||||
|
UserJobModel = db.getModel('users_jobs');
|
||||||
|
|
||||||
|
users = await UserModel.bulkCreate([
|
||||||
|
{ id: 1, nickname: 'a' },
|
||||||
|
{ id: 2, nickname: 'b' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userPlugin = app.getPlugin('users') as UserPlugin;
|
||||||
|
userAgents = users.map((user) => app.agent().auth(userPlugin.jwtService.sign({
|
||||||
|
userId: user.id,
|
||||||
|
}), { type: 'bearer' }));
|
||||||
|
|
||||||
|
workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'collection',
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => db.close());
|
||||||
|
|
||||||
|
describe('mode: 0 (single record)', () => {
|
||||||
|
it('the only user assigned could submit', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id],
|
||||||
|
actions: [JOB_STATUS.RESOLVED]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [pending] = await workflow.getExecutions();
|
||||||
|
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
|
||||||
|
const [j1] = await pending.getJobs();
|
||||||
|
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
const usersJobs = await UserJobModel.findAll();
|
||||||
|
expect(usersJobs.length).toBe(1);
|
||||||
|
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
|
||||||
|
expect(usersJobs[0].userId).toBe(users[0].id);
|
||||||
|
expect(usersJobs[0].jobId).toBe(j1.id);
|
||||||
|
|
||||||
|
const res1 = await agent.resource('users_jobs').submit({
|
||||||
|
filterByTk: usersJobs[0].id,
|
||||||
|
values: { status: JOB_STATUS.RESOLVED }
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(401);
|
||||||
|
|
||||||
|
const res2 = await userAgents[1].resource('users_jobs').submit({
|
||||||
|
filterByTk: usersJobs[0].id,
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(403);
|
||||||
|
|
||||||
|
const res3 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: usersJobs[0].id,
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: { a: 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res3.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [j2] = await pending.getJobs();
|
||||||
|
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
||||||
|
expect(j2.result).toEqual({ a: 1 });
|
||||||
|
|
||||||
|
const usersJobsAfter = await UserJobModel.findAll();
|
||||||
|
expect(usersJobsAfter.length).toBe(1);
|
||||||
|
expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED);
|
||||||
|
expect(usersJobsAfter[0].result).toEqual({ a: 1 });
|
||||||
|
|
||||||
|
const res4 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: usersJobs[0].id,
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: { a: 2 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res4.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('any user assigned could submit', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id, users[1].id],
|
||||||
|
actions: [JOB_STATUS.RESOLVED],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const [pending] = await workflow.getExecutions();
|
||||||
|
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
|
||||||
|
const [j1] = await pending.getJobs();
|
||||||
|
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
||||||
|
|
||||||
|
const usersJobs = await j1.getUsersJobs();
|
||||||
|
|
||||||
|
const res1 = await userAgents[1].resource('users_jobs').submit({
|
||||||
|
filterByTk: usersJobs.find(item => item.userId === users[1].id).id,
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: { a: 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [j2] = await pending.getJobs();
|
||||||
|
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
||||||
|
expect(j2.result).toEqual({ a: 1 });
|
||||||
|
|
||||||
|
const res2 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: usersJobs.find(item => item.userId === users[0].id).id,
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: { a: 2 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also could submit to users_jobs api', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id],
|
||||||
|
actions: [JOB_STATUS.RESOLVED]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const UserJobModel = db.getModel('users_jobs');
|
||||||
|
const usersJobs = await UserJobModel.findAll();
|
||||||
|
expect(usersJobs.length).toBe(1);
|
||||||
|
expect(usersJobs[0].get('status')).toBe(JOB_STATUS.PENDING);
|
||||||
|
expect(usersJobs[0].get('userId')).toBe(users[0].id);
|
||||||
|
|
||||||
|
const res = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: usersJobs[0].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: { a: 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [execution] = await workflow.getExecutions();
|
||||||
|
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
const [job] = await execution.getJobs();
|
||||||
|
expect(job.status).toBe(JOB_STATUS.RESOLVED);
|
||||||
|
expect(job.result).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mode: 1 (multiple record, all)', () => {
|
||||||
|
it('all resolved', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id, users[1].id],
|
||||||
|
mode: 1,
|
||||||
|
actions: [JOB_STATUS.RESOLVED]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const UserJobModel = db.getModel('users_jobs');
|
||||||
|
const pendingJobs = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(pendingJobs.length).toBe(2);
|
||||||
|
|
||||||
|
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[0].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: { a: 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e1] = await workflow.getExecutions();
|
||||||
|
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
||||||
|
const [j1] = await e1.getJobs();
|
||||||
|
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
||||||
|
expect(j1.result).toBe(0.5);
|
||||||
|
const usersJobs1 = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(usersJobs1.length).toBe(2);
|
||||||
|
|
||||||
|
const res2 = await userAgents[1].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[1].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: { a: 2 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e2] = await workflow.getExecutions();
|
||||||
|
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
const [j2] = await e2.getJobs();
|
||||||
|
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
||||||
|
expect(j2.result).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first rejected', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id, users[1].id],
|
||||||
|
mode: 1,
|
||||||
|
actions: [JOB_STATUS.REJECTED]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const UserJobModel = db.getModel('users_jobs');
|
||||||
|
const pendingJobs = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(pendingJobs.length).toBe(2);
|
||||||
|
|
||||||
|
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[0].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.REJECTED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e1] = await workflow.getExecutions();
|
||||||
|
expect(e1.status).toBe(EXECUTION_STATUS.REJECTED);
|
||||||
|
const [j1] = await e1.getJobs();
|
||||||
|
expect(j1.status).toBe(JOB_STATUS.REJECTED);
|
||||||
|
expect(j1.result).toBe(0.5);
|
||||||
|
const usersJobs1 = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(usersJobs1.length).toBe(2);
|
||||||
|
|
||||||
|
const res2 = await userAgents[1].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[1].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.REJECTED,
|
||||||
|
result: { a: 2 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('last rejected', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id, users[1].id],
|
||||||
|
mode: 1,
|
||||||
|
actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const UserJobModel = db.getModel('users_jobs');
|
||||||
|
const pendingJobs = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(pendingJobs.length).toBe(2);
|
||||||
|
|
||||||
|
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[0].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e1] = await workflow.getExecutions();
|
||||||
|
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
||||||
|
const [j1] = await e1.getJobs();
|
||||||
|
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
||||||
|
expect(j1.result).toBe(0.5);
|
||||||
|
const usersJobs1 = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(usersJobs1.length).toBe(2);
|
||||||
|
|
||||||
|
const res2 = await userAgents[1].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[1].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.REJECTED,
|
||||||
|
result: { a: 2 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e2] = await workflow.getExecutions();
|
||||||
|
expect(e2.status).toBe(EXECUTION_STATUS.REJECTED);
|
||||||
|
const [j2] = await e2.getJobs();
|
||||||
|
expect(j2.status).toBe(JOB_STATUS.REJECTED);
|
||||||
|
expect(j2.result).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mode: -1 (multiple record, any)', () => {
|
||||||
|
it('first resolved', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id, users[1].id],
|
||||||
|
mode: -1,
|
||||||
|
actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const UserJobModel = db.getModel('users_jobs');
|
||||||
|
const pendingJobs = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(pendingJobs.length).toBe(2);
|
||||||
|
|
||||||
|
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[0].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e1] = await workflow.getExecutions();
|
||||||
|
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
const [j1] = await e1.getJobs();
|
||||||
|
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
|
||||||
|
expect(j1.result).toBe(0.5);
|
||||||
|
|
||||||
|
const res2 = await userAgents[1].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[1].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.REJECTED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('any resolved', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id, users[1].id],
|
||||||
|
mode: -1,
|
||||||
|
actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const UserJobModel = db.getModel('users_jobs');
|
||||||
|
const pendingJobs = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(pendingJobs.length).toBe(2);
|
||||||
|
|
||||||
|
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[0].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.REJECTED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e1] = await workflow.getExecutions();
|
||||||
|
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
||||||
|
const [j1] = await e1.getJobs();
|
||||||
|
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
||||||
|
expect(j1.result).toBe(0.5);
|
||||||
|
|
||||||
|
const res2 = await userAgents[1].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[1].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e2] = await workflow.getExecutions();
|
||||||
|
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
const [j2] = await e2.getJobs();
|
||||||
|
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
||||||
|
expect(j2.result).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all rejected', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id, users[1].id],
|
||||||
|
mode: -1,
|
||||||
|
actions: [JOB_STATUS.REJECTED]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const UserJobModel = db.getModel('users_jobs');
|
||||||
|
const pendingJobs = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(pendingJobs.length).toBe(2);
|
||||||
|
|
||||||
|
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[0].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.REJECTED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e1] = await workflow.getExecutions();
|
||||||
|
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
||||||
|
const [j1] = await e1.getJobs();
|
||||||
|
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
||||||
|
expect(j1.result).toBe(0.5);
|
||||||
|
|
||||||
|
const res2 = await userAgents[1].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[1].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.REJECTED
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e2] = await workflow.getExecutions();
|
||||||
|
expect(e2.status).toBe(EXECUTION_STATUS.REJECTED);
|
||||||
|
const [j2] = await e2.getJobs();
|
||||||
|
expect(j2.status).toBe(JOB_STATUS.REJECTED);
|
||||||
|
expect(j2.result).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mode: (0,1) (multiple record, all to percent)', () => {
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mode: (-1,0) (multiple record, any to percent)', () => {
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('use result of submitted form in manual node', () => {
|
||||||
|
it('result should be available and correct', async () => {
|
||||||
|
const n1 = await workflow.createNode({
|
||||||
|
type: 'manual',
|
||||||
|
config: {
|
||||||
|
assignees: [users[0].id, users[1].id],
|
||||||
|
actions: [JOB_STATUS.RESOLVED],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const n2 = await workflow.createNode({
|
||||||
|
type: 'calculation',
|
||||||
|
config: {
|
||||||
|
engine: 'math.js',
|
||||||
|
expression: `{{$jobsMapByNodeId.${n1.id}.number}} + 1`
|
||||||
|
},
|
||||||
|
upstreamId: n1.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await n1.setDownstream(n2);
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const UserJobModel = db.getModel('users_jobs');
|
||||||
|
const pendingJobs = await UserJobModel.findAll({
|
||||||
|
order: [[ 'userId', 'ASC' ]]
|
||||||
|
});
|
||||||
|
expect(pendingJobs.length).toBe(2);
|
||||||
|
|
||||||
|
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||||
|
filterByTk: pendingJobs[0].get('id'),
|
||||||
|
values: {
|
||||||
|
status: JOB_STATUS.RESOLVED,
|
||||||
|
result: { number: 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(202);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const [e2] = await workflow.getExecutions();
|
||||||
|
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
const [j1, j2] = await e2.getJobs({ order: [['createdAt', 'ASC']] });
|
||||||
|
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
||||||
|
expect(j2.result).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,7 +1,7 @@
|
|||||||
import Database from '@nocobase/database';
|
import Database from '@nocobase/database';
|
||||||
import { Application } from '@nocobase/server';
|
import { Application } from '@nocobase/server';
|
||||||
import { getApp, sleep } from '..';
|
import { getApp, sleep } from '..';
|
||||||
import { EXECUTION_STATUS } from '../../constants';
|
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -79,7 +79,7 @@ describe('workflow > instructions > parallel', () => {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
const [execution] = await workflow.getExecutions();
|
||||||
expect(execution.status).toBe(EXECUTION_STATUS.REJECTED);
|
expect(execution.status).toBe(EXECUTION_STATUS.ERROR);
|
||||||
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
||||||
expect(jobs.length).toBe(3);
|
expect(jobs.length).toBe(3);
|
||||||
});
|
});
|
||||||
@ -106,7 +106,7 @@ describe('workflow > instructions > parallel', () => {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
const [execution] = await workflow.getExecutions();
|
||||||
expect(execution.status).toBe(EXECUTION_STATUS.REJECTED);
|
expect(execution.status).toBe(EXECUTION_STATUS.ERROR);
|
||||||
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
||||||
expect(jobs.length).toBe(2);
|
expect(jobs.length).toBe(2);
|
||||||
});
|
});
|
||||||
@ -192,7 +192,7 @@ describe('workflow > instructions > parallel', () => {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
const [execution] = await workflow.getExecutions();
|
||||||
expect(execution.status).toBe(EXECUTION_STATUS.REJECTED);
|
expect(execution.status).toBe(EXECUTION_STATUS.FAILED);
|
||||||
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
||||||
expect(jobs.length).toBe(3);
|
expect(jobs.length).toBe(3);
|
||||||
});
|
});
|
||||||
@ -250,7 +250,7 @@ describe('workflow > instructions > parallel', () => {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
const [execution] = await workflow.getExecutions();
|
||||||
expect(execution.status).toBe(EXECUTION_STATUS.REJECTED);
|
expect(execution.status).toBe(EXECUTION_STATUS.ERROR);
|
||||||
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
||||||
expect(jobs.length).toBe(2);
|
expect(jobs.length).toBe(2);
|
||||||
});
|
});
|
||||||
@ -359,8 +359,8 @@ describe('workflow > instructions > parallel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const n2 = await workflow.createNode({
|
const n2 = await workflow.createNode({
|
||||||
title: 'prompt',
|
title: 'manual',
|
||||||
type: 'prompt',
|
type: 'manual',
|
||||||
upstreamId: n1.id,
|
upstreamId: n1.id,
|
||||||
branchIndex: 0
|
branchIndex: 0
|
||||||
});
|
});
|
||||||
@ -384,18 +384,22 @@ describe('workflow > instructions > parallel', () => {
|
|||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
const [e1] = await workflow.getExecutions();
|
||||||
expect(execution.status).toBe(EXECUTION_STATUS.STARTED);
|
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
||||||
|
|
||||||
const [pending] = await execution.getJobs({ where: { nodeId: n2.id } });
|
const [pending] = await e1.getJobs({ where: { nodeId: n2.id } });
|
||||||
pending.set('result', 123);
|
pending.set({
|
||||||
pending.execution = execution;
|
status: JOB_STATUS.RESOLVED,
|
||||||
await plugin.resume(pending);
|
result: 123
|
||||||
|
});
|
||||||
|
pending.execution = e1;
|
||||||
|
plugin.resume(pending);
|
||||||
|
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
const [e2] = await workflow.getExecutions();
|
||||||
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
const jobs = await e2.getJobs({ order: [['id', 'ASC']] });
|
||||||
expect(jobs.length).toBe(4);
|
expect(jobs.length).toBe(4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,594 +0,0 @@
|
|||||||
import Database from '@nocobase/database';
|
|
||||||
import UserPlugin from '@nocobase/plugin-users';
|
|
||||||
import { MockServer } from '@nocobase/test';
|
|
||||||
import { getApp, sleep } from '..';
|
|
||||||
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// NOTE: skipped because time is not stable on github ci, but should work in local
|
|
||||||
describe.skip('workflow > instructions > prompt', () => {
|
|
||||||
describe('base', () => {
|
|
||||||
let app: MockServer;
|
|
||||||
let agent;
|
|
||||||
let db: Database;
|
|
||||||
let PostRepo;
|
|
||||||
let WorkflowModel;
|
|
||||||
let workflow;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
app = await getApp();
|
|
||||||
agent = app.agent();
|
|
||||||
db = app.db;
|
|
||||||
WorkflowModel = db.getCollection('workflows').model;
|
|
||||||
PostRepo = db.getCollection('posts').repository;
|
|
||||||
|
|
||||||
workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'collection',
|
|
||||||
config: {
|
|
||||||
mode: 1,
|
|
||||||
collection: 'posts'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => db.close());
|
|
||||||
|
|
||||||
it('resume to resolve', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
actions: {
|
|
||||||
[JOB_STATUS.RESOLVED]: 'submit'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const [pending] = await workflow.getExecutions();
|
|
||||||
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
|
|
||||||
const [j1] = await pending.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
|
||||||
|
|
||||||
const { status } = await agent.resource('jobs').submit({
|
|
||||||
filterByTk: j1.id,
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED,
|
|
||||||
result: { a: 1 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(status).toBe(202);
|
|
||||||
|
|
||||||
// NOTE: wait for no await execution
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [resolved] = await workflow.getExecutions();
|
|
||||||
expect(resolved.status).toBe(EXECUTION_STATUS.RESOLVED);
|
|
||||||
const [j2] = await resolved.getJobs();
|
|
||||||
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
|
||||||
expect(j2.result).toEqual({ a: 1 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('assignees', () => {
|
|
||||||
let app: MockServer;
|
|
||||||
let agent;
|
|
||||||
let userAgents;
|
|
||||||
let db: Database;
|
|
||||||
let PostRepo;
|
|
||||||
let WorkflowModel;
|
|
||||||
let workflow;
|
|
||||||
let UserModel;
|
|
||||||
let users;
|
|
||||||
let UserJobModel;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
app = await getApp({
|
|
||||||
plugins: [
|
|
||||||
'users'
|
|
||||||
]
|
|
||||||
});
|
|
||||||
agent = app.agent();
|
|
||||||
db = app.db;
|
|
||||||
WorkflowModel = db.getCollection('workflows').model;
|
|
||||||
PostRepo = db.getCollection('posts').repository;
|
|
||||||
UserModel = db.getCollection('users').model;
|
|
||||||
UserJobModel = db.getModel('users_jobs');
|
|
||||||
|
|
||||||
users = await UserModel.bulkCreate([
|
|
||||||
{ id: 1, nickname: 'a' },
|
|
||||||
{ id: 2, nickname: 'b' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UserPlugin;
|
|
||||||
userAgents = users.map((user) => app.agent().auth(userPlugin.jwtService.sign({
|
|
||||||
userId: user.id,
|
|
||||||
}), { type: 'bearer' }));
|
|
||||||
|
|
||||||
workflow = await WorkflowModel.create({
|
|
||||||
enabled: true,
|
|
||||||
type: 'collection',
|
|
||||||
config: {
|
|
||||||
mode: 1,
|
|
||||||
collection: 'posts'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => db.close());
|
|
||||||
|
|
||||||
describe('mode: 0 (single record)', () => {
|
|
||||||
it('the only user assigned could submit', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const [pending] = await workflow.getExecutions();
|
|
||||||
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
|
|
||||||
const [j1] = await pending.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
|
||||||
|
|
||||||
const usersJobs = await UserJobModel.findAll();
|
|
||||||
expect(usersJobs.length).toBe(1);
|
|
||||||
expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING);
|
|
||||||
expect(usersJobs[0].userId).toBe(users[0].id);
|
|
||||||
expect(usersJobs[0].jobId).toBe(j1.id);
|
|
||||||
|
|
||||||
const res1 = await agent.resource('jobs').submit({
|
|
||||||
filterByTk: j1.id
|
|
||||||
});
|
|
||||||
expect(res1.status).toBe(401);
|
|
||||||
|
|
||||||
const res2 = await userAgents[1].resource('jobs').submit({
|
|
||||||
filterByTk: j1.id,
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res2.status).toBe(404);
|
|
||||||
|
|
||||||
const res3 = await userAgents[0].resource('jobs').submit({
|
|
||||||
filterByTk: j1.id,
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED,
|
|
||||||
result: { a: 1 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res3.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [j2] = await pending.getJobs();
|
|
||||||
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
|
||||||
expect(j2.result).toEqual({ a: 1 });
|
|
||||||
|
|
||||||
const usersJobsAfter = await UserJobModel.findAll();
|
|
||||||
expect(usersJobsAfter.length).toBe(1);
|
|
||||||
expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED);
|
|
||||||
expect(usersJobsAfter[0].result).toEqual({ a: 1 });
|
|
||||||
|
|
||||||
const res4 = await userAgents[0].resource('jobs').submit({
|
|
||||||
filterByTk: j1.id,
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED,
|
|
||||||
result: { a: 2 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res4.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('any user assigned could submit', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id, users[1].id]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const [pending] = await workflow.getExecutions();
|
|
||||||
expect(pending.status).toBe(EXECUTION_STATUS.STARTED);
|
|
||||||
const [j1] = await pending.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
|
||||||
|
|
||||||
const res1 = await userAgents[1].resource('jobs').submit({
|
|
||||||
filterByTk: j1.id,
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED,
|
|
||||||
result: { a: 1 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res1.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [j2] = await pending.getJobs();
|
|
||||||
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
|
||||||
expect(j2.result).toEqual({ a: 1 });
|
|
||||||
|
|
||||||
const res2 = await userAgents[0].resource('jobs').submit({
|
|
||||||
filterByTk: j1.id,
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED,
|
|
||||||
result: { a: 2 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res2.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('also could submit to users_jobs api', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const UserJobModel = db.getModel('users_jobs');
|
|
||||||
const usersJobs = await UserJobModel.findAll();
|
|
||||||
expect(usersJobs.length).toBe(1);
|
|
||||||
expect(usersJobs[0].get('status')).toBe(JOB_STATUS.PENDING);
|
|
||||||
expect(usersJobs[0].get('userId')).toBe(users[0].id);
|
|
||||||
|
|
||||||
const res = await userAgents[0].resource('users_jobs').submit({
|
|
||||||
filterByTk: usersJobs[0].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED,
|
|
||||||
result: { a: 1 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [execution] = await workflow.getExecutions();
|
|
||||||
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
|
||||||
const [job] = await execution.getJobs();
|
|
||||||
expect(job.status).toBe(JOB_STATUS.RESOLVED);
|
|
||||||
expect(job.result).toEqual({ a: 1 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mode: 1 (multiple record, all)', () => {
|
|
||||||
it('all resolved', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id, users[1].id],
|
|
||||||
mode: 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const UserJobModel = db.getModel('users_jobs');
|
|
||||||
const pendingJobs = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(pendingJobs.length).toBe(2);
|
|
||||||
|
|
||||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[0].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED,
|
|
||||||
result: { a: 1 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res1.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e1] = await workflow.getExecutions();
|
|
||||||
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
|
||||||
const [j1] = await e1.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
|
||||||
expect(j1.result).toBe(0.5);
|
|
||||||
const usersJobs1 = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(usersJobs1.length).toBe(2);
|
|
||||||
|
|
||||||
const res2 = await userAgents[1].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[1].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED,
|
|
||||||
result: { a: 2 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res2.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e2] = await workflow.getExecutions();
|
|
||||||
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
|
||||||
const [j2] = await e2.getJobs();
|
|
||||||
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
|
||||||
expect(j2.result).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('first rejected', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id, users[1].id],
|
|
||||||
mode: 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const UserJobModel = db.getModel('users_jobs');
|
|
||||||
const pendingJobs = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(pendingJobs.length).toBe(2);
|
|
||||||
|
|
||||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[0].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.REJECTED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res1.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e1] = await workflow.getExecutions();
|
|
||||||
expect(e1.status).toBe(EXECUTION_STATUS.REJECTED);
|
|
||||||
const [j1] = await e1.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.REJECTED);
|
|
||||||
expect(j1.result).toBe(0.5);
|
|
||||||
const usersJobs1 = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(usersJobs1.length).toBe(2);
|
|
||||||
|
|
||||||
const res2 = await userAgents[1].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[1].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.REJECTED,
|
|
||||||
result: { a: 2 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res2.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('last rejected', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id, users[1].id],
|
|
||||||
mode: 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const UserJobModel = db.getModel('users_jobs');
|
|
||||||
const pendingJobs = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(pendingJobs.length).toBe(2);
|
|
||||||
|
|
||||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[0].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res1.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e1] = await workflow.getExecutions();
|
|
||||||
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
|
||||||
const [j1] = await e1.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
|
||||||
expect(j1.result).toBe(0.5);
|
|
||||||
const usersJobs1 = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(usersJobs1.length).toBe(2);
|
|
||||||
|
|
||||||
const res2 = await userAgents[1].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[1].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.REJECTED,
|
|
||||||
result: { a: 2 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res2.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e2] = await workflow.getExecutions();
|
|
||||||
expect(e2.status).toBe(EXECUTION_STATUS.REJECTED);
|
|
||||||
const [j2] = await e2.getJobs();
|
|
||||||
expect(j2.status).toBe(JOB_STATUS.REJECTED);
|
|
||||||
expect(j2.result).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mode: -1 (multiple record, any)', () => {
|
|
||||||
it('first resolved', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id, users[1].id],
|
|
||||||
mode: -1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const UserJobModel = db.getModel('users_jobs');
|
|
||||||
const pendingJobs = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(pendingJobs.length).toBe(2);
|
|
||||||
|
|
||||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[0].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res1.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e1] = await workflow.getExecutions();
|
|
||||||
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
|
|
||||||
const [j1] = await e1.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
|
|
||||||
expect(j1.result).toBe(0.5);
|
|
||||||
|
|
||||||
const res2 = await userAgents[1].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[1].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.REJECTED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res2.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('any resolved', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id, users[1].id],
|
|
||||||
mode: -1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const UserJobModel = db.getModel('users_jobs');
|
|
||||||
const pendingJobs = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(pendingJobs.length).toBe(2);
|
|
||||||
|
|
||||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[0].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.REJECTED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res1.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e1] = await workflow.getExecutions();
|
|
||||||
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
|
||||||
const [j1] = await e1.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
|
||||||
expect(j1.result).toBe(0.5);
|
|
||||||
|
|
||||||
const res2 = await userAgents[1].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[1].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.RESOLVED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res2.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e2] = await workflow.getExecutions();
|
|
||||||
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
|
||||||
const [j2] = await e2.getJobs();
|
|
||||||
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
|
||||||
expect(j2.result).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all rejected', async () => {
|
|
||||||
const n1 = await workflow.createNode({
|
|
||||||
type: 'prompt',
|
|
||||||
config: {
|
|
||||||
assignees: [users[0].id, users[1].id],
|
|
||||||
mode: -1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
|
|
||||||
const UserJobModel = db.getModel('users_jobs');
|
|
||||||
const pendingJobs = await UserJobModel.findAll({
|
|
||||||
order: [[ 'userId', 'ASC' ]]
|
|
||||||
});
|
|
||||||
expect(pendingJobs.length).toBe(2);
|
|
||||||
|
|
||||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[0].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.REJECTED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res1.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e1] = await workflow.getExecutions();
|
|
||||||
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
|
||||||
const [j1] = await e1.getJobs();
|
|
||||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
|
||||||
expect(j1.result).toBe(0.5);
|
|
||||||
|
|
||||||
const res2 = await userAgents[1].resource('users_jobs').submit({
|
|
||||||
filterByTk: pendingJobs[1].get('id'),
|
|
||||||
values: {
|
|
||||||
status: JOB_STATUS.REJECTED
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(res2.status).toBe(202);
|
|
||||||
|
|
||||||
await sleep(1000);
|
|
||||||
|
|
||||||
const [e2] = await workflow.getExecutions();
|
|
||||||
expect(e2.status).toBe(EXECUTION_STATUS.REJECTED);
|
|
||||||
const [j2] = await e2.getJobs();
|
|
||||||
expect(j2.status).toBe(JOB_STATUS.REJECTED);
|
|
||||||
expect(j2.result).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mode: (0,1) (multiple record, all to percent)', () => {
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mode: (-1,0) (multiple record, any to percent)', () => {
|
|
||||||
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -2,7 +2,7 @@ import { Application } from '@nocobase/server';
|
|||||||
import Database from '@nocobase/database';
|
import Database from '@nocobase/database';
|
||||||
import { getApp, sleep } from '..';
|
import { getApp, sleep } from '..';
|
||||||
import { RequestConfig } from '../../instructions/request';
|
import { RequestConfig } from '../../instructions/request';
|
||||||
import { JOB_STATUS } from '../../constants';
|
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
|
||||||
|
|
||||||
const PORT = 12345;
|
const PORT = 12345;
|
||||||
|
|
||||||
@ -72,6 +72,7 @@ describe('workflow > instructions > request', () => {
|
|||||||
await sleep(500);
|
await sleep(500);
|
||||||
|
|
||||||
let [execution] = await workflow.getExecutions();
|
let [execution] = await workflow.getExecutions();
|
||||||
|
expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
|
||||||
let [job] = await execution.getJobs();
|
let [job] = await execution.getJobs();
|
||||||
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||||
});
|
});
|
||||||
@ -92,7 +93,7 @@ describe('workflow > instructions > request', () => {
|
|||||||
|
|
||||||
let [execution] = await workflow.getExecutions();
|
let [execution] = await workflow.getExecutions();
|
||||||
let [job] = await execution.getJobs();
|
let [job] = await execution.getJobs();
|
||||||
expect(job.status).toEqual(JOB_STATUS.REJECTED);
|
expect(job.status).toEqual(JOB_STATUS.FAILED);
|
||||||
|
|
||||||
expect(job.result).toMatchObject({
|
expect(job.result).toMatchObject({
|
||||||
code: 'ECONNABORTED',
|
code: 'ECONNABORTED',
|
||||||
@ -144,7 +145,7 @@ describe('workflow > instructions > request', () => {
|
|||||||
|
|
||||||
let [execution] = await workflow.getExecutions();
|
let [execution] = await workflow.getExecutions();
|
||||||
let [job] = await execution.getJobs();
|
let [job] = await execution.getJobs();
|
||||||
expect(job.status).toEqual(JOB_STATUS.REJECTED);
|
expect(job.status).toEqual(JOB_STATUS.FAILED);
|
||||||
expect(job.result.status).toBe(400);
|
expect(job.result.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Application } from '@nocobase/server';
|
import { Application } from '@nocobase/server';
|
||||||
import Database from '@nocobase/database';
|
import Database from '@nocobase/database';
|
||||||
import { getApp, sleep } from '..';
|
import { getApp, sleep } from '..';
|
||||||
|
import DefinedWorkflowModel from '../../models/Workflow';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -9,7 +10,7 @@ describe('workflow > instructions > update', () => {
|
|||||||
let db: Database;
|
let db: Database;
|
||||||
let PostRepo;
|
let PostRepo;
|
||||||
let WorkflowModel;
|
let WorkflowModel;
|
||||||
let workflow;
|
let workflow: DefinedWorkflowModel;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
app = await getApp();
|
app = await getApp();
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import * as workflows from './workflows';
|
import * as workflows from './workflows';
|
||||||
import * as nodes from './nodes';
|
import * as nodes from './nodes';
|
||||||
import * as jobs from './jobs';
|
|
||||||
|
|
||||||
function make(name, mod) {
|
function make(name, mod) {
|
||||||
return Object.keys(mod).reduce((result, key) => ({
|
return Object.keys(mod).reduce((result, key) => ({
|
||||||
@ -19,6 +18,5 @@ export default function({ app }) {
|
|||||||
...make('flow_nodes', {
|
...make('flow_nodes', {
|
||||||
update: nodes.update
|
update: nodes.update
|
||||||
}),
|
}),
|
||||||
...make('jobs', jobs)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
import { Context } from '@nocobase/actions';
|
|
||||||
import { JOB_STATUS } from '../constants';
|
|
||||||
|
|
||||||
export async function submit(context: Context, next) {
|
|
||||||
const { values } = context.action.params;
|
|
||||||
|
|
||||||
const { body: instance } = context;
|
|
||||||
|
|
||||||
// NOTE: validate status
|
|
||||||
if (instance.status !== JOB_STATUS.PENDING) {
|
|
||||||
return context.throw(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: validate assignee
|
|
||||||
instance.set({
|
|
||||||
status: values.status,
|
|
||||||
result: values.result
|
|
||||||
});
|
|
||||||
|
|
||||||
context.status = 202;
|
|
||||||
|
|
||||||
await next();
|
|
||||||
|
|
||||||
const plugin = context.app.pm.get('workflow');
|
|
||||||
// NOTE: resume the process and no `await` for quick returning
|
|
||||||
plugin.resume(instance);
|
|
||||||
}
|
|
@ -1,19 +1,56 @@
|
|||||||
import actions, { Context, utils } from '@nocobase/actions';
|
import actions, { Context, utils } from '@nocobase/actions';
|
||||||
import { Repository } from '@nocobase/database';
|
import { Op, Repository } from '@nocobase/database';
|
||||||
|
|
||||||
export async function update(context: Context, next) {
|
export async function update(context: Context, next) {
|
||||||
const repository = utils.getRepositoryFromParams(context) as Repository;
|
const repository = utils.getRepositoryFromParams(context) as Repository;
|
||||||
const { filterByTk, values } = context.action.params;
|
const { filterByTk, values } = context.action.params;
|
||||||
|
context.action.mergeParams({
|
||||||
|
whitelist: ['title', 'description', 'enabled', 'config']
|
||||||
|
});
|
||||||
// only enable/disable
|
// only enable/disable
|
||||||
if (Object.keys(values).sort().join() !== 'enabled,key'){
|
if (Object.keys(values).includes('config')){
|
||||||
const workflow = await repository.findById(filterByTk);
|
const workflow = await repository.findById(filterByTk);
|
||||||
if (workflow.get('executed')) {
|
if (workflow.get('executed')) {
|
||||||
return context.throw(400, 'executed workflow can not be updated');
|
return context.throw(400, 'config of executed workflow can not be updated');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return actions.update(context, next);
|
return actions.update(context, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function destroy(context: Context, next) {
|
||||||
|
const repository = utils.getRepositoryFromParams(context) as Repository;
|
||||||
|
const { filterByTk, filter } = context.action.params;
|
||||||
|
|
||||||
|
await context.db.sequelize.transaction(async transaction => {
|
||||||
|
const items = await repository.find({
|
||||||
|
filterByTk,
|
||||||
|
filter,
|
||||||
|
fields: ['id', 'key', 'current'],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
const ids = new Set<number>(items.map(item => item.id));
|
||||||
|
const keysSet = new Set<string>(items.filter(item => item.current).map(item => item.key));
|
||||||
|
const revisions = await repository.find({
|
||||||
|
filter: {
|
||||||
|
key: Array.from(keysSet),
|
||||||
|
current: { [Op.not]: true }
|
||||||
|
},
|
||||||
|
fields: ['id'],
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
|
||||||
|
revisions.forEach(item => ids.add(item.id));
|
||||||
|
|
||||||
|
context.body = await repository.destroy({
|
||||||
|
filterByTk: Array.from(ids),
|
||||||
|
individualHooks: true,
|
||||||
|
transaction
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
function typeOf(value) {
|
function typeOf(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return 'array';
|
return 'array';
|
||||||
@ -43,16 +80,13 @@ function migrateConfig(config, oldToNew) {
|
|||||||
case 'array':
|
case 'array':
|
||||||
return value.map(item => migrate(item));
|
return value.map(item => migrate(item));
|
||||||
case 'string':
|
case 'string':
|
||||||
const matcher = value.match(/(\{\{\$jobsMapByNodeId\.)([\w-]+)/);
|
return value.replace(/(\{\{\$jobsMapByNodeId\.)([\w-]+)/g, (_, jobVar, oldNodeId) => {
|
||||||
if (!matcher) {
|
const newNode = oldToNew.get(Number.parseInt(oldNodeId, 10));
|
||||||
return value;
|
if (!newNode) {
|
||||||
}
|
throw new Error('node configurated for result is not existed');
|
||||||
const oldNodeId = Number.parseInt(matcher[2], 10);
|
}
|
||||||
const newNode = oldToNew.get(oldNodeId);
|
return `{{$jobsMapByNodeId.${newNode.id}`;
|
||||||
if (!newNode) {
|
});
|
||||||
throw new Error('node configurated for result is not existed');
|
|
||||||
}
|
|
||||||
return value.replace(matcher[0], `{{$jobsMapByNodeId.${newNode.id}`);
|
|
||||||
default:
|
default:
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
84
packages/plugins/workflow/src/server/calculators/basic.ts
Normal file
84
packages/plugins/workflow/src/server/calculators/basic.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
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));
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
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;
|
||||||
|
}
|
@ -1,218 +1,24 @@
|
|||||||
import { toNumber, get as getWithPath } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { Registry } from "@nocobase/utils";
|
|
||||||
|
|
||||||
import JobModel from '../models/Job';
|
import Plugin from "..";
|
||||||
import Processor from '../Processor';
|
import basic from './basic';
|
||||||
|
import mathjs from "./mathjs";
|
||||||
|
import formulajs from "./formulajs";
|
||||||
|
|
||||||
export const calculators = new Registry<Function>();
|
export type Scope = { [key: string]: any };
|
||||||
|
|
||||||
export default calculators;
|
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 type OperandType = '$context' | '$input' | '$jobsMapByNodeId' | '$calculation';
|
export default function(plugin: Plugin) {
|
||||||
|
plugin.calculators.register('basic', basic);
|
||||||
export type ObjectGetterOptions = {
|
plugin.calculators.register('math.js', mathjs);
|
||||||
type?: string;
|
plugin.calculators.register('formula.js', formulajs);
|
||||||
path?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JobGetterOptions = ObjectGetterOptions & {
|
|
||||||
nodeId: number
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CalculationOptions = {
|
|
||||||
calculator: string,
|
|
||||||
operands: Operand[]
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ValueOperand = string | number | boolean | null | Date;
|
|
||||||
|
|
||||||
export type ConstantOperand = {
|
|
||||||
type?: 'constant';
|
|
||||||
value: any
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ContextOperand = {
|
|
||||||
type: '$context';
|
|
||||||
options: ObjectGetterOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InputOperand = {
|
|
||||||
type: '$input';
|
|
||||||
options: ObjectGetterOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type JobOperand = {
|
|
||||||
type: '$jobsMapByNodeId';
|
|
||||||
options: JobGetterOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Calculation = {
|
|
||||||
type: '$calculation';
|
|
||||||
options: CalculationOptions
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO(type): union type here is wrong
|
|
||||||
export type Operand = ValueOperand | ContextOperand | InputOperand | JobOperand | ConstantOperand | Calculation;
|
|
||||||
|
|
||||||
// @deprecated
|
|
||||||
// HACK: if no path provided, return self
|
|
||||||
// @see https://github.com/lodash/lodash/pull/1270
|
|
||||||
// TODO(question): should add default value as lodash?
|
|
||||||
function get(object, path?: string | Array<string>) {
|
|
||||||
return path == null || !path.length ? object : getWithPath(object, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE:
|
|
||||||
// this method could only be used in executing nodes.
|
|
||||||
// because type of 'job' need loaded jobs in runtime execution.
|
|
||||||
// or the execution should be prepared first.
|
|
||||||
export function calculate(operand, lastJob: JobModel, processor: Processor) {
|
|
||||||
if (typeof operand !== 'object' || operand == null) {
|
|
||||||
return operand;
|
|
||||||
}
|
|
||||||
// @Deprecated
|
|
||||||
switch (operand.type) {
|
|
||||||
// from execution context
|
|
||||||
case '$context':
|
|
||||||
return get(processor.execution.context, [operand.options.type, operand.options.path].filter(Boolean).join('.'));
|
|
||||||
|
|
||||||
// from last job (or input job)
|
|
||||||
case '$input':
|
|
||||||
return lastJob ?? get(lastJob.result, operand.options.path);
|
|
||||||
|
|
||||||
// from job in execution
|
|
||||||
case '$jobsMapByNodeId':
|
|
||||||
// assume jobs have been fetched from execution before
|
|
||||||
const job = processor.jobsMapByNodeId[operand.options.nodeId];
|
|
||||||
return job && get(job, operand.options.path);
|
|
||||||
|
|
||||||
case '$calculation':
|
|
||||||
const fn = calculators.get(operand.options.calculator);
|
|
||||||
if (!fn) {
|
|
||||||
throw new Error(`no calculator function registered for "${operand.options.calculator}"`);
|
|
||||||
}
|
|
||||||
return fn(...operand.options.operands.map(item => calculate(item, lastJob, processor)));
|
|
||||||
|
|
||||||
// constant
|
|
||||||
default:
|
|
||||||
return operand.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 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 add(...args) {
|
|
||||||
return args.reduce((sum, a) => sum + toNumber(a), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function minus(a, b) {
|
|
||||||
return toNumber(a) - toNumber(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function multiple(...args) {
|
|
||||||
return args.reduce((result, a) => result * toNumber(a), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function divide(a, b) {
|
|
||||||
return toNumber(a) / toNumber(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mod(a, b) {
|
|
||||||
return toNumber(a) % toNumber(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
calculators.register('add', add);
|
|
||||||
calculators.register('minus', minus);
|
|
||||||
calculators.register('multiple', multiple);
|
|
||||||
calculators.register('divide', divide);
|
|
||||||
calculators.register('mod', mod);
|
|
||||||
|
|
||||||
calculators.register('+', add);
|
|
||||||
calculators.register('-', minus);
|
|
||||||
calculators.register('*', multiple);
|
|
||||||
calculators.register('/', divide);
|
|
||||||
calculators.register('%', mod);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
function concat(a: string, b: string) {
|
|
||||||
return a.concat(b);
|
|
||||||
}
|
|
||||||
|
|
||||||
calculators.register('concat', concat);
|
|
||||||
|
|
||||||
calculators.register('now', () => new Date());
|
|
||||||
|
|
||||||
// TODO: add more common calculators
|
|
||||||
|
16
packages/plugins/workflow/src/server/calculators/mathjs.ts
Normal file
16
packages/plugins/workflow/src/server/calculators/mathjs.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
|
}
|
@ -18,18 +18,13 @@ export default {
|
|||||||
name: 'useTransaction',
|
name: 'useTransaction',
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
},
|
},
|
||||||
// @deprecated
|
|
||||||
{
|
|
||||||
type: 'uuid',
|
|
||||||
name: 'transaction',
|
|
||||||
defaultValue: null
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: 'hasMany',
|
type: 'hasMany',
|
||||||
name: 'jobs',
|
name: 'jobs',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'jsonb',
|
type: 'json',
|
||||||
name: 'context',
|
name: 'context',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -4,29 +4,22 @@ export default {
|
|||||||
namespace: 'workflow',
|
namespace: 'workflow',
|
||||||
duplicator: 'required',
|
duplicator: 'required',
|
||||||
name: 'flow_nodes',
|
name: 'flow_nodes',
|
||||||
// model: 'FlowNodeModel',
|
|
||||||
title: 'Workflow Nodes',
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
interface: 'string',
|
|
||||||
type: 'string',
|
type: 'string',
|
||||||
name: 'title',
|
name: 'title',
|
||||||
title: '名称'
|
|
||||||
},
|
},
|
||||||
// which workflow belongs to
|
// which workflow belongs to
|
||||||
{
|
{
|
||||||
interface: 'linkTo',
|
|
||||||
name: 'workflow',
|
name: 'workflow',
|
||||||
type: 'belongsTo',
|
type: 'belongsTo',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interface: 'linkTo',
|
|
||||||
name: 'upstream',
|
name: 'upstream',
|
||||||
type: 'belongsTo',
|
type: 'belongsTo',
|
||||||
target: 'flow_nodes'
|
target: 'flow_nodes'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interface: 'linkTo',
|
|
||||||
name: 'branches',
|
name: 'branches',
|
||||||
type: 'hasMany',
|
type: 'hasMany',
|
||||||
target: 'flow_nodes',
|
target: 'flow_nodes',
|
||||||
@ -37,31 +30,24 @@ export default {
|
|||||||
// put here because the design of flow-links model is not really necessary for now.
|
// put here because the design of flow-links model is not really necessary for now.
|
||||||
// or it should be put into flow-links model.
|
// or it should be put into flow-links model.
|
||||||
{
|
{
|
||||||
interface: 'select',
|
|
||||||
name: 'branchIndex',
|
name: 'branchIndex',
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
title: 'branch index'
|
|
||||||
},
|
},
|
||||||
// Note: for reasons:
|
// Note: for reasons:
|
||||||
// 1. redirect type node to solve cycle flow.
|
// 1. redirect type node to solve cycle flow.
|
||||||
// 2. recognize as real next node after branches.
|
// 2. recognize as real next node after branches.
|
||||||
{
|
{
|
||||||
interface: 'linkTo',
|
|
||||||
name: 'downstream',
|
name: 'downstream',
|
||||||
type: 'belongsTo',
|
type: 'belongsTo',
|
||||||
target: 'flow_nodes'
|
target: 'flow_nodes'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interface: 'select',
|
|
||||||
type: 'string',
|
type: 'string',
|
||||||
name: 'type',
|
name: 'type',
|
||||||
title: '类型'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interface: 'json',
|
type: 'json',
|
||||||
type: 'jsonb',
|
|
||||||
name: 'config',
|
name: 'config',
|
||||||
title: '配置',
|
|
||||||
defaultValue: {}
|
defaultValue: {}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -24,7 +24,7 @@ export default {
|
|||||||
name: 'status'
|
name: 'status'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'jsonb',
|
type: 'json',
|
||||||
name: 'result'
|
name: 'result'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -30,7 +30,7 @@ export default function () {
|
|||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'jsonb',
|
type: 'json',
|
||||||
name: 'config',
|
name: 'config',
|
||||||
required: true,
|
required: true,
|
||||||
defaultValue: {}
|
defaultValue: {}
|
||||||
@ -43,11 +43,13 @@ export default function () {
|
|||||||
{
|
{
|
||||||
type: 'hasMany',
|
type: 'hasMany',
|
||||||
name: 'nodes',
|
name: 'nodes',
|
||||||
target: 'flow_nodes'
|
target: 'flow_nodes',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'hasMany',
|
type: 'hasMany',
|
||||||
name: 'executions'
|
name: 'executions',
|
||||||
|
onDelete: 'CASCADE'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
@ -62,7 +64,7 @@ export default function () {
|
|||||||
{
|
{
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
name: 'current',
|
name: 'current',
|
||||||
defaultValue: null
|
defaultValue: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'hasMany',
|
type: 'hasMany',
|
||||||
|
@ -1,16 +1,22 @@
|
|||||||
export const EXECUTION_STATUS = {
|
export const EXECUTION_STATUS = {
|
||||||
CREATED: null,
|
QUEUEING: null,
|
||||||
STARTED: 0,
|
STARTED: 0,
|
||||||
RESOLVED: 1,
|
RESOLVED: 1,
|
||||||
REJECTED: -1,
|
FAILED: -1,
|
||||||
CANCELED: -2
|
ERROR: -2,
|
||||||
|
ABORTED: -3,
|
||||||
|
CANCELED: -4,
|
||||||
|
REJECTED: -5,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const JOB_STATUS = {
|
export const JOB_STATUS = {
|
||||||
PENDING: 0,
|
PENDING: 0,
|
||||||
RESOLVED: 1,
|
RESOLVED: 1,
|
||||||
REJECTED: -1,
|
FAILED: -1,
|
||||||
CANCELED: -2
|
ERROR: -2,
|
||||||
|
ABORTED: -3,
|
||||||
|
CANCELED: -4,
|
||||||
|
REJECTED: -5,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BRANCH_INDEX = {
|
export const BRANCH_INDEX = {
|
||||||
|
@ -1,57 +0,0 @@
|
|||||||
import { Context, utils } from '@nocobase/actions';
|
|
||||||
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function submit(context: Context, next) {
|
|
||||||
const repository = utils.getRepositoryFromParams(context);
|
|
||||||
const { filterByTk, values } = context.action.params;
|
|
||||||
const { currentUser } = context.state;
|
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
return context.throw(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const instance = await repository.findOne({
|
|
||||||
filterByTk,
|
|
||||||
// filter: {
|
|
||||||
// userId: currentUser?.id
|
|
||||||
// },
|
|
||||||
appends: ['job', 'node', 'execution'],
|
|
||||||
context
|
|
||||||
});
|
|
||||||
|
|
||||||
const { actions, assignees } = instance.node.config;
|
|
||||||
|
|
||||||
// NOTE: validate status
|
|
||||||
if (instance.status !== JOB_STATUS.PENDING
|
|
||||||
|| instance.job.status !== JOB_STATUS.PENDING
|
|
||||||
|| instance.execution.status !== EXECUTION_STATUS.STARTED
|
|
||||||
|| (actions && !actions[values.status])
|
|
||||||
) {
|
|
||||||
context.throw(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assignees.includes(currentUser.id)
|
|
||||||
|| instance.userId !== currentUser.id
|
|
||||||
) {
|
|
||||||
return context.throw(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: validate assignee
|
|
||||||
await instance.update({
|
|
||||||
status: values.status,
|
|
||||||
result: values.result
|
|
||||||
});
|
|
||||||
|
|
||||||
context.body = instance;
|
|
||||||
context.status = 202;
|
|
||||||
|
|
||||||
await next();
|
|
||||||
|
|
||||||
instance.job.latestUserJob = instance;
|
|
||||||
|
|
||||||
const plugin = context.app.pm.get('workflow');
|
|
||||||
// NOTE: resume the process and no `await` for quick returning
|
|
||||||
plugin.resume(instance.job);
|
|
||||||
}
|
|
@ -1,210 +0,0 @@
|
|||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { requireModule } from '@nocobase/utils';
|
|
||||||
import { Context } from '@nocobase/actions';
|
|
||||||
|
|
||||||
import Plugin from '../../Plugin';
|
|
||||||
import Prompt, { PromptConfig } from '../../instructions/prompt';
|
|
||||||
import { submit } from './actions';
|
|
||||||
import { JOB_STATUS } from '../../constants';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface AssignedPromptConfig extends PromptConfig {
|
|
||||||
assignees?: number[];
|
|
||||||
mode?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: for single record mode (mode: 0/null)
|
|
||||||
async function middleware(context: Context, next) {
|
|
||||||
const { body: job, state, action } = context;
|
|
||||||
const { assignees, mode } = job.node.config as AssignedPromptConfig;
|
|
||||||
|
|
||||||
// NOTE: skip to no user implementation
|
|
||||||
if (!assignees) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.currentUser) {
|
|
||||||
return context.throw(401);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!assignees.includes(state.currentUser.id)) {
|
|
||||||
return context.throw(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: multiple record mode could not use jobs:submit action
|
|
||||||
// should use users_jobs:submit/:id instead
|
|
||||||
if (mode) {
|
|
||||||
return context.throw(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
userId: context.state.currentUser.id,
|
|
||||||
jobId: job.id,
|
|
||||||
nodeId: job.nodeId,
|
|
||||||
executionId: job.executionId,
|
|
||||||
workflowId: job.execution.workflowId,
|
|
||||||
status: job.status,
|
|
||||||
result: job.result
|
|
||||||
};
|
|
||||||
|
|
||||||
// NOTE: update users job after main job is done
|
|
||||||
const UserJobModel = context.db.getModel('users_jobs');
|
|
||||||
let userJob = await UserJobModel.findOne({
|
|
||||||
where: {
|
|
||||||
userId: context.state.currentUser.id,
|
|
||||||
jobId: job.id,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (userJob) {
|
|
||||||
await userJob.update(data);
|
|
||||||
} else {
|
|
||||||
userJob = await UserJobModel.create(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(node, prevJob, processor) {
|
|
||||||
const { assignees, mode } = node.config as AssignedPromptConfig;
|
|
||||||
if (!assignees) {
|
|
||||||
const { plugin } = processor.options;
|
|
||||||
const origin = plugin.instructions.get('prompt') as Prompt;
|
|
||||||
return origin.constructor.prototype.run.call(this, node, prevJob, processor);
|
|
||||||
}
|
|
||||||
|
|
||||||
const job = await processor.saveJob({
|
|
||||||
status: JOB_STATUS.PENDING,
|
|
||||||
result: mode ? [] : null,
|
|
||||||
nodeId: node.id,
|
|
||||||
upstreamId: prevJob?.id ?? null
|
|
||||||
});
|
|
||||||
|
|
||||||
// NOTE: batch create users jobs
|
|
||||||
const UserJobModel = processor.options.plugin.db.getModel('users_jobs');
|
|
||||||
await UserJobModel.bulkCreate(assignees.map(userId => ({
|
|
||||||
userId,
|
|
||||||
jobId: job.id,
|
|
||||||
nodeId: node.id,
|
|
||||||
executionId: job.executionId,
|
|
||||||
workflowId: node.workflowId,
|
|
||||||
status: JOB_STATUS.PENDING
|
|
||||||
})), {
|
|
||||||
transaction: processor.transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROMPT_ASSIGNED_MODE = {
|
|
||||||
SINGLE: Symbol('single'),
|
|
||||||
ALL: Symbol('all'),
|
|
||||||
ANY: Symbol('any'),
|
|
||||||
ALL_PERCENTAGE: Symbol('all percentage'),
|
|
||||||
ANY_PERCENTAGE: Symbol('any percentage')
|
|
||||||
};
|
|
||||||
|
|
||||||
const Modes = {
|
|
||||||
[PROMPT_ASSIGNED_MODE.SINGLE]: {
|
|
||||||
getStatus(distribution, assignees) {
|
|
||||||
const done = distribution.find(item => item.status !== JOB_STATUS.PENDING && item.count > 0);
|
|
||||||
return done ? done.status : null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[PROMPT_ASSIGNED_MODE.ALL]: {
|
|
||||||
getStatus(distribution, assignees) {
|
|
||||||
const resolved = distribution.find(item => item.status === JOB_STATUS.RESOLVED);
|
|
||||||
if (resolved && resolved.count === assignees.length) {
|
|
||||||
return JOB_STATUS.RESOLVED;
|
|
||||||
}
|
|
||||||
// NOTE: `rejected` or `canceled`
|
|
||||||
const failed = distribution.find(item => item.status < JOB_STATUS.PENDING);
|
|
||||||
if (failed && failed.count) {
|
|
||||||
return failed.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[PROMPT_ASSIGNED_MODE.ANY]: {
|
|
||||||
getStatus(distribution, assignees) {
|
|
||||||
const resolved = distribution.find(item => item.status === JOB_STATUS.RESOLVED);
|
|
||||||
if (resolved && resolved.count) {
|
|
||||||
return JOB_STATUS.RESOLVED;
|
|
||||||
}
|
|
||||||
const failedCount = distribution.reduce((count, item) => item.status < JOB_STATUS.PENDING ? count + item.count : count, 0);
|
|
||||||
// NOTE: all failures are considered as rejected for now
|
|
||||||
if (failedCount === assignees.length) {
|
|
||||||
return JOB_STATUS.REJECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function getMode(mode) {
|
|
||||||
switch (true) {
|
|
||||||
case mode === 1:
|
|
||||||
return Modes[PROMPT_ASSIGNED_MODE.ALL];
|
|
||||||
case mode === -1:
|
|
||||||
return Modes[PROMPT_ASSIGNED_MODE.ANY];
|
|
||||||
case mode > 0:
|
|
||||||
return Modes[PROMPT_ASSIGNED_MODE.ALL_PERCENTAGE];
|
|
||||||
case mode < 0:
|
|
||||||
return Modes[PROMPT_ASSIGNED_MODE.ANY_PERCENTAGE];
|
|
||||||
default:
|
|
||||||
return Modes[PROMPT_ASSIGNED_MODE.SINGLE];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resume(node, job, processor) {
|
|
||||||
// NOTE: check all users jobs related if all done then continue as parallel
|
|
||||||
const { assignees, mode } = node.config as AssignedPromptConfig;
|
|
||||||
|
|
||||||
if (!assignees) {
|
|
||||||
const { plugin } = processor.options;
|
|
||||||
const origin = plugin.instructions.get('prompt') as Prompt;
|
|
||||||
return origin.constructor.prototype.resume.call(this, node, job, processor);
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserJobModel = processor.options.plugin.db.getModel('users_jobs');
|
|
||||||
const distribution = await UserJobModel.count({
|
|
||||||
where: {
|
|
||||||
jobId: job.id
|
|
||||||
},
|
|
||||||
group: ['status']
|
|
||||||
});
|
|
||||||
|
|
||||||
const submitted = distribution.reduce((count, item) => item.status !== JOB_STATUS.PENDING ? count + item.count : count, 0);
|
|
||||||
const result = mode ? (submitted || 0) / assignees.length : job.latestUserJob?.result ?? job.result;
|
|
||||||
job.set({
|
|
||||||
status: getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING,
|
|
||||||
result
|
|
||||||
});
|
|
||||||
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function(plugin: Plugin) {
|
|
||||||
const instruction = plugin.instructions.get('prompt') as Prompt;
|
|
||||||
instruction.extend({
|
|
||||||
run,
|
|
||||||
resume
|
|
||||||
});
|
|
||||||
|
|
||||||
instruction.use(middleware);
|
|
||||||
|
|
||||||
// TODO(bug): through table should be load first because primary
|
|
||||||
// await plugin.db.import({
|
|
||||||
// directory: path.join(__dirname, './collections')
|
|
||||||
// });
|
|
||||||
plugin.db.collection(requireModule(path.join(__dirname, './collections/users_jobs')));
|
|
||||||
plugin.db.extendCollection(requireModule(path.join(__dirname, './collections/users')));
|
|
||||||
plugin.db.extendCollection(requireModule(path.join(__dirname, './collections/jobs')));
|
|
||||||
|
|
||||||
plugin.app.actions({
|
|
||||||
'users_jobs:submit': submit
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import assignees from './assignees';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
assignees
|
|
||||||
];
|
|
13
packages/plugins/workflow/src/server/functions/index.ts
Normal file
13
packages/plugins/workflow/src/server/functions/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import Plugin from "..";
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function({ functions }: Plugin, more: { [key: string]: Function } = {}) {
|
||||||
|
functions.register('now', now);
|
||||||
|
|
||||||
|
for (const [name, fn] of Object.entries(more)) {
|
||||||
|
functions.register(name, fn);
|
||||||
|
}
|
||||||
|
};
|
@ -1,48 +1,36 @@
|
|||||||
|
import { get } from "lodash";
|
||||||
|
|
||||||
|
import { Evaluator, Processor } from '..';
|
||||||
import { JOB_STATUS } from "../constants";
|
import { JOB_STATUS } from "../constants";
|
||||||
import FlowNodeModel from "../models/FlowNode";
|
import FlowNodeModel from "../models/FlowNode";
|
||||||
import { calculate } from "../calculators";
|
import { Instruction } from ".";
|
||||||
|
|
||||||
// @calculation: {
|
|
||||||
// calculator: 'concat',
|
|
||||||
// operands: [
|
interface CalculationConfig {
|
||||||
// {
|
engine: string;
|
||||||
// type: 'calculation',
|
expression: string;
|
||||||
// options: {
|
}
|
||||||
// calculator: 'add',
|
|
||||||
// operands: [{ value: 1 }, { value: 2 }]
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// type: 'constant',
|
|
||||||
// value: '{{$context.data.title}}'
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// type: 'context',
|
|
||||||
// options: {
|
|
||||||
// path: 'data.title'
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// type: 'constant',
|
|
||||||
// value: 1
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
// }
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async run(node: FlowNodeModel, prevJob, processor) {
|
async run(node: FlowNodeModel, prevJob, processor: Processor) {
|
||||||
const { calculation } = node.config || {};
|
const { engine, expression = '' } = <CalculationConfig>node.config || {};
|
||||||
|
const evaluator = <Evaluator | undefined>processor.options.plugin.calculators.get(engine);
|
||||||
|
const scope = processor.getScope();
|
||||||
|
|
||||||
const result = calculation
|
try {
|
||||||
? calculate({
|
const result = evaluator && expression
|
||||||
type: '$calculation',
|
? evaluator(expression, scope)
|
||||||
options: processor.getParsedValue(calculation)
|
: null;
|
||||||
}, prevJob, processor)
|
return {
|
||||||
: null;
|
result,
|
||||||
|
status: JOB_STATUS.RESOLVED
|
||||||
return {
|
};
|
||||||
result,
|
} catch (e) {
|
||||||
status: JOB_STATUS.RESOLVED
|
return {
|
||||||
};
|
result: e.toString(),
|
||||||
|
status: JOB_STATUS.ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} as Instruction;
|
||||||
|
@ -1,88 +1,50 @@
|
|||||||
import calculators, { calculate, Operand } from "../calculators";
|
import { Evaluator, Processor } from '..';
|
||||||
import { JOB_STATUS } from "../constants";
|
import { JOB_STATUS } from "../constants";
|
||||||
|
import FlowNodeModel from "../models/FlowNode";
|
||||||
|
import { Instruction } from ".";
|
||||||
|
|
||||||
type BaseCalculation = {
|
function logicCalculate(calculation, evaluator, scope) {
|
||||||
not?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SingleCalculation = BaseCalculation & {
|
|
||||||
calculation: string;
|
|
||||||
operands?: Operand[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type GroupCalculationOptions = {
|
|
||||||
type: 'and' | 'or';
|
|
||||||
calculations: Calculation[]
|
|
||||||
};
|
|
||||||
|
|
||||||
type GroupCalculation = BaseCalculation & {
|
|
||||||
group: GroupCalculationOptions
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO(type)
|
|
||||||
type Calculation = SingleCalculation | GroupCalculation;
|
|
||||||
|
|
||||||
// @calculation: {
|
|
||||||
// not: false,
|
|
||||||
// group: {
|
|
||||||
// type: 'and',
|
|
||||||
// calculations: [
|
|
||||||
// {
|
|
||||||
// calculator: 'time.equal',
|
|
||||||
// operands: [{ value: '{{$context.time}}' }, { value: '{{$fn.now}}' }]
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// calculator: 'value.equal',
|
|
||||||
// operands: [{ value: '{{$jobsMapByNodeId.213}}' }, { value: 1 }]
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// group: {
|
|
||||||
// type: 'or',
|
|
||||||
// calculations: [
|
|
||||||
// {
|
|
||||||
// calculator: 'value.equal',
|
|
||||||
// operands: [{ value: '{{$jobsMapByNodeId.213}}' }, { value: 1 }]
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
function logicCalculate(calculation, input, processor) {
|
|
||||||
if (!calculation) {
|
if (!calculation) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { not, group } = calculation;
|
if (typeof calculation === 'object' && calculation.group) {
|
||||||
let result;
|
const method = calculation.group.type === 'and' ? 'every' : 'some';
|
||||||
if (group) {
|
return calculation.group.calculations[method](item => logicCalculate(item, evaluator, scope));
|
||||||
const method = group.type === 'and' ? 'every' : 'some';
|
|
||||||
result = group.calculations[method](item => logicCalculate(item, input, processor));
|
|
||||||
} else {
|
|
||||||
const args = calculation.operands.map(operand => calculate(operand, input, processor));
|
|
||||||
const fn = calculators.get(calculation.calculator);
|
|
||||||
if (!fn) {
|
|
||||||
throw new Error(`no calculator function registered for "${calculation.calculator}"`);
|
|
||||||
}
|
|
||||||
result = fn(...args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return not ? !result : result;
|
return evaluator(calculation, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async run(node, prevJob, processor) {
|
async run(node: FlowNodeModel, prevJob, processor: Processor) {
|
||||||
// TODO(optimize): loading of jobs could be reduced and turned into incrementally in processor
|
// TODO(optimize): loading of jobs could be reduced and turned into incrementally in processor
|
||||||
// const jobs = await processor.getJobs();
|
// const jobs = await processor.getJobs();
|
||||||
const { calculation, rejectOnFalse } = node.config || {};
|
const { engine = 'basic', calculation, 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 result = logicCalculate(processor.getParsedValue(calculation), prevJob, processor);
|
const scope = processor.getScope();
|
||||||
|
let result = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = logicCalculate(processor.getParsedValue(calculation), evaluator, scope);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
result: e.toString(),
|
||||||
|
status: JOB_STATUS.ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!result && rejectOnFalse) {
|
if (!result && rejectOnFalse) {
|
||||||
return {
|
return {
|
||||||
status: JOB_STATUS.REJECTED,
|
status: JOB_STATUS.FAILED,
|
||||||
result
|
result
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -107,7 +69,7 @@ export default {
|
|||||||
return processor.run(branchNode, savedJob);
|
return processor.run(branchNode, savedJob);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resume(node, branchJob, processor) {
|
async resume(node: FlowNodeModel, branchJob, processor: Processor) {
|
||||||
if (branchJob.status === JOB_STATUS.RESOLVED) {
|
if (branchJob.status === JOB_STATUS.RESOLVED) {
|
||||||
// return to continue node.downstream
|
// return to continue node.downstream
|
||||||
return branchJob;
|
return branchJob;
|
||||||
@ -116,4 +78,4 @@ export default {
|
|||||||
// pass control to upper scope by ending current scope
|
// pass control to upper scope by ending current scope
|
||||||
return processor.end(node, branchJob);
|
return processor.end(node, branchJob);
|
||||||
}
|
}
|
||||||
};
|
} as Instruction;
|
||||||
|
@ -39,7 +39,7 @@ export default function<T extends Instruction>(
|
|||||||
'condition',
|
'condition',
|
||||||
'parallel',
|
'parallel',
|
||||||
'delay',
|
'delay',
|
||||||
'prompt',
|
'manual',
|
||||||
'query',
|
'query',
|
||||||
'create',
|
'create',
|
||||||
'update',
|
'update',
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user