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:
Junyi 2023-02-20 11:52:06 +08:00 committed by GitHub
parent 651200f3ab
commit 4fbad75ea9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
111 changed files with 4844 additions and 2721 deletions

View File

@ -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__/',

View File

@ -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"}

View File

@ -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);

View File

@ -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": "下拉菜单(单选)",

View File

@ -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();

View File

@ -69,7 +69,7 @@ export const Action: ComposedAction = observer((props: any) => {
const {
popover,
confirm,
// openMode,
openMode: om,
containerRefKey,
component,
useAction = useA,

View File

@ -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);

View File

@ -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> */}

View File

@ -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>
);
});

View File

@ -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);

View File

@ -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);
},
};
};

View File

@ -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();

View File

@ -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);

View File

@ -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'],

View File

@ -12,6 +12,7 @@ export const TabsDesigner = () => {
return (
<GeneralSchemaDesigner disableInitializer>
<SchemaSettings.ModalItem
key="edit"
title={t('Edit')}
schema={
{

View File

@ -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

View File

@ -3,3 +3,4 @@ export * from './SchemaInitializerProvider';
export * from './types';
export * from './items';
export { gridRowColWrap, useRecordCollectionDataSourceItems } from './utils';
export * from './buttons';

View File

@ -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"
},

View File

@ -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[]);

View File

@ -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);
}

View File

@ -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);

View File

@ -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
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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
}
}
});
}}
/>
);
}

View File

@ -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}
/>
)
}

View File

@ -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')}
/>
);
}

View File

@ -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>
);
})

View File

@ -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>
);
}

View File

@ -0,0 +1,3 @@
export function NullRender() {
return null;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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 }), {});

View File

@ -1,5 +1,4 @@
export { triggers } from './triggers';
export * from './nodes';
export { calculators } from './calculators';
export * from './FlowContext';
export { WorkflowProvider as default } from './WorkflowProvider';

View File

@ -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',

View File

@ -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': '任务',
};

View File

@ -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>;
}
};

View File

@ -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));
}
};

View File

@ -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
};

View File

@ -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
};

View 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}}}`
},
}
}
});
}}
/>
)
}

View File

@ -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
}
};

View File

@ -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
}
};

View File

@ -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: {

View File

@ -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
}
};

View File

@ -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
}
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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'
},
}
});
}}
/>
);
}

View 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
}
};

View File

@ -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
}
};

View File

@ -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
}
};

View File

@ -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
},
};

View File

@ -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
}
};

View File

@ -55,6 +55,6 @@ export const filter = {
`
};
},
dynamicComponent: 'VariableComponent'
dynamicComponent: 'FilterDynamicComponent'
}
};

View File

@ -22,7 +22,7 @@ export const executionCollection = {
} as ISchema,
},
{
interface: 'object',
interface: 'm2o',
type: 'belongsTo',
name: 'workflowId',
uiSchema: {

View File

@ -96,7 +96,7 @@ export const workflowSchema: ISchema = {
filter: {
current: true
},
sort: ['-enabled', '-createdAt'],
sort: ['-createdAt'],
except: ['config'],
},
},

View File

@ -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;

View File

@ -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
}
};

View File

@ -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);
}

View File

@ -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
}
};

View 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);
}
}
}

View 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,
});
}

View File

@ -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 });
}
}

View File

@ -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));
}
}

View File

@ -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({

View File

@ -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);

View File

@ -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 });

View File

@ -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({

View File

@ -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');
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});

View File

@ -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)', () => {
});
});
});

View File

@ -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);
});

View File

@ -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();

View File

@ -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)
});
}

View File

@ -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);
}

View File

@ -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;
}

View 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));
}

View File

@ -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;
}

View File

@ -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

View 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;
}

View File

@ -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',
},
{

View File

@ -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: {}
}
]

View File

@ -24,7 +24,7 @@ export default {
name: 'status'
},
{
type: 'jsonb',
type: 'json',
name: 'result'
}
]

View File

@ -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',

View File

@ -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 = {

View File

@ -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);
}

View File

@ -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
});
}

View File

@ -1,5 +0,0 @@
import assignees from './assignees';
export default [
assignees
];

View 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);
}
};

View File

@ -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;

View File

@ -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;

View File

@ -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