diff --git a/jest.config.js b/jest.config.js index ea7c9c603..ed9c3853e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -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__/', diff --git a/packages/app/client/public/favicon/site.webmanifest b/packages/app/client/public/favicon/site.webmanifest index 45dc8a206..0a037fe8a 100644 --- a/packages/app/client/public/favicon/site.webmanifest +++ b/packages/app/client/public/favicon/site.webmanifest @@ -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"} \ No newline at end of file +{"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"} diff --git a/packages/core/client/src/block-provider/BlockProvider.tsx b/packages/core/client/src/block-provider/BlockProvider.tsx index d64407f17..4c9c90188 100644 --- a/packages/core/client/src/block-provider/BlockProvider.tsx +++ b/packages/core/client/src/block-provider/BlockProvider.tsx @@ -21,7 +21,7 @@ import { SharedFilterProvider } from './SharedFilterProvider'; export const BlockResourceContext = createContext(null); export const BlockAssociationContext = createContext(null); -export const BlockRequestContext = createContext(null); +export const BlockRequestContext = createContext({}); export const useBlockResource = () => { return useContext(BlockResourceContext); diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 647e06734..468c97499 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -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": "下拉菜单(单选)", diff --git a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx index 77a2cd7f9..732063837 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx @@ -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(); - const actionType = fieldSchema['x-action'] || ''; + const actionType = fieldSchema['x-action'] ?? ''; useEffect(() => { const schemaUid = uid(); diff --git a/packages/core/client/src/schema-component/antd/action/Action.tsx b/packages/core/client/src/schema-component/antd/action/Action.tsx index 691370db6..6de3f0296 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.tsx @@ -69,7 +69,7 @@ export const Action: ComposedAction = observer((props: any) => { const { popover, confirm, - // openMode, + openMode: om, containerRefKey, component, useAction = useA, diff --git a/packages/core/client/src/schema-component/antd/filter/DynamicComponent.tsx b/packages/core/client/src/schema-component/antd/filter/DynamicComponent.tsx index 34dd228fc..968741756 100644 --- a/packages/core/client/src/schema-component/antd/filter/DynamicComponent.tsx +++ b/packages/core/client/src/schema-component/antd/filter/DynamicComponent.tsx @@ -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); diff --git a/packages/core/client/src/schema-component/antd/filter/Filter.tsx b/packages/core/client/src/schema-component/antd/filter/Filter.tsx index 62fff2267..4d6f2adde 100644 --- a/packages/core/client/src/schema-component/antd/filter/Filter.tsx +++ b/packages/core/client/src/schema-component/antd/filter/Filter.tsx @@ -27,7 +27,7 @@ export const Filter: any = observer((props: any) => { return (
{/*
{JSON.stringify(field.value, null, 2)}
*/} diff --git a/packages/core/client/src/schema-component/antd/filter/FilterGroup.tsx b/packages/core/client/src/schema-component/antd/filter/FilterGroup.tsx index 712eb091b..520d68d28 100644 --- a/packages/core/client/src/schema-component/antd/filter/FilterGroup.tsx +++ b/packages/core/client/src/schema-component/antd/filter/FilterGroup.tsx @@ -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(); 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 ( - - -
- {remove && !mergedDisabled && ( - - remove()} - /> - - )} -
- - {'Meet '} - - {' conditions in the group'} - -
-
- -
- {!mergedDisabled && ( - - { - const value = field.value || {}; - const items = value[logic] || []; - items.push({}); - field.value = { - [logic]: items, - }; - }} - > - {t('Add condition')} - - { - const value = field.value || {}; - const items = value[logic] || []; - items.push({ - $and: [{}], - }); - field.value = { - [logic]: items, - }; - }} - > - {t('Add condition group')} - - - )} + + + ); }); diff --git a/packages/core/client/src/schema-component/antd/filter/context.ts b/packages/core/client/src/schema-component/antd/filter/context.ts index ccb5733f1..00d061f84 100644 --- a/packages/core/client/src/schema-component/antd/filter/context.ts +++ b/packages/core/client/src/schema-component/antd/filter/context.ts @@ -7,8 +7,9 @@ export interface FilterContextProps { fieldSchema?: Schema; dynamicComponent?: any; options?: any[]; + disabled?: boolean } export const RemoveConditionContext = createContext(null); export const FilterContext = createContext(null); -export const FilterLogicContext = createContext(null); \ No newline at end of file +export const FilterLogicContext = createContext(null); diff --git a/packages/core/client/src/schema-component/antd/filter/useValues.ts b/packages/core/client/src/schema-component/antd/filter/useValues.ts index 3ba233230..4e9b2d5ab 100644 --- a/packages/core/client/src/schema-component/antd/filter/useValues.ts +++ b/packages/core/client/src/schema-component/antd/filter/useValues.ts @@ -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); }, }; }; diff --git a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx index 33d4e71ff..506536d67 100644 --- a/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/FormItem.tsx @@ -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) => { ); -}; +}); FormItem.Designer = (props) => { const { getCollectionFields, getCollection, getInterface, getCollectionJoinField } = useCollectionManager(); diff --git a/packages/core/client/src/schema-component/antd/icon-picker/IconPicker.tsx b/packages/core/client/src/schema-component/antd/icon-picker/IconPicker.tsx index 8dcd882bd..c63fd3e77 100644 --- a/packages/core/client/src/schema-component/antd/icon-picker/IconPicker.tsx +++ b/packages/core/client/src/schema-component/antd/icon-picker/IconPicker.tsx @@ -28,6 +28,7 @@ function IconField(props: any) {
{[...icons.keys()].map((key) => ( { onChange(key); diff --git a/packages/core/client/src/schema-component/antd/page/FixedBlock.tsx b/packages/core/client/src/schema-component/antd/page/FixedBlock.tsx index 518a6732e..4b2847d9a 100644 --- a/packages/core/client/src/schema-component/antd/page/FixedBlock.tsx +++ b/packages/core/client/src/schema-component/antd/page/FixedBlock.tsx @@ -59,7 +59,7 @@ export const useFixedBlockDesignerSetting = () => { return ( { const decoratorProps = { ...fieldSchema['x-decorator-props'], diff --git a/packages/core/client/src/schema-component/antd/tabs/Tabs.Designer.tsx b/packages/core/client/src/schema-component/antd/tabs/Tabs.Designer.tsx index 5f21d7ee5..21264be1d 100644 --- a/packages/core/client/src/schema-component/antd/tabs/Tabs.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/tabs/Tabs.Designer.tsx @@ -12,6 +12,7 @@ export const TabsDesigner = () => { return ( { return ; } if (item.type === 'itemGroup') { - console.log(item.children); return ( 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[]); diff --git a/packages/plugins/workflow/src/client/Branch.tsx b/packages/plugins/workflow/src/client/Branch.tsx index 8a26026db..3f9f4b6c1 100644 --- a/packages/plugins/workflow/src/client/Branch.tsx +++ b/packages/plugins/workflow/src/client/Branch.tsx @@ -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); } diff --git a/packages/plugins/workflow/src/client/ExecutionCanvas.tsx b/packages/plugins/workflow/src/client/ExecutionCanvas.tsx index cb2b54a45..10bafeeeb 100644 --- a/packages/plugins/workflow/src/client/ExecutionCanvas.tsx +++ b/packages/plugins/workflow/src/client/ExecutionCanvas.tsx @@ -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); diff --git a/packages/plugins/workflow/src/client/WorkflowCanvas.tsx b/packages/plugins/workflow/src/client/WorkflowCanvas.tsx index f45fb071b..e9675034d 100644 --- a/packages/plugins/workflow/src/client/WorkflowCanvas.tsx +++ b/packages/plugins/workflow/src/client/WorkflowCanvas.tsx @@ -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 } diff --git a/packages/plugins/workflow/src/client/WorkflowProvider.tsx b/packages/plugins/workflow/src/client/WorkflowProvider.tsx index c5e680db5..ec3c2e528 100644 --- a/packages/plugins/workflow/src/client/WorkflowProvider.tsx +++ b/packages/plugins/workflow/src/client/WorkflowProvider.tsx @@ -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) => { }, }} > - - {props.children} + + + + {props.children} + + diff --git a/packages/plugins/workflow/src/client/calculators.tsx b/packages/plugins/workflow/src/client/calculators.tsx deleted file mode 100644 index 76ea93067..000000000 --- a/packages/plugins/workflow/src/client/calculators.tsx +++ /dev/null @@ -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(); - -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 ( - onChange(ev.target.value)} - /> - ); - }, - default: '' - }, - number: { - title: '{{t("Number")}}', - value: 'number', - component({ onChange, value }) { - return ( - - ); - }, - default: 0 - }, - boolean: { - title: `{{t("Boolean", { ns: "${NAMESPACE}" })}}`, - value: 'boolean', - component({ onChange, value }) { - const { t } = useTranslation(); - return ( - - ); - }, - default: false - }, - // date: { - // title: '日期', - // value: 'date', - // component({ onChange, type, options, value }) { - // return 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 ( -
- { - 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 }); - } - }} - /> - - {children ?? } - -
- ); -} - -export function Calculation({ calculator, operands = [null], onChange }) { - const { t } = useWorkflowTranslation(); - const compile = useCompile(); - return ( - -
- onChange({ calculator, operands: [v, operands[1]] }))} /> - {typeof operands[0] !== 'undefined' - ? ( - <> - - onChange({ calculator, operands: [operands[0], v] }))} /> - - ) - : null - } -
-
- ); -} - -export function VariableComponent({ value, onChange, renderSchemaComponent }) { - const VTypes = { - ...VariableTypes, - constant: { - title: `{{t("Constant", { ns: "${NAMESPACE}" })}}`, - value: 'constant', - } - }; - - const { type } = parseValue(value, VTypes); - - return ( - - - {type === 'constant' ? renderSchemaComponent() : null} - - - ); -} diff --git a/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx b/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx new file mode 100644 index 000000000..d69a4e4a4 --- /dev/null +++ b/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx @@ -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 ( + { + 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 + } + } + }); + }} + /> + ); +} diff --git a/packages/plugins/workflow/src/client/components/CollectionFieldInitializers.tsx b/packages/plugins/workflow/src/client/components/CollectionFieldInitializers.tsx new file mode 100644 index 000000000..34f6a2667 --- /dev/null +++ b/packages/plugins/workflow/src/client/components/CollectionFieldInitializers.tsx @@ -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 ( + + ); +} + +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 ( + + ) +} diff --git a/packages/plugins/workflow/src/client/components/CollectionFieldSelect.tsx b/packages/plugins/workflow/src/client/components/CollectionFieldSelect.tsx deleted file mode 100644 index 7c799b139..000000000 --- a/packages/plugins/workflow/src/client/components/CollectionFieldSelect.tsx +++ /dev/null @@ -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 ( - - ); -} - - -function SelectWithAssociations(props) { - const { collection, value, onChange } = props; - const { t } = useTranslation(); - const compile = useCompile(); - const fields = useCollectionFilterOptions(collection); - - return ( - - ); -} diff --git a/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx b/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx index eaa8573bd..604677c58 100644 --- a/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx +++ b/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx @@ -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 (
{ {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 to replace this map return ( { display: flex; } `}> - - { - onChange({ ...value, [field.name]: next }); - }} - > - {operand.type === 'constant' - ? ( - - ) - // ? - : null - } - - + )} + + ) + : null + } + + ); +} diff --git a/packages/plugins/workflow/src/client/components/VariableJSONInput.tsx b/packages/plugins/workflow/src/client/components/VariableJSONInput.tsx index 71cd6c81e..eb6d4c6cb 100644 --- a/packages/plugins/workflow/src/client/components/VariableJSONInput.tsx +++ b/packages/plugins/workflow/src/client/components/VariableJSONInput.tsx @@ -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(''); 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 (
- - - - - - - -
+ + + - - + +
); diff --git a/packages/plugins/workflow/src/client/components/VariableTextArea.tsx b/packages/plugins/workflow/src/client/components/VariableTextArea.tsx new file mode 100644 index 000000000..1a6a7b70d --- /dev/null +++ b/packages/plugins/workflow/src/client/components/VariableTextArea.tsx @@ -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(); + 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 `${labels?.join(' / ')}`; +} + +export function VariableTextArea(props) { + const { value = '', scope, onChange, multiline = true, button } = props; + const inputRef = useRef(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 ( + +
{ + 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 }} + /> + + + {button ?? ( + + )} + + + + ); +} diff --git a/packages/plugins/workflow/src/client/constants.tsx b/packages/plugins/workflow/src/client/constants.tsx index c6da8be17..206bf3b74 100644 --- a/packages/plugins/workflow/src/client/constants.tsx +++ b/packages/plugins/workflow/src/client/constants.tsx @@ -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: }, - { value: JOB_STATUS.RESOLVED, label: `{{t("Succeeded", { ns: "${NAMESPACE}" })}}`, color: '#67c068', icon: }, - { value: JOB_STATUS.REJECTED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: '#f40', icon: }, - { value: JOB_STATUS.CANCELED, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`, color: '#f40', icon: } + { value: JOB_STATUS.PENDING, label: `{{t("Pending", { ns: "${NAMESPACE}" })}}`, color: 'gold', icon: }, + { value: JOB_STATUS.RESOLVED, label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`, color: 'green', icon: }, + { value: JOB_STATUS.FAILED, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, color: 'red', icon: }, + { value: JOB_STATUS.ERROR, label: `{{t("Error", { ns: "${NAMESPACE}" })}}`, color: 'red', icon: }, + { value: JOB_STATUS.ABORTED, label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`, color: 'red', icon: }, + { value: JOB_STATUS.CANCELED, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`, color: 'volcano', icon: }, + { value: JOB_STATUS.REJECTED, label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`, color: 'volcano', icon: }, ]; export const JobStatusOptionsMap = JobStatusOptions.reduce((map, option) => Object.assign(map, { [option.value]: option }), {}); diff --git a/packages/plugins/workflow/src/client/index.tsx b/packages/plugins/workflow/src/client/index.tsx index b7b42845f..cc7b4880d 100644 --- a/packages/plugins/workflow/src/client/index.tsx +++ b/packages/plugins/workflow/src/client/index.tsx @@ -1,5 +1,4 @@ export { triggers } from './triggers'; export * from './nodes'; -export { calculators } from './calculators'; export * from './FlowContext'; export { WorkflowProvider as default } from './WorkflowProvider'; diff --git a/packages/plugins/workflow/src/client/locale/en-US.ts b/packages/plugins/workflow/src/client/locale/en-US.ts index b4f2a0634..379c95c48 100644 --- a/packages/plugins/workflow/src/client/locale/en-US.ts +++ b/packages/plugins/workflow/src/client/locale/en-US.ts @@ -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', diff --git a/packages/plugins/workflow/src/client/locale/zh-CN.ts b/packages/plugins/workflow/src/client/locale/zh-CN.ts index ccffda3f6..1a31e0764 100644 --- a/packages/plugins/workflow/src/client/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/client/locale/zh-CN.ts @@ -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': '任务', }; diff --git a/packages/plugins/workflow/src/client/nodes/calculation.tsx b/packages/plugins/workflow/src/client/nodes/calculation.tsx deleted file mode 100644 index 402ce1044..000000000 --- a/packages/plugins/workflow/src/client/nodes/calculation.tsx +++ /dev/null @@ -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 ( - - ); - } - }, - getter() { - const { t } = useWorkflowTranslation(); - return
{t('Calculation result')}
; - } -}; diff --git a/packages/plugins/workflow/src/client/nodes/calculation/engines/formulajs.ts b/packages/plugins/workflow/src/client/nodes/calculation/engines/formulajs.ts new file mode 100644 index 000000000..6c5f1ba93 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/calculation/engines/formulajs.ts @@ -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)); + } +}; diff --git a/packages/plugins/workflow/src/client/nodes/calculation/engines/index.tsx b/packages/plugins/workflow/src/client/nodes/calculation/engines/index.tsx new file mode 100644 index 000000000..a6488a062 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/calculation/engines/index.tsx @@ -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(); + +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 + ? ( + <> + + {i18n.t('Syntax references')} + + {engine.label} + + ) + : null +}; diff --git a/packages/plugins/workflow/src/client/nodes/calculation/engines/mathjs.ts b/packages/plugins/workflow/src/client/nodes/calculation/engines/mathjs.ts new file mode 100644 index 000000000..e2908fa69 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/calculation/engines/mathjs.ts @@ -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 +}; diff --git a/packages/plugins/workflow/src/client/nodes/calculation/index.tsx b/packages/plugins/workflow/src/client/nodes/calculation/index.tsx new file mode 100644 index 000000000..e722f1978 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/calculation/index.tsx @@ -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 ( +
+          {JSON.stringify(result, null, 2)}
+        
+ ); + }, + 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 ( + { + 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}}}` + }, + } + } + }); + }} + /> + ) +} diff --git a/packages/plugins/workflow/src/client/nodes/condition.tsx b/packages/plugins/workflow/src/client/nodes/condition.tsx index 14a189737..38ff8c22d 100644 --- a/packages/plugins/workflow/src/client/nodes/condition.tsx +++ b/packages/plugins/workflow/src/client/nodes/condition.tsx @@ -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(); + +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 ( +
+ onChange({ calculator, operands: [v, operands[1]] }))} + scope={options} + /> + + onChange({ calculator, operands: [operands[0], v] }))} + scope={options} + /> +
+ ); +} 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 (
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 { ) }, + scope: { + renderReference, + useWorkflowVariableOptions + }, components: { - CalculationConfig + CalculationConfig, + VariableTextArea, + RadioWithTooltip } }; diff --git a/packages/plugins/workflow/src/client/nodes/create.tsx b/packages/plugins/workflow/src/client/nodes/create.tsx index 6f307c848..82172687e 100644 --- a/packages/plugins/workflow/src/client/nodes/create.tsx +++ b/packages/plugins/workflow/src/client/nodes/create.tsx @@ -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 ( - { - 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 } }; diff --git a/packages/plugins/workflow/src/client/nodes/delay.tsx b/packages/plugins/workflow/src/client/nodes/delay.tsx index 55a759587..ea3a7c3f0 100644 --- a/packages/plugins/workflow/src/client/nodes/delay.tsx +++ b/packages/plugins/workflow/src/client/nodes/delay.tsx @@ -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: { diff --git a/packages/plugins/workflow/src/client/nodes/destroy.tsx b/packages/plugins/workflow/src/client/nodes/destroy.tsx index 75c0457f0..c3b9c91b3 100644 --- a/packages/plugins/workflow/src/client/nodes/destroy.tsx +++ b/packages/plugins/workflow/src/client/nodes/destroy.tsx @@ -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 } }; diff --git a/packages/plugins/workflow/src/client/nodes/index.tsx b/packages/plugins/workflow/src/client/nodes/index.tsx index 6489e2c01..565e71cf1 100644 --- a/packages/plugins/workflow/src/client/nodes/index.tsx +++ b/packages/plugins/workflow/src/client/nodes/index.tsx @@ -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(); @@ -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({}); 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: {icon}, 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 } } } diff --git a/packages/plugins/workflow/src/client/nodes/manual/AssigneesSelect.tsx b/packages/plugins/workflow/src/client/nodes/manual/AssigneesSelect.tsx new file mode 100644 index 000000000..27ade0fd1 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/AssigneesSelect.tsx @@ -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 ( + { + onChange([next]); + }} + > + { + onChange([v]); + }} + /> + + ); +} diff --git a/packages/plugins/workflow/src/client/nodes/manual/ModeConfig.tsx b/packages/plugins/workflow/src/client/nodes/manual/ModeConfig.tsx new file mode 100644 index 000000000..81691bb8d --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/ModeConfig.tsx @@ -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 ( +
+ + { + console.log(v); + onChange(Number(v)) + }}> + + + {lang('Separately')} + + + + + + {lang('Collaboratively')} + + + + + + {value + ? ( +
+ + + + + + {lang('All pass')} + + + + + + {lang('Any pass')} + + + + + + +
+ ) + : null + } +
+ ); +} diff --git a/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx b/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx new file mode 100644 index 000000000..47c521e9b --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx @@ -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 ( + + + + + + ); +} + +function FormBlockInitializer({ insert, ...props }) { + return ( + { + 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 ( + + ); +}; + +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({}); + +function AddFormField(props) { + const { insertPosition = 'beforeEnd', component } = props; + const items = useCommonInterfaceInitializers(); + const collection = useContext(CollectionContext); + const [interfaceOptions, setInterface] = useState(null); + const [insert, setCallback] = useState(); + + return ( + + + + {interfaceOptions + ? ( + 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} + + + ); +} + +function FormFieldInitializer(props) { + const { item, insert } = props; + const { onAddField, setCallback } = useContext(AddFormFieldButtonContext); + const { getInterface } = useCollectionManager(); + + const interfaceOptions = getInterface(item.fieldInterface); + + return ( + { + 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 ( + + {props.children} + + ); +}; + +function ActionInitializer({ action, actionProps, ...props }) { + return ( + + ); +} + +function AddActionButton(props) { + return ( + + ); +} + +// 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 ( + + + { + 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 + }); + }} + > + + + + + + + ); +} + +export function SchemaConfigButton(props) { + const { workflow } = useFlowContext(); + const [visible, setVisible] = useState(false); + return ( + <> +
setVisible(true)}> + {workflow.executed ? lang('View user interface') : lang('Configure user interface')} +
+ + {props.children} + + + ); +} diff --git a/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx b/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx new file mode 100644 index 000000000..8fcffa3c9 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx @@ -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(); + return field?.value?.title ?? `#${field.value?.id}`; +}); + +const WorkflowColumn = observer(() => { + const field = useField(); + return field?.value?.title ?? `#${field.value?.id}`; +}); + +const UserColumn = observer(() => { + const field = useField(); + return field?.value?.nickname ?? field.value?.id; +}); + +export function WorkflowTodo() { + return ( + + ) +} + +const ManualActionStatusContext = createContext(null); + +function useManualActionStatusContext() { + return useContext(ManualActionStatusContext); +} + +function ManualActionStatusProvider({ value, children }) { + return ( + + {children} + + ) +} + +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(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 ( + + + {props.children} + + + ); +} + +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 ( + + + + + + ) +} + +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 ( + + {children} + + ); +} diff --git a/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodoBlockInitializer.tsx b/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodoBlockInitializer.tsx new file mode 100644 index 000000000..860a963a8 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodoBlockInitializer.tsx @@ -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 ( + } + {...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' + }, + } + }); + }} + /> + ); +} diff --git a/packages/plugins/workflow/src/client/nodes/manual/index.tsx b/packages/plugins/workflow/src/client/nodes/manual/index.tsx new file mode 100644 index 000000000..f4a302ea3 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/index.tsx @@ -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 + } +}; diff --git a/packages/plugins/workflow/src/client/nodes/parallel.tsx b/packages/plugins/workflow/src/client/nodes/parallel.tsx index 9db723c49..aaa24f06f 100644 --- a/packages/plugins/workflow/src/client/nodes/parallel.tsx +++ b/packages/plugins/workflow/src/client/nodes/parallel.tsx @@ -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: ( - - {lang('All succeeded')} - - ) - }, - { - value: 'any', - label: ( - - {lang('Any succeeded')} - - ) - }, - { - value: 'race', - label: ( - - {lang('Any succeeded or failed')} - - ) - }, - ], default: 'all' } }, @@ -143,5 +123,8 @@ export default {
) + }, + components: { + RadioWithTooltip } }; diff --git a/packages/plugins/workflow/src/client/nodes/query.tsx b/packages/plugins/workflow/src/client/nodes/query.tsx index f22e4b821..2a021754d 100644 --- a/packages/plugins/workflow/src/client/nodes/query.tsx +++ b/packages/plugins/workflow/src/client/nodes/query.tsx @@ -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 ( - { - 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 } }; diff --git a/packages/plugins/workflow/src/client/nodes/request.tsx b/packages/plugins/workflow/src/client/nodes/request.tsx index 3f479a5c1..b7a1707f3 100644 --- a/packages/plugins/workflow/src/client/nodes/request.tsx +++ b/packages/plugins/workflow/src/client/nodes/request.tsx @@ -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 ( - - {props.children} - - ) -} - 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 }, }; diff --git a/packages/plugins/workflow/src/client/nodes/update.tsx b/packages/plugins/workflow/src/client/nodes/update.tsx index 57723a857..f8a729e15 100644 --- a/packages/plugins/workflow/src/client/nodes/update.tsx +++ b/packages/plugins/workflow/src/client/nodes/update.tsx @@ -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 } }; diff --git a/packages/plugins/workflow/src/client/schemas/collection.ts b/packages/plugins/workflow/src/client/schemas/collection.ts index 5857d0ce0..b617e78f5 100644 --- a/packages/plugins/workflow/src/client/schemas/collection.ts +++ b/packages/plugins/workflow/src/client/schemas/collection.ts @@ -55,6 +55,6 @@ export const filter = { ` }; }, - dynamicComponent: 'VariableComponent' + dynamicComponent: 'FilterDynamicComponent' } }; diff --git a/packages/plugins/workflow/src/client/schemas/executions.tsx b/packages/plugins/workflow/src/client/schemas/executions.tsx index 0fd19c0e7..681ad35d5 100644 --- a/packages/plugins/workflow/src/client/schemas/executions.tsx +++ b/packages/plugins/workflow/src/client/schemas/executions.tsx @@ -22,7 +22,7 @@ export const executionCollection = { } as ISchema, }, { - interface: 'object', + interface: 'm2o', type: 'belongsTo', name: 'workflowId', uiSchema: { diff --git a/packages/plugins/workflow/src/client/schemas/workflows.ts b/packages/plugins/workflow/src/client/schemas/workflows.ts index 8704fe0ca..784763966 100644 --- a/packages/plugins/workflow/src/client/schemas/workflows.ts +++ b/packages/plugins/workflow/src/client/schemas/workflows.ts @@ -96,7 +96,7 @@ export const workflowSchema: ISchema = { filter: { current: true }, - sort: ['-enabled', '-createdAt'], + sort: ['-createdAt'], except: ['config'], }, }, diff --git a/packages/plugins/workflow/src/client/style.tsx b/packages/plugins/workflow/src/client/style.tsx index 73955cba5..56612872c 100644 --- a/packages/plugins/workflow/src/client/style.tsx +++ b/packages/plugins/workflow/src/client/style.tsx @@ -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; diff --git a/packages/plugins/workflow/src/client/triggers/collection.tsx b/packages/plugins/workflow/src/client/triggers/collection.tsx index eff78ccef..8ee3fbb40 100644 --- a/packages/plugins/workflow/src/client/triggers/collection.tsx +++ b/packages/plugins/workflow/src/client/triggers/collection.tsx @@ -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 ( - {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 ( - { - onChange(`{{$context.data.${path}}}`); - }} - /> - ); + return { + type: 'item', + title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`, + component: CollectionBlockInitializer, + collection: config.collection, + dataSource: '{{$context.data}}' + }; + }, + initializers: { + CollectionFieldInitializers } }; diff --git a/packages/plugins/workflow/src/client/triggers/index.tsx b/packages/plugins/workflow/src/client/triggers/index.tsx index 442223737..4d71482bb 100644 --- a/packages/plugins/workflow/src/client/triggers/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/index.tsx @@ -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(); @@ -231,3 +232,8 @@ export const TriggerConfig = () => {
); } + +export function useTrigger() { + const { workflow } = useFlowContext(); + return triggers.get(workflow.type); +} diff --git a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx index 2cb35106e..a0c09c130 100644 --- a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx @@ -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 ( - 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 } }; diff --git a/packages/plugins/workflow/src/client/utils.ts b/packages/plugins/workflow/src/client/utils.ts new file mode 100644 index 000000000..9ca03bbb9 --- /dev/null +++ b/packages/plugins/workflow/src/client/utils.ts @@ -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); + } + } +} diff --git a/packages/plugins/workflow/src/client/variable.tsx b/packages/plugins/workflow/src/client/variable.tsx new file mode 100644 index 000000000..8e6bc5302 --- /dev/null +++ b/packages/plugins/workflow/src/client/variable.tsx @@ -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, + }); +} diff --git a/packages/plugins/workflow/src/server/Plugin.ts b/packages/plugins/workflow/src/server/Plugin.ts index 4f982d1c2..ca5dea720 100644 --- a/packages/plugins/workflow/src/server/Plugin.ts +++ b/packages/plugins/workflow/src/server/Plugin.ts @@ -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 = new Registry(); triggers: Registry = new Registry(); - calculators = calculators; - extensions = extensions; + calculators: Registry = new Registry(); + functions: Registry = 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 }); } } diff --git a/packages/plugins/workflow/src/server/Processor.ts b/packages/plugins/workflow/src/server/Processor.ts index 08873c72b..ebbb59a75 100644 --- a/packages/plugins/workflow/src/server/Processor.ts +++ b/packages/plugins/workflow/src/server/Processor.ts @@ -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(); @@ -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)); } } diff --git a/packages/plugins/workflow/src/server/__tests__/Plugin.test.ts b/packages/plugins/workflow/src/server/__tests__/Plugin.test.ts index f814a0e78..60e62f864 100644 --- a/packages/plugins/workflow/src/server/__tests__/Plugin.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/Plugin.test.ts @@ -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({ diff --git a/packages/plugins/workflow/src/server/__tests__/Processor.test.ts b/packages/plugins/workflow/src/server/__tests__/Processor.test.ts index d12700e2c..a751119d8 100644 --- a/packages/plugins/workflow/src/server/__tests__/Processor.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/Processor.test.ts @@ -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); diff --git a/packages/plugins/workflow/src/server/__tests__/actions/workflows.test.ts b/packages/plugins/workflow/src/server/__tests__/actions/workflows.test.ts index cb66fd0ca..aca189d93 100644 --- a/packages/plugins/workflow/src/server/__tests__/actions/workflows.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/actions/workflows.test.ts @@ -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 }); diff --git a/packages/plugins/workflow/src/server/__tests__/index.ts b/packages/plugins/workflow/src/server/__tests__/index.ts index d23369c5f..7c859096e 100644 --- a/packages/plugins/workflow/src/server/__tests__/index.ts +++ b/packages/plugins/workflow/src/server/__tests__/index.ts @@ -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({ diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts index 28ce689ac..42c14b479 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/calculation.test.ts @@ -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'); }); }); }); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/condition.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/condition.test.ts index bd7a9e79c..02d365009 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/condition.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/condition.test.ts @@ -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); + }); + }); }); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/delay.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/delay.test.ts index 3164f35e4..eb81293da 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/delay.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/delay.test.ts @@ -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); }); }); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts new file mode 100644 index 000000000..69a92e657 --- /dev/null +++ b/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts @@ -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); + }); + }); +}); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/parallel.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/parallel.test.ts index 28c05e2c1..bd019cafa 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/parallel.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/parallel.test.ts @@ -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); }); }); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/prompt.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/prompt.test.ts deleted file mode 100644 index c13a7e29c..000000000 --- a/packages/plugins/workflow/src/server/__tests__/instructions/prompt.test.ts +++ /dev/null @@ -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)', () => { - - }); - }); -}); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/request.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/request.test.ts index 78d7babbb..243684175 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/request.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/request.test.ts @@ -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); }); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/update.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/update.test.ts index b3883fcfb..c6c5caa56 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/update.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/update.test.ts @@ -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(); diff --git a/packages/plugins/workflow/src/server/actions/index.ts b/packages/plugins/workflow/src/server/actions/index.ts index 2922d5057..cf733131f 100644 --- a/packages/plugins/workflow/src/server/actions/index.ts +++ b/packages/plugins/workflow/src/server/actions/index.ts @@ -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) }); } diff --git a/packages/plugins/workflow/src/server/actions/jobs.ts b/packages/plugins/workflow/src/server/actions/jobs.ts deleted file mode 100644 index cff78bb21..000000000 --- a/packages/plugins/workflow/src/server/actions/jobs.ts +++ /dev/null @@ -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); -} diff --git a/packages/plugins/workflow/src/server/actions/workflows.ts b/packages/plugins/workflow/src/server/actions/workflows.ts index 9d783ff24..136eccc9c 100644 --- a/packages/plugins/workflow/src/server/actions/workflows.ts +++ b/packages/plugins/workflow/src/server/actions/workflows.ts @@ -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(items.map(item => item.id)); + const keysSet = new Set(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; } diff --git a/packages/plugins/workflow/src/server/calculators/basic.ts b/packages/plugins/workflow/src/server/calculators/basic.ts new file mode 100644 index 000000000..22b87c907 --- /dev/null +++ b/packages/plugins/workflow/src/server/calculators/basic.ts @@ -0,0 +1,84 @@ +import { Registry } from "@nocobase/utils"; + +export const calculators = new Registry(); + + +// 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)); +} diff --git a/packages/plugins/workflow/src/server/calculators/formulajs.ts b/packages/plugins/workflow/src/server/calculators/formulajs.ts new file mode 100644 index 000000000..107f59471 --- /dev/null +++ b/packages/plugins/workflow/src/server/calculators/formulajs.ts @@ -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; +} diff --git a/packages/plugins/workflow/src/server/calculators/index.ts b/packages/plugins/workflow/src/server/calculators/index.ts index 14d627d4d..a7dad08f0 100644 --- a/packages/plugins/workflow/src/server/calculators/index.ts +++ b/packages/plugins/workflow/src/server/calculators/index.ts @@ -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(); +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) { - 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 diff --git a/packages/plugins/workflow/src/server/calculators/mathjs.ts b/packages/plugins/workflow/src/server/calculators/mathjs.ts new file mode 100644 index 000000000..ceb79925c --- /dev/null +++ b/packages/plugins/workflow/src/server/calculators/mathjs.ts @@ -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; +} diff --git a/packages/plugins/workflow/src/server/collections/executions.ts b/packages/plugins/workflow/src/server/collections/executions.ts index 8bc0d45f1..df5a1a899 100644 --- a/packages/plugins/workflow/src/server/collections/executions.ts +++ b/packages/plugins/workflow/src/server/collections/executions.ts @@ -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', }, { diff --git a/packages/plugins/workflow/src/server/collections/flow_nodes.ts b/packages/plugins/workflow/src/server/collections/flow_nodes.ts index fce168cf0..e84a6a5b9 100644 --- a/packages/plugins/workflow/src/server/collections/flow_nodes.ts +++ b/packages/plugins/workflow/src/server/collections/flow_nodes.ts @@ -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: {} } ] diff --git a/packages/plugins/workflow/src/server/collections/jobs.ts b/packages/plugins/workflow/src/server/collections/jobs.ts index 024545da5..e60a5ee1b 100644 --- a/packages/plugins/workflow/src/server/collections/jobs.ts +++ b/packages/plugins/workflow/src/server/collections/jobs.ts @@ -24,7 +24,7 @@ export default { name: 'status' }, { - type: 'jsonb', + type: 'json', name: 'result' } ] diff --git a/packages/plugins/workflow/src/server/collections/workflows.ts b/packages/plugins/workflow/src/server/collections/workflows.ts index 64531413e..276cdd2ab 100644 --- a/packages/plugins/workflow/src/server/collections/workflows.ts +++ b/packages/plugins/workflow/src/server/collections/workflows.ts @@ -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', diff --git a/packages/plugins/workflow/src/server/constants.ts b/packages/plugins/workflow/src/server/constants.ts index 0d746bea6..b4ac161e2 100644 --- a/packages/plugins/workflow/src/server/constants.ts +++ b/packages/plugins/workflow/src/server/constants.ts @@ -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 = { diff --git a/packages/plugins/workflow/src/server/extensions/assignees/actions.ts b/packages/plugins/workflow/src/server/extensions/assignees/actions.ts deleted file mode 100644 index 4fc2adee4..000000000 --- a/packages/plugins/workflow/src/server/extensions/assignees/actions.ts +++ /dev/null @@ -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); -} diff --git a/packages/plugins/workflow/src/server/extensions/assignees/index.ts b/packages/plugins/workflow/src/server/extensions/assignees/index.ts deleted file mode 100644 index 040bf83c5..000000000 --- a/packages/plugins/workflow/src/server/extensions/assignees/index.ts +++ /dev/null @@ -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 - }); -} diff --git a/packages/plugins/workflow/src/server/extensions/index.ts b/packages/plugins/workflow/src/server/extensions/index.ts deleted file mode 100644 index f3fd53529..000000000 --- a/packages/plugins/workflow/src/server/extensions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import assignees from './assignees'; - -export default [ - assignees -]; diff --git a/packages/plugins/workflow/src/server/functions/index.ts b/packages/plugins/workflow/src/server/functions/index.ts new file mode 100644 index 000000000..76f120396 --- /dev/null +++ b/packages/plugins/workflow/src/server/functions/index.ts @@ -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); + } +}; diff --git a/packages/plugins/workflow/src/server/instructions/calculation.ts b/packages/plugins/workflow/src/server/instructions/calculation.ts index 47d7dc791..7b809e6b9 100644 --- a/packages/plugins/workflow/src/server/instructions/calculation.ts +++ b/packages/plugins/workflow/src/server/instructions/calculation.ts @@ -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 = '' } = node.config || {}; + const evaluator = 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; diff --git a/packages/plugins/workflow/src/server/instructions/condition.ts b/packages/plugins/workflow/src/server/instructions/condition.ts index ef3501064..83a674b1c 100644 --- a/packages/plugins/workflow/src/server/instructions/condition.ts +++ b/packages/plugins/workflow/src/server/instructions/condition.ts @@ -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 = 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; diff --git a/packages/plugins/workflow/src/server/instructions/index.ts b/packages/plugins/workflow/src/server/instructions/index.ts index 3c81c62a8..52c50ac9a 100644 --- a/packages/plugins/workflow/src/server/instructions/index.ts +++ b/packages/plugins/workflow/src/server/instructions/index.ts @@ -39,7 +39,7 @@ export default function( 'condition', 'parallel', 'delay', - 'prompt', + 'manual', 'query', 'create', 'update', diff --git a/packages/plugins/workflow/src/server/instructions/manual/actions.ts b/packages/plugins/workflow/src/server/instructions/manual/actions.ts new file mode 100644 index 000000000..6ee8b60d2 --- /dev/null +++ b/packages/plugins/workflow/src/server/instructions/manual/actions.ts @@ -0,0 +1,79 @@ +import { Context, utils } from '@nocobase/actions'; +import Plugin from '../..'; +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 plugin: Plugin = context.app.pm.get('workflow') as Plugin; + + const userJob = await context.db.sequelize.transaction(async transaction => { + const instance = await repository.findOne({ + filterByTk, + // filter: { + // userId: currentUser?.id + // }, + appends: ['job', 'node', 'execution', 'workflow'], + context, + transaction + }); + + if (!instance) { + return context.throw(404); + } + + const { actions = [] } = instance.node.config; + + // NOTE: validate status + if (instance.status !== JOB_STATUS.PENDING + || instance.job.status !== JOB_STATUS.PENDING + || instance.execution.status !== EXECUTION_STATUS.STARTED + || !instance.workflow.enabled + || !actions.includes(values.status) + ) { + return context.throw(400); + } + + instance.execution.workflow = instance.workflow; + const processor = plugin.createProcessor(instance.execution, { transaction }); + await processor.prepare(); + + const assignees = processor.getParsedValue(instance.node.config.assignees ?? []); + if (!assignees.includes(currentUser.id) + || instance.userId !== currentUser.id + ) { + return context.throw(403); + } + + // NOTE: validate assignee + await instance.update({ + status: values.status, + result: values.result + }, { + transaction + }); + + return instance; + }); + + + // await transaction.commit(); + + context.body = userJob; + context.status = 202; + + await next(); + + userJob.job.latestUserJob = userJob; + + // NOTE: resume the process and no `await` for quick returning + plugin.resume(userJob.job); +} diff --git a/packages/plugins/workflow/src/server/extensions/assignees/collections/jobs.ts b/packages/plugins/workflow/src/server/instructions/manual/collecions/jobs.ts similarity index 81% rename from packages/plugins/workflow/src/server/extensions/assignees/collections/jobs.ts rename to packages/plugins/workflow/src/server/instructions/manual/collecions/jobs.ts index 294eccec8..18164a3d5 100644 --- a/packages/plugins/workflow/src/server/extensions/assignees/collections/jobs.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/collecions/jobs.ts @@ -10,7 +10,8 @@ export default { type: 'hasMany', name: 'usersJobs', target: 'users_jobs', - foreignKey: 'jobId' + foreignKey: 'jobId', + onDelete: 'CASCADE' } ] }; diff --git a/packages/plugins/workflow/src/server/extensions/assignees/collections/users.ts b/packages/plugins/workflow/src/server/instructions/manual/collecions/users.ts similarity index 100% rename from packages/plugins/workflow/src/server/extensions/assignees/collections/users.ts rename to packages/plugins/workflow/src/server/instructions/manual/collecions/users.ts diff --git a/packages/plugins/workflow/src/server/extensions/assignees/collections/users_jobs.ts b/packages/plugins/workflow/src/server/instructions/manual/collecions/users_jobs.ts similarity index 87% rename from packages/plugins/workflow/src/server/extensions/assignees/collections/users_jobs.ts rename to packages/plugins/workflow/src/server/instructions/manual/collecions/users_jobs.ts index 7d0549fab..8f604d109 100644 --- a/packages/plugins/workflow/src/server/extensions/assignees/collections/users_jobs.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/collecions/users_jobs.ts @@ -11,23 +11,19 @@ export default { primaryKey: true, autoIncrement: true, }, - { - type: 'bigInt', - name: 'userId', - primaryKey: false, - }, - { - type: 'bigInt', - name: 'jobId', - primaryKey: false, - }, { type: 'belongsTo', name: 'job', + target: 'jobs', + foreignKey: 'jobId', + primaryKey: false, }, { type: 'belongsTo', name: 'user', + target: 'users', + foreignKey: 'userId', + primaryKey: false, }, { type: 'belongsTo', diff --git a/packages/plugins/workflow/src/server/instructions/manual/index.ts b/packages/plugins/workflow/src/server/instructions/manual/index.ts new file mode 100644 index 000000000..1b933acb1 --- /dev/null +++ b/packages/plugins/workflow/src/server/instructions/manual/index.ts @@ -0,0 +1,167 @@ +import actions from '@nocobase/actions'; +import { HandlerType } from '@nocobase/resourcer'; + +import Plugin from '../..'; +import { JOB_STATUS } from "../../constants"; +import { Instruction } from '..'; +import jobsCollection from './collecions/jobs'; +import usersCollection from './collecions/users'; +import usersJobsCollection from './collecions/users_jobs'; +import { submit } from './actions'; + + + +export interface ManualConfig { + schema: { + collection: { + name: string; + fields: any[]; + }; + blocks: { [key: string]: any }, + actions: { [key: string]: any }, + }; + actions: number[]; + assignees?: number[]; + mode?: number; +} + +const MULTIPLE_ASSIGNED_MODE = { + SINGLE: Symbol('single'), + ALL: Symbol('all'), + ANY: Symbol('any'), + ALL_PERCENTAGE: Symbol('all percentage'), + ANY_PERCENTAGE: Symbol('any percentage') +}; + +const Modes = { + [MULTIPLE_ASSIGNED_MODE.SINGLE]: { + getStatus(distribution, assignees) { + const done = distribution.find(item => item.status !== JOB_STATUS.PENDING && item.count > 0); + return done ? done.status : null + } + }, + [MULTIPLE_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; + } + const rejected = distribution.find(item => item.status < JOB_STATUS.PENDING); + if (rejected && rejected.count) { + return rejected.status; + } + + return null; + } + }, + [MULTIPLE_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 rejectedCount = distribution.reduce((count, item) => item.status < JOB_STATUS.PENDING ? count + item.count : count, 0); + // NOTE: all failures are considered as rejected for now + if (rejectedCount === assignees.length) { + return JOB_STATUS.REJECTED; + } + + return null; + } + } +}; + +function getMode(mode) { + switch (true) { + case mode === 1: + return Modes[MULTIPLE_ASSIGNED_MODE.ALL]; + case mode === -1: + return Modes[MULTIPLE_ASSIGNED_MODE.ANY]; + case mode > 0: + return Modes[MULTIPLE_ASSIGNED_MODE.ALL_PERCENTAGE]; + case mode < 0: + return Modes[MULTIPLE_ASSIGNED_MODE.ANY_PERCENTAGE]; + default: + return Modes[MULTIPLE_ASSIGNED_MODE.SINGLE]; + } +} + +export default class implements Instruction { + constructor(protected plugin: Plugin) { + plugin.db.collection(usersJobsCollection); + plugin.db.extendCollection(usersCollection); + plugin.db.extendCollection(jobsCollection); + + plugin.app.resource({ + name: 'users_jobs', + actions: { + list: { + filter: { + $or: [ + { + 'workflow.enabled': true + }, + { + 'workflow.enabled': false, + status: { + $ne: JOB_STATUS.PENDING + } + } + ] + }, + handler: actions.list as HandlerType + }, + submit + } + }); + } + + async run(node, prevJob, processor) { + const { mode, ...config } = node.config as ManualConfig; + const assignees = [...(new Set(processor.getParsedValue(config.assignees) || []))]; + + 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; + } + + async resume(node, job, processor) { + // NOTE: check all users jobs related if all done then continue as parallel + const { assignees = [], mode } = node.config as ManualConfig; + + 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: job.status || (getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING), + result + }); + + return job; + } +}; diff --git a/packages/plugins/workflow/src/server/instructions/parallel.ts b/packages/plugins/workflow/src/server/instructions/parallel.ts index 7403003b0..c889e218c 100644 --- a/packages/plugins/workflow/src/server/instructions/parallel.ts +++ b/packages/plugins/workflow/src/server/instructions/parallel.ts @@ -12,11 +12,12 @@ export const PARALLEL_MODE = { const Modes = { [PARALLEL_MODE.ALL]: { next(previous) { - return previous.status !== JOB_STATUS.REJECTED; + return previous.status >= JOB_STATUS.PENDING; }, getStatus(result) { - if (result.some(status => status != null && status === JOB_STATUS.REJECTED)) { - return JOB_STATUS.REJECTED; + const failedStatus = result.find(status => status != null && status < JOB_STATUS.PENDING) + if (typeof failedStatus !== 'undefined') { + return failedStatus; } if (result.every(status => status != null && status === JOB_STATUS.RESOLVED)) { return JOB_STATUS.RESOLVED; @@ -26,7 +27,7 @@ const Modes = { }, [PARALLEL_MODE.ANY]: { next(previous) { - return previous.status !== JOB_STATUS.RESOLVED; + return previous.status <= JOB_STATUS.PENDING; }, getStatus(result) { if (result.some(status => status != null && status === JOB_STATUS.RESOLVED)) { @@ -35,7 +36,7 @@ const Modes = { if (result.some(status => status != null ? status === JOB_STATUS.PENDING : true)) { return JOB_STATUS.PENDING; } - return JOB_STATUS.REJECTED; + return JOB_STATUS.FAILED; } }, [PARALLEL_MODE.RACE]: { @@ -46,8 +47,9 @@ const Modes = { if (result.some(status => status != null && status === JOB_STATUS.RESOLVED)) { return JOB_STATUS.RESOLVED; } - if (result.some(status => status != null && status === JOB_STATUS.REJECTED)) { - return JOB_STATUS.REJECTED; + const failedStatus = result.find(status => status != null && status < JOB_STATUS.PENDING); + if (typeof failedStatus !== 'undefined') { + return failedStatus; } return JOB_STATUS.PENDING; } @@ -83,7 +85,7 @@ export default { }, async resume(node: FlowNodeModel, branchJob, processor: Processor) { - const job = processor.findBranchParentJob(branchJob, node); + const job = processor.findBranchParentJob(branchJob, node) as JobModel; const { result, status } = job; // if parallel has been done (resolved / rejected), do not care newly executed branch jobs. @@ -92,8 +94,8 @@ export default { } // find the index of the node which start the branch - const jobNode = processor.nodesMap.get(branchJob.nodeId); - const branchStartNode = processor.findBranchStartNode(jobNode, node); + const jobNode = processor.nodesMap.get(branchJob.nodeId) as FlowNodeModel; + const branchStartNode = processor.findBranchStartNode(jobNode, node) as FlowNodeModel; const branches = processor.getBranches(node); const branchIndex = branches.indexOf(branchStartNode); const { mode = PARALLEL_MODE.ALL } = node.config || {}; diff --git a/packages/plugins/workflow/src/server/instructions/prompt.ts b/packages/plugins/workflow/src/server/instructions/prompt.ts deleted file mode 100644 index 6addbfe3c..000000000 --- a/packages/plugins/workflow/src/server/instructions/prompt.ts +++ /dev/null @@ -1,82 +0,0 @@ -import compose from 'koa-compose'; - -import { Context, utils } from '@nocobase/actions'; - -import Plugin from '..'; -import { JOB_STATUS } from "../constants"; -import { Instruction } from '.'; - - - -export interface PromptConfig { - fields: []; - actions; -} - -async function loadJob(context: Context, next) { - const { filterByTk, values } = context.action.params; - if (!context.body) { - const jobRepo = utils.getRepositoryFromParams(context); - const job = await jobRepo.findOne({ - filterByTk, - appends: ['node', 'execution'], - context - }); - - if (!filterByTk || !job) { - return context.throw(404); - } - - // cache - context.body = job; - } - - const { type, config } = context.body.node; - if (type === 'prompt' - && config.actions - && !config.actions[values.status]) { - return context.throw(400); - } - - await next(); -} - -export default class implements Instruction { - middlewares = []; - - constructor(protected plugin: Plugin) { - plugin.app.resourcer.use(this.middleware); - } - - middleware = async (context: Context, next) => { - const { actionName, resourceName } = context.action; - if (actionName === 'submit' - && resourceName === 'jobs' - // && this.middlewares.length - ) { - return compose([loadJob, ...this.middlewares])(context, next); - } - await next(); - }; - - use(middleware) { - this.middlewares.push(middleware); - } - - run(node, input, processor) { - return { - status: JOB_STATUS.PENDING - }; - } - - resume(node, job, processor) { - if (!node.config.actions) { - job.set('status', JOB_STATUS.RESOLVED); - } - return job; - } - - extend(options: Instruction) { - Object.assign(this, options); - } -}; diff --git a/packages/plugins/workflow/src/server/instructions/request.ts b/packages/plugins/workflow/src/server/instructions/request.ts index a11417cd2..6349c8384 100644 --- a/packages/plugins/workflow/src/server/instructions/request.ts +++ b/packages/plugins/workflow/src/server/instructions/request.ts @@ -59,7 +59,7 @@ export default class implements Instruction { }) .catch(error => { job.set({ - status: JOB_STATUS.REJECTED, + status: JOB_STATUS.FAILED, result: error.isAxiosError ? error.toJSON() : error.message }); }) diff --git a/packages/plugins/workflow/src/server/migrations/20230122071831-calculation-expression.ts b/packages/plugins/workflow/src/server/migrations/20230122071831-calculation-expression.ts new file mode 100644 index 000000000..c6281e0ac --- /dev/null +++ b/packages/plugins/workflow/src/server/migrations/20230122071831-calculation-expression.ts @@ -0,0 +1,93 @@ +import { Migration } from '@nocobase/server'; + +function addQuote(v) { + if (typeof v !== 'string') { + return v; + } + + if (v.match(/^{{\s*([^{}]+)\s*}}$/)) { + return v; + } + + return `'${v}'`; +} + +const calculatorsMap = { + 'equal': '==', + '===': '==', + 'notEqual': '!=', + '!==': '!=', + 'gt': '>', + 'gte': '>=', + 'lt': '<', + 'lte': '<=', + 'add': '+', + 'minus': '-', + 'multiple': '*', + 'divide': '/', + 'mod': '%', + includes(a, b) { + return `SEARCH(${addQuote(b)}, ${addQuote(a)}) >= 0`; + }, + notIncludes(a, b) { + return `SEARCH(${addQuote(b)}, ${addQuote(a)}) < 0`; + }, + startsWith(a, b) { + return `SEARCH(${addQuote(b)}, ${addQuote(a)}) == 0` + }, + endsWith(a, b) { + return `RIGHT(${addQuote(a)}, LEN(${addQuote(b)})) == ${addQuote(b)}`; + }, + notStartsWith(a, b) { + return `SEARCH(${addQuote(b)}, ${addQuote(a)}) != 0`; + }, + notEndsWith(a, b) { + return `RIGHT(${addQuote(a)}, LEN(${addQuote(b)})) != ${addQuote(b)}`; + }, + concat(a, b) { + return `CONCATENATE(${addQuote(a)}, ${addQuote(b)})`; + } +} + +function migrateConfig({ calculation }) { + if (!calculation?.calculator || !calculation?.operands) { + return {}; + } + + const calculator = calculatorsMap[calculation.calculator]; + + return { + engine: 'formula.js', + expression: typeof calculator === 'function' + ? calculator(...calculation.operands) + : calculation.operands.join(` ${calculator ?? calculation.calculator} `) + } +} + +export default class extends Migration { + async up() { + const match = await this.app.version.satisfies('<=0.9.0-alpha.2'); + if (!match) { + return; + } + + const NodeRepo = this.context.db.getRepository('flow_nodes'); + await this.context.db.sequelize.transaction(async (transaction) => { + const nodes = await NodeRepo.find({ + filter: { + type: 'calculation' + }, + transaction + }); + console.log('%d nodes need to be migrated.', nodes.length); + + await nodes.reduce((promise, node) => promise.then(() => { + return node.update({ + config: migrateConfig(node.config) + }, { + transaction + }); + }), Promise.resolve()); + }); + } +} diff --git a/packages/plugins/workflow/src/server/migrations/20230203121203-condition-calculation.ts b/packages/plugins/workflow/src/server/migrations/20230203121203-condition-calculation.ts new file mode 100644 index 000000000..fd6ec98c6 --- /dev/null +++ b/packages/plugins/workflow/src/server/migrations/20230203121203-condition-calculation.ts @@ -0,0 +1,75 @@ +import { Migration } from '@nocobase/server'; + + + +const calculatorsMap = { + 'equal': '==', + '===': '==', + 'notEqual': '!=', + '!==': '!=', + 'gt': '>', + 'gte': '>=', + 'lt': '<', + 'lte': '<=', + includes(a, b) { + return `SEARCH('${b}', '${a}') >= 0`; + }, + notIncludes(a, b) { + return `SEARCH('${b}', '${a}') < 0`; + }, + startsWith(a, b) { + return `SEARCH('${b}', '${a}') == 0` + }, + endsWith(a, b) { + return `RIGHT('${a}', LEN('${b}')) == '${b}'`; + }, + notStartsWith(a, b) { + return `SEARCH('${b}', '${a}') != 0`; + }, + notEndsWith(a, b) { + return `RIGHT('${a}', LEN('${b}')) != '${b}'`; + }, +} + +function migrateConfig({ group: { type = 'and', calculations = [] } }) { + return { + group: { + type, + calculations: calculations.map(({ calculator = '===', operands = [] }: any) => { + return `(${operands.map((operand) => operand?.group ? migrateConfig(operand) : operand).join(` ${calculatorsMap[calculator]} `)})`; + }) + } + } +} + +export default class extends Migration { + async up() { + const match = await this.app.version.satisfies('<=0.9.0-alpha.2'); + if (!match) { + return; + } + + const NodeRepo = this.context.db.getRepository('flow_nodes'); + await this.context.db.sequelize.transaction(async (transaction) => { + const nodes = await NodeRepo.find({ + filter: { + type: 'condition' + }, + transaction + }); + console.log('%d nodes need to be migrated.', nodes.length); + + await nodes.reduce((promise, node) => promise.then(() => { + return node.update({ + config: { + ...node.config, + engine: 'basic', + // calculation: migrateConfig(node.config.calculation) + } + }, { + transaction + }); + }), Promise.resolve()); + }); + } +} diff --git a/packages/plugins/workflow/src/server/migrations/20230203162901-jsonb-to-json.ts b/packages/plugins/workflow/src/server/migrations/20230203162901-jsonb-to-json.ts new file mode 100644 index 000000000..3c4825004 --- /dev/null +++ b/packages/plugins/workflow/src/server/migrations/20230203162901-jsonb-to-json.ts @@ -0,0 +1,28 @@ +import { DataTypes } from 'sequelize'; + +import { Migration } from '@nocobase/server'; + +export default class extends Migration { + async up() { + const match = await this.app.version.satisfies('<=0.9.0-alpha.2'); + if (!match) { + return; + } + + const { context: { sequelize, queryInterface } } = arguments[0]; + await sequelize.transaction(async (transaction) => { + await queryInterface.changeColumn('workflows', 'config', { + type: DataTypes.JSON + }, { transaction }); + await queryInterface.changeColumn('flow_nodes', 'config', { + type: DataTypes.JSON + }, { transaction }); + await queryInterface.changeColumn('executions', 'context', { + type: DataTypes.JSON + }, { transaction }); + await queryInterface.changeColumn('jobs', 'result', { + type: DataTypes.JSON + }, { transaction }); + }); + } +}