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 { compilerOptions } = require('./tsconfig.json');
|
||||
const { defaults } = require('jest-config');
|
||||
|
||||
module.exports = {
|
||||
rootDir: process.cwd(),
|
||||
@ -21,6 +22,8 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
modulePathIgnorePatterns: ['/esm/', '/es/', '/dist/', '/lib/'],
|
||||
// add .mjs .cjs for formula.js
|
||||
moduleFileExtensions: [...defaults.moduleFileExtensions, 'mjs', 'cjs'],
|
||||
coveragePathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/__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 BlockAssociationContext = createContext(null);
|
||||
export const BlockRequestContext = createContext<any>(null);
|
||||
export const BlockRequestContext = createContext<any>({});
|
||||
|
||||
export const useBlockResource = () => {
|
||||
return useContext(BlockResourceContext);
|
||||
|
@ -164,6 +164,9 @@ export default {
|
||||
"Advanced type": "高级类型",
|
||||
"Formula": "公式",
|
||||
"Formula description": "基于同一条记录中的其他字段计算出一个值。",
|
||||
"Syntax references": "语法参考",
|
||||
"Math.js comes with a large set of built-in functions and constants, and offers an integrated solution to work with different data types": "Math.js 包含大量内置函数和常量,并提供了集成的解决方案来处理不同的数据类型。",
|
||||
"Formula.js supports most Microsoft Excel formula functions.": "Formula.js 支持大部分 Mircrosoft Excel 公式。",
|
||||
"Choices": "选择类型",
|
||||
"Checkbox": "勾选",
|
||||
"Single select": "下拉菜单(单选)",
|
||||
|
@ -43,7 +43,7 @@ export const ActionDesigner = (props) => {
|
||||
const isUpdateModePopupAction = ['customize:bulkUpdate', 'customize:bulkEdit'].includes(fieldSchema['x-action']);
|
||||
const context = useActionContext();
|
||||
const [initialSchema, setInitialSchema] = useState<ISchema>();
|
||||
const actionType = fieldSchema['x-action'] || '';
|
||||
const actionType = fieldSchema['x-action'] ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
const schemaUid = uid();
|
||||
|
@ -69,7 +69,7 @@ export const Action: ComposedAction = observer((props: any) => {
|
||||
const {
|
||||
popover,
|
||||
confirm,
|
||||
// openMode,
|
||||
openMode: om,
|
||||
containerRefKey,
|
||||
component,
|
||||
useAction = useA,
|
||||
|
@ -53,13 +53,14 @@ const VariableCascader = connect((props) => {
|
||||
});
|
||||
|
||||
export const DynamicComponent = (props) => {
|
||||
const { dynamicComponent } = useContext(FilterContext);
|
||||
const { dynamicComponent, disabled } = useContext(FilterContext);
|
||||
const component = useComponent(dynamicComponent);
|
||||
const form = useMemo(() => {
|
||||
return createForm({
|
||||
values: {
|
||||
value: props.value,
|
||||
},
|
||||
disabled,
|
||||
effects() {
|
||||
onFieldValueChange('value', (field) => {
|
||||
props?.onChange?.(field.value);
|
||||
|
@ -27,7 +27,7 @@ export const Filter: any = observer((props: any) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<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} />
|
||||
{/* <pre>{JSON.stringify(field.value, null, 2)}</pre> */}
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { ObjectField as ObjectFieldModel } from '@formily/core';
|
||||
import { ArrayField, connect, useField } from '@formily/react';
|
||||
import { ConfigProvider, Select, Space } from 'antd';
|
||||
import { Select, Space } from 'antd';
|
||||
import React, { useContext } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { FilterLogicContext, RemoveConditionContext } from './context';
|
||||
import { FilterItems } from './FilterItems';
|
||||
|
||||
export const FilterGroup = connect((props) => {
|
||||
const { bordered = true } = props;
|
||||
const { bordered = true, disabled } = props;
|
||||
const field = useField<ObjectFieldModel>();
|
||||
const remove = useContext(RemoveConditionContext);
|
||||
const { t } = useTranslation();
|
||||
@ -20,89 +20,87 @@ export const FilterGroup = connect((props) => {
|
||||
[value]: [...(obj[logic] || [])],
|
||||
};
|
||||
};
|
||||
const mergedDisabled = field.disabled;
|
||||
const mergedDisabled = disabled || field.disabled;
|
||||
return (
|
||||
<ConfigProvider componentDisabled={mergedDisabled}>
|
||||
<FilterLogicContext.Provider value={logic}>
|
||||
<div
|
||||
style={
|
||||
bordered
|
||||
? {
|
||||
position: 'relative',
|
||||
border: '1px dashed #dedede',
|
||||
padding: 14,
|
||||
marginBottom: 8,
|
||||
}
|
||||
: {
|
||||
position: 'relative',
|
||||
marginBottom: 8,
|
||||
}
|
||||
}
|
||||
>
|
||||
{remove && !mergedDisabled && (
|
||||
<a>
|
||||
<CloseCircleOutlined
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
top: 10,
|
||||
color: '#bfbfbf',
|
||||
}}
|
||||
onClick={() => remove()}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Trans>
|
||||
{'Meet '}
|
||||
<Select
|
||||
style={{ width: 'auto' }}
|
||||
value={logic}
|
||||
onChange={(value) => {
|
||||
setLogic(value);
|
||||
}}
|
||||
>
|
||||
<Select.Option value={'$and'}>All</Select.Option>
|
||||
<Select.Option value={'$or'}>Any</Select.Option>
|
||||
</Select>
|
||||
{' conditions in the group'}
|
||||
</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>
|
||||
)}
|
||||
<FilterLogicContext.Provider value={logic}>
|
||||
<div
|
||||
style={
|
||||
bordered
|
||||
? {
|
||||
position: 'relative',
|
||||
border: '1px dashed #dedede',
|
||||
padding: 14,
|
||||
marginBottom: 8,
|
||||
}
|
||||
: {
|
||||
position: 'relative',
|
||||
marginBottom: 8,
|
||||
}
|
||||
}
|
||||
>
|
||||
{remove && !mergedDisabled && (
|
||||
<a>
|
||||
<CloseCircleOutlined
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
top: 10,
|
||||
color: '#bfbfbf',
|
||||
}}
|
||||
onClick={() => remove()}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Trans>
|
||||
{'Meet '}
|
||||
<Select
|
||||
style={{ width: 'auto' }}
|
||||
value={logic}
|
||||
onChange={(value) => {
|
||||
setLogic(value);
|
||||
}}
|
||||
>
|
||||
<Select.Option value={'$and'}>All</Select.Option>
|
||||
<Select.Option value={'$or'}>Any</Select.Option>
|
||||
</Select>
|
||||
{' conditions in the group'}
|
||||
</Trans>
|
||||
</div>
|
||||
</FilterLogicContext.Provider>
|
||||
</ConfigProvider>
|
||||
<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>
|
||||
</FilterLogicContext.Provider>
|
||||
);
|
||||
});
|
||||
|
@ -7,8 +7,9 @@ export interface FilterContextProps {
|
||||
fieldSchema?: Schema;
|
||||
dynamicComponent?: any;
|
||||
options?: any[];
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const RemoveConditionContext = createContext(null);
|
||||
export const FilterContext = createContext<FilterContextProps>(null);
|
||||
export const FilterLogicContext = createContext(null);
|
||||
export const FilterLogicContext = createContext(null);
|
||||
|
@ -29,6 +29,7 @@ export const useValues = () => {
|
||||
});
|
||||
};
|
||||
const value2data = () => {
|
||||
field.data = field.data || {};
|
||||
const values = flat(field.value);
|
||||
const path = Object.keys(values).shift() || '';
|
||||
if (!path) {
|
||||
@ -40,16 +41,13 @@ export const useValues = () => {
|
||||
const option = findOption(dataIndex, options);
|
||||
const operators = option?.operators;
|
||||
const operator = operators?.find?.((item) => item.value === `$${operatorValue}`);
|
||||
field.data = field.data || {};
|
||||
field.data.dataIndex = dataIndex;
|
||||
field.data.operators = operators;
|
||||
field.data.operator = operator;
|
||||
field.data.schema = merge(option?.schema, operator?.schema);
|
||||
field.data.value = get(field.value, `${fieldPath}.$${operatorValue}`);
|
||||
};
|
||||
useEffect(() => {
|
||||
value2data();
|
||||
}, [logic]);
|
||||
useEffect(value2data, [logic]);
|
||||
return {
|
||||
fields: options,
|
||||
...field.data,
|
||||
@ -63,7 +61,6 @@ export const useValues = () => {
|
||||
field.data.dataIndex = dataIndex;
|
||||
field.data.value = null;
|
||||
data2value();
|
||||
console.log('setDataIndex', field.data);
|
||||
},
|
||||
setOperator(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.value = operator.noValue ? operator.default || true : null;
|
||||
data2value();
|
||||
console.log('setOperator', field.data);
|
||||
},
|
||||
setValue(value) {
|
||||
field.data.value = value;
|
||||
data2value();
|
||||
console.log('setValue', field.data);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ArrayCollapse, FormItem as Item, FormLayout } from '@formily/antd';
|
||||
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 _ from 'lodash';
|
||||
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 ctx = useContext(BlockRequestContext);
|
||||
const schema = useFieldSchema();
|
||||
@ -60,7 +60,7 @@ export const FormItem: any = (props) => {
|
||||
</BlockItem>
|
||||
</ACLCollectionFieldProvider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
FormItem.Designer = (props) => {
|
||||
const { getCollectionFields, getCollection, getInterface, getCollectionJoinField } = useCollectionManager();
|
||||
|
@ -28,6 +28,7 @@ function IconField(props: any) {
|
||||
<div style={{ width: '26em', maxHeight: '20em', overflowY: 'auto' }}>
|
||||
{[...icons.keys()].map((key) => (
|
||||
<span
|
||||
key={key}
|
||||
style={{ fontSize: 18, marginRight: 10, cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
onChange(key);
|
||||
|
@ -59,7 +59,7 @@ export const useFixedBlockDesignerSetting = () => {
|
||||
return (
|
||||
<SchemaSettings.SwitchItem
|
||||
title={t('Fix block')}
|
||||
checked={fieldSchema['x-decorator-props']['fixedBlock']}
|
||||
checked={fieldSchema['x-decorator-props']?.fixedBlock}
|
||||
onChange={async (fixedBlock) => {
|
||||
const decoratorProps = {
|
||||
...fieldSchema['x-decorator-props'],
|
||||
|
@ -12,6 +12,7 @@ export const TabsDesigner = () => {
|
||||
return (
|
||||
<GeneralSchemaDesigner disableInitializer>
|
||||
<SchemaSettings.ModalItem
|
||||
key="edit"
|
||||
title={t('Edit')}
|
||||
schema={
|
||||
{
|
||||
|
@ -161,7 +161,6 @@ SchemaInitializer.Item = (props: SchemaInitializerItemProps) => {
|
||||
return <Menu.Divider key={`divider-${indexA}`} />;
|
||||
}
|
||||
if (item.type === 'itemGroup') {
|
||||
console.log(item.children);
|
||||
return (
|
||||
<Menu.ItemGroup
|
||||
// @ts-ignore
|
||||
|
@ -3,3 +3,4 @@ export * from './SchemaInitializerProvider';
|
||||
export * from './types';
|
||||
export * from './items';
|
||||
export { gridRowColWrap, useRecordCollectionDataSourceItems } from './utils';
|
||||
export * from './buttons';
|
||||
|
@ -9,13 +9,16 @@
|
||||
"@nocobase/actions": "0.9.0-alpha.2",
|
||||
"@nocobase/client": "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/utils": "0.9.0-alpha.2",
|
||||
"antd": "4.22.8",
|
||||
"axios": "^0.27.2",
|
||||
"classnames": "^2.3.1",
|
||||
"cron-parser": "4.4.0",
|
||||
"@formulajs/formulajs": "4.2.0",
|
||||
"json-templates": "^4.2.0",
|
||||
"mathjs": "^10.6.0",
|
||||
"moment": "^2.29.2",
|
||||
"react-js-cron": "^1.4.0"
|
||||
},
|
||||
|
@ -7,14 +7,14 @@ import {
|
||||
useCompile
|
||||
} from '@nocobase/client';
|
||||
import { useFlowContext } from './FlowContext';
|
||||
import { Instruction, instructions, Node } from './nodes';
|
||||
import { Instruction, instructions } from './nodes';
|
||||
import { addButtonClass } from './style';
|
||||
import { NAMESPACE } from './locale';
|
||||
|
||||
|
||||
interface AddButtonProps {
|
||||
upstream;
|
||||
branchIndex?: number;
|
||||
branchIndex?: number | null;
|
||||
};
|
||||
|
||||
export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
||||
@ -31,11 +31,11 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
||||
const config = {};
|
||||
const [optionKey] = keyPath;
|
||||
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);
|
||||
}
|
||||
|
||||
const { data: { data: node } } = await resource.create({
|
||||
const { data: { data: node } } = await resource.create!({
|
||||
values: {
|
||||
type,
|
||||
upstreamId: upstream?.id ?? null,
|
||||
@ -50,6 +50,7 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) {
|
||||
const groups = [
|
||||
{ value: 'control', name: `{{t("Control", { 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}" })}}` },
|
||||
];
|
||||
const instructionList = (Array.from(instructions.getValues()) as Instruction[]);
|
||||
|
@ -9,8 +9,13 @@ export function Branch({
|
||||
entry = null,
|
||||
branchIndex = 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) {
|
||||
list.push(node);
|
||||
}
|
||||
|
@ -16,24 +16,17 @@ import { TriggerConfig } from './triggers';
|
||||
import { Branch } from './Branch';
|
||||
import { ExecutionStatusOptionsMap } from './constants';
|
||||
import { lang } from './locale';
|
||||
import { linkNodes } from './utils';
|
||||
|
||||
|
||||
|
||||
|
||||
function makeNodes(nodes, jobs: any[] = []): void {
|
||||
function attachJobs(nodes, jobs: any[] = []): void {
|
||||
const nodesMap = new Map();
|
||||
nodes.forEach(item => nodesMap.set(item.id, item));
|
||||
const jobsMap = new Map();
|
||||
jobs.forEach(item => jobsMap.set(item.nodeId, 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);
|
||||
}
|
||||
|
||||
if (jobsMap.has(node.id)) {
|
||||
node.job = jobsMap.get(node.id);
|
||||
}
|
||||
@ -63,7 +56,8 @@ export function ExecutionCanvas() {
|
||||
...execution
|
||||
} = data?.data ?? {};
|
||||
|
||||
makeNodes(nodes, jobs);
|
||||
linkNodes(nodes);
|
||||
attachJobs(nodes, jobs);
|
||||
|
||||
const entry = nodes.find(item => !item.upstream);
|
||||
|
||||
|
@ -22,6 +22,7 @@ import { Branch } from './Branch';
|
||||
import { executionSchema } from './schemas/executions';
|
||||
import { ExecutionLink } from './ExecutionLink';
|
||||
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() {
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const { data, refresh, loading } = useResourceActionContext();
|
||||
const { resource, targetKey } = useResourceContext();
|
||||
const { resource } = useResourceContext();
|
||||
const { setTitle } = useDocumentTitle();
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
@ -79,7 +67,7 @@ export function WorkflowCanvas() {
|
||||
|
||||
const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
|
||||
|
||||
makeNodes(nodes);
|
||||
linkNodes(nodes);
|
||||
|
||||
const entry = nodes.find(item => !item.upstream);
|
||||
|
||||
@ -91,11 +79,9 @@ export function WorkflowCanvas() {
|
||||
|
||||
async function onToggle(value) {
|
||||
await resource.update({
|
||||
filterByTk: workflow[targetKey],
|
||||
filterByTk: workflow.id,
|
||||
values: {
|
||||
enabled: value,
|
||||
// NOTE: keep `key` field to adapt for backend
|
||||
key: workflow.key
|
||||
enabled: value
|
||||
}
|
||||
});
|
||||
refresh();
|
||||
@ -103,7 +89,7 @@ export function WorkflowCanvas() {
|
||||
|
||||
async function onRevision() {
|
||||
const { data: { data: revision } } = await resource.revision({
|
||||
filterByTk: workflow[targetKey],
|
||||
filterByTk: workflow.id,
|
||||
filter: {
|
||||
key: workflow.key
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useContext } from 'react';
|
||||
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 { ExecutionPage } from './ExecutionPage';
|
||||
import { triggers } from './triggers';
|
||||
@ -11,6 +11,8 @@ import { WorkflowLink } from './WorkflowLink';
|
||||
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
|
||||
import { ExecutionLink } from './ExecutionLink';
|
||||
import OpenDrawer from './components/OpenDrawer';
|
||||
import { WorkflowTodo } from './nodes/manual/WorkflowTodo';
|
||||
import { WorkflowTodoBlockInitializer } from './nodes/manual/WorkflowTodoBlockInitializer';
|
||||
|
||||
export const WorkflowContext = React.createContext({});
|
||||
|
||||
@ -74,10 +76,12 @@ export const WorkflowProvider = (props) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<RouteSwitchContext.Provider
|
||||
value={{ components: { ...components, WorkflowPage, ExecutionPage }, ...others, routes }}
|
||||
>
|
||||
<WorkflowContext.Provider value={{ triggers, instructions }}>{props.children}</WorkflowContext.Provider>
|
||||
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage, ExecutionPage }, ...others, routes }}>
|
||||
<SchemaComponentOptions components={{ WorkflowTodo, WorkflowTodoBlockInitializer }}>
|
||||
<WorkflowContext.Provider value={{ triggers, instructions }}>
|
||||
{props.children}
|
||||
</WorkflowContext.Provider>
|
||||
</SchemaComponentOptions>
|
||||
</RouteSwitchContext.Provider>
|
||||
</PluginManagerContext.Provider>
|
||||
</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 { CollectionField, CollectionProvider, SchemaComponent, useCollectionManager, useCompile } from "@nocobase/client";
|
||||
import { Operand, parseValue, VariableTypes, VariableTypesContext } from "../calculators";
|
||||
import { lang, NAMESPACE } from "../locale";
|
||||
import { VariableInput } from "./VariableInput";
|
||||
import { lang } from "../locale";
|
||||
import { useWorkflowVariableOptions } from "../variable";
|
||||
|
||||
function AssociationInput(props) {
|
||||
const { getCollectionFields } = useCollectionManager();
|
||||
@ -28,9 +29,10 @@ function AssociationInput(props) {
|
||||
}
|
||||
|
||||
// NOTE: observer for watching useProps
|
||||
export default observer(({ value, onChange }: any) => {
|
||||
export default observer(({ value, disabled, onChange }: any) => {
|
||||
const { t } = useTranslation();
|
||||
const compile = useCompile();
|
||||
const form = useForm();
|
||||
const { getCollection, getCollectionFields } = useCollectionManager();
|
||||
const { values: data } = useForm();
|
||||
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 scope = useWorkflowVariableOptions();
|
||||
const mergedDisabled = disabled || form.disabled;
|
||||
|
||||
return (
|
||||
<fieldset className={css`
|
||||
@ -61,18 +65,11 @@ export default observer(({ value, onChange }: any) => {
|
||||
{fields
|
||||
.filter(field => field.name in value)
|
||||
.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
|
||||
// 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
|
||||
return (
|
||||
<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;
|
||||
}
|
||||
`}>
|
||||
<VariableTypesContext.Provider value={VTypes}>
|
||||
<Operand
|
||||
value={value[field.name]}
|
||||
onChange={(next) => {
|
||||
onChange({ ...value, [field.name]: next });
|
||||
}}
|
||||
>
|
||||
{operand.type === 'constant'
|
||||
? (
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[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);
|
||||
<VariableInput
|
||||
scope={['hasMany', 'belongsToMany'].includes(field.type) ? [] : scope}
|
||||
value={value[field.name]}
|
||||
onChange={(next) => {
|
||||
onChange({ ...value, [field.name]: next });
|
||||
}}
|
||||
>
|
||||
<SchemaComponent
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
[field.name]: {
|
||||
'x-component': ConstantCompoent
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</VariableTypesContext.Provider>
|
||||
</VariableInput>
|
||||
{!mergedDisabled
|
||||
? (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => {
|
||||
const { [field.name]: _, ...rest } = value;
|
||||
onChange(rest);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</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 { Popover, Button } from 'antd';
|
||||
import React, { useRef } from 'react';
|
||||
import { Button, Cascader } from 'antd';
|
||||
import { css } from "@emotion/css";
|
||||
|
||||
import { Input } from "@nocobase/client";
|
||||
|
||||
import { Operand, VariableTypes, VariableTypesContext } from '../calculators';
|
||||
import { lang } from '../locale';
|
||||
|
||||
|
||||
@ -19,9 +18,9 @@ function setNativeInputValue(input, value) {
|
||||
}
|
||||
|
||||
export function VariableJSONInput(props) {
|
||||
const [variable, setVariable] = useState<string>('');
|
||||
const inputRef = useRef(null);
|
||||
const { value, space = 2 } = props;
|
||||
const { value, space = 2, scope } = props;
|
||||
const options = typeof scope === 'function' ? scope() : (scope ?? []);
|
||||
|
||||
function onFormat() {
|
||||
if (!inputRef.current) {
|
||||
@ -38,17 +37,16 @@ export function VariableJSONInput(props) {
|
||||
textArea.focus();
|
||||
}
|
||||
|
||||
function onInsert() {
|
||||
function onInsert(selected) {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!variable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variable = `"{{${selected.join('.')}}}"`;
|
||||
|
||||
const { textArea } = inputRef.current.resizableTextArea;
|
||||
const nextValue = textArea.value.slice(0, textArea.selectionStart) + `"${variable}"` + textArea.value.slice(textArea.selectionEnd);
|
||||
const nextPos = [textArea.selectionStart, textArea.selectionStart + variable.length + 2];
|
||||
const nextValue = textArea.value.slice(0, textArea.selectionStart) + variable + textArea.value.slice(textArea.selectionEnd);
|
||||
const nextPos = [textArea.selectionStart, textArea.selectionStart + variable.length];
|
||||
setNativeInputValue(textArea, nextValue);
|
||||
textArea.setSelectionRange(...nextPos);
|
||||
textArea.focus();
|
||||
@ -56,39 +54,36 @@ export function VariableJSONInput(props) {
|
||||
return (
|
||||
<div className={css`
|
||||
position: relative;
|
||||
.ant-input{
|
||||
width: 100%;
|
||||
}
|
||||
`}>
|
||||
<Input.JSON {...props} ref={inputRef} />
|
||||
<Button.Group
|
||||
className={css`
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
top: 2px;
|
||||
.ant-btn-sm{
|
||||
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.Group
|
||||
className={css`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
.ant-btn-sm{
|
||||
font-size: 85%;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button onClick={onFormat}>{lang('Format')}</Button>
|
||||
<Cascader
|
||||
value={[]}
|
||||
options={options}
|
||||
onChange={onInsert}
|
||||
>
|
||||
<Button size="small">{lang('Use variables')}</Button>
|
||||
</Popover>
|
||||
<Button
|
||||
className={css`
|
||||
font-style: italic;
|
||||
font-family: "New York", "Times New Roman", Times, serif;
|
||||
`}
|
||||
>
|
||||
x
|
||||
</Button>
|
||||
</Cascader>
|
||||
</Button.Group>
|
||||
</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,
|
||||
ClockCircleOutlined,
|
||||
CheckOutlined,
|
||||
MinusOutlined,
|
||||
ExclamationOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { NAMESPACE } from './locale';
|
||||
@ -10,33 +11,45 @@ import { NAMESPACE } from './locale';
|
||||
export const EXECUTION_STATUS = {
|
||||
QUEUEING: null,
|
||||
STARTED: 0,
|
||||
SUCCEEDED: 1,
|
||||
RESOLVED: 1,
|
||||
FAILED: -1,
|
||||
CANCELED: -2
|
||||
ERROR: -2,
|
||||
ABORTED: -3,
|
||||
CANCELED: -4,
|
||||
REJECTED: -5
|
||||
};
|
||||
|
||||
export const ExecutionStatusOptions = [
|
||||
{ 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.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.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 = {
|
||||
PENDING: 0,
|
||||
RESOLVED: 1,
|
||||
REJECTED: -1,
|
||||
CANCELED: -2
|
||||
FAILED: -1,
|
||||
ERROR: -2,
|
||||
ABORTED: -3,
|
||||
CANCELED: -4,
|
||||
REJECTED: -5
|
||||
};
|
||||
|
||||
export const JobStatusOptions = [
|
||||
{ value: JOB_STATUS.PENDING, label: `{{t("Pending", { ns: "${NAMESPACE}" })}}`, color: '#d4c306', icon: <ClockCircleOutlined /> },
|
||||
{ value: JOB_STATUS.RESOLVED, label: `{{t("Succeeded", { ns: "${NAMESPACE}" })}}`, color: '#67c068', icon: <CheckOutlined /> },
|
||||
{ value: JOB_STATUS.REJECTED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: '#f40', icon: <ExclamationOutlined /> },
|
||||
{ value: JOB_STATUS.CANCELED, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`, color: '#f40', icon: <CloseOutlined /> }
|
||||
{ value: JOB_STATUS.PENDING, label: `{{t("Pending", { ns: "${NAMESPACE}" })}}`, color: 'gold', icon: <ClockCircleOutlined /> },
|
||||
{ value: JOB_STATUS.RESOLVED, label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`, color: 'green', icon: <CheckOutlined /> },
|
||||
{ value: JOB_STATUS.FAILED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: 'red', icon: <ExclamationOutlined /> },
|
||||
{ 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 }), {});
|
||||
|
@ -1,5 +1,4 @@
|
||||
export { triggers } from './triggers';
|
||||
export * from './nodes';
|
||||
export { calculators } from './calculators';
|
||||
export * from './FlowContext';
|
||||
export { WorkflowProvider as default } from './WorkflowProvider';
|
||||
|
@ -118,11 +118,11 @@ export default {
|
||||
'Parameters': 'Parameters',
|
||||
'Add parameter': 'Add parameter',
|
||||
'Body': 'Body',
|
||||
'Use variables': '使用变量',
|
||||
'Format': '格式化',
|
||||
'Insert': '插入',
|
||||
'Timeout config': '超时设置',
|
||||
'ms': '毫秒',
|
||||
'Use variable': 'Use variable',
|
||||
'Format': 'Format',
|
||||
'Insert': 'Insert',
|
||||
'Timeout config': 'Timeout config',
|
||||
'ms': 'ms',
|
||||
'Input request data': 'Input request 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',
|
||||
|
@ -58,23 +58,41 @@ export default {
|
||||
'Null': '空值',
|
||||
'Boolean': '逻辑值',
|
||||
'String': '字符串',
|
||||
'Date': '日期',
|
||||
'Calculator': '运算',
|
||||
'Arithmetic calculation': '算术运算',
|
||||
'String operation': '字符串',
|
||||
'System variables': '系统变量',
|
||||
'Current time': '当前时间',
|
||||
|
||||
'Executed at': '执行于',
|
||||
'Queueing': '队列中',
|
||||
'On going': '进行中',
|
||||
'Succeeded': '成功',
|
||||
'Resolved': '完成',
|
||||
'Pending': '待处理',
|
||||
'Failed': '失败',
|
||||
'Pending': '等待处理',
|
||||
'Error': '出错',
|
||||
'Aborted': '已终止',
|
||||
'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?': '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',
|
||||
'Control': '流程控制',
|
||||
'Collection operations': '数据表操作',
|
||||
'Manual': '人工处理',
|
||||
'Extended types': '扩展类型',
|
||||
'Node type': '节点类型',
|
||||
'Calculation': '运算',
|
||||
'Configure calculation': '配置运算',
|
||||
'Calculation engine': '运算引擎',
|
||||
'Basic': '基础',
|
||||
'Calculation expression': '运算表达式',
|
||||
'Expression syntax error': '表达式语法错误',
|
||||
'Syntax references: ': '语法参考:',
|
||||
'Calculation result': '运算结果',
|
||||
'True': '真',
|
||||
'False': '假',
|
||||
@ -83,7 +101,7 @@ export default {
|
||||
'Mode': '模式',
|
||||
'Continue when "Yes"': '“是”则继续',
|
||||
'Branch into "Yes" and "No"': '“是”和“否”分别继续',
|
||||
'Conditions': '条件配置',
|
||||
'Condition expression': '条件表达式',
|
||||
'Parallel branch': '分支',
|
||||
'Add branch': '增加分支',
|
||||
'All succeeded': '全部成功',
|
||||
@ -98,6 +116,23 @@ export default {
|
||||
'Select status': '选择状态',
|
||||
'Succeed and continue': '通过并继续',
|
||||
'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': '新增数据',
|
||||
'Update record': '更新数据',
|
||||
'Query record': '查询数据',
|
||||
@ -118,7 +153,7 @@ export default {
|
||||
'Parameters': '参数',
|
||||
'Add parameter': '添加参数',
|
||||
'Body': '请求体',
|
||||
'Use variables': '使用变量',
|
||||
'Use variable': '使用变量',
|
||||
'Format': '格式化',
|
||||
'Insert': '插入',
|
||||
'Timeout config': '超时设置',
|
||||
@ -127,4 +162,7 @@ export default {
|
||||
'Only support standard JSON data': '仅支持标准 JSON 数据',
|
||||
'"Content-Type" only support "application/json", and no need to specify': '"Content-Type" 请求头仅支持 "application/json",无需填写',
|
||||
'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 { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Registry } from "@nocobase/utils/client";
|
||||
|
||||
import { NodeDefaultView } from ".";
|
||||
import { Branch } from "../Branch";
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
||||
import { Calculation } from "../calculators";
|
||||
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 }) {
|
||||
@ -18,7 +185,7 @@ function CalculationItem({ value, onChange, onRemove }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { calculator, operands = [null] } = value;
|
||||
const { calculator, operands = [] } = value;
|
||||
|
||||
return (
|
||||
<div className={css`
|
||||
@ -73,12 +240,10 @@ function CalculationGroup({ value, onChange }) {
|
||||
<div className={cx('node-type-condition-group', css`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.node-type-condition-group{
|
||||
padding: .5em 1em;
|
||||
border: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
+ button{
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@ -88,7 +253,6 @@ function CalculationGroup({ value, onChange }) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5em;
|
||||
|
||||
.ant-select{
|
||||
width: auto;
|
||||
min-width: 6em;
|
||||
@ -116,7 +280,6 @@ function CalculationGroup({ value, onChange }) {
|
||||
<div className={css`
|
||||
button{
|
||||
padding: 0;
|
||||
|
||||
&:not(:last-child){
|
||||
margin-right: 1em;
|
||||
}
|
||||
@ -155,28 +318,85 @@ export default {
|
||||
enum: [
|
||||
{
|
||||
value: true,
|
||||
label: lang('Continue when "Yes"')
|
||||
label: `{{t('Continue when "Yes"', { ns: "${NAMESPACE}" })}}`
|
||||
},
|
||||
{
|
||||
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': {
|
||||
type: 'string',
|
||||
name: 'config.calculation',
|
||||
title: `{{t("Conditions", { ns: "${NAMESPACE}" })}}`,
|
||||
title: `{{t("Condition", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'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: {
|
||||
|
||||
},
|
||||
options: [
|
||||
{ label: lang('Continue when "Yes"'), key: 'rejectOnFalse', value: { rejectOnFalse: true } },
|
||||
{ label: lang('Branch into "Yes" and "No"'), key: 'branch', value: { rejectOnFalse: false } }
|
||||
{ label: `{{t('Continue when "Yes"', { ns: "${NAMESPACE}" })}}`, key: 'rejectOnFalse', value: { rejectOnFalse: true } },
|
||||
{ label: `{{t('Branch into "Yes" and "No"', { ns: "${NAMESPACE}" })}}`, key: 'branch', value: { rejectOnFalse: false } }
|
||||
],
|
||||
render(data) {
|
||||
const { t } = useTranslation();
|
||||
@ -224,7 +444,13 @@ export default {
|
||||
</NodeDefaultView>
|
||||
)
|
||||
},
|
||||
scope: {
|
||||
renderReference,
|
||||
useWorkflowVariableOptions
|
||||
},
|
||||
components: {
|
||||
CalculationConfig
|
||||
CalculationConfig,
|
||||
VariableTextArea,
|
||||
RadioWithTooltip
|
||||
}
|
||||
};
|
||||
|
@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { useCollectionDataSource } from '@nocobase/client';
|
||||
import { SchemaInitializerItemOptions, useCollectionDataSource } from '@nocobase/client';
|
||||
|
||||
import { collection, values } from '../schemas/collection';
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import CollectionFieldSelect from '../components/CollectionFieldSelect';
|
||||
import CollectionFieldset from '../components/CollectionFieldset';
|
||||
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: {
|
||||
CollectionFieldset
|
||||
},
|
||||
getter({ onChange }) {
|
||||
const { options } = useOperandContext();
|
||||
const { nodes } = useFlowContext();
|
||||
const { config } = nodes.find(n => n.id == options.nodeId);
|
||||
const value = options?.path;
|
||||
getOptions(config, types) {
|
||||
return useCollectionFieldOptions({ collection: config.collection, types });
|
||||
},
|
||||
useInitializers(node): SchemaInitializerItemOptions | null {
|
||||
if (!node.config.collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollectionFieldSelect
|
||||
collection={config.collection}
|
||||
value={value}
|
||||
onChange={(path) => {
|
||||
onChange(`{{$jobsMapByNodeId.${options.nodeId}.${path}}}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return {
|
||||
type: 'item',
|
||||
title: node.title ?? `#${node.id}`,
|
||||
component: CollectionBlockInitializer,
|
||||
collection: node.config.collection,
|
||||
dataSource: `{{$jobsMapByNodeId.${node.id}}}`
|
||||
};
|
||||
},
|
||||
initializers: {
|
||||
CollectionFieldInitializers
|
||||
}
|
||||
};
|
||||
|
@ -1,4 +1,5 @@
|
||||
import Duration from "../components/Duration";
|
||||
import { JOB_STATUS } from "../constants";
|
||||
import { NAMESPACE } from "../locale";
|
||||
|
||||
export default {
|
||||
@ -12,7 +13,8 @@ export default {
|
||||
title: `{{t("Duration", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Duration',
|
||||
default: 60000
|
||||
default: 60000,
|
||||
required: true
|
||||
},
|
||||
'config.endStatus': {
|
||||
type: 'number',
|
||||
@ -24,9 +26,10 @@ export default {
|
||||
placeholder: `{{t("Select status", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
enum: [
|
||||
{ label: `{{t("Succeed and continue", { ns: "${NAMESPACE}" })}}`, value: 1 },
|
||||
{ label: `{{t("Fail and exit", { ns: "${NAMESPACE}" })}}`, value: -1 },
|
||||
]
|
||||
{ label: `{{t("Succeed and continue", { ns: "${NAMESPACE}" })}}`, value: JOB_STATUS.RESOLVED },
|
||||
{ label: `{{t("Fail and exit", { ns: "${NAMESPACE}" })}}`, value: JOB_STATUS.FAILED },
|
||||
],
|
||||
required: true
|
||||
}
|
||||
},
|
||||
view: {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useCollectionDataSource } from '@nocobase/client';
|
||||
|
||||
import { VariableComponent } from '../calculators';
|
||||
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
|
||||
import { collection, filter } from '../schemas/collection';
|
||||
|
||||
export default {
|
||||
@ -26,6 +26,6 @@ export default {
|
||||
useCollectionDataSource
|
||||
},
|
||||
components: {
|
||||
VariableComponent
|
||||
FilterDynamicComponent
|
||||
}
|
||||
};
|
||||
|
@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import parse from 'json-templates';
|
||||
|
||||
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 { AddButton } from '../AddButton';
|
||||
@ -21,6 +21,8 @@ import condition from './condition';
|
||||
import parallel from './parallel';
|
||||
import delay from './delay';
|
||||
|
||||
import manual from './manual';
|
||||
|
||||
import query from './query';
|
||||
import create from './create';
|
||||
import update from './update';
|
||||
@ -28,6 +30,7 @@ import destroy from './destroy';
|
||||
import { JobStatusOptions, JobStatusOptionsMap } from '../constants';
|
||||
import { lang, NAMESPACE } from '../locale';
|
||||
import request from "./request";
|
||||
import { VariableOption } from '../variable';
|
||||
|
||||
export interface Instruction {
|
||||
title: string;
|
||||
@ -38,9 +41,11 @@ export interface Instruction {
|
||||
view?: ISchema;
|
||||
scope?: { [key: string]: any };
|
||||
components?: { [key: string]: any };
|
||||
render?(props): React.ReactElement;
|
||||
render?(props): React.ReactNode;
|
||||
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>();
|
||||
@ -50,6 +55,8 @@ instructions.register('parallel', parallel);
|
||||
instructions.register('calculation', calculation);
|
||||
instructions.register('delay', delay);
|
||||
|
||||
instructions.register('manual', manual);
|
||||
|
||||
instructions.register('query', query);
|
||||
instructions.register('create', create);
|
||||
instructions.register('update', update);
|
||||
@ -70,8 +77,12 @@ function useUpdateAction() {
|
||||
return;
|
||||
}
|
||||
// TODO: how to do validation separately for each field? especially disabled for dynamic fields?
|
||||
// await form.submit();
|
||||
await api.resource('flow_nodes', data.id).update({
|
||||
try {
|
||||
await form.submit();
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
await api.resource('flow_nodes', data.id).update?.({
|
||||
filterByTk: data.id,
|
||||
values: {
|
||||
title: form.values.title,
|
||||
@ -84,12 +95,21 @@ function useUpdateAction() {
|
||||
};
|
||||
};
|
||||
|
||||
const NodeContext = React.createContext(null);
|
||||
export const NodeContext = React.createContext<any>({});
|
||||
|
||||
export function useNodeContext() {
|
||||
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 }) {
|
||||
const instruction = instructions.get(data.type);
|
||||
|
||||
@ -142,7 +162,7 @@ export function RemoveButton() {
|
||||
|
||||
async function onRemove() {
|
||||
async function onOk() {
|
||||
const { data: { data: node } } = await resource.destroy({
|
||||
const { data: { data: node } } = await resource.destroy?.({
|
||||
filterByTk: current.id
|
||||
});
|
||||
onNodeRemoved(node);
|
||||
@ -218,21 +238,24 @@ export function JobButton() {
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
job: {
|
||||
[`${job.id}-button`]: {
|
||||
type: 'void',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
title: icon,
|
||||
title: <Tag color={color}>{icon}</Tag>,
|
||||
shape: 'circle',
|
||||
className: ['workflow-node-job-button', css`
|
||||
background-color: ${color};
|
||||
&:hover,&:focus{
|
||||
background-color: ${color}
|
||||
.ant-tag{
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
line-height: 18px;
|
||||
margin-right: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
`]
|
||||
},
|
||||
properties: {
|
||||
[job.id]: {
|
||||
[`${job.id}-modal`]: {
|
||||
type: 'void',
|
||||
'x-decorator': 'Form',
|
||||
'x-decorator-props': {
|
||||
@ -319,7 +342,7 @@ export function NodeDefaultView(props) {
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
view: instruction.view,
|
||||
...(instruction.view ? { view: instruction.view } : {}),
|
||||
config: {
|
||||
type: 'void',
|
||||
title: detailText,
|
||||
@ -328,7 +351,7 @@ export function NodeDefaultView(props) {
|
||||
type: 'primary',
|
||||
},
|
||||
properties: {
|
||||
[instruction.type]: {
|
||||
[`${instruction.type}_${data.id}`]: {
|
||||
type: 'void',
|
||||
title: instruction.title,
|
||||
'x-component': 'Action.Drawer',
|
||||
@ -371,6 +394,7 @@ export function NodeDefaultView(props) {
|
||||
'x-component': 'fieldset',
|
||||
'x-component-props': {
|
||||
className: css`
|
||||
.ant-input,
|
||||
.ant-select,
|
||||
.ant-cascader-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 { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { i18n } from "@nocobase/client";
|
||||
|
||||
import { NodeDefaultView } from ".";
|
||||
import { Branch } from "../Branch";
|
||||
import { useFlowContext } from '../FlowContext';
|
||||
import { branchBlockClass, nodeSubtreeClass } from "../style";
|
||||
import { lang, NAMESPACE } from "../locale";
|
||||
import { RadioWithTooltip } from "../components/RadioWithTooltip";
|
||||
|
||||
|
||||
|
||||
@ -24,44 +22,26 @@ export default {
|
||||
name: 'config.mode',
|
||||
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Radio.Group',
|
||||
'x-component': 'RadioWithTooltip',
|
||||
'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'
|
||||
}
|
||||
},
|
||||
@ -143,5 +123,8 @@ export default {
|
||||
</div>
|
||||
</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 CollectionFieldSelect from '../components/CollectionFieldSelect';
|
||||
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',
|
||||
fieldset: {
|
||||
'config.collection': collection,
|
||||
'config.multiple': {
|
||||
type: 'boolean',
|
||||
title: `{{t("Multiple records", { ns: "${NAMESPACE}" })}}`,
|
||||
name: 'config.multiple',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
'x-component-props': {
|
||||
disabled: true
|
||||
}
|
||||
},
|
||||
// 'config.multiple': {
|
||||
// type: 'boolean',
|
||||
// title: `{{t("Multiple records", { ns: "${NAMESPACE}" })}}`,
|
||||
// name: 'config.multiple',
|
||||
// 'x-decorator': 'FormItem',
|
||||
// 'x-component': 'Checkbox',
|
||||
// 'x-component-props': {
|
||||
// disabled: true
|
||||
// }
|
||||
// },
|
||||
'config.params': {
|
||||
type: 'object',
|
||||
name: 'config.params',
|
||||
@ -43,23 +42,25 @@ export default {
|
||||
useCollectionDataSource
|
||||
},
|
||||
components: {
|
||||
VariableComponent
|
||||
FilterDynamicComponent
|
||||
},
|
||||
getter(props) {
|
||||
const { onChange } = props;
|
||||
const { nodes } = useFlowContext();
|
||||
const { options } = useOperandContext();
|
||||
const { config } = nodes.find(n => n.id == options.nodeId);
|
||||
const value = options?.path;
|
||||
getOptions(config, types) {
|
||||
return useCollectionFieldOptions({ collection: config.collection, types });
|
||||
},
|
||||
useInitializers(node): SchemaInitializerItemOptions | null {
|
||||
if (!node.config.collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollectionFieldSelect
|
||||
collection={config.collection}
|
||||
value={value}
|
||||
onChange={(path) => {
|
||||
onChange(`{{$jobsMapByNodeId.${options.nodeId}.${path}}}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return {
|
||||
type: 'item',
|
||||
title: node.title ?? `#${node.id}`,
|
||||
component: CollectionBlockInitializer,
|
||||
collection: node.config.collection,
|
||||
dataSource: `{{$jobsMapByNodeId.${node.id}}}`
|
||||
};
|
||||
},
|
||||
initializers: {
|
||||
CollectionFieldInitializers
|
||||
}
|
||||
};
|
||||
|
@ -1,21 +1,13 @@
|
||||
import React from 'react';
|
||||
import { ArrayItems } from '@formily/antd';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { NAMESPACE } from '../locale';
|
||||
import { Operand, VariableTypes, VariableTypesContext } from '../calculators';
|
||||
import { useWorkflowVariableOptions } from '../variable';
|
||||
import { VariableJSONInput } from '../components/VariableJSONInput';
|
||||
import { VariableInput } from '../components/VariableInput';
|
||||
|
||||
|
||||
|
||||
function VariableTypesContextProvider(props) {
|
||||
return (
|
||||
<VariableTypesContext.Provider value={VariableTypes}>
|
||||
{props.children}
|
||||
</VariableTypesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: `{{t("HTTP request", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'request',
|
||||
@ -29,7 +21,6 @@ export default {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
'x-component-props': {
|
||||
defaultValue: 'POST',
|
||||
showSearch: false,
|
||||
allowClear: false,
|
||||
},
|
||||
@ -40,6 +31,7 @@ export default {
|
||||
{ label: 'PATCH', value: 'PATCH' },
|
||||
{ label: 'DELETE', value: 'DELETE' },
|
||||
],
|
||||
default: 'POST'
|
||||
},
|
||||
'config.url': {
|
||||
type: 'string',
|
||||
@ -71,7 +63,6 @@ export default {
|
||||
description: `{{t('"Content-Type" only support "application/json", and no need to specify', { ns: "${NAMESPACE}" })}}`,
|
||||
items: {
|
||||
type: 'object',
|
||||
'x-decorator': 'VariableTypesContextProvider',
|
||||
properties: {
|
||||
space: {
|
||||
type: 'void',
|
||||
@ -88,7 +79,10 @@ export default {
|
||||
value: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Operand',
|
||||
'x-component': 'VariableInput',
|
||||
'x-component-props': {
|
||||
scope: useWorkflowVariableOptions
|
||||
}
|
||||
},
|
||||
remove: {
|
||||
type: 'void',
|
||||
@ -115,7 +109,6 @@ export default {
|
||||
title: `{{t("Parameters", { ns: "${NAMESPACE}" })}}`,
|
||||
items: {
|
||||
type: 'object',
|
||||
'x-decorator': 'VariableTypesContextProvider',
|
||||
properties: {
|
||||
space: {
|
||||
type: 'void',
|
||||
@ -132,7 +125,10 @@ export default {
|
||||
value: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Operand',
|
||||
'x-component': 'VariableInput',
|
||||
'x-component-props': {
|
||||
scope: useWorkflowVariableOptions
|
||||
}
|
||||
},
|
||||
remove: {
|
||||
type: 'void',
|
||||
@ -159,6 +155,7 @@ export default {
|
||||
'x-decorator-props': {},
|
||||
'x-component': 'VariableJSONInput',
|
||||
'x-component-props': {
|
||||
scope: useWorkflowVariableOptions,
|
||||
autoSize: {
|
||||
minRows: 10,
|
||||
},
|
||||
@ -196,8 +193,7 @@ export default {
|
||||
scope: {},
|
||||
components: {
|
||||
ArrayItems,
|
||||
Operand,
|
||||
VariableTypesContextProvider,
|
||||
VariableInput,
|
||||
VariableJSONInput
|
||||
},
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useCollectionDataSource } from '@nocobase/client';
|
||||
|
||||
import { VariableComponent } from '../calculators';
|
||||
import { FilterDynamicComponent } from '../components/FilterDynamicComponent';
|
||||
import CollectionFieldset from '../components/CollectionFieldset';
|
||||
import { NAMESPACE } from '../locale';
|
||||
import { collection, filter, values } from '../schemas/collection';
|
||||
@ -26,7 +26,7 @@ export default {
|
||||
useCollectionDataSource
|
||||
},
|
||||
components: {
|
||||
VariableComponent,
|
||||
FilterDynamicComponent,
|
||||
CollectionFieldset
|
||||
}
|
||||
};
|
||||
|
@ -55,6 +55,6 @@ export const filter = {
|
||||
`
|
||||
};
|
||||
},
|
||||
dynamicComponent: 'VariableComponent'
|
||||
dynamicComponent: 'FilterDynamicComponent'
|
||||
}
|
||||
};
|
||||
|
@ -22,7 +22,7 @@ export const executionCollection = {
|
||||
} as ISchema,
|
||||
},
|
||||
{
|
||||
interface: 'object',
|
||||
interface: 'm2o',
|
||||
type: 'belongsTo',
|
||||
name: 'workflowId',
|
||||
uiSchema: {
|
||||
|
@ -96,7 +96,7 @@ export const workflowSchema: ISchema = {
|
||||
filter: {
|
||||
current: true
|
||||
},
|
||||
sort: ['-enabled', '-createdAt'],
|
||||
sort: ['-createdAt'],
|
||||
except: ['config'],
|
||||
},
|
||||
},
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
export const workflowPageClass = css`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
|
||||
.workflow-toolbar{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -1,16 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { onFieldValueChange } from '@formily/core';
|
||||
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 { css } from '@emotion/css';
|
||||
import { onFieldValueChange } from '@formily/core';
|
||||
import CollectionFieldSelect from '../components/CollectionFieldSelect';
|
||||
import { useCollectionFieldOptions } from '../variable';
|
||||
import { CollectionBlockInitializer } from '../components/CollectionBlockInitializer';
|
||||
import { CollectionFieldInitializers } from '../components/CollectionFieldInitializers';
|
||||
import { NAMESPACE, useWorkflowTranslation } from '../locale';
|
||||
import { useOperandContext } from '../calculators';
|
||||
|
||||
const FieldsSelect = observer((props) => {
|
||||
const compile = useCompile();
|
||||
@ -25,12 +29,7 @@ const FieldsSelect = observer((props) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
{...props}
|
||||
className={css`
|
||||
min-width: 6em;
|
||||
`}
|
||||
>
|
||||
<Select {...props}>
|
||||
{fields
|
||||
.filter(field => (
|
||||
!field.hidden
|
||||
@ -58,8 +57,6 @@ const collectionModeOptions = [
|
||||
{ label: `{{t("After record deleted", { ns: "${NAMESPACE}" })}}`, value: COLLECTION_TRIGGER_MODE.DELETED },
|
||||
];
|
||||
|
||||
|
||||
|
||||
export default {
|
||||
title: `{{t("Collection event", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'collection',
|
||||
@ -140,26 +137,28 @@ export default {
|
||||
components: {
|
||||
FieldsSelect
|
||||
},
|
||||
getOptions(config) {
|
||||
getOptions(config, types) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const fieldOptions = useCollectionFieldOptions({ collection: config.collection, types });
|
||||
const options: any[] = [
|
||||
{ value: 'data', label: t('Trigger data') },
|
||||
...(fieldOptions?.length ? [{ label: t('Trigger data'), key: 'data', value: 'data', children: fieldOptions }] : []),
|
||||
];
|
||||
return options;
|
||||
},
|
||||
getter(props) {
|
||||
const { onChange } = props;
|
||||
const { workflow } = useFlowContext();
|
||||
const { options } = useOperandContext();
|
||||
useInitializers(config): SchemaInitializerItemOptions | null {
|
||||
if (!config.collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CollectionFieldSelect
|
||||
collection={workflow.config.collection}
|
||||
value={options?.path}
|
||||
onChange={(path) => {
|
||||
onChange(`{{$context.data.${path}}}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return {
|
||||
type: 'item',
|
||||
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
|
||||
component: CollectionBlockInitializer,
|
||||
collection: config.collection,
|
||||
dataSource: '{{$context.data}}'
|
||||
};
|
||||
},
|
||||
initializers: {
|
||||
CollectionFieldInitializers
|
||||
}
|
||||
};
|
||||
|
@ -6,7 +6,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useFlowContext } from "../FlowContext";
|
||||
@ -43,13 +43,14 @@ export interface Trigger {
|
||||
title: string;
|
||||
type: 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 };
|
||||
view?: ISchema;
|
||||
scope?: { [key: string]: any };
|
||||
components?: { [key: string]: any };
|
||||
render?(props): React.ReactNode;
|
||||
getter?(node: any): React.ReactNode;
|
||||
useInitializers?(config): SchemaInitializerItemOptions | null;
|
||||
initializers?: any;
|
||||
};
|
||||
|
||||
export const triggers = new Registry<Trigger>();
|
||||
@ -231,3 +232,8 @@ export const TriggerConfig = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTrigger() {
|
||||
const { workflow } = useFlowContext();
|
||||
return triggers.get(workflow.type);
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Cascader } from 'antd';
|
||||
|
||||
import { useCompile, useCollectionDataSource, useCollectionManager } from '@nocobase/client';
|
||||
import { useCollectionDataSource, SchemaInitializerItemOptions } from '@nocobase/client';
|
||||
|
||||
import { ScheduleConfig } from './ScheduleConfig';
|
||||
import { useFlowContext } from '../../FlowContext';
|
||||
import { BaseTypeSet, useOperandContext } from '../../calculators';
|
||||
import { SCHEDULE_MODE } from './constants';
|
||||
import { NAMESPACE, useWorkflowTranslation } from '../../locale';
|
||||
import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers';
|
||||
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
|
||||
import { useCollectionFieldOptions } from '../../variable';
|
||||
|
||||
|
||||
|
||||
export default {
|
||||
title: `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`,
|
||||
@ -27,45 +27,40 @@ export default {
|
||||
components: {
|
||||
ScheduleConfig
|
||||
},
|
||||
getOptions(config) {
|
||||
getOptions(config, types) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const options: any[] = [
|
||||
{ value: 'date', label: t('Trigger time') },
|
||||
];
|
||||
const options: any[] = [];
|
||||
if (!types || types.includes('date')) {
|
||||
options.push({ key: 'date', value: 'date', label: t('Trigger time') });
|
||||
}
|
||||
if (config.mode === SCHEDULE_MODE.COLLECTION_FIELD) {
|
||||
options.push({
|
||||
value: 'data',
|
||||
label: t('Trigger data')
|
||||
});
|
||||
const fieldOptions = useCollectionFieldOptions({ collection: config.collection });
|
||||
|
||||
if (fieldOptions.length) {
|
||||
options.push({
|
||||
key: 'data',
|
||||
value: 'data',
|
||||
label: t('Trigger data'),
|
||||
children: fieldOptions
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
},
|
||||
getter({ onChange }) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
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') {
|
||||
useInitializers(config): SchemaInitializerItemOptions | null {
|
||||
if (!config.collection) {
|
||||
return null;
|
||||
}
|
||||
const collection = collections.find(item => item.name === workflow.config.collection) ?? { fields: [] };
|
||||
return (
|
||||
<Cascader
|
||||
placeholder={t('Trigger data')}
|
||||
value={path}
|
||||
options={collection.fields
|
||||
.filter(field => BaseTypeSet.has(field?.uiSchema?.type))
|
||||
.map(field => ({
|
||||
value: field.name,
|
||||
label: compile(field.uiSchema?.title),
|
||||
}))}
|
||||
onChange={(next) => {
|
||||
onChange(`{{$context.${next.join('.')}}}`);
|
||||
}}
|
||||
allowClear={false}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'item',
|
||||
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
|
||||
component: CollectionBlockInitializer,
|
||||
collection: config.collection,
|
||||
dataSource: '{{$context.data}}'
|
||||
};
|
||||
},
|
||||
initializers: {
|
||||
CollectionFieldInitializers
|
||||
}
|
||||
};
|
||||
|
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 initActions from './actions';
|
||||
import calculators from './calculators';
|
||||
import initCalculationEngines from './calculators';
|
||||
import type { Evaluator } from './calculators';
|
||||
import { EXECUTION_STATUS } from './constants';
|
||||
import extensions from './extensions';
|
||||
import initInstructions, { Instruction } from './instructions';
|
||||
import ExecutionModel from './models/Execution';
|
||||
import JobModel from './models/Job';
|
||||
import WorkflowModel from './models/Workflow';
|
||||
import Processor from './Processor';
|
||||
import initTriggers, { Trigger } from './triggers';
|
||||
import initFunctions from './functions';
|
||||
|
||||
type Pending = [ExecutionModel, JobModel?];
|
||||
export default class WorkflowPlugin extends Plugin {
|
||||
instructions: Registry<Instruction> = new Registry();
|
||||
triggers: Registry<Trigger> = new Registry();
|
||||
calculators = calculators;
|
||||
extensions = extensions;
|
||||
calculators: Registry<Evaluator> = new Registry();
|
||||
functions: Registry<Function> = new Registry();
|
||||
executing: ExecutionModel | null = null;
|
||||
pending: Pending[] = [];
|
||||
events: [WorkflowModel, any, { context?: any }][] = [];
|
||||
@ -74,6 +75,13 @@ export default class WorkflowPlugin extends Plugin {
|
||||
async load() {
|
||||
const { db, options } = this;
|
||||
|
||||
initActions(this);
|
||||
initTriggers(this, options.triggers);
|
||||
initInstructions(this, options.instructions);
|
||||
initCalculationEngines(this);
|
||||
initFunctions(this, options.functions);
|
||||
|
||||
|
||||
this.app.acl.registerSnippet({
|
||||
name: `pm.${this.name}.workflows`,
|
||||
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.afterSave', (model: WorkflowModel) => this.toggle(model));
|
||||
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]:
|
||||
// * load all workflows in db
|
||||
// * add all hooks for enabled workflows
|
||||
@ -200,16 +200,13 @@ export default class WorkflowPlugin extends Plugin {
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
const execution = await this.db.sequelize.transaction(async (transaction) => {
|
||||
const execution = await workflow.createExecution(
|
||||
{
|
||||
context,
|
||||
key: workflow.key,
|
||||
status: EXECUTION_STATUS.CREATED,
|
||||
useTransaction: workflow.useTransaction,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
const execution = await this.db.sequelize.transaction(async transaction => {
|
||||
const execution = await workflow.createExecution({
|
||||
context,
|
||||
key: workflow.key,
|
||||
status: EXECUTION_STATUS.QUEUEING,
|
||||
useTransaction: workflow.useTransaction,
|
||||
}, { transaction });
|
||||
|
||||
const executed = await workflow.countExecutions({ transaction });
|
||||
|
||||
@ -276,7 +273,7 @@ export default class WorkflowPlugin extends Plugin {
|
||||
} else {
|
||||
const execution = (await this.db.getRepository('executions').findOne({
|
||||
filter: {
|
||||
status: EXECUTION_STATUS.CREATED,
|
||||
status: EXECUTION_STATUS.QUEUEING
|
||||
},
|
||||
sort: 'createdAt',
|
||||
})) as ExecutionModel;
|
||||
@ -292,13 +289,13 @@ export default class WorkflowPlugin extends Plugin {
|
||||
private async process(execution: ExecutionModel, job?: JobModel) {
|
||||
this.executing = execution;
|
||||
|
||||
if (execution.status === EXECUTION_STATUS.CREATED) {
|
||||
if (execution.status === EXECUTION_STATUS.QUEUEING) {
|
||||
await execution.update({ status: EXECUTION_STATUS.STARTED });
|
||||
}
|
||||
|
||||
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 {
|
||||
await (job ? processor.resume(job) : processor.start());
|
||||
@ -311,7 +308,7 @@ export default class WorkflowPlugin extends Plugin {
|
||||
this.dispatch();
|
||||
}
|
||||
|
||||
private createProcessor(execution: ExecutionModel, options = {}): Processor {
|
||||
public createProcessor(execution: ExecutionModel, options = {}): Processor {
|
||||
return new Processor(execution, { ...options, plugin: this });
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,6 @@ import Plugin from '.';
|
||||
import ExecutionModel from './models/Execution';
|
||||
import JobModel from './models/Job';
|
||||
import FlowNodeModel from './models/FlowNode';
|
||||
import calculators from './calculators';
|
||||
import { EXECUTION_STATUS, JOB_STATUS } from './constants';
|
||||
|
||||
|
||||
@ -22,11 +21,14 @@ export default class Processor {
|
||||
static StatusMap = {
|
||||
[JOB_STATUS.PENDING]: EXECUTION_STATUS.STARTED,
|
||||
[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.REJECTED]: EXECUTION_STATUS.REJECTED,
|
||||
};
|
||||
|
||||
transaction: Transaction;
|
||||
transaction?: Transaction;
|
||||
|
||||
nodes: FlowNodeModel[] = [];
|
||||
nodesMap = new Map<number, FlowNodeModel>();
|
||||
@ -37,7 +39,7 @@ export default class Processor {
|
||||
}
|
||||
|
||||
// make dual linked nodes list then cache
|
||||
private makeNodes(nodes = []) {
|
||||
private makeNodes(nodes: FlowNodeModel[] = []) {
|
||||
this.nodes = nodes;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
@ -46,11 +48,11 @@ export default class Processor {
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.upstreamId) {
|
||||
node.upstream = this.nodesMap.get(node.upstreamId);
|
||||
node.upstream = this.nodesMap.get(node.upstreamId) as FlowNodeModel;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private async prepare() {
|
||||
public async prepare() {
|
||||
const transaction = await this.getTransaction();
|
||||
this.transaction = transaction;
|
||||
|
||||
@ -139,12 +141,12 @@ export default class Processor {
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
// for uncaught error, set to rejected
|
||||
// for uncaught error, set to error
|
||||
job = {
|
||||
result: err instanceof Error
|
||||
? { message: err.message, stack: process.env.NODE_ENV === 'production' ? [] : err.stack }
|
||||
: err,
|
||||
status: JOB_STATUS.REJECTED,
|
||||
status: JOB_STATUS.ERROR,
|
||||
};
|
||||
// if previous job is from resuming
|
||||
if (prevJob && prevJob.nodeId === node.id) {
|
||||
@ -153,17 +155,11 @@ export default class Processor {
|
||||
}
|
||||
}
|
||||
|
||||
let savedJob;
|
||||
if (job instanceof Model) {
|
||||
savedJob = (await job.save({ transaction: this.transaction })) as unknown as JobModel;
|
||||
} else {
|
||||
const upstreamId = prevJob instanceof Model ? prevJob.get('id') : null;
|
||||
savedJob = await this.saveJob({
|
||||
nodeId: node.id,
|
||||
upstreamId,
|
||||
...job,
|
||||
});
|
||||
if (!(job instanceof Model)) {
|
||||
job.upstreamId = prevJob instanceof Model ? prevJob.get('id') : null;
|
||||
job.nodeId = node.id;
|
||||
}
|
||||
const savedJob = await this.saveJob(job);
|
||||
|
||||
if (savedJob.status === JOB_STATUS.RESOLVED && node.downstream) {
|
||||
// run next node
|
||||
@ -244,7 +240,7 @@ export default class Processor {
|
||||
getBranches(node: FlowNodeModel): FlowNodeModel[] {
|
||||
return this.nodes
|
||||
.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
|
||||
@ -274,7 +270,7 @@ export default class Processor {
|
||||
}
|
||||
|
||||
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) {
|
||||
return j;
|
||||
}
|
||||
@ -282,20 +278,24 @@ export default class Processor {
|
||||
return null;
|
||||
}
|
||||
|
||||
public getParsedValue(value, node?) {
|
||||
const injectedFns = {};
|
||||
public getScope(node?) {
|
||||
const systemFns = {};
|
||||
const scope = {
|
||||
execution: this.execution,
|
||||
node
|
||||
};
|
||||
for (let [name, fn] of calculators.getEntities()) {
|
||||
injectedFns[name] = fn.bind(scope);
|
||||
for (let [name, fn] of this.options.plugin.functions.getEntities()) {
|
||||
systemFns[name] = fn.bind(scope);
|
||||
}
|
||||
|
||||
return parse(value)({
|
||||
return {
|
||||
$context: this.execution.context,
|
||||
$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', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
let PostModel;
|
||||
let PostRepo;
|
||||
let WorkflowModel;
|
||||
|
||||
@ -16,7 +15,6 @@ describe('workflow > Plugin', () => {
|
||||
app = await getApp();
|
||||
db = app.db;
|
||||
WorkflowModel = db.getCollection('workflows').model;
|
||||
PostModel = db.getCollection('posts').model;
|
||||
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', () => {
|
||||
it('trigger should not be triggered more than once in same execution', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
|
@ -121,12 +121,12 @@ describe('workflow > Processor', () => {
|
||||
await sleep(500);
|
||||
|
||||
const [execution] = await workflow.getExecutions();
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.ERROR);
|
||||
|
||||
const jobs = await execution.getJobs();
|
||||
expect(jobs.length).toEqual(1);
|
||||
const { status, result } = jobs[0].get();
|
||||
expect(status).toEqual(JOB_STATUS.REJECTED);
|
||||
expect(status).toEqual(JOB_STATUS.ERROR);
|
||||
expect(result.message).toBe('definite error');
|
||||
});
|
||||
});
|
||||
@ -134,7 +134,7 @@ describe('workflow > Processor', () => {
|
||||
describe('manual nodes', () => {
|
||||
it('manual node should suspend execution, and could be manually resume', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'prompt',
|
||||
type: 'manual',
|
||||
});
|
||||
|
||||
const n2 = await workflow.createNode({
|
||||
@ -154,7 +154,10 @@ describe('workflow > Processor', () => {
|
||||
expect(pending.status).toEqual(JOB_STATUS.PENDING);
|
||||
expect(pending.result).toEqual(null);
|
||||
|
||||
pending.set('result', 123);
|
||||
pending.set({
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: 123
|
||||
});
|
||||
pending.execution = execution;
|
||||
await plugin.resume(pending);
|
||||
|
||||
@ -196,11 +199,11 @@ describe('workflow > Processor', () => {
|
||||
|
||||
await sleep(500);
|
||||
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.ERROR);
|
||||
|
||||
const jobs = await execution.getJobs();
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -245,7 +248,7 @@ describe('workflow > Processor', () => {
|
||||
});
|
||||
|
||||
const n2 = await workflow.createNode({
|
||||
type: 'prompt',
|
||||
type: 'manual',
|
||||
branchIndex: BRANCH_INDEX.ON_TRUE,
|
||||
upstreamId: n1.id
|
||||
});
|
||||
@ -265,7 +268,10 @@ describe('workflow > Processor', () => {
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.STARTED);
|
||||
|
||||
const [pending] = await execution.getJobs({ where: { nodeId: n2.id } });
|
||||
pending.set('result', 123);
|
||||
pending.set({
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: 123
|
||||
});
|
||||
pending.execution = execution;
|
||||
await plugin.resume(pending);
|
||||
|
||||
@ -275,7 +281,7 @@ describe('workflow > Processor', () => {
|
||||
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({
|
||||
type: 'condition',
|
||||
// no config means always true
|
||||
@ -308,7 +314,7 @@ describe('workflow > Processor', () => {
|
||||
|
||||
await sleep(500);
|
||||
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.REJECTED);
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.ERROR);
|
||||
|
||||
const jobs = await execution.getJobs();
|
||||
expect(jobs.length).toEqual(2);
|
||||
@ -328,7 +334,7 @@ describe('workflow > Processor', () => {
|
||||
});
|
||||
|
||||
const n3 = await workflow.createNode({
|
||||
type: 'prompt',
|
||||
type: 'manual',
|
||||
upstreamId: n2.id,
|
||||
branchIndex: 0
|
||||
});
|
||||
@ -359,7 +365,10 @@ describe('workflow > Processor', () => {
|
||||
expect(pendingJobs.length).toBe(4);
|
||||
|
||||
const pending = pendingJobs.find(item => item.nodeId === n3.id );
|
||||
pending.set('result', 123);
|
||||
pending.set({
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: 123
|
||||
});
|
||||
pending.execution = execution;
|
||||
await plugin.resume(pending);
|
||||
|
||||
@ -376,7 +385,7 @@ describe('workflow > Processor', () => {
|
||||
});
|
||||
|
||||
const n2 = await workflow.createNode({
|
||||
type: 'prompt',
|
||||
type: 'manual',
|
||||
upstreamId: n1.id,
|
||||
branchIndex: 0
|
||||
});
|
||||
@ -413,7 +422,10 @@ describe('workflow > Processor', () => {
|
||||
expect(pendingJobs.length).toBe(4);
|
||||
|
||||
const pending = pendingJobs.find(item => item.nodeId === n2.id );
|
||||
pending.set('result', 123);
|
||||
pending.set({
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: 123
|
||||
});
|
||||
pending.execution = e1;
|
||||
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', () => {
|
||||
it('create revision', async () => {
|
||||
const w1 = await WorkflowModel.create({
|
||||
@ -193,21 +261,8 @@ describe('workflow > actions > workflows', () => {
|
||||
const n2 = await w1.createNode({
|
||||
type: 'calculation',
|
||||
config: {
|
||||
calculation: {
|
||||
calculator: 'add',
|
||||
operands: [
|
||||
{
|
||||
type: '$jobsMapByNodeId',
|
||||
options: {
|
||||
nodeId: n1.id,
|
||||
path: 'data.read'
|
||||
}
|
||||
},
|
||||
{
|
||||
value: `{{$jobsMapByNodeId.${n1.id}.data.read}}`
|
||||
}
|
||||
]
|
||||
}
|
||||
engine: 'math.js',
|
||||
expression: `{{$jobsMapByNodeId.${n1.id}.data.read}} + {{$jobsMapByNodeId.${n1.id}.data.read}}`
|
||||
},
|
||||
upstreamId: n1.id
|
||||
});
|
||||
@ -231,21 +286,8 @@ describe('workflow > actions > workflows', () => {
|
||||
expect(n1_2.type).toBe('echo');
|
||||
expect(n2_2.type).toBe('calculation');
|
||||
expect(n2_2.config).toMatchObject({
|
||||
calculation: {
|
||||
calculator: 'add',
|
||||
operands: [
|
||||
{
|
||||
type: '$jobsMapByNodeId',
|
||||
options: {
|
||||
nodeId: n1_2.id,
|
||||
path: 'data.read'
|
||||
}
|
||||
},
|
||||
{
|
||||
value: `{{$jobsMapByNodeId.${n1_2.id}.data.read}}`
|
||||
}
|
||||
]
|
||||
}
|
||||
engine: 'math.js',
|
||||
expression: `{{$jobsMapByNodeId.${n1_2.id}.data.read}} + {{$jobsMapByNodeId.${n1_2.id}.data.read}}`
|
||||
});
|
||||
|
||||
await w2.update({ enabled: true });
|
||||
|
@ -45,17 +45,14 @@ export async function getApp({ manual, ...options }: MockAppOptions = {}): Promi
|
||||
},
|
||||
resume(node, input, execution) {
|
||||
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.db.import({
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Application } from '@nocobase/server';
|
||||
import Database from '@nocobase/database';
|
||||
import { getApp, sleep } from '..';
|
||||
import { JOB_STATUS } from '../../constants';
|
||||
|
||||
|
||||
|
||||
@ -31,15 +32,32 @@ describe('workflow > instructions > calculation', () => {
|
||||
|
||||
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 () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'calculation',
|
||||
config: {
|
||||
calculation: {
|
||||
calculator: 'add',
|
||||
operands: [1, 1]
|
||||
}
|
||||
engine: 'math.js',
|
||||
expression: ' 1 + 1 '
|
||||
}
|
||||
});
|
||||
|
||||
@ -52,40 +70,12 @@ describe('workflow > instructions > calculation', () => {
|
||||
expect(job.result).toBe(2);
|
||||
});
|
||||
|
||||
it('constant (legacy)', async () => {
|
||||
it('$context', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'calculation',
|
||||
config: {
|
||||
calculation: {
|
||||
calculator: 'add',
|
||||
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' } }
|
||||
]
|
||||
}
|
||||
engine: 'math.js',
|
||||
expression: '{{$context.data.read}} + 1',
|
||||
}
|
||||
});
|
||||
|
||||
@ -98,27 +88,7 @@ describe('workflow > instructions > calculation', () => {
|
||||
expect(job.result).toBe(2);
|
||||
});
|
||||
|
||||
it('context by json-template', 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 () => {
|
||||
it('$jobsMapByNodeId', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'echo'
|
||||
});
|
||||
@ -126,13 +96,8 @@ describe('workflow > instructions > calculation', () => {
|
||||
const n2 = await workflow.createNode({
|
||||
type: 'calculation',
|
||||
config: {
|
||||
calculation: {
|
||||
calculator: 'add',
|
||||
operands: [
|
||||
{ value: 1 },
|
||||
{ type: '$jobsMapByNodeId', options: { nodeId: n1.id, path: 'data.read' } }
|
||||
]
|
||||
}
|
||||
engine: 'math.js',
|
||||
expression: `{{$jobsMapByNodeId.${n1.id}.data.read}} + 1`,
|
||||
},
|
||||
upstreamId: n1.id
|
||||
});
|
||||
@ -148,44 +113,12 @@ describe('workflow > instructions > calculation', () => {
|
||||
expect(n2Job.result).toBe(1);
|
||||
});
|
||||
|
||||
it('job result by json-template', 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 () => {
|
||||
it('$system', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'calculation',
|
||||
config: {
|
||||
calculation: {
|
||||
calculator: 'add',
|
||||
operands: [
|
||||
{ value: 1 },
|
||||
{ value: '{{$fn.no1}}' }
|
||||
]
|
||||
}
|
||||
engine: 'math.js',
|
||||
expression: '1 + {{$system.no1}}',
|
||||
}
|
||||
});
|
||||
|
||||
@ -199,24 +132,13 @@ describe('workflow > instructions > calculation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested operands', () => {
|
||||
it('1 + ( 0 - 2 )', async () => {
|
||||
describe('formula.js', () => {
|
||||
it('string variable with quote should be wrong result', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'calculation',
|
||||
config: {
|
||||
calculation: {
|
||||
calculator: 'add',
|
||||
operands: [
|
||||
{ value: 1 },
|
||||
{
|
||||
type: '$calculation',
|
||||
options: {
|
||||
calculator: 'minus',
|
||||
operands: ['{{$context.data.read}}', 2]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
engine: 'formula.js',
|
||||
expression: `CONCATENATE('a', '{{$context.data.title}}')`,
|
||||
}
|
||||
});
|
||||
|
||||
@ -226,7 +148,25 @@ describe('workflow > instructions > calculation', () => {
|
||||
|
||||
const [execution] = await workflow.getExecutions();
|
||||
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',
|
||||
type: 'condition',
|
||||
config: {
|
||||
// (1 === 1): true
|
||||
calculation: {
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 1 }, { value: 1 }]
|
||||
}
|
||||
engine: 'math.js',
|
||||
calculation: '1 == 1'
|
||||
}
|
||||
});
|
||||
|
||||
@ -82,11 +79,9 @@ describe('workflow > instructions > condition', () => {
|
||||
title: 'condition',
|
||||
type: 'condition',
|
||||
config: {
|
||||
// (0 === 1): false
|
||||
calculation: {
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 0 }, { value: 1 }]
|
||||
}
|
||||
engine: 'math.js',
|
||||
// false
|
||||
calculation: '0 == 1'
|
||||
}
|
||||
});
|
||||
|
||||
@ -115,10 +110,6 @@ describe('workflow > instructions > condition', () => {
|
||||
expect(jobs.length).toEqual(2);
|
||||
expect(jobs[1].result).toEqual(false);
|
||||
});
|
||||
|
||||
it('not', async () => {
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('group calculation', () => {
|
||||
@ -126,18 +117,13 @@ describe('workflow > instructions > condition', () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'condition',
|
||||
config: {
|
||||
engine: 'math.js',
|
||||
calculation: {
|
||||
group: {
|
||||
type: 'and',
|
||||
calculations: [
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 1 }, { value: 1 }]
|
||||
},
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 1 }, { value: 1 }]
|
||||
}
|
||||
'1 == 1',
|
||||
'1 == 1',
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -157,18 +143,13 @@ describe('workflow > instructions > condition', () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'condition',
|
||||
config: {
|
||||
engine: 'math.js',
|
||||
calculation: {
|
||||
group: {
|
||||
type: 'and',
|
||||
calculations: [
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 1 }, { value: 1 }]
|
||||
},
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 0 }, { value: 1 }]
|
||||
}
|
||||
'1 == 1',
|
||||
'0 == 1',
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -188,18 +169,13 @@ describe('workflow > instructions > condition', () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'condition',
|
||||
config: {
|
||||
engine: 'math.js',
|
||||
calculation: {
|
||||
group: {
|
||||
type: 'or',
|
||||
calculations: [
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 1 }, { value: 1 }]
|
||||
},
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 0 }, { value: 1 }]
|
||||
}
|
||||
'1 == 1',
|
||||
'0 == 1',
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -219,18 +195,13 @@ describe('workflow > instructions > condition', () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'condition',
|
||||
config: {
|
||||
engine: 'math.js',
|
||||
calculation: {
|
||||
group: {
|
||||
type: 'and',
|
||||
calculations: [
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 0 }, { value: 1 }]
|
||||
},
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 0 }, { value: 1 }]
|
||||
}
|
||||
'0 == 1',
|
||||
'0 == 1',
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -250,20 +221,18 @@ describe('workflow > instructions > condition', () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'condition',
|
||||
config: {
|
||||
engine: 'math.js',
|
||||
calculation: {
|
||||
group: {
|
||||
type: 'and',
|
||||
calculations: [
|
||||
{
|
||||
calculator: 'equal',
|
||||
operands: [{ value: 1 }, { value: 1 }]
|
||||
},
|
||||
'1 == 1',
|
||||
{
|
||||
group: {
|
||||
type: 'or',
|
||||
calculations: [
|
||||
{ calculator: 'equal', operands: [{ value: 0 }, { value: 1 }] },
|
||||
{ calculator: 'equal', operands: [{ value: 0 }, { value: 1 }] }
|
||||
'0 == 1',
|
||||
'0 == 1',
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -282,4 +251,95 @@ describe('workflow > instructions > condition', () => {
|
||||
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',
|
||||
config: {
|
||||
duration: 2000,
|
||||
endStatus: JOB_STATUS.REJECTED,
|
||||
},
|
||||
endStatus: JOB_STATUS.FAILED
|
||||
}
|
||||
});
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||
@ -78,9 +78,9 @@ describe('workflow > instructions > delay', () => {
|
||||
await sleep(2000);
|
||||
|
||||
const [e2] = await workflow.getExecutions();
|
||||
expect(e2.status).toEqual(EXECUTION_STATUS.REJECTED);
|
||||
expect(e2.status).toEqual(EXECUTION_STATUS.FAILED);
|
||||
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 () => {
|
||||
@ -117,10 +117,10 @@ describe('workflow > instructions > delay', () => {
|
||||
await sleep(2000);
|
||||
|
||||
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']] });
|
||||
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 { Application } from '@nocobase/server';
|
||||
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);
|
||||
|
||||
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']] });
|
||||
expect(jobs.length).toBe(3);
|
||||
});
|
||||
@ -106,7 +106,7 @@ describe('workflow > instructions > parallel', () => {
|
||||
await sleep(500);
|
||||
|
||||
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']] });
|
||||
expect(jobs.length).toBe(2);
|
||||
});
|
||||
@ -192,7 +192,7 @@ describe('workflow > instructions > parallel', () => {
|
||||
await sleep(500);
|
||||
|
||||
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']] });
|
||||
expect(jobs.length).toBe(3);
|
||||
});
|
||||
@ -250,7 +250,7 @@ describe('workflow > instructions > parallel', () => {
|
||||
await sleep(500);
|
||||
|
||||
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']] });
|
||||
expect(jobs.length).toBe(2);
|
||||
});
|
||||
@ -359,8 +359,8 @@ describe('workflow > instructions > parallel', () => {
|
||||
});
|
||||
|
||||
const n2 = await workflow.createNode({
|
||||
title: 'prompt',
|
||||
type: 'prompt',
|
||||
title: 'manual',
|
||||
type: 'manual',
|
||||
upstreamId: n1.id,
|
||||
branchIndex: 0
|
||||
});
|
||||
@ -384,18 +384,22 @@ describe('workflow > instructions > parallel', () => {
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const [execution] = await workflow.getExecutions();
|
||||
expect(execution.status).toBe(EXECUTION_STATUS.STARTED);
|
||||
const [e1] = await workflow.getExecutions();
|
||||
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
||||
|
||||
const [pending] = await execution.getJobs({ where: { nodeId: n2.id } });
|
||||
pending.set('result', 123);
|
||||
pending.execution = execution;
|
||||
await plugin.resume(pending);
|
||||
const [pending] = await e1.getJobs({ where: { nodeId: n2.id } });
|
||||
pending.set({
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: 123
|
||||
});
|
||||
pending.execution = e1;
|
||||
plugin.resume(pending);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
||||
const [e2] = await workflow.getExecutions();
|
||||
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||
const jobs = await e2.getJobs({ order: [['id', 'ASC']] });
|
||||
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 { getApp, sleep } from '..';
|
||||
import { RequestConfig } from '../../instructions/request';
|
||||
import { JOB_STATUS } from '../../constants';
|
||||
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
|
||||
|
||||
const PORT = 12345;
|
||||
|
||||
@ -72,6 +72,7 @@ describe('workflow > instructions > request', () => {
|
||||
await sleep(500);
|
||||
|
||||
let [execution] = await workflow.getExecutions();
|
||||
expect(execution.status).toEqual(EXECUTION_STATUS.RESOLVED);
|
||||
let [job] = await execution.getJobs();
|
||||
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||
});
|
||||
@ -92,7 +93,7 @@ describe('workflow > instructions > request', () => {
|
||||
|
||||
let [execution] = await workflow.getExecutions();
|
||||
let [job] = await execution.getJobs();
|
||||
expect(job.status).toEqual(JOB_STATUS.REJECTED);
|
||||
expect(job.status).toEqual(JOB_STATUS.FAILED);
|
||||
|
||||
expect(job.result).toMatchObject({
|
||||
code: 'ECONNABORTED',
|
||||
@ -144,7 +145,7 @@ describe('workflow > instructions > request', () => {
|
||||
|
||||
let [execution] = await workflow.getExecutions();
|
||||
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);
|
||||
});
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Application } from '@nocobase/server';
|
||||
import Database from '@nocobase/database';
|
||||
import { getApp, sleep } from '..';
|
||||
import DefinedWorkflowModel from '../../models/Workflow';
|
||||
|
||||
|
||||
|
||||
@ -9,7 +10,7 @@ describe('workflow > instructions > update', () => {
|
||||
let db: Database;
|
||||
let PostRepo;
|
||||
let WorkflowModel;
|
||||
let workflow;
|
||||
let workflow: DefinedWorkflowModel;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await getApp();
|
||||
|
@ -1,6 +1,5 @@
|
||||
import * as workflows from './workflows';
|
||||
import * as nodes from './nodes';
|
||||
import * as jobs from './jobs';
|
||||
|
||||
function make(name, mod) {
|
||||
return Object.keys(mod).reduce((result, key) => ({
|
||||
@ -19,6 +18,5 @@ export default function({ app }) {
|
||||
...make('flow_nodes', {
|
||||
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 { Repository } from '@nocobase/database';
|
||||
import { Op, Repository } from '@nocobase/database';
|
||||
|
||||
export async function update(context: Context, next) {
|
||||
const repository = utils.getRepositoryFromParams(context) as Repository;
|
||||
const { filterByTk, values } = context.action.params;
|
||||
context.action.mergeParams({
|
||||
whitelist: ['title', 'description', 'enabled', 'config']
|
||||
});
|
||||
// only enable/disable
|
||||
if (Object.keys(values).sort().join() !== 'enabled,key'){
|
||||
if (Object.keys(values).includes('config')){
|
||||
const workflow = await repository.findById(filterByTk);
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (Array.isArray(value)) {
|
||||
return 'array';
|
||||
@ -43,16 +80,13 @@ function migrateConfig(config, oldToNew) {
|
||||
case 'array':
|
||||
return value.map(item => migrate(item));
|
||||
case 'string':
|
||||
const matcher = value.match(/(\{\{\$jobsMapByNodeId\.)([\w-]+)/);
|
||||
if (!matcher) {
|
||||
return value;
|
||||
}
|
||||
const oldNodeId = Number.parseInt(matcher[2], 10);
|
||||
const newNode = oldToNew.get(oldNodeId);
|
||||
if (!newNode) {
|
||||
throw new Error('node configurated for result is not existed');
|
||||
}
|
||||
return value.replace(matcher[0], `{{$jobsMapByNodeId.${newNode.id}`);
|
||||
return value.replace(/(\{\{\$jobsMapByNodeId\.)([\w-]+)/g, (_, jobVar, oldNodeId) => {
|
||||
const newNode = oldToNew.get(Number.parseInt(oldNodeId, 10));
|
||||
if (!newNode) {
|
||||
throw new Error('node configurated for result is not existed');
|
||||
}
|
||||
return `{{$jobsMapByNodeId.${newNode.id}`;
|
||||
});
|
||||
default:
|
||||
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 { Registry } from "@nocobase/utils";
|
||||
import { get } from 'lodash';
|
||||
|
||||
import JobModel from '../models/Job';
|
||||
import Processor from '../Processor';
|
||||
import Plugin from "..";
|
||||
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 type ObjectGetterOptions = {
|
||||
type?: string;
|
||||
path?: string;
|
||||
export default function(plugin: Plugin) {
|
||||
plugin.calculators.register('basic', basic);
|
||||
plugin.calculators.register('math.js', mathjs);
|
||||
plugin.calculators.register('formula.js', formulajs);
|
||||
};
|
||||
|
||||
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',
|
||||
defaultValue: false
|
||||
},
|
||||
// @deprecated
|
||||
{
|
||||
type: 'uuid',
|
||||
name: 'transaction',
|
||||
defaultValue: null
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'jobs',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
{
|
||||
type: 'jsonb',
|
||||
type: 'json',
|
||||
name: 'context',
|
||||
},
|
||||
{
|
||||
|
@ -4,29 +4,22 @@ export default {
|
||||
namespace: 'workflow',
|
||||
duplicator: 'required',
|
||||
name: 'flow_nodes',
|
||||
// model: 'FlowNodeModel',
|
||||
title: 'Workflow Nodes',
|
||||
fields: [
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
title: '名称'
|
||||
},
|
||||
// which workflow belongs to
|
||||
{
|
||||
interface: 'linkTo',
|
||||
name: 'workflow',
|
||||
type: 'belongsTo',
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
name: 'upstream',
|
||||
type: 'belongsTo',
|
||||
target: 'flow_nodes'
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
name: 'branches',
|
||||
type: 'hasMany',
|
||||
target: 'flow_nodes',
|
||||
@ -37,31 +30,24 @@ export default {
|
||||
// put here because the design of flow-links model is not really necessary for now.
|
||||
// or it should be put into flow-links model.
|
||||
{
|
||||
interface: 'select',
|
||||
name: 'branchIndex',
|
||||
type: 'integer',
|
||||
title: 'branch index'
|
||||
},
|
||||
// Note: for reasons:
|
||||
// 1. redirect type node to solve cycle flow.
|
||||
// 2. recognize as real next node after branches.
|
||||
{
|
||||
interface: 'linkTo',
|
||||
name: 'downstream',
|
||||
type: 'belongsTo',
|
||||
target: 'flow_nodes'
|
||||
},
|
||||
{
|
||||
interface: 'select',
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
title: '类型'
|
||||
},
|
||||
{
|
||||
interface: 'json',
|
||||
type: 'jsonb',
|
||||
type: 'json',
|
||||
name: 'config',
|
||||
title: '配置',
|
||||
defaultValue: {}
|
||||
}
|
||||
]
|
||||
|
@ -24,7 +24,7 @@ export default {
|
||||
name: 'status'
|
||||
},
|
||||
{
|
||||
type: 'jsonb',
|
||||
type: 'json',
|
||||
name: 'result'
|
||||
}
|
||||
]
|
||||
|
@ -30,7 +30,7 @@ export default function () {
|
||||
required: true
|
||||
},
|
||||
{
|
||||
type: 'jsonb',
|
||||
type: 'json',
|
||||
name: 'config',
|
||||
required: true,
|
||||
defaultValue: {}
|
||||
@ -43,11 +43,13 @@ export default function () {
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'nodes',
|
||||
target: 'flow_nodes'
|
||||
target: 'flow_nodes',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
name: 'executions'
|
||||
name: 'executions',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
{
|
||||
type: 'integer',
|
||||
@ -62,7 +64,7 @@ export default function () {
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'current',
|
||||
defaultValue: null
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
type: 'hasMany',
|
||||
|
@ -1,16 +1,22 @@
|
||||
export const EXECUTION_STATUS = {
|
||||
CREATED: null,
|
||||
QUEUEING: null,
|
||||
STARTED: 0,
|
||||
RESOLVED: 1,
|
||||
REJECTED: -1,
|
||||
CANCELED: -2
|
||||
FAILED: -1,
|
||||
ERROR: -2,
|
||||
ABORTED: -3,
|
||||
CANCELED: -4,
|
||||
REJECTED: -5,
|
||||
};
|
||||
|
||||
export const JOB_STATUS = {
|
||||
PENDING: 0,
|
||||
RESOLVED: 1,
|
||||
REJECTED: -1,
|
||||
CANCELED: -2
|
||||
FAILED: -1,
|
||||
ERROR: -2,
|
||||
ABORTED: -3,
|
||||
CANCELED: -4,
|
||||
REJECTED: -5,
|
||||
};
|
||||
|
||||
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 FlowNodeModel from "../models/FlowNode";
|
||||
import { calculate } from "../calculators";
|
||||
import { Instruction } from ".";
|
||||
|
||||
// @calculation: {
|
||||
// calculator: 'concat',
|
||||
// operands: [
|
||||
// {
|
||||
// type: 'calculation',
|
||||
// options: {
|
||||
// calculator: 'add',
|
||||
// operands: [{ value: 1 }, { value: 2 }]
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// type: 'constant',
|
||||
// value: '{{$context.data.title}}'
|
||||
// },
|
||||
// {
|
||||
// type: 'context',
|
||||
// options: {
|
||||
// path: 'data.title'
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// type: 'constant',
|
||||
// value: 1
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
|
||||
interface CalculationConfig {
|
||||
engine: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
async run(node: FlowNodeModel, prevJob, processor) {
|
||||
const { calculation } = node.config || {};
|
||||
async run(node: FlowNodeModel, prevJob, processor: Processor) {
|
||||
const { engine, expression = '' } = <CalculationConfig>node.config || {};
|
||||
const evaluator = <Evaluator | undefined>processor.options.plugin.calculators.get(engine);
|
||||
const scope = processor.getScope();
|
||||
|
||||
const result = calculation
|
||||
? calculate({
|
||||
type: '$calculation',
|
||||
options: processor.getParsedValue(calculation)
|
||||
}, prevJob, processor)
|
||||
: null;
|
||||
|
||||
return {
|
||||
result,
|
||||
status: JOB_STATUS.RESOLVED
|
||||
};
|
||||
try {
|
||||
const result = evaluator && expression
|
||||
? evaluator(expression, scope)
|
||||
: null;
|
||||
return {
|
||||
result,
|
||||
status: JOB_STATUS.RESOLVED
|
||||
};
|
||||
} catch (e) {
|
||||
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 FlowNodeModel from "../models/FlowNode";
|
||||
import { Instruction } from ".";
|
||||
|
||||
type BaseCalculation = {
|
||||
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) {
|
||||
function logicCalculate(calculation, evaluator, scope) {
|
||||
if (!calculation) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { not, group } = calculation;
|
||||
let result;
|
||||
if (group) {
|
||||
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);
|
||||
if (typeof calculation === 'object' && calculation.group) {
|
||||
const method = calculation.group.type === 'and' ? 'every' : 'some';
|
||||
return calculation.group.calculations[method](item => logicCalculate(item, evaluator, scope));
|
||||
}
|
||||
|
||||
return not ? !result : result;
|
||||
return evaluator(calculation, scope);
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
// 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) {
|
||||
return {
|
||||
status: JOB_STATUS.REJECTED,
|
||||
status: JOB_STATUS.FAILED,
|
||||
result
|
||||
};
|
||||
}
|
||||
@ -107,7 +69,7 @@ export default {
|
||||
return processor.run(branchNode, savedJob);
|
||||
},
|
||||
|
||||
async resume(node, branchJob, processor) {
|
||||
async resume(node: FlowNodeModel, branchJob, processor: Processor) {
|
||||
if (branchJob.status === JOB_STATUS.RESOLVED) {
|
||||
// return to continue node.downstream
|
||||
return branchJob;
|
||||
@ -116,4 +78,4 @@ export default {
|
||||
// pass control to upper scope by ending current scope
|
||||
return processor.end(node, branchJob);
|
||||
}
|
||||
};
|
||||
} as Instruction;
|
||||
|
@ -39,7 +39,7 @@ export default function<T extends Instruction>(
|
||||
'condition',
|
||||
'parallel',
|
||||
'delay',
|
||||
'prompt',
|
||||
'manual',
|
||||
'query',
|
||||
'create',
|
||||
'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