From a17c1ad4e478a822108320d7f9b8e0f352784e48 Mon Sep 17 00:00:00 2001 From: Junyi Date: Tue, 18 Jul 2023 11:50:24 +0700 Subject: [PATCH] feat(plugin-workflow): allow manual form button to be configured with preset values (#2225) * refactor(client): split to small components * fix(client): fix component warning * feat(plugin-workflow): allow form button to be configured more than one for each type * test(plugin-workflow): add test cases * chore(plugin-workflow): add modal tips * fix(plugin-workflow): fix test bugs * fix(plugin-workflow): fix manual button configuration and params * test(plugin-workflow): fix test cases * fix(plugin-workflow): fix manual form values * refactor(plugin-workflow): adjust component * fix(plugin-workflow): fix typo * refactor(plugin-workflow): avoid one more load when manual node resume * fix(plugin-workflow): fix currentUser to be plain object * chore(plugin-workflow): clean code * fix(plugin-workflow): fix typo --- .../src/collection-manager/action-hooks.ts | 6 +- .../antd/action/Action.Designer.tsx | 964 ++++++++++-------- .../antd/action/ActionBar.tsx | 1 + .../schema-component/antd/select/Select.tsx | 4 +- .../src/schema-component/antd/tabs/Tabs.tsx | 2 +- .../antd/variable/Variable.tsx | 15 + .../assigned-field/AssignedField.tsx | 10 +- .../plugins/workflow/src/client/AddButton.tsx | 18 +- .../components/CollectionBlockInitializer.tsx | 6 +- .../workflow/src/client/locale/zh-CN.ts | 2 + .../workflow/src/client/nodes/aggregate.tsx | 8 +- .../workflow/src/client/nodes/calculation.tsx | 9 +- .../workflow/src/client/nodes/loop.tsx | 20 +- .../nodes/manual/FormBlockInitializer.tsx | 11 +- .../src/client/nodes/manual/SchemaConfig.tsx | 170 ++- .../src/client/nodes/manual/WorkflowTodo.tsx | 90 +- .../src/client/nodes/manual/forms/create.tsx | 6 +- .../src/client/nodes/manual/forms/custom.tsx | 10 +- .../src/client/nodes/manual/forms/update.tsx | 6 +- .../src/client/nodes/manual/index.tsx | 10 +- .../src/client/triggers/schedule/index.tsx | 3 +- .../plugins/workflow/src/client/variable.tsx | 74 +- .../plugins/workflow/src/server/Processor.ts | 4 +- .../__tests__/instructions/manual.test.ts | 463 +++++++-- .../src/server/instructions/manual/actions.ts | 20 +- .../instructions/manual/forms/create.ts | 3 +- .../instructions/manual/forms/update.ts | 5 +- .../20230710115902-manual-action-values.ts | 78 ++ 28 files changed, 1359 insertions(+), 659 deletions(-) create mode 100644 packages/plugins/workflow/src/server/migrations/20230710115902-manual-action-values.ts diff --git a/packages/core/client/src/collection-manager/action-hooks.ts b/packages/core/client/src/collection-manager/action-hooks.ts index 8cf240c3f..5dd35f86e 100644 --- a/packages/core/client/src/collection-manager/action-hooks.ts +++ b/packages/core/client/src/collection-manager/action-hooks.ts @@ -127,10 +127,10 @@ export const useSelfAndChildrenCollections = (collectionName: string) => { return options; }; -export const useCollectionFilterOptions = (collectionName: string) => { +export const useCollectionFilterOptions = (collection: any) => { const { getCollectionFields, getInterface } = useCollectionManager(); return useMemo(() => { - const fields = getCollectionFields(collectionName); + const fields = getCollectionFields(collection); const field2option = (field, depth) => { if (!field.interface) { return; @@ -179,7 +179,7 @@ export const useCollectionFilterOptions = (collectionName: string) => { }; const options = getOptions(fields, 1); return options; - }, [collectionName]); + }, [collection]); }; export const useLinkageCollectionFilterOptions = (collectionName: string) => { 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 dcac17f52..343ea14d6 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 @@ -6,12 +6,12 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDesignable } from '../..'; import { useCollection, useCollectionManager } from '../../../collection-manager'; -import { useRecord } from '../../../record-provider'; import { OpenModeSchemaItems } from '../../../schema-items'; import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings'; import { useCollectionState } from '../../../schema-settings/DataTemplates/hooks/useCollectionState'; import { useLinkageAction } from './hooks'; import { requestSettingsSchema } from './utils'; +import { useRecord } from '../../../record-provider'; const Tree = connect( AntdTree, @@ -53,31 +53,328 @@ const MenuGroup = (props) => { ); }; -export const ActionDesigner = (props) => { - const { modalTip, linkageAction, ...restProps } = props; +function ButtonEditor() { + const field = useField(); + const fieldSchema = useFieldSchema(); + const { dn } = useDesignable(); + const { t } = useTranslation(); + const isLink = fieldSchema['x-component'] === 'Action.Link'; + + return ( + { + fieldSchema.title = title; + field.title = title; + field.componentProps.icon = icon; + field.componentProps.danger = type === 'danger'; + field.componentProps.type = type; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props'].icon = icon; + fieldSchema['x-component-props'].danger = type === 'danger'; + fieldSchema['x-component-props'].type = type; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + title, + 'x-component-props': { + ...fieldSchema['x-component-props'], + }, + }, + }); + dn.refresh(); + }} + /> + ); +} + +function SaveMode() { + const { dn } = useDesignable(); + const { t } = useTranslation(); const field = useField(); const fieldSchema = useFieldSchema(); const { name } = useCollection(); - const { getChildrenCollections } = useCollectionManager(); + const { getEnableFieldTree, getOnLoadData } = useCollectionState(name); + + return ( + { + field.componentProps.saveMode = saveMode; + field.componentProps.filterKeys = filterKeys; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props'].saveMode = saveMode; + fieldSchema['x-component-props'].filterKeys = filterKeys; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-component-props': { + ...fieldSchema['x-component-props'], + }, + }, + }); + dn.refresh(); + }} + /> + ); +} + +function DuplicationMode() { const { dn } = useDesignable(); const { t } = useTranslation(); - const isAction = useLinkageAction(); - const record = useRecord(); - const isPopupAction = ['create', 'update', 'view', 'customize:popup', 'duplicate'].includes( - fieldSchema['x-action'] || '', - ); - const isUpdateModePopupAction = ['customize:bulkUpdate', 'customize:bulkEdit'].includes(fieldSchema['x-action']); - const [initialSchema, setInitialSchema] = useState(); - const actionType = fieldSchema['x-action'] ?? ''; - const isLinkageAction = linkageAction || isAction; - const isChildCollectionAction = getChildrenCollections(name).length > 0 && fieldSchema['x-action'] === 'create'; - const isLink = fieldSchema['x-component'] === 'Action.Link'; - const isDelete = fieldSchema?.parent['x-component'] === 'CollectionField'; - const isDraggable = fieldSchema?.parent['x-component'] !== 'CollectionField'; - const isDuplicateAction = fieldSchema['x-action'] === 'duplicate'; + const field = useField(); + const fieldSchema = useFieldSchema(); + const { name } = useCollection(); const { collectionList, getEnableFieldTree, getOnLoadData, getOnCheck } = useCollectionState(name); const duplicateValues = cloneDeep(fieldSchema['x-component-props'].duplicateFields || []); + const record = useRecord(); + return ( + 1 ? 'visible' : 'hidden', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + options: collectionList, + }, + 'x-reactions': [ + { + dependencies: ['.duplicateMode'], + fulfill: { + state: { + disabled: `{{ $deps[0]==="quickDulicate" }}`, + value: `{{ $deps[0]==="quickDulicate"? currentCollection:collectionName }}`, + }, + }, + }, + ], + }, + duplicateFields: { + type: 'array', + title: '{{ t("Data fields") }}', + required: true, + default: duplicateValues, + description: t('Only the selected fields will be used as the initialization data for the form'), + 'x-decorator': 'FormItem', + 'x-component': Tree, + 'x-component-props': { + defaultCheckedKeys: duplicateValues, + treeData: [], + checkable: true, + checkStrictly: true, + selectable: false, + loadData: '{{ getOnLoadData($self) }}', + onCheck: '{{ getOnCheck($self) }}', + rootStyle: { + padding: '8px 0', + border: '1px solid #d9d9d9', + borderRadius: '2px', + maxHeight: '30vh', + overflow: 'auto', + margin: '2px 0', + }, + }, + 'x-reactions': [ + { + dependencies: ['.collection'], + fulfill: { + state: { + disabled: '{{ !$deps[0] }}', + componentProps: { + treeData: '{{ getEnableFieldTree($deps[0], $self) }}', + }, + }, + }, + }, + ], + }, + }, + } as ISchema + } + onSubmit={({ duplicateMode, collection, duplicateFields }) => { + const fields = Array.isArray(duplicateFields) ? duplicateFields : duplicateFields.checked || []; + field.componentProps.duplicateMode = duplicateMode; + field.componentProps.duplicateFields = fields; + fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props'].duplicateMode = duplicateMode; + fieldSchema['x-component-props'].duplicateFields = fields; + fieldSchema['x-component-props'].duplicateCollection = collection; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-component-props': { + ...fieldSchema['x-component-props'], + }, + }, + }); + dn.refresh(); + }} + /> + ); +} + +function UpdateMode() { + const { dn } = useDesignable(); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + + return ( + { + fieldSchema['x-action-settings']['updateMode'] = value; + dn.emit('patch', { + schema: { + 'x-uid': fieldSchema['x-uid'], + 'x-action-settings': fieldSchema['x-action-settings'], + }, + }); + dn.refresh(); + }} + /> + ); +} + +function AssignedFieldValues() { + const { dn } = useDesignable(); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + const field = useField(); + const [initialSchema, setInitialSchema] = useState(); useEffect(() => { const schemaUid = uid(); const schema: ISchema = { @@ -88,6 +385,7 @@ export const ActionDesigner = (props) => { }; setInitialSchema(schema); }, [field.address]); + const tips = { 'customize:update': t( 'After clicking the custom button, the following fields of the current record will be saved according to the following form.', @@ -96,440 +394,222 @@ export const ActionDesigner = (props) => { 'After clicking the custom button, the following fields of the current record will be saved according to the following form.', ), }; + const actionType = fieldSchema['x-action'] ?? ''; + + return ( + { + fieldSchema['x-action-settings']['assignedValues'] = assignedValues; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': fieldSchema['x-action-settings'], + }, + }); + dn.refresh(); + }} + /> + ); +} + +function RequestSettings() { + const { dn } = useDesignable(); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + + return ( + { + fieldSchema['x-action-settings']['requestSettings'] = requestSettings; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': fieldSchema['x-action-settings'], + }, + }); + dn.refresh(); + }} + /> + ); +} + +function SkipValidation() { + const { dn } = useDesignable(); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + + return ( + { + fieldSchema['x-action-settings'].skipValidator = value; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': { + ...fieldSchema['x-action-settings'], + }, + }, + }); + }} + /> + ); +} + +function AfterSuccess() { + const { dn } = useDesignable(); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + const actionType = fieldSchema['x-action'] ?? ''; + + return ( + { + fieldSchema['x-action-settings']['onSuccess'] = onSuccess; + dn.emit('patch', { + schema: { + ['x-uid']: fieldSchema['x-uid'], + 'x-action-settings': fieldSchema['x-action-settings'], + }, + }); + }} + /> + ); +} + +function RemoveButton() { + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + const isDeletable = fieldSchema?.parent['x-component'] === 'CollectionField'; + return ( + !isDeletable && ( + <> + + { + return s['x-component'] === 'Space' || s['x-component'].endsWith('ActionBar'); + }} + confirm={{ + title: t('Delete action'), + }} + /> + + ) + ); +} + +export const ActionDesigner = (props) => { + const { modalTip, linkageAction, ...restProps } = props; + const fieldSchema = useFieldSchema(); + const { name } = useCollection(); + const { getChildrenCollections } = useCollectionManager(); + const isAction = useLinkageAction(); + const isPopupAction = ['create', 'update', 'view', 'customize:popup', 'duplicate'].includes( + fieldSchema['x-action'] || '', + ); + const isUpdateModePopupAction = ['customize:bulkUpdate', 'customize:bulkEdit'].includes(fieldSchema['x-action']); + const isLinkageAction = linkageAction || isAction; + const isChildCollectionAction = getChildrenCollections(name).length > 0 && fieldSchema['x-action'] === 'create'; + const isDraggable = fieldSchema?.parent['x-component'] !== 'CollectionField'; + const isDuplicateAction = fieldSchema['x-action'] === 'duplicate'; return ( - { - fieldSchema.title = title; - field.title = title; - field.componentProps.icon = icon; - field.componentProps.danger = type === 'danger'; - field.componentProps.type = type; - fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; - fieldSchema['x-component-props'].icon = icon; - fieldSchema['x-component-props'].danger = type === 'danger'; - fieldSchema['x-component-props'].type = type; - dn.emit('patch', { - schema: { - ['x-uid']: fieldSchema['x-uid'], - title, - 'x-component-props': { - ...fieldSchema['x-component-props'], - }, - }, - }); - dn.refresh(); - }} - /> + {fieldSchema['x-action'] === 'submit' && - fieldSchema.parent?.['x-initializer'] === 'CreateFormActionInitializers' && ( - { - field.componentProps.saveMode = saveMode; - field.componentProps.filterKeys = filterKeys; - fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; - fieldSchema['x-component-props'].saveMode = saveMode; - fieldSchema['x-component-props'].filterKeys = filterKeys; - dn.emit('patch', { - schema: { - ['x-uid']: fieldSchema['x-uid'], - 'x-component-props': { - ...fieldSchema['x-component-props'], - }, - }, - }); - dn.refresh(); - }} - /> - )} + fieldSchema.parent?.['x-initializer'] === 'CreateFormActionInitializers' && } {isLinkageAction && } - {isDuplicateAction && ( - 1 ? 'visible' : 'hidden', - 'x-decorator': 'FormItem', - 'x-component': 'Select', - 'x-component-props': { - options: collectionList, - }, - 'x-reactions': [ - { - dependencies: ['.duplicateMode'], - fulfill: { - state: { - disabled: `{{ $deps[0]==="quickDulicate" }}`, - value: `{{ $deps[0]==="quickDulicate"? currentCollection:collectionName }}`, - }, - }, - }, - ], - }, - duplicateFields: { - type: 'array', - title: '{{ t("Data fields") }}', - required: true, - default: duplicateValues, - description: t('Only the selected fields will be used as the initialization data for the form'), - 'x-decorator': 'FormItem', - 'x-component': Tree, - 'x-component-props': { - defaultCheckedKeys: duplicateValues, - treeData: [], - checkable: true, - checkStrictly: true, - selectable: false, - loadData: '{{ getOnLoadData($self) }}', - onCheck: '{{ getOnCheck($self) }}', - rootStyle: { - padding: '8px 0', - border: '1px solid #d9d9d9', - borderRadius: '2px', - maxHeight: '30vh', - overflow: 'auto', - margin: '2px 0', - }, - }, - 'x-reactions': [ - { - dependencies: ['.collection'], - fulfill: { - state: { - disabled: '{{ !$deps[0] }}', - componentProps: { - treeData: '{{ getEnableFieldTree($deps[0], $self) }}', - }, - }, - }, - }, - ], - }, - }, - } as ISchema - } - onSubmit={({ duplicateMode, collection, duplicateFields }) => { - const fields = Array.isArray(duplicateFields) ? duplicateFields : duplicateFields.checked || []; - field.componentProps.duplicateMode = duplicateMode; - field.componentProps.duplicateFields = fields; - fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {}; - fieldSchema['x-component-props'].duplicateMode = duplicateMode; - fieldSchema['x-component-props'].duplicateFields = fields; - fieldSchema['x-component-props'].duplicateCollection = collection; - dn.emit('patch', { - schema: { - ['x-uid']: fieldSchema['x-uid'], - 'x-component-props': { - ...fieldSchema['x-component-props'], - }, - }, - }); - dn.refresh(); - }} - /> - )} - - {isUpdateModePopupAction && ( - { - fieldSchema['x-action-settings']['updateMode'] = value; - dn.emit('patch', { - schema: { - 'x-uid': fieldSchema['x-uid'], - 'x-action-settings': fieldSchema['x-action-settings'], - }, - }); - dn.refresh(); - }} - /> - )} - - {isValid(fieldSchema?.['x-action-settings']?.assignedValues) && ( - { - fieldSchema['x-action-settings']['assignedValues'] = assignedValues; - dn.emit('patch', { - schema: { - ['x-uid']: fieldSchema['x-uid'], - 'x-action-settings': fieldSchema['x-action-settings'], - }, - }); - dn.refresh(); - }} - /> - )} - {isValid(fieldSchema?.['x-action-settings']?.requestSettings) && ( - { - fieldSchema['x-action-settings']['requestSettings'] = requestSettings; - dn.emit('patch', { - schema: { - ['x-uid']: fieldSchema['x-uid'], - 'x-action-settings': fieldSchema['x-action-settings'], - }, - }); - dn.refresh(); - }} - /> - )} - {isValid(fieldSchema?.['x-action-settings']?.skipValidator) && ( - { - fieldSchema['x-action-settings'].skipValidator = value; - dn.emit('patch', { - schema: { - ['x-uid']: fieldSchema['x-uid'], - 'x-action-settings': { - ...fieldSchema['x-action-settings'], - }, - }, - }); - }} - /> - )} - {isValid(fieldSchema?.['x-action-settings']?.['onSuccess']) && ( - { - fieldSchema['x-action-settings']['onSuccess'] = onSuccess; - dn.emit('patch', { - schema: { - ['x-uid']: fieldSchema['x-uid'], - 'x-action-settings': fieldSchema['x-action-settings'], - }, - }); - }} - /> - )} + {isDuplicateAction && } + + {isUpdateModePopupAction && } + {isValid(fieldSchema?.['x-action-settings']?.assignedValues) && } + {isValid(fieldSchema?.['x-action-settings']?.requestSettings) && } + {isValid(fieldSchema?.['x-action-settings']?.skipValidator) && } + {isValid(fieldSchema?.['x-action-settings']?.['onSuccess']) && } {isChildCollectionAction && } - {!isDelete && ( - <> - - { - return s['x-component'] === 'Space' || s['x-component'].endsWith('ActionBar'); - }} - confirm={{ - title: t('Delete action'), - }} - /> - - )} + {} ); }; + +ActionDesigner.ButtonEditor = ButtonEditor; diff --git a/packages/core/client/src/schema-component/antd/action/ActionBar.tsx b/packages/core/client/src/schema-component/antd/action/ActionBar.tsx index 275c7f419..1f8c704d8 100644 --- a/packages/core/client/src/schema-component/antd/action/ActionBar.tsx +++ b/packages/core/client/src/schema-component/antd/action/ActionBar.tsx @@ -103,6 +103,7 @@ export const ActionBar = observer( alignItems: 'center', width: '100%', overflow: 'hidden', + flexWrap: 'wrap', }} > diff --git a/packages/core/client/src/schema-component/antd/select/Select.tsx b/packages/core/client/src/schema-component/antd/select/Select.tsx index f8c447aac..ac068c2ac 100644 --- a/packages/core/client/src/schema-component/antd/select/Select.tsx +++ b/packages/core/client/src/schema-component/antd/select/Select.tsx @@ -78,13 +78,13 @@ const filterOption = (input, option) => (option?.label ?? '').toLowerCase().incl const InternalSelect = connect( (props: Props) => { - const { objectValue, loading, value, ...others } = props; + const { objectValue, loading, value, rawOptions, ...others } = props; let mode: any = props.multiple ? 'multiple' : props.mode; if (mode && !['multiple', 'tags'].includes(mode)) { mode = undefined; } if (objectValue) { - return ; + return ; } const toValue = (v) => { if (['tags', 'multiple'].includes(props.mode) || props.multiple) { diff --git a/packages/core/client/src/schema-component/antd/tabs/Tabs.tsx b/packages/core/client/src/schema-component/antd/tabs/Tabs.tsx index 5aaab19bc..a41479c68 100644 --- a/packages/core/client/src/schema-component/antd/tabs/Tabs.tsx +++ b/packages/core/client/src/schema-component/antd/tabs/Tabs.tsx @@ -26,7 +26,7 @@ export const Tabs: any = observer( key, label: , children: ( - + ), diff --git a/packages/core/client/src/schema-component/antd/variable/Variable.tsx b/packages/core/client/src/schema-component/antd/variable/Variable.tsx index 564f1101e..c8555769f 100644 --- a/packages/core/client/src/schema-component/antd/variable/Variable.tsx +++ b/packages/core/client/src/schema-component/antd/variable/Variable.tsx @@ -1,3 +1,4 @@ +import React, { createContext, useContext } from 'react'; import { connect, mapReadPretty } from '@formily/react'; import { IField } from '../../../collection-manager'; @@ -6,6 +7,20 @@ import { JSONInput } from './JSONInput'; import { RawTextArea } from './RawTextArea'; import { TextArea } from './TextArea'; +const VariableScopeContext = createContext([]); + +export function VariableScopeProvider({ scope = [], children }) { + return ( + + {children} + + ) +} + +export function useVariableScope() { + return useContext(VariableScopeContext); +} + export function Variable() { return null; } diff --git a/packages/core/client/src/schema-initializer/components/assigned-field/AssignedField.tsx b/packages/core/client/src/schema-initializer/components/assigned-field/AssignedField.tsx index 53353a7d9..a3ab2c26d 100644 --- a/packages/core/client/src/schema-initializer/components/assigned-field/AssignedField.tsx +++ b/packages/core/client/src/schema-initializer/components/assigned-field/AssignedField.tsx @@ -11,7 +11,7 @@ import { useCollectionField, useCollectionFilterOptions, } from '../../../collection-manager'; -import { Variable, useCompile, useComponent } from '../../../schema-component'; +import { Variable, useCompile, useComponent, useVariableScope } from '../../../schema-component'; import { DeletedField } from '../DeletedField'; const InternalField: React.FC = (props) => { @@ -91,8 +91,9 @@ export const AssignedField = (props: any) => { const collectionField = getField(fieldSchema.name); const [options, setOptions] = useState([]); const collection = useCollection(); - const fields = compile(useCollectionFilterOptions(collection?.name)); + const fields = compile(useCollectionFilterOptions(collection)); const userFields = compile(useCollectionFilterOptions('users')); + const scope = useVariableScope(); useEffect(() => { const opt = [ { @@ -113,8 +114,9 @@ export const AssignedField = (props: any) => { children: null, }); } - setOptions(opt); - }, [fields, userFields]); + const next = opt.concat(scope); + setOptions(next); + }, [fields, userFields, scope]); return ( (() => { return { - onClick: (ev) => onCreate(ev), + onClick: onCreate, items: compile(groups), }; }, [groups, onCreate]); @@ -92,8 +92,18 @@ export function AddButton({ upstream, branchIndex = null }: AddButtonProps) { } return ( -
- +
+
diff --git a/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx b/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx index 620f5a6c4..0e143dae5 100644 --- a/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx +++ b/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx @@ -14,14 +14,14 @@ function InnerCollectionBlockInitializer({ insert, collection, dataSource, ...pr const { getTemplateSchemaByMode } = useSchemaTemplateManager(); const { getCollection } = useCollectionManager(); const items = useRecordCollectionDataSourceItems('FormItem') as SchemaInitializerItemOptions[]; - const resovledCollection = getCollection(collection); + const resolvedCollection = getCollection(collection); async function onConfirm({ item }) { const template = item.template ? await getTemplateSchemaByMode(item) : null; const result = { type: 'void', - name: resovledCollection.name, - title: resovledCollection.title, + name: resolvedCollection.name, + title: resolvedCollection.title, 'x-decorator': 'DetailsBlockProvider', 'x-decorator-props': { collection, diff --git a/packages/plugins/workflow/src/client/locale/zh-CN.ts b/packages/plugins/workflow/src/client/locale/zh-CN.ts index e91b55f32..018643c9e 100644 --- a/packages/plugins/workflow/src/client/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/client/locale/zh-CN.ts @@ -90,6 +90,8 @@ export default { Manual: '人工处理', 'Could be used for manually submitting data, and determine whether to continue or exit. Workflow will generate a todo item for assigned user when it reaches a manual node, and continue processing after user submits the form.': '可用于人工提交数据,并决定是否继续或退出流程。工作流在执行到人工节点时会为被指派的用户生成待办事项,直到用户提交对应表单后继续处理该流程。', + 'Values preset in this form will override user submitted ones when continue or reject.': + '表单中预设的字段值会在用户提交继续或拒绝时覆盖相应字段的值。', 'Extended types': '扩展类型', 'Node type': '节点类型', Calculation: '运算', diff --git a/packages/plugins/workflow/src/client/nodes/aggregate.tsx b/packages/plugins/workflow/src/client/nodes/aggregate.tsx index 31c094d6e..873b71c1d 100644 --- a/packages/plugins/workflow/src/client/nodes/aggregate.tsx +++ b/packages/plugins/workflow/src/client/nodes/aggregate.tsx @@ -13,7 +13,7 @@ import { import { collection, filter } from '../schemas/collection'; import { NAMESPACE, lang } from '../locale'; import { FilterDynamicComponent } from '../components/FilterDynamicComponent'; -import { BaseTypeSets, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable'; +import { BaseTypeSets, defaultFieldNames, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable'; import { FieldsSelect } from '../components/FieldsSelect'; import { ValueBlock } from '../components/ValueBlock'; import { useNodeContext } from '.'; @@ -299,7 +299,7 @@ export default { ValueBlock, AssociatedConfig, }, - useVariables({ id, title }, { types }) { + useVariables({ id, title }, { types, fieldNames = defaultFieldNames }) { if ( types && !types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type))) @@ -307,8 +307,8 @@ export default { return null; } return { - value: `${id}`, - label: title, + [fieldNames.value]: `${id}`, + [fieldNames.label]: title, }; }, useInitializers(node): SchemaInitializerItemOptions | null { diff --git a/packages/plugins/workflow/src/client/nodes/calculation.tsx b/packages/plugins/workflow/src/client/nodes/calculation.tsx index 4f750974a..d52db9052 100644 --- a/packages/plugins/workflow/src/client/nodes/calculation.tsx +++ b/packages/plugins/workflow/src/client/nodes/calculation.tsx @@ -1,5 +1,5 @@ import { FormItem, FormLayout } from '@formily/antd-v5'; -import { SchemaInitializerItemOptions, Variable, css, useCollectionManager } from '@nocobase/client'; +import { SchemaInitializerItemOptions, Variable, css, defaultFieldNames, useCollectionManager } from '@nocobase/client'; import { Evaluator, evaluators, getOptions } from '@nocobase/evaluators/client'; import { parse } from '@nocobase/utils/client'; import { Radio } from 'antd'; @@ -192,8 +192,7 @@ export default { RadioWithTooltip, DynamicConfig, }, - useVariables({ id, title }, options) { - const { types } = options ?? {}; + useVariables({ id, title }, { types, fieldNames = defaultFieldNames }) { if ( types && !types.some((type) => type in BaseTypeSets || Object.values(BaseTypeSets).some((set) => set.has(type))) @@ -201,8 +200,8 @@ export default { return null; } return { - value: id, - label: title, + [fieldNames.value]: `${id}`, + [fieldNames.label]: title, }; }, useInitializers(node): SchemaInitializerItemOptions { diff --git a/packages/plugins/workflow/src/client/nodes/loop.tsx b/packages/plugins/workflow/src/client/nodes/loop.tsx index 609e3c132..146183af6 100644 --- a/packages/plugins/workflow/src/client/nodes/loop.tsx +++ b/packages/plugins/workflow/src/client/nodes/loop.tsx @@ -6,7 +6,7 @@ import { Branch } from '../Branch'; import { useFlowContext } from '../FlowContext'; import { NAMESPACE, lang } from '../locale'; import useStyles from '../style'; -import { VariableOption, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable'; +import { VariableOption, defaultFieldNames, nodesOptions, triggerOptions, useWorkflowVariableOptions } from '../variable'; function findOption(options: VariableOption[], paths: string[]) { let opts = options; @@ -64,7 +64,7 @@ export default { return ( -
+
-
+
@@ -103,13 +103,15 @@ export default { return null; } + const { fieldNames = defaultFieldNames } = options; + // const { workflow } = useFlowContext(); // const current = useNodeContext(); // const upstreams = useAvailableUpstreams(current); // find target data model by path described in `config.target` // 1. get options from $context/$jobsMapByNodeId // 2. route to sub-options and use as loop target options - let targetOption: VariableOption = { key: 'item', value: 'item', label: lang('Loop target') }; + let targetOption: VariableOption = { key: 'item', [fieldNames.value]: 'item', [fieldNames.label]: lang('Loop target') }; if (typeof target === 'string' && target.startsWith('{{') && target.endsWith('}}')) { const paths = target @@ -120,10 +122,10 @@ export default { const targetOptions = [nodesOptions, triggerOptions].map((item: any) => { const opts = item.useOptions(options).filter(Boolean); return { - label: compile(item.title), - value: item.value, + [fieldNames.label]: compile(item.title), + [fieldNames.value]: item.value, key: item.value, - children: opts, + [fieldNames.children]: opts, disabled: opts && !opts.length, }; }); @@ -135,8 +137,8 @@ export default { return [ targetOption, - { key: 'index', value: 'index', label: lang('Loop index') }, - { key: 'length', value: 'length', label: lang('Loop length') }, + { key: 'index', [fieldNames.value]: 'index', [fieldNames.label]: lang('Loop index') }, + { key: 'length', [fieldNames.value]: 'length', [fieldNames.label]: lang('Loop length') }, ]; }, }; diff --git a/packages/plugins/workflow/src/client/nodes/manual/FormBlockInitializer.tsx b/packages/plugins/workflow/src/client/nodes/manual/FormBlockInitializer.tsx index 8d2a56504..1a4bece06 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/FormBlockInitializer.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/FormBlockInitializer.tsx @@ -34,11 +34,8 @@ function InternalFormBlockInitializer({ insert, schema, ...others }) { type: 'primary', useAction: '{{ useSubmit }}', }, - 'x-designer': 'Action.Designer', - 'x-designer-props': { - type: 'record', - }, - 'x-action': `${JOB_STATUS.RESOLVED}`, + 'x-designer': 'ManualActionDesigner', + 'x-designer-props': {}, }, }, ...schema, @@ -48,6 +45,10 @@ function InternalFormBlockInitializer({ insert, schema, ...others }) { delete result['x-acl-action']; const [formKey] = Object.keys(result.properties); result.properties[formKey].properties.actions['x-decorator'] = 'ActionBarProvider'; + result.properties[formKey].properties.actions['x-component-props'].style = { + marginTop: '1.5em', + flexWrap: 'wrap', + }; traverseSchema(result, (node) => { if (node['x-uid']) { delete node['x-uid']; diff --git a/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx b/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx index fed6147b0..419179e5a 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx @@ -1,8 +1,12 @@ -import { default as React, useContext, useMemo, useState } from 'react'; - -import { ISchema, Schema, useFieldSchema, useForm } from '@formily/react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import { createForm } from '@formily/core'; +import { FormProvider, ISchema, Schema, useFieldSchema, useForm } from '@formily/react'; +import { FormLayout } from '@formily/antd-v5'; +import { Alert, Button, Modal, Space } from 'antd'; +import { useTranslation } from 'react-i18next'; import { + Action, ActionContextProvider, GeneralSchemaDesigner, InitializerWithSwitch, @@ -12,22 +16,24 @@ import { SchemaInitializerItemOptions, SchemaInitializerProvider, SchemaSettings, + VariableScopeProvider, gridRowColWrap, useCompile, useFormBlockContext, + useSchemaOptionsContext, } from '@nocobase/client'; import { Registry, lodash } from '@nocobase/utils/client'; -import { Button } from 'antd'; import { instructions, useAvailableUpstreams, useNodeContext } from '..'; -import { useFlowContext } from '../../FlowContext'; import { JOB_STATUS } from '../../constants'; +import { useFlowContext } from '../../FlowContext'; import { NAMESPACE, lang } from '../../locale'; import { useTrigger } from '../../triggers'; import { DetailsBlockProvider } from './DetailsBlockProvider'; import { FormBlockProvider } from './FormBlockProvider'; -import createForm from './forms/create'; -import customForm from './forms/custom'; -import updateForm from './forms/update'; +import createRecordForm from './forms/create'; +import customRecordForm from './forms/custom'; +import updateRecordForm from './forms/update'; +import { useWorkflowVariableOptions } from '../../variable'; type ValueOf = T[keyof T]; @@ -68,9 +74,9 @@ export type ManualFormType = { export const manualFormTypes = new Registry(); -manualFormTypes.register('customForm', customForm); -manualFormTypes.register('createForm', createForm); -manualFormTypes.register('updateForm', updateForm); +manualFormTypes.register('customForm', customRecordForm); +manualFormTypes.register('createForm', createRecordForm); +manualFormTypes.register('updateForm', updateRecordForm); function useTriggerInitializers(): SchemaInitializerItemOptions | null { const { workflow } = useFlowContext(); @@ -79,7 +85,7 @@ function useTriggerInitializers(): SchemaInitializerItemOptions | null { } const blockTypeNames = { - customForm: customForm.title, + customForm: customRecordForm.title, record: `{{t("Data record", { ns: "${NAMESPACE}" })}}`, }; @@ -159,6 +165,142 @@ function AddBlockButton(props: any) { return ; } +function AssignedFieldValues() { + const ctx = useContext(SchemaComponentContext); + const { t } = useTranslation(); + const fieldSchema = useFieldSchema(); + const scope = useWorkflowVariableOptions({ fieldNames: { label: 'title', value: 'name' } }); + const [open, setOpen] = useState(false); + const [initialSchema, setInitialSchema] = useState(fieldSchema?.['x-action-settings']?.assignedValues?.schema ?? { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'CustomFormItemInitializers', + properties: {}, + }); + const [schema, setSchema] = useState(null); + const { components } = useSchemaOptionsContext(); + useEffect(() => { + setSchema(new Schema({ + properties: { + grid: initialSchema + }, + })); + }, [initialSchema]); + const form = useMemo( + () => { + const initialValues = fieldSchema?.['x-action-settings']?.assignedValues?.values; + return createForm({ + initialValues: lodash.cloneDeep(initialValues), + values: lodash.cloneDeep(initialValues), + }); + }, + [], + ); + + const title = t('Assign field values'); + + function onCancel() { + setOpen(false); + } + + function onSubmit() { + if (!fieldSchema['x-action-settings']) { + fieldSchema['x-action-settings'] = {}; + } + if (!fieldSchema['x-action-settings'].assignedValues) { + fieldSchema['x-action-settings'].assignedValues = {}; + } + fieldSchema['x-action-settings'].assignedValues.schema = initialSchema; + fieldSchema['x-action-settings'].assignedValues.values = form.values; + setOpen(false); + setTimeout(() => { + ctx.refresh?.(); + }, 300); + } + + return ( + <> + setOpen(true)}> + {title} + + + + + + } + > + + + + +
+ {open && schema && ( + + + + )} +
+
+
+
+ + ); +} + +function ManualActionDesigner(props) { + return ( + + + + + + + ); +} + +function ContinueInitializer({ action, actionProps, insert, ...props }) { + return ( + { + insert({ + type: 'void', + title: props.title, + 'x-decorator': 'ManualActionStatusProvider', + 'x-decorator-props': { + value: action, + }, + 'x-component': 'Action', + 'x-component-props': { + ...actionProps, + useAction: '{{ useSubmit }}', + }, + 'x-designer': 'ManualActionDesigner', + 'x-action-settings': {}, + }); + }} + /> + ); +} + function ActionInitializer({ action, actionProps, ...props }) { return ( (null); function ManualActionStatusProvider({ value, children }) { const { userJob } = useFlowContext(); const button = useField(); + const buttonSchema = useFieldSchema(); useEffect(() => { if (userJob.status) { button.disabled = true; - button.visible = userJob.status === value; + button.visible = userJob.status === value && userJob.result._ === buttonSchema.name; } - }, [userJob.status, value, button]); + }, [userJob, value, button]); return {children}; } @@ -398,21 +394,21 @@ function useSubmit() { const { setVisible } = useActionContext(); const { values, submit } = useForm(); const buttonSchema = useFieldSchema(); - const nextStatus = useContext(ManualActionStatusContext); const { service } = useTableBlockContext(); const { userJob } = useFlowContext(); - const { updateAssociationValues } = useContext(FormBlockContext); + const { name: actionKey } = buttonSchema; + const { name: formKey } = buttonSchema.parent.parent; return { async run() { + if (userJob.status) { + return; + } await submit(); - const { name } = buttonSchema.parent.parent.toJSON(); await api.resource('users_jobs').submit({ filterByTk: userJob.id, values: { - status: nextStatus, - result: { [name]: values }, + result: { [formKey]: values, _: actionKey }, }, - updateAssociationValues, }); setVisible(false); service.refresh(); @@ -520,40 +516,33 @@ function useDetailsBlockProps() { return { form }; } +function FooterStatus() { + const compile = useCompile(); + const { status, updatedAt } = useRecord(); + const statusOption = JobStatusOptionsMap[status]; + return status ? ( + + + {compile(statusOption.label)} + + ) : null; +} + function Drawer() { const ctx = useContext(SchemaComponentContext); - const { id, node, workflow, status, updatedAt } = useRecord(); - - const statusOption = JobStatusOptionsMap[status]; - const footerSchema = status - ? { - date: { - type: 'void', - 'x-component': 'time', - 'x-component-props': { - className: css` - margin-right: 0.5em; - `, - }, - 'x-content': dayjs(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, - }, - } - : null; + const { id, node, workflow, status } = useRecord(); return ( { + if (!collections.find((item) => item.name === collection.name)) { + collections.push(collection); + } + }); + return ( {children} diff --git a/packages/plugins/workflow/src/client/nodes/manual/forms/create.tsx b/packages/plugins/workflow/src/client/nodes/manual/forms/create.tsx index bf48feae2..b36f4d7fc 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/forms/create.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/forms/create.tsx @@ -71,7 +71,11 @@ export default { type: 'create', title: formBlock['x-component-props']?.title || formKey, actions: findSchema(formSchema.properties.actions, (item) => item['x-component'] === 'Action').map( - (item) => item['x-decorator-props'].value, + (item) => ({ + status: item['x-decorator-props'].value, + values: item['x-action-settings']?.assignedValues?.values, + key: item.name, + }), ), collection: formBlock['x-decorator-props'].collection, }; diff --git a/packages/plugins/workflow/src/client/nodes/manual/forms/custom.tsx b/packages/plugins/workflow/src/client/nodes/manual/forms/custom.tsx index bdfed0714..e08575b81 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/forms/custom.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/forms/custom.tsx @@ -105,6 +105,7 @@ function CustomFormBlockInitializer({ insert, ...props }) { layout: 'one-column', style: { marginTop: '1.5em', + flexWrap: 'wrap', }, }, 'x-initializer': 'AddActionButton', @@ -121,8 +122,7 @@ function CustomFormBlockInitializer({ insert, ...props }) { type: 'primary', useAction: '{{ useSubmit }}', }, - 'x-designer': 'Action.Designer', - 'x-action': `${JOB_STATUS.RESOLVED}`, + 'x-designer': 'ManualActionDesigner', }, }, }, @@ -371,7 +371,11 @@ export default { type: 'custom', title: formBlock['x-component-props']?.title || formKey, actions: findSchema(formSchema.properties.actions, (item) => item['x-component'] === 'Action').map( - (item) => item['x-decorator-props'].value, + (item) => ({ + status: item['x-decorator-props'].value, + values: item['x-action-settings']?.assignedValues?.values, + key: item.name, + }), ), collection: formBlock['x-decorator-props'].collection, }; diff --git a/packages/plugins/workflow/src/client/nodes/manual/forms/update.tsx b/packages/plugins/workflow/src/client/nodes/manual/forms/update.tsx index 5737284e7..3facff263 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/forms/update.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/forms/update.tsx @@ -114,7 +114,11 @@ export default { type: 'update', title: formBlock['x-component-props']?.title || formKey, actions: findSchema(formSchema.properties.actions, (item) => item['x-component'] === 'Action').map( - (item) => item['x-decorator-props'].value, + (item) => ({ + status: item['x-decorator-props'].value, + values: item['x-action-settings']?.assignedValues?.values, + key: item.name, + }), ), }; }); diff --git a/packages/plugins/workflow/src/client/nodes/manual/index.tsx b/packages/plugins/workflow/src/client/nodes/manual/index.tsx index 3038d727d..b9b31acd2 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/index.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/index.tsx @@ -1,7 +1,7 @@ import { BlockInitializers, SchemaInitializerItemOptions, useCollectionManager, useCompile } from '@nocobase/client'; import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer'; -import { getCollectionFieldOptions } from '../../variable'; +import { defaultFieldNames, getCollectionFieldOptions } from '../../variable'; import { NAMESPACE } from '../../locale'; import { SchemaConfig, SchemaConfigButton } from './SchemaConfig'; import { ModeConfig } from './ModeConfig'; @@ -85,7 +85,7 @@ export default { ModeConfig, AssigneesSelect, }, - useVariables({ id, title, config }, { types }) { + useVariables({ id, title, config }, { types, fieldNames = defaultFieldNames }) { const compile = useCompile(); const { getCollectionFields } = useCollectionManager(); const formKeys = Object.keys(config.forms ?? {}); @@ -119,9 +119,9 @@ export default { return options.length ? { - value: `${id}`, - label: title, - children: options, + [fieldNames.value]: `${id}`, + [fieldNames.label]: title, + [fieldNames.children]: options, } : null; }, diff --git a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx index 3945e6ee2..79321263f 100644 --- a/packages/plugins/workflow/src/client/triggers/schedule/index.tsx +++ b/packages/plugins/workflow/src/client/triggers/schedule/index.tsx @@ -4,7 +4,7 @@ import { ScheduleConfig } from './ScheduleConfig'; import { SCHEDULE_MODE } from './constants'; import { NAMESPACE, lang } from '../../locale'; import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer'; -import { getCollectionFieldOptions } from '../../variable'; +import { defaultFieldNames, getCollectionFieldOptions } from '../../variable'; import { FieldsSelect } from '../../components/FieldsSelect'; export default { @@ -26,6 +26,7 @@ export default { useVariables(config, opts) { const compile = useCompile(); const { getCollectionFields } = useCollectionManager(); + const { fieldNames = defaultFieldNames } = opts; const options: any[] = []; if (!opts?.types || opts.types.includes('date')) { options.push({ key: 'date', value: 'date', label: lang('Trigger time') }); diff --git a/packages/plugins/workflow/src/client/variable.tsx b/packages/plugins/workflow/src/client/variable.tsx index d60d55caf..1de2b4c19 100644 --- a/packages/plugins/workflow/src/client/variable.tsx +++ b/packages/plugins/workflow/src/client/variable.tsx @@ -6,17 +6,37 @@ import { triggers } from './triggers'; export type VariableOption = { key?: string; - value: string; - label: string; + value?: string; + label?: string; children?: VariableOptions; + [key: string]: any; }; export type VariableOptions = VariableOption[] | null; +export type VariableDataType = + string | + { + type: string; + options?: { entity?: boolean; collection?: string } + } | + ((field: any, appends?: string[]) => boolean); + +export type OptionsOfUseVariableOptions = { + types?: VariableDataType[]; + fieldNames?: { + label?: string; + value?: string; + children?: string; + }; +} + +export const defaultFieldNames = { label: 'label', value: 'value', children: 'children' } as const; + export const nodesOptions = { label: `{{t("Node result", { ns: "${NAMESPACE}" })}}`, value: '$jobsMapByNodeId', - useOptions(options) { + useOptions(options: OptionsOfUseVariableOptions) { const current = useNodeContext(); const upstreams = useAvailableUpstreams(current); const result: VariableOption[] = []; @@ -34,7 +54,7 @@ export const nodesOptions = { export const triggerOptions = { label: `{{t("Trigger variables", { ns: "${NAMESPACE}" })}}`, value: '$context', - useOptions(options) { + useOptions(options: OptionsOfUseVariableOptions) { const { workflow } = useFlowContext(); const trigger = triggers.get(workflow.type); return trigger?.useVariables?.(workflow.config, options) ?? null; @@ -44,7 +64,8 @@ export const triggerOptions = { export const scopeOptions = { label: `{{t("Scope variables", { ns: "${NAMESPACE}" })}}`, value: '$scopes', - useOptions(options) { + useOptions(options: OptionsOfUseVariableOptions) { + const { fieldNames = defaultFieldNames } = options; const current = useNodeContext(); const scopes = useUpstreamScopes(current); const result: VariableOption[] = []; @@ -54,9 +75,9 @@ export const scopeOptions = { if (subOptions) { result.push({ key: node.id.toString(), - value: node.id.toString(), - label: node.title ?? `#${node.id}`, - children: subOptions, + [fieldNames.value]: node.id.toString(), + [fieldNames.label]: node.title ?? `#${node.id}`, + [fieldNames.children]: subOptions, }); } }); @@ -67,14 +88,14 @@ export const scopeOptions = { export const systemOptions = { label: `{{t("System variables", { ns: "${NAMESPACE}" })}}`, value: '$system', - useOptions({ types }) { + useOptions({ types, fieldNames = defaultFieldNames }: OptionsOfUseVariableOptions) { return [ ...(!types || types.includes('date') ? [ { key: 'now', - value: 'now', - label: lang('System time'), + [fieldNames.label]: lang('System time'), + [fieldNames.value]: 'now', }, ] : []), @@ -169,16 +190,18 @@ function filterTypedFields({ fields, types, appends, compile, getCollectionField }); } -export function useWorkflowVariableOptions(options = {}) { +export function useWorkflowVariableOptions(options: OptionsOfUseVariableOptions = {}) { + const fieldNames = Object.assign({}, defaultFieldNames, options.fieldNames ?? {}); + const opts = Object.assign(options, { fieldNames }); const compile = useCompile(); const result = [scopeOptions, nodesOptions, triggerOptions, systemOptions].map((item: any) => { - const opts = item.useOptions(options).filter(Boolean); + const children = item.useOptions(opts).filter(Boolean); return { - label: compile(item.label), - value: item.value, - key: item.value, - children: opts, - disabled: opts && !opts.length, + [fieldNames.label]: compile(item.label), + [fieldNames.value]: item.value, + key: item[fieldNames.value], + [fieldNames.children]: children, + disabled: children && !children.length, }; }); @@ -245,15 +268,13 @@ async function loadChildren(option) { collection: option.field.target, types: option.types, appends: getNextAppends(option.field, option.appends), - sourceKey: option.field.key, - compile: this.compile, - getCollectionFields: this.getCollectionFields, + ...this, }); + option.loadChildren = null; if (result.length) { option.children = result; } else { option.isLeaf = true; - option.loadChildren = null; const matchingType = option.types?.some((type) => matchFieldType(option.field, type, 0)); if (!matchingType) { option.disabled = true; @@ -262,10 +283,10 @@ async function loadChildren(option) { } export function getCollectionFieldOptions(options): VariableOption[] { - const { fields, collection, types, appends = [], compile, getCollectionFields } = options; + const { fields, collection, types, appends = [], compile, getCollectionFields, fieldNames = defaultFieldNames } = options; const normalizedFields = getNormalizedFields(collection, { compile, getCollectionFields }); const computedFields = fields ?? normalizedFields; - const boundLoadChildren = loadChildren.bind({ compile, getCollectionFields }); + const boundLoadChildren = loadChildren.bind({ compile, getCollectionFields, fieldNames }); const result: VariableOption[] = filterTypedFields({ fields: computedFields, @@ -276,13 +297,14 @@ export function getCollectionFieldOptions(options): VariableOption[] { getCollectionFields, }).map((field) => { const label = compile(field.uiSchema?.title || field.name); + // console.log('===', label, field); const nextAppends = getNextAppends(field, appends); // TODO: no matching fields in next appends should consider isLeaf as true const isLeaf = !isAssociationField(field) || (!nextAppends.length && !appends.includes(field.name)); return { - label, + [fieldNames.label]: label, key: field.name, - value: field.name, + [fieldNames.value]: field.name, isLeaf, loadChildren: isLeaf ? null : boundLoadChildren, field, diff --git a/packages/plugins/workflow/src/server/Processor.ts b/packages/plugins/workflow/src/server/Processor.ts index 3492f5c01..473315d11 100644 --- a/packages/plugins/workflow/src/server/Processor.ts +++ b/packages/plugins/workflow/src/server/Processor.ts @@ -343,9 +343,9 @@ export default class Processor { }; } - public getParsedValue(value, node?) { + public getParsedValue(value, node?, additionalScope?: object) { const template = parse(value); - const scope = this.getScope(node); + const scope = Object.assign(this.getScope(node), additionalScope); template.parameters.forEach(({ key }) => { appendArrayColumn(scope, key); }); diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts index d8d5f7d1b..af0c01ee4 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts @@ -51,6 +51,293 @@ describe('workflow > instructions > manual', () => { afterEach(() => db.close()); + describe('actions configuration', () => { + it('no action configured', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + }, + }, + }, + }); + + 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 userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + + expect(res1.status).toBe(400); + }); + + it('no actionKey provided', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + ], + }, + }, + }, + }); + + 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 userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: { a: 1 } }, + }, + }); + + expect(res1.status).toBe(400); + }); + + it('values resolved will be overrided by action assigned', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + actions: [ + { + status: JOB_STATUS.RESOLVED, + key: 'resolve', + values: { a: 2 }, + }, + ], + }, + }, + }, + }); + + 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 userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + + await sleep(500); + + 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({ f1: { a: 2 }, _: 'resolve' }); + }); + + it('values rejected will not be overrided by action assigned', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + actions: [ + { + status: JOB_STATUS.REJECTED, + key: 'reject', + values: { a: 2 }, + }, + ], + }, + }, + }, + }); + + 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 userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: { a: 1 }, _: 'reject' }, + }, + }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.REJECTED); + const [job] = await execution.getJobs(); + expect(job.status).toBe(JOB_STATUS.REJECTED); + expect(job.result).toEqual({ f1: { a: 1 }, _: 'reject' }); + }); + + it('values saved as pending will not be overrided by action assigned', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + actions: [ + { + status: JOB_STATUS.PENDING, + key: 'save', + values: { a: 2 }, + }, + ], + }, + }, + }, + }); + + 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 userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: { a: 1 }, _: 'save' }, + }, + }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.STARTED); + const [job] = await execution.getJobs(); + expect(job.status).toBe(JOB_STATUS.PENDING); + expect(job.result).toEqual({ f1: { a: 1 }, _: 'save' }); + }); + + it('variable within assigned values should work when resolve', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + actions: [ + { + status: JOB_STATUS.RESOLVED, + key: 'resolve', + values: { + a: '{{currentUser.id}}', + b: '{{currentRecord.id}}', + c: '{{currentTime}}', + d: '{{$context.data.title}}', + }, + }, + ], + }, + }, + }, + }); + + 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 now = new Date(); + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: { a: 2, id: 3 }, _: 'resolve' }, + }, + }); + + await sleep(500); + + 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).toMatchObject({ f1: { a: users[0].id, id: 3, b: 3, d: post.title }, _: 'resolve' }); + const time = new Date(job.result.f1.c); + expect(time.getTime() - now.getTime()).toBeLessThan(1000); + }); + }); + describe('mode: 0 (single record)', () => { it('the only user assigned could submit', async () => { const n1 = await workflow.createNode({ @@ -59,7 +346,9 @@ describe('workflow > instructions > manual', () => { assignees: [users[0].id], forms: { f1: { - actions: [JOB_STATUS.RESOLVED], + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + ], }, }, }, @@ -82,15 +371,14 @@ describe('workflow > instructions > manual', () => { const res1 = await agent.resource('users_jobs').submit({ filterByTk: usersJobs[0].id, - values: { status: JOB_STATUS.RESOLVED }, + values: { result: { f1: {}, _: 'resolve' } }, }); expect(res1.status).toBe(401); const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: usersJobs[0].id, values: { - status: JOB_STATUS.RESOLVED, - result: { f1: {} }, + result: { f1: {}, _: 'resolve' }, }, }); expect(res2.status).toBe(403); @@ -98,8 +386,7 @@ describe('workflow > instructions > manual', () => { const res3 = await userAgents[0].resource('users_jobs').submit({ filterByTk: usersJobs[0].id, values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { a: 1 } }, + result: { f1: { a: 1 }, _: 'resolve' }, }, }); expect(res3.status).toBe(202); @@ -108,18 +395,17 @@ describe('workflow > instructions > manual', () => { const [j2] = await pending.getJobs(); expect(j2.status).toBe(JOB_STATUS.RESOLVED); - expect(j2.result).toEqual({ f1: { a: 1 } }); + expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); const usersJobsAfter = await UserJobModel.findAll(); expect(usersJobsAfter.length).toBe(1); expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED); - expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 } }); + expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 }, _: 'resolve' }); const res4 = await userAgents[0].resource('users_jobs').submit({ filterByTk: usersJobs[0].id, values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { a: 2 } }, + result: { f1: { a: 2 }, _: 'resolve' }, }, }); expect(res4.status).toBe(400); @@ -131,7 +417,11 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], forms: { - f1: { actions: [JOB_STATUS.RESOLVED] }, + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + ] + }, }, }, }); @@ -150,8 +440,7 @@ describe('workflow > instructions > manual', () => { 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: { f1: { a: 1 } }, + result: { f1: { a: 1 }, _: 'resolve' }, }, }); expect(res1.status).toBe(202); @@ -160,13 +449,12 @@ describe('workflow > instructions > manual', () => { const [j2] = await pending.getJobs(); expect(j2.status).toBe(JOB_STATUS.RESOLVED); - expect(j2.result).toEqual({ f1: { a: 1 } }); + expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); 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: { f1: { a: 1 } }, + result: { f1: { a: 1 }, _: 'resolve' }, }, }); expect(res2.status).toBe(400); @@ -178,7 +466,11 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id], forms: { - f1: { actions: [JOB_STATUS.RESOLVED] }, + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + ], + }, }, }, }); @@ -196,8 +488,7 @@ describe('workflow > instructions > manual', () => { const res = await userAgents[0].resource('users_jobs').submit({ filterByTk: usersJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { a: 1 } }, + result: { f1: { a: 1 }, _: 'resolve' }, }, }); expect(res.status).toBe(202); @@ -208,7 +499,7 @@ describe('workflow > instructions > manual', () => { expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); const [job] = await execution.getJobs(); expect(job.status).toBe(JOB_STATUS.RESOLVED); - expect(job.result).toEqual({ f1: { a: 1 } }); + expect(job.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); }); }); @@ -220,7 +511,11 @@ describe('workflow > instructions > manual', () => { assignees: [users[0].id, users[1].id], mode: 1, forms: { - f1: { actions: [JOB_STATUS.RESOLVED] }, + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + ], + }, }, }, }); @@ -238,8 +533,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { a: 1 } }, + result: { f1: { a: 1 }, _: 'resolve' }, }, }); expect(res1.status).toBe(202); @@ -259,8 +553,7 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { a: 2 } }, + result: { f1: { a: 2 }, _: 'resolve' }, }, }); expect(res2.status).toBe(202); @@ -281,7 +574,11 @@ describe('workflow > instructions > manual', () => { assignees: [users[0].id, users[1].id], mode: 1, forms: { - f1: { actions: [JOB_STATUS.REJECTED] }, + f1: { + actions: [ + { status: JOB_STATUS.REJECTED, key: 'reject' }, + ], + }, }, }, }); @@ -299,8 +596,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.REJECTED, - result: { f1: { a: 0 } }, + result: { f1: { a: 0 }, _: 'reject' }, }, }); expect(res1.status).toBe(202); @@ -320,8 +616,7 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.REJECTED, - result: { f1: { a: 0 } }, + result: { f1: { a: 0 }, _: 'reject' }, }, }); expect(res2.status).toBe(400); @@ -334,7 +629,12 @@ describe('workflow > instructions > manual', () => { assignees: [users[0].id, users[1].id], mode: 1, forms: { - f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] }, + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.REJECTED, key: 'reject' }, + ], + }, }, }, }); @@ -352,8 +652,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { a: 1 } }, + result: { f1: { a: 1 }, _: 'resolve' }, }, }); expect(res1.status).toBe(202); @@ -373,8 +672,7 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.REJECTED, - result: { f1: { a: 0 } }, + result: { f1: { a: 0 }, _: 'reject' }, }, }); expect(res2.status).toBe(202); @@ -397,7 +695,12 @@ describe('workflow > instructions > manual', () => { assignees: [users[0].id, users[1].id], mode: -1, forms: { - f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] }, + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.REJECTED, key: 'reject' }, + ], + }, }, }, }); @@ -415,8 +718,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { a: 1 } }, + result: { f1: { a: 1 }, _: 'resolve' }, }, }); expect(res1.status).toBe(202); @@ -432,8 +734,7 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.REJECTED, - result: { f1: { a: 0 } }, + result: { f1: { a: 0 }, _: 'reject' }, }, }); expect(res2.status).toBe(400); @@ -446,7 +747,12 @@ describe('workflow > instructions > manual', () => { assignees: [users[0].id, users[1].id], mode: -1, forms: { - f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] }, + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.REJECTED, key: 'reject' }, + ], + }, }, }, }); @@ -464,8 +770,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.REJECTED, - result: { f1: { a: 0 } }, + result: { f1: { a: 0 }, _: 'reject' }, }, }); expect(res1.status).toBe(202); @@ -481,8 +786,7 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { a: 1 } }, + result: { f1: { a: 1 }, _: 'resolve' }, }, }); expect(res2.status).toBe(202); @@ -503,7 +807,11 @@ describe('workflow > instructions > manual', () => { assignees: [users[0].id, users[1].id], mode: -1, forms: { - f1: { actions: [JOB_STATUS.REJECTED] }, + f1: { + actions: [ + { status: JOB_STATUS.REJECTED, key: 'reject' }, + ], + }, }, }, }); @@ -521,8 +829,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.REJECTED, - result: { f1: { a: 0 } }, + result: { f1: { a: 0 }, _: 'reject' }, }, }); expect(res1.status).toBe(202); @@ -538,8 +845,7 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.REJECTED, - result: { f1: { a: 0 } }, + result: { f1: { a: 0 }, _: 'reject' }, }, }); expect(res2.status).toBe(202); @@ -565,7 +871,11 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], forms: { - f1: { actions: [JOB_STATUS.RESOLVED] }, + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + ], + }, }, }, }); @@ -594,8 +904,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { number: 1 } }, + result: { f1: { number: 1 }, _: 'resolve' }, }, }); expect(res1.status).toBe(202); @@ -615,8 +924,18 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], forms: { - f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING] }, - f2: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING] }, + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.PENDING, key: 'pending' }, + ], + }, + f2: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.PENDING, key: 'pending' }, + ], + }, }, }, }); @@ -634,8 +953,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.PENDING, - result: { f1: { number: 1 } }, + result: { f1: { number: 1 }, _: 'pending' }, }, }); expect(res1.status).toBe(202); @@ -651,8 +969,7 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.PENDING, - result: { f2: { number: 2 } }, + result: { f2: { number: 2 }, _: 'pending' }, }, }); expect(res2.status).toBe(202); @@ -671,8 +988,7 @@ describe('workflow > instructions > manual', () => { const res3 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f2: { number: 3 } }, + result: { f2: { number: 3 }, _: 'resolve' }, }, }); expect(res3.status).toBe(202); @@ -697,7 +1013,9 @@ describe('workflow > instructions > manual', () => { forms: { f1: { type: 'create', - actions: [JOB_STATUS.RESOLVED], + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + ], collection: 'comments', }, }, @@ -717,8 +1035,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { status: 1 } }, + result: { f1: { status: 1 }, _: 'resolve' }, }, }); expect(res1.status).toBe(202); @@ -744,7 +1061,10 @@ describe('workflow > instructions > manual', () => { forms: { f1: { type: 'create', - actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING], + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.PENDING, key: 'pending' }, + ], collection: 'comments', }, }, @@ -764,8 +1084,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.PENDING, - result: { f1: { status: 1 } }, + result: { f1: { status: 1 }, _: 'pending' }, }, }); expect(res1.status).toBe(202); @@ -784,8 +1103,7 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { status: 1 } }, + result: { f1: { status: 1 }, _: 'resolve' }, }, }); @@ -811,7 +1129,9 @@ describe('workflow > instructions > manual', () => { forms: { f1: { type: 'update', - actions: [JOB_STATUS.RESOLVED], + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + ], collection: 'posts', }, }, @@ -831,8 +1151,7 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED, - result: { f1: { title: 't2' } }, + result: { f1: { title: 't2' }, _: 'resolve' }, }, }); expect(res1.status).toBe(202); diff --git a/packages/plugins/workflow/src/server/instructions/manual/actions.ts b/packages/plugins/workflow/src/server/instructions/manual/actions.ts index f2a7a3299..91f54f853 100644 --- a/packages/plugins/workflow/src/server/instructions/manual/actions.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/actions.ts @@ -30,15 +30,17 @@ export async function submit(context: Context, next) { } const { forms = {} } = userJob.node.config; - const [formKey] = Object.keys(values.result ?? {}); + const [formKey] = Object.keys(values.result ?? {}).filter(key => key !== '_'); + const actionKey = values.result?._; + const actionItem = forms[formKey]?.actions?.find((item) => item.key === actionKey); // NOTE: validate status if ( userJob.status !== JOB_STATUS.PENDING || userJob.job.status !== JOB_STATUS.PENDING || userJob.execution.status !== EXECUTION_STATUS.STARTED || !userJob.workflow.enabled || - !forms[formKey]?.actions?.includes(values.status) + !actionKey || actionItem?.status == null ) { return context.throw(400); } @@ -52,10 +54,17 @@ export async function submit(context: Context, next) { if (!assignees.includes(currentUser.id) || userJob.userId !== currentUser.id) { return context.throw(403); } + const presetValues = processor.getParsedValue(actionItem.values ?? {}, null, { + currentUser: currentUser.toJSON(), + currentRecord: values.result[formKey], + currentTime: new Date(), + }); userJob.set({ - status: values.status, - result: values.status ? values.result : Object.assign(userJob.result ?? {}, values.result), + status: actionItem.status, + result: actionItem.status > JOB_STATUS.PENDING + ? { [formKey]: Object.assign(values.result[formKey], presetValues), _: actionKey } + : Object.assign(userJob.result ?? {}, values.result), }); const handler = instruction.formTypes.get(forms[formKey].type); @@ -72,8 +81,11 @@ export async function submit(context: Context, next) { await next(); + userJob.job.execution = userJob.execution; userJob.job.latestUserJob = userJob; // NOTE: resume the process and no `await` for quick returning + processor.logger.info(`manual node (${userJob.nodeId}) action trigger execution (${userJob.execution.id}) to resume`); + plugin.resume(userJob.job); } diff --git a/packages/plugins/workflow/src/server/instructions/manual/forms/create.ts b/packages/plugins/workflow/src/server/instructions/manual/forms/create.ts index 0b77bf393..87251837d 100644 --- a/packages/plugins/workflow/src/server/instructions/manual/forms/create.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/forms/create.ts @@ -7,7 +7,8 @@ export default async function (this: ManualInstruction, instance, { collection } throw new Error(`collection ${collection} for create data on manual node not found`); } - const [values] = Object.values(instance.result); + const { _, ...form } = instance.result; + const [values] = Object.values(form); await repo.create({ values: { ...((values as { [key: string]: any }) ?? {}), diff --git a/packages/plugins/workflow/src/server/instructions/manual/forms/update.ts b/packages/plugins/workflow/src/server/instructions/manual/forms/update.ts index 9764e4672..f765e149f 100644 --- a/packages/plugins/workflow/src/server/instructions/manual/forms/update.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/forms/update.ts @@ -7,11 +7,12 @@ export default async function (this: ManualInstruction, instance, { collection, throw new Error(`collection ${collection} for update data on manual node not found`); } - const [values] = Object.values(instance.result as { [formKey: string]: { [key: string]: any } }); + const { _, ...form } = instance.result; + const [values] = Object.values(form); await repo.update({ filter: processor.getParsedValue(filter), values: { - ...(values ?? {}), + ...((values as { [key: string]: any }) ?? {}), updatedBy: instance.userId, }, context: { diff --git a/packages/plugins/workflow/src/server/migrations/20230710115902-manual-action-values.ts b/packages/plugins/workflow/src/server/migrations/20230710115902-manual-action-values.ts new file mode 100644 index 000000000..a68a912e0 --- /dev/null +++ b/packages/plugins/workflow/src/server/migrations/20230710115902-manual-action-values.ts @@ -0,0 +1,78 @@ +import { Migration } from '@nocobase/server'; + +function findSchema(root, filter, onlyLeaf = false) { + const result = []; + + if (!root) { + return result; + } + + if (filter(root) && (!onlyLeaf || !root.properties)) { + result.push(root); + return result; + } + + if (root.properties) { + Object.keys(root.properties).forEach((key) => { + result.push(...findSchema(root.properties[key], filter)); + }); + } + return result; +} + +function migrateConfig(config): object { + const { forms = {}, schema = {} } = config; + const root = { properties: schema }; + Object.keys(forms).forEach((key) => { + const form = forms[key]; + const formSchema = findSchema(root, (item) => item.name === key); + const actions = findSchema(formSchema[0], (item) => item['x-component'] === 'Action'); + form.actions = actions.map((action) => { + action['x-designer'] = 'ManualActionDesigner'; + action['x-action-settings'] = {}; + delete action['x-action']; + return { + status: action['x-decorator-props'].value, + values: {}, + key: action.name, + }; + }); + }); + + return config; +} + +export default class extends Migration { + async up() { + const match = await this.app.version.satisfies('<0.11.0-alpha.2'); + if (!match) { + return; + } + const { db } = this.context; + const NodeRepo = db.getRepository('flow_nodes'); + await db.sequelize.transaction(async (transaction) => { + const nodes = await NodeRepo.find({ + filter: { + type: 'manual', + }, + transaction, + }); + console.log('%d nodes need to be migrated.', nodes.length); + + await nodes.reduce( + (promise, node) => + promise.then(() => { + node.set('config', { + ...migrateConfig(node.config), + }); + node.changed('config', true); + return node.save({ + silent: true, + transaction, + }); + }), + Promise.resolve(), + ); + }); + } +}