diff --git a/packages/core/client/src/block-provider/TableBlockProvider.tsx b/packages/core/client/src/block-provider/TableBlockProvider.tsx index 9744bb8f22..8a93794c24 100644 --- a/packages/core/client/src/block-provider/TableBlockProvider.tsx +++ b/packages/core/client/src/block-provider/TableBlockProvider.tsx @@ -46,7 +46,7 @@ const InternalTableBlockProvider = (props: Props) => { ); }; -export const useAssociationNames = (collection) => { +const useAssociationNames = (collection) => { const { getCollectionFields } = useCollectionManager(); const collectionFields = getCollectionFields(collection); const associationFields = new Set(); diff --git a/packages/core/client/src/block-provider/index.tsx b/packages/core/client/src/block-provider/index.tsx index fba7585e02..5847e88cbc 100644 --- a/packages/core/client/src/block-provider/index.tsx +++ b/packages/core/client/src/block-provider/index.tsx @@ -11,3 +11,4 @@ export * from './TableSelectorProvider'; export * from './FormFieldProvider'; export * from './GanttBlockProvider'; export * from './SharedFilterProvider'; +export * from './hooks'; diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 26a7be0483..f9ca3cb92b 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -112,6 +112,7 @@ export default { "Enable SMS authentication": "启用短信登录和注册", "Sign out": "注销", "Cancel": "取消", + "Confirm": "确定", "Submit": "提交", "Close": "关闭", "Set the data scope": "设置数据范围", 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 3e4f619589..e4d3380bec 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 @@ -832,7 +832,7 @@ export function isFileCollection(collection: Collection) { FormItem.FilterFormDesigner = FilterFormDesigner; export function getFieldDefaultValue(fieldSchema: ISchema, collectionField: CollectionFieldOptions) { - const result = fieldSchema?.default || collectionField?.defaultValue; + const result = fieldSchema?.default ?? collectionField?.defaultValue; if (collectionField?.uiSchema?.['x-component'] === 'DatePicker' && result) { return moment(result); } diff --git a/packages/core/client/src/schema-component/antd/form-v2/index.ts b/packages/core/client/src/schema-component/antd/form-v2/index.ts index 7b0aef196b..c469c0584a 100644 --- a/packages/core/client/src/schema-component/antd/form-v2/index.ts +++ b/packages/core/client/src/schema-component/antd/form-v2/index.ts @@ -1,10 +1,12 @@ import { Form as FormV2 } from './Form'; import { DetailsDesigner, FormDesigner, ReadPrettyFormDesigner } from './Form.Designer'; import { FilterDesigner } from './Form.FilterDesigner'; +import { Templates } from './Templates'; FormV2.Designer = FormDesigner; FormV2.FilterDesigner = FilterDesigner; FormV2.ReadPrettyDesigner = ReadPrettyFormDesigner; +FormV2.Templates = Templates; export * from './FormField'; export { FormV2, DetailsDesigner }; diff --git a/packages/core/client/src/schema-component/hooks/useDesignable.tsx b/packages/core/client/src/schema-component/hooks/useDesignable.tsx index 5b2fec7ed6..1c48dcf76f 100644 --- a/packages/core/client/src/schema-component/hooks/useDesignable.tsx +++ b/packages/core/client/src/schema-component/hooks/useDesignable.tsx @@ -107,7 +107,7 @@ export class Designable { } loadAPIClientEvents() { - const { refresh, api, t = translate } = this.options; + const { api, t = translate } = this.options; if (!api) { return; } @@ -140,7 +140,7 @@ export class Designable { if (removed?.['x-component'] === 'Grid.Col') { schemas = schemas.concat(updateColumnSize(removed.parent)); } - refresh(); + this.refresh(); if (!current['x-uid']) { return; } @@ -169,7 +169,7 @@ export class Designable { message.success(t('Saved successfully'), 0.2); }); this.on('patch', async ({ schema }) => { - refresh(); + this.refresh(); if (!schema?.['x-uid']) { return; } @@ -183,7 +183,7 @@ export class Designable { message.success(t('Saved successfully'), 0.2); }); this.on('batchPatch', async ({ schemas }) => { - refresh(); + this.refresh(); await api.request({ url: `/uiSchemas:batchPatch`, method: 'post', @@ -196,7 +196,7 @@ export class Designable { if (removed?.['x-component'] === 'Grid.Col') { schemas = updateColumnSize(removed.parent); } - refresh(); + this.refresh(); if (!removed?.['x-uid']) { return; } diff --git a/packages/core/client/src/schema-component/types.ts b/packages/core/client/src/schema-component/types.ts index e625603269..f3acb70f70 100644 --- a/packages/core/client/src/schema-component/types.ts +++ b/packages/core/client/src/schema-component/types.ts @@ -1,6 +1,7 @@ import { Form } from '@formily/core'; import { IRecursionFieldProps, ISchemaFieldProps, SchemaReactComponents } from '@formily/react'; import React from 'react'; +import { Designable } from './hooks'; export interface ISchemaComponentContext { scope?: any; diff --git a/packages/core/client/src/schema-initializer/index.ts b/packages/core/client/src/schema-initializer/index.ts index 5e95eac966..2565c7fbfa 100644 --- a/packages/core/client/src/schema-initializer/index.ts +++ b/packages/core/client/src/schema-initializer/index.ts @@ -7,6 +7,7 @@ export { useRecordCollectionDataSourceItems, createTableBlockSchema, createFilterFormBlockSchema, + createFormBlockSchema, useAssociatedTableColumnInitializerFields, useInheritsTableColumnInitializerFields, useTableColumnInitializerFields, diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index dcd9d8f1d8..fa6759d819 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -946,7 +946,6 @@ export const createDetailsBlockSchema = (options) => { }, }, }; - console.log(JSON.stringify(schema, null, 2)); return schema; }; @@ -1129,7 +1128,10 @@ export const createFormBlockSchema = (options) => { resource, association, action, + actions = {}, + 'x-designer': designer = 'FormV2.Designer', template, + title, ...others } = options; const resourceName = resource || association || collection; @@ -1149,8 +1151,11 @@ export const createFormBlockSchema = (options) => { // action: 'get', // useParams: '{{ useParamsFromRecord }}', }, - 'x-designer': 'FormV2.Designer', + 'x-designer': designer, 'x-component': 'CardItem', + 'x-component-props': { + title, + }, properties: { [uid()]: { type: 'void', @@ -1175,7 +1180,7 @@ export const createFormBlockSchema = (options) => { marginTop: 24, }, }, - properties: {}, + properties: actions, }, }, }, diff --git a/packages/plugins/workflow/src/client/locale/zh-CN.ts b/packages/plugins/workflow/src/client/locale/zh-CN.ts index ab860b3758..7fdee99685 100644 --- a/packages/plugins/workflow/src/client/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/client/locale/zh-CN.ts @@ -161,6 +161,9 @@ export default { 'Field name existed in form': '表单中已有对应标识的字段', 'Custom form': '自定义表单', 'Data record': '数据记录', + 'Create record form': '新增数据表单', + 'Update record form': '更新数据表单', + 'Filter settings': '筛选设置', 'Create record': '新增数据', 'Add new record to a collection. You can use variables from upstream nodes to assign values to fields.': diff --git a/packages/plugins/workflow/src/client/nodes/manual/FormBlockInitializer.tsx b/packages/plugins/workflow/src/client/nodes/manual/FormBlockInitializer.tsx new file mode 100644 index 0000000000..851b327866 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/FormBlockInitializer.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { + CollectionProvider, + SchemaInitializer, + SchemaInitializerItemOptions, + createFormBlockSchema, + useRecordCollectionDataSourceItems, + useSchemaTemplateManager, +} from '@nocobase/client'; + +import { traverseSchema } from './utils'; + +import { JOB_STATUS } from '../../constants'; +import { NAMESPACE } from '../../locale'; + +function InternalFormBlockInitializer({ insert, schema, ...others }) { + const { getTemplateSchemaByMode } = useSchemaTemplateManager(); + const items = useRecordCollectionDataSourceItems('FormItem') as SchemaInitializerItemOptions[]; + async function onConfirm({ item }) { + const template = item.template ? await getTemplateSchemaByMode(item) : null; + const result = createFormBlockSchema({ + actionInitializers: 'AddActionButton', + 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}`, + }, + }, + ...schema, + template, + }); + delete result['x-acl-action-props']; + delete result['x-acl-action']; + const [formKey] = Object.keys(result.properties); + result.properties[formKey].properties.actions['x-decorator'] = 'ActionBarProvider'; + traverseSchema(result, (node) => { + if (node['x-uid']) { + delete node['x-uid']; + } + }); + insert(result); + } + + return ; +} + +export function FormBlockInitializer(props) { + return ( + + + + ); +} diff --git a/packages/plugins/workflow/src/client/nodes/manual/FormBlockProvider.tsx b/packages/plugins/workflow/src/client/nodes/manual/FormBlockProvider.tsx new file mode 100644 index 0000000000..10405e9aaf --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/FormBlockProvider.tsx @@ -0,0 +1,75 @@ +import React, { useRef, useMemo, useContext } from 'react'; +import { createForm } from '@formily/core'; +import { useFieldSchema, useField, RecursionField } from '@formily/react'; +import { + BlockRequestContext, + CollectionProvider, + FormBlockContext, + FormV2, + RecordProvider, + useAPIClient, + useAssociationNames, + useDesignable, + useRecord, +} from '@nocobase/client'; + +export function FormBlockProvider(props) { + const userJob = useRecord(); + const fieldSchema = useFieldSchema(); + const field = useField(); + const formBlockRef = useRef(null); + const { appends, updateAssociationValues } = useAssociationNames(props.collection); + const [formKey] = Object.keys(fieldSchema.toJSON().properties ?? {}); + const values = userJob?.result?.[formKey]; + + const { findComponent } = useDesignable(); + const Component = findComponent(field.component?.[0]) || React.Fragment; + + const form = useMemo( + () => + createForm({ + initialValues: values, + }), + [values], + ); + + const params = { + appends, + ...props.params, + }; + const service = { + loading: false, + data: { + data: values, + }, + }; + const api = useAPIClient(); + const resource = api.resource(props.collection); + const __parent = useContext(BlockRequestContext); + + return ( + + + + + + +
+ +
+
+
+
+
+
+ ); +} diff --git a/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx b/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx index 6590837579..9cb3b42542 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx @@ -1,4 +1,4 @@ -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, useMemo } from 'react'; import { useForm, ISchema, Schema, useFieldSchema } from '@formily/react'; import { get } from 'lodash'; @@ -10,19 +10,66 @@ import { SchemaInitializerItemOptions, InitializerWithSwitch, SchemaInitializerProvider, - useSchemaComponentContext, gridRowColWrap, ActionContext, GeneralSchemaDesigner, SchemaSettings, useCompile, } from '@nocobase/client'; +import { Registry } 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'; -import customForm from './forms/customForm'; +import customForm from './forms/custom'; +import createForm from './forms/create'; +import updateForm from './forms/update'; +import { FormBlockProvider } from './FormBlockProvider'; + +type ValueOf = T[keyof T]; + +export type FormType = { + type: 'create' | 'update' | 'custom'; + title: string; + actions: ValueOf[]; + collection: + | string + | { + name: string; + fields: any[]; + [key: string]: any; + }; +}; + +export type ManualFormType = { + title: string; + config: { + useInitializer: () => SchemaInitializerItemOptions; + initializers?: { + [key: string]: React.FC; + }; + components?: { + [key: string]: React.FC; + }; + parseFormOptions(root: ISchema): { [key: string]: FormType }; + }; + block: { + scope?: { + [key: string]: () => any; + }; + components?: { + [key: string]: React.FC; + }; + }; +}; + +export const manualFormTypes = new Registry(); + +manualFormTypes.register('customForm', customForm); +manualFormTypes.register('createForm', createForm); +manualFormTypes.register('updateForm', updateForm); function useTriggerInitializers(): SchemaInitializerItemOptions | null { const { workflow } = useFlowContext(); @@ -90,21 +137,10 @@ function AddBlockButton(props: any) { { type: 'itemGroup', title: '{{t("Form")}}', - children: [ - customForm.config.initializer, - // { - // key: 'createForm', - // type: 'item', - // title: '{{t("Create record form")}}', - // component: CustomFormBlockInitializer, - // }, - // { - // key: 'updateForm', - // type: 'item', - // title: '{{t("Update record form")}}', - // component: CustomFormBlockInitializer, - // } - ], + children: Array.from(manualFormTypes.getValues()).map((item) => { + const { useInitializer: getInitializer } = item.config; + return getInitializer(); + }), }, { type: 'itemGroup', @@ -122,43 +158,6 @@ function AddBlockButton(props: any) { return ; } -function findSchema(schema, filter, onlyLeaf = false) { - const result = []; - - if (!schema) { - return result; - } - - if (filter(schema) && (!onlyLeaf || !schema.properties)) { - result.push(schema); - return result; - } - - if (schema.properties) { - Object.keys(schema.properties).forEach((key) => { - result.push(...findSchema(schema.properties[key], filter)); - }); - } - return result; -} - -function SchemaComponentRefreshProvider(props) { - const ctx = useSchemaComponentContext(); - return ( - - {props.children} - - ); -} - function ActionInitializer({ action, actionProps, ...props }) { return ( + new Schema({ properties: { - tabs: { + drawer: { type: 'void', - 'x-component': 'Tabs', - 'x-component-props': {}, - 'x-initializer': 'TabPaneInitializers', - 'x-initializer-props': { - gridInitializer: 'AddBlockButton', + title: '{{t("Configure form")}}', + 'x-decorator': 'Form', + 'x-component': 'Action.Drawer', + 'x-component-props': { + className: 'nb-action-popup', }, - properties: value ?? { - tab1: { + properties: { + tabs: { type: 'void', - title: `{{t("Manual", { ns: "${NAMESPACE}" })}}`, - 'x-component': 'Tabs.TabPane', - 'x-designer': 'Tabs.Designer', - properties: { - grid: { + 'x-component': 'Tabs', + 'x-component-props': {}, + 'x-initializer': 'TabPaneInitializers', + 'x-initializer-props': { + gridInitializer: 'AddBlockButton', + }, + properties: value ?? { + tab1: { type: 'void', - 'x-component': 'Grid', - 'x-initializer': 'AddBlockButton', - properties: {}, + title: `{{t("Manual", { ns: "${NAMESPACE}" })}}`, + 'x-component': 'Tabs.TabPane', + 'x-designer': 'Tabs.Designer', + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'AddBlockButton', + properties: {}, + }, + }, }, }, }, }, }, }, - }, - }, - }); + }), + [], + ); return ( - + Object.assign(result, item.config.parseFormOptions(tabs)), + {}, + ); + form.setValuesIn('forms', forms); + + onChange(tabs.properties); + }, + }} + > Object.assign(result, item.config.initializers), + {}, + ), }} > - { - const forms = {}; - const { tabs } = get(schema.toJSON(), 'properties.drawer.properties') as { tabs: ISchema }; - const formBlocks: any[] = findSchema(tabs, (item) => item['x-decorator'] === 'FormCollectionProvider'); - formBlocks.forEach((formBlock) => { - const [formKey] = Object.keys(formBlock.properties); - const formSchema = formBlock.properties[formKey]; - const fields = findSchema( - formSchema.properties.grid, - (item) => item['x-component'] === 'CollectionField', - true, - ); - formBlock['x-decorator-props'].collection.fields = fields.map((field) => field['x-interface-options']); - forms[formKey] = { - 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, - ), - collection: formBlock['x-decorator-props'].collection, - }; - }); - - form.setValuesIn('forms', forms); - - onChange(tabs.properties); + Object.assign(result, item.config.components), + {}, + ), + FormBlockProvider, + // NOTE: fake provider component + ManualActionStatusProvider(props) { + return props.children; + }, + ActionBarProvider(props) { + return props.children; + }, + SimpleDesigner, }} - > - - + scope={{ + useSubmit, + useFlowRecordFromBlock, + }} + /> ); diff --git a/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx b/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx index 67125d1371..476359a4db 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx @@ -1,6 +1,6 @@ import React, { useContext, createContext, useEffect, useState } from 'react'; import { observer, useForm, useField, useFieldSchema } from '@formily/react'; -import { Tag } from 'antd'; +import { Spin, Tag } from 'antd'; import { css } from '@emotion/css'; import moment from 'moment'; @@ -8,7 +8,6 @@ import { CollectionManagerProvider, SchemaComponent, SchemaComponentContext, - SchemaComponentOptions, TableBlockProvider, useActionContext, useAPIClient, @@ -17,15 +16,18 @@ import { useRecord, useRequest, useTableBlockContext, + FormBlockContext, + useFormBlockContext, } from '@nocobase/client'; import { uid, parse } from '@nocobase/utils/client'; -import { JobStatusOptions, JobStatusOptionsMap, JOB_STATUS } from '../../constants'; +import { JobStatusOptions, JobStatusOptionsMap } from '../../constants'; import { NAMESPACE } from '../../locale'; import { FlowContext, useFlowContext } from '../../FlowContext'; import { instructions, useAvailableUpstreams } from '..'; import { linkNodes } from '../../utils'; -import customForm from './forms/customForm'; +import { manualFormTypes } from './SchemaConfig'; +import { FormBlockProvider } from './FormBlockProvider'; const nodeCollection = { title: `{{t("Task", { ns: "${NAMESPACE}" })}}`, @@ -197,7 +199,7 @@ const UserColumn = observer(() => { return field?.value?.nickname ?? field.value?.id; }); -export function WorkflowTodo() { +export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC } = () => { return ( ); -} +}; function ActionBarProvider(props) { // * status is done: @@ -348,7 +350,8 @@ function ActionBarProvider(props) { // 2. not current user: disabled action bar const { data: user } = useCurrentUserContext(); - const { status, result, userId } = useRecord(); + const { userJob } = useFlowContext(); + const { status, result, userId } = userJob; const buttonSchema = useFieldSchema(); const { name } = buttonSchema.parent.toJSON(); @@ -369,15 +372,15 @@ function ActionBarProvider(props) { const ManualActionStatusContext = createContext(null); function ManualActionStatusProvider({ value, children }) { - const { status } = useRecord(); + const { userJob } = useFlowContext(); const button = useField(); useEffect(() => { - if (status) { + if (userJob.status) { button.disabled = true; - button.visible = status === value; + button.visible = userJob.status === value; } - }, [status, value]); + }, [userJob.status, value, button]); return {children}; } @@ -389,17 +392,19 @@ function useSubmit() { const buttonSchema = useFieldSchema(); const nextStatus = useContext(ManualActionStatusContext); const { service } = useTableBlockContext(); - const { id } = useRecord(); + const { userJob } = useFlowContext(); + const { updateAssociationValues } = useContext(FormBlockContext); return { async run() { await submit(); const { name } = buttonSchema.parent.parent.toJSON(); await api.resource('users_jobs').submit({ - filterByTk: id, + filterByTk: userJob.id, values: { status: nextStatus, result: { [name]: values }, }, + updateAssociationValues, }); setVisible(false); service.refresh(); @@ -407,6 +412,7 @@ function useSubmit() { }; } +// parse datasource block from execution context function useFlowRecordFromBlock(opts) { const { ['x-context-datasource']: dataSource } = useFieldSchema(); const { execution } = useFlowContext(); @@ -425,8 +431,9 @@ function useFlowRecordFromBlock(opts) { function FlowContextProvider(props) { const api = useAPIClient(); - const { id, node } = useRecord(); + const { id } = useRecord(); const [flowContext, setFlowContext] = useState(null); + const [node, setNode] = useState(null); useEffect(() => { if (!id) { @@ -436,42 +443,87 @@ function FlowContextProvider(props) { .resource('users_jobs') .get?.({ filterByTk: id, - appends: ['workflow', 'workflow.nodes', 'execution', 'execution.jobs'], + appends: ['node', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'], }) .then(({ data }) => { - const { workflow: { nodes = [], ...workflow } = {}, execution } = data?.data ?? {}; + const { node, workflow: { nodes = [], ...workflow } = {}, execution, ...userJob } = data?.data ?? {}; linkNodes(nodes); + setNode(node); setFlowContext({ + userJob, workflow, nodes, execution, }); + return; }); }, [id]); - if (!flowContext) { - return null; - } - - const upstreams = useAvailableUpstreams(flowContext.nodes.find((item) => item.id === node.id)); + const upstreams = useAvailableUpstreams(flowContext?.nodes.find((item) => item.id === node.id)); const nodeComponents = upstreams.reduce( (components, { type }) => Object.assign(components, instructions.get(type).components), {}, ); - return ( + return node && flowContext ? ( - {props.children} + Object.assign(result, item.block.components), + {}, + ), + ...nodeComponents, + }} + scope={{ + useSubmit, + useFormBlockProps, + ...Array.from(manualFormTypes.getValues()).reduce( + (result, item) => Object.assign(result, item.block.scope), + {}, + ), + }} + schema={{ + type: 'void', + name: 'tabs', + 'x-component': 'Tabs', + properties: node.config?.schema, + }} + /> + ) : ( + ); } -WorkflowTodo.Drawer = function () { +function useFormBlockProps() { + const { userJob } = useFlowContext(); + const record = useRecord(); + const { data: user } = useCurrentUserContext(); + const { form } = useFormBlockContext(); + + const pattern = userJob.status + ? record + ? 'readPretty' + : 'disabled' + : user?.data?.id !== userJob.userId + ? 'disabled' + : 'editable'; + + useEffect(() => { + form?.setPattern(pattern); + }, [pattern, form]); + + return { form }; +} + +function Drawer() { const ctx = useContext(SchemaComponentContext); const { id, node, workflow, status, updatedAt } = useRecord(); - const { schema } = node.config ?? {}; - const statusOption = JobStatusOptionsMap[status]; const footerSchema = status ? { @@ -498,14 +550,11 @@ WorkflowTodo.Drawer = function () { : null; return ( - + ); -}; +} -WorkflowTodo.Decorator = function ({ children }) { +function Decorator({ children }) { const { collections, ...cm } = useCollectionManager(); const blockProps = { collection: 'users_jobs', @@ -549,7 +594,7 @@ WorkflowTodo.Decorator = function ({ children }) { pageSize: 20, sort: ['-createdAt'], appends: ['user', 'node', 'workflow'], - except: ['workflow.config'], + except: ['node.config', 'workflow.config'], }, rowKey: 'id', showIndex: true, @@ -564,4 +609,7 @@ WorkflowTodo.Decorator = function ({ children }) { {children} ); -}; +} + +WorkflowTodo.Drawer = Drawer; +WorkflowTodo.Decorator = Decorator; diff --git a/packages/plugins/workflow/src/client/nodes/manual/forms/create.tsx b/packages/plugins/workflow/src/client/nodes/manual/forms/create.tsx new file mode 100644 index 0000000000..bf48feae21 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/forms/create.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { useFieldSchema } from '@formily/react'; + +import { GeneralSchemaDesigner, SchemaSettings, useCollection, useCollectionManager } from '@nocobase/client'; + +import { NAMESPACE } from '../../../locale'; +import { findSchema } from '../utils'; +import { ManualFormType } from '../SchemaConfig'; +import { FormBlockInitializer } from '../FormBlockInitializer'; + +function CreateFormDesigner() { + const { name, title } = useCollection(); + + return ( + + + + + + + + ); +} + +export default { + title: `{{t("Create record form", { ns: "${NAMESPACE}" })}}`, + config: { + useInitializer() { + const { collections } = useCollectionManager(); + return { + key: 'createRecordForm', + type: 'subMenu', + title: `{{t("Create record form", { ns: "${NAMESPACE}" })}}`, + children: collections + .filter((item) => !item.hidden) + .map((item) => ({ + key: `createForm-${item.name}`, + type: 'item', + title: item.title, + schema: { + collection: item.name, + title: `{{t("Create record", { ns: "${NAMESPACE}" })}}`, + formType: 'create', + 'x-designer': 'CreateFormDesigner', + }, + component: FormBlockInitializer, + })), + }; + }, + initializers: { + // AddCustomFormField + }, + components: { + CreateFormDesigner, + }, + parseFormOptions(root) { + const forms = {}; + const formBlocks: any[] = findSchema( + root, + (item) => item['x-decorator'] === 'FormBlockProvider' && item['x-decorator-props'].formType === 'create', + ); + formBlocks.forEach((formBlock) => { + const [formKey] = Object.keys(formBlock.properties); + const formSchema = formBlock.properties[formKey]; + forms[formKey] = { + 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, + ), + collection: formBlock['x-decorator-props'].collection, + }; + }); + return forms; + }, + }, + block: { + scope: { + // useFormBlockProps + }, + components: {}, + }, +} as ManualFormType; diff --git a/packages/plugins/workflow/src/client/nodes/manual/forms/customForm.tsx b/packages/plugins/workflow/src/client/nodes/manual/forms/custom.tsx similarity index 87% rename from packages/plugins/workflow/src/client/nodes/manual/forms/customForm.tsx rename to packages/plugins/workflow/src/client/nodes/manual/forms/custom.tsx index cd4d1c8405..4a882f844a 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/forms/customForm.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/forms/custom.tsx @@ -1,8 +1,8 @@ -import React, { useState, useContext, useMemo } from 'react'; +import React, { useState, useContext } from 'react'; import { cloneDeep, set } from 'lodash'; -import { Field, createForm } from '@formily/core'; -import { useForm, useFieldSchema } from '@formily/react'; +import { Field } from '@formily/core'; +import { useForm } from '@formily/react'; import { ArrayTable } from '@formily/antd'; import { @@ -14,13 +14,13 @@ import { SchemaInitializer, SchemaInitializerItemOptions, useCollectionManager, - useCurrentUserContext, - useRecord, } from '@nocobase/client'; import { merge, uid } from '@nocobase/utils/client'; import { JOB_STATUS } from '../../../constants'; import { lang, NAMESPACE } from '../../../locale'; +import { findSchema } from '../utils'; +import { ManualFormType } from '../SchemaConfig'; const FormCollectionContext = React.createContext(null); @@ -314,38 +314,16 @@ function CustomFormFieldInitializer(props) { ); } -function useFormBlockProps() { - const { status, result, userId } = useRecord(); - const { data: user } = useCurrentUserContext(); - const { name } = useFieldSchema(); - - const pattern = status - ? result?.[name] - ? 'readPretty' - : 'disabled' - : user?.data?.id !== userId - ? 'disabled' - : 'editable'; - const form = useMemo( - () => - createForm({ - pattern, - initialValues: result?.[name] ?? {}, - }), - [result, name], - ); - - return { form }; -} - export default { title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`, config: { - initializer: { - key: 'customForm', - type: 'item', - title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`, - component: CustomFormBlockInitializer, + useInitializer() { + return { + key: 'customForm', + type: 'item', + title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`, + component: CustomFormBlockInitializer, + }; }, initializers: { AddCustomFormField, @@ -353,13 +331,34 @@ export default { components: { FormCollectionProvider, }, + parseFormOptions(root) { + const forms = {}; + const formBlocks: any[] = findSchema(root, (item) => item['x-decorator'] === 'FormCollectionProvider'); + formBlocks.forEach((formBlock) => { + const [formKey] = Object.keys(formBlock.properties); + const formSchema = formBlock.properties[formKey]; + const fields = findSchema( + formSchema.properties.grid, + (item) => item['x-component'] === 'CollectionField', + true, + ); + formBlock['x-decorator-props'].collection.fields = fields.map((field) => field['x-interface-options']); + forms[formKey] = { + 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, + ), + collection: formBlock['x-decorator-props'].collection, + }; + }); + return forms; + }, }, block: { - scope: { - useFormBlockProps, - }, + scope: {}, components: { FormCollectionProvider: CollectionProvider, }, }, -}; +} as ManualFormType; diff --git a/packages/plugins/workflow/src/client/nodes/manual/forms/update.tsx b/packages/plugins/workflow/src/client/nodes/manual/forms/update.tsx new file mode 100644 index 0000000000..5737284e78 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/forms/update.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { useFieldSchema } from '@formily/react'; +import { useTranslation } from 'react-i18next'; + +import { + GeneralSchemaDesigner, + SchemaSettings, + useCollection, + useCollectionFilterOptions, + useCollectionManager, + useDesignable, +} from '@nocobase/client'; + +import { NAMESPACE } from '../../../locale'; +import { findSchema } from '../utils'; +import { ManualFormType } from '../SchemaConfig'; +import { FilterDynamicComponent } from '../../../components/FilterDynamicComponent'; +import { FormBlockInitializer } from '../FormBlockInitializer'; + +function UpdateFormDesigner() { + const { name, title } = useCollection(); + const fieldSchema = useFieldSchema(); + const { t } = useTranslation(); + const { dn } = useDesignable(); + + return ( + + + { + fieldSchema['x-decorator-props'].filter = filter; + dn.emit('patch', { + schema: { + // ['x-uid']: fieldSchema['x-uid'], + 'x-decorator-props': fieldSchema['x-decorator-props'], + }, + }); + dn.refresh(); + }} + /> + + + + + ); +} + +export default { + title: `{{t("Update record form", { ns: "${NAMESPACE}" })}}`, + config: { + useInitializer() { + const { collections } = useCollectionManager(); + return { + key: 'updateRecordForm', + type: 'subMenu', + title: `{{t("Update record form", { ns: "${NAMESPACE}" })}}`, + children: collections + .filter((item) => !item.hidden) + .map((item) => ({ + key: `updateForm-${item.name}`, + type: 'item', + title: item.title, + schema: { + collection: item.name, + title: `{{t("Update record", { ns: "${NAMESPACE}" })}}`, + formType: 'update', + 'x-designer': 'UpdateFormDesigner', + }, + component: FormBlockInitializer, + })), + }; + }, + initializers: { + // AddCustomFormField + }, + components: { + FilterDynamicComponent, + UpdateFormDesigner, + }, + parseFormOptions(root) { + const forms = {}; + const formBlocks: any[] = findSchema( + root, + (item) => item['x-decorator'] === 'FormBlockProvider' && item['x-decorator-props'].formType === 'update', + ); + formBlocks.forEach((formBlock) => { + const [formKey] = Object.keys(formBlock.properties); + const formSchema = formBlock.properties[formKey]; + forms[formKey] = { + ...formBlock['x-decorator-props'], + 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, + ), + }; + }); + return forms; + }, + }, + block: { + scope: { + // useFormBlockProps + }, + components: {}, + }, +} as ManualFormType; diff --git a/packages/plugins/workflow/src/client/nodes/manual/index.tsx b/packages/plugins/workflow/src/client/nodes/manual/index.tsx index 184c4d1542..94cedefdd4 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/index.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/index.tsx @@ -1,13 +1,12 @@ -import { BlockInitializers, SchemaInitializerItemOptions } from '@nocobase/client'; +import { BlockInitializers, SchemaInitializerItemOptions, useCollectionManager } from '@nocobase/client'; import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer'; import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers'; -import { filterTypedFields } from '../../variable'; +import { filterTypedFields, useCollectionFieldOptions } from '../../variable'; import { NAMESPACE } from '../../locale'; import { SchemaConfig, SchemaConfigButton } from './SchemaConfig'; import { ModeConfig } from './ModeConfig'; import { AssigneesSelect } from './AssigneesSelect'; -import { JOB_STATUS } from '../../constants'; const MULTIPLE_ASSIGNED_MODE = { SINGLE: Symbol('single'), @@ -97,20 +96,19 @@ export default { .map((formKey) => { const form = config.forms[formKey]; - const fields = (form.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 + // eslint-disable-next-line react-hooks/rules-of-hooks + const options = useCollectionFieldOptions({ + fields: form.collection?.fields, + collection: form.collection, + types, + }); + return options.length ? { key: formKey, value: formKey, label: form.title || formKey, title: form.title || formKey, - children: filteredFields, + children: options, } : null; }) @@ -119,6 +117,7 @@ export default { return options.length ? options : null; }, useInitializers(node): SchemaInitializerItemOptions | null { + const { getCollection } = useCollectionManager(); const formKeys = Object.keys(node.config.forms ?? {}); if (!formKeys.length || node.config.mode) { return null; @@ -127,8 +126,9 @@ export default { const forms = formKeys .map((formKey) => { const form = node.config.forms[formKey]; + const { fields = [] } = getCollection(form.collection); - return form.collection?.fields?.length + return fields.length ? ({ type: 'item', title: form.title ?? formKey, diff --git a/packages/plugins/workflow/src/client/nodes/manual/utils.ts b/packages/plugins/workflow/src/client/nodes/manual/utils.ts new file mode 100644 index 0000000000..10c7118cea --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/utils.ts @@ -0,0 +1,28 @@ +export function traverseSchema(schema, fn) { + fn(schema); + if (schema.properties) { + Object.keys(schema.properties).forEach((key) => { + traverseSchema(schema.properties[key], fn); + }); + } +} + +export function findSchema(schema, filter, onlyLeaf = false) { + const result = []; + + if (!schema) { + return result; + } + + if (filter(schema) && (!onlyLeaf || !schema.properties)) { + result.push(schema); + return result; + } + + if (schema.properties) { + Object.keys(schema.properties).forEach((key) => { + result.push(...findSchema(schema.properties[key], filter)); + }); + } + return result; +} diff --git a/packages/plugins/workflow/src/server/Processor.ts b/packages/plugins/workflow/src/server/Processor.ts index 5a14c34a3e..95ba65d4a8 100644 --- a/packages/plugins/workflow/src/server/Processor.ts +++ b/packages/plugins/workflow/src/server/Processor.ts @@ -111,7 +111,6 @@ export default class Processor { } else { await this.exit(null); } - await this.commit(); } public async resume(job: JobModel) { @@ -122,7 +121,6 @@ export default class Processor { await this.prepare(); const node = this.nodesMap.get(job.nodeId); await this.recall(node, job); - await this.commit(); } private async commit() { @@ -225,6 +223,7 @@ export default class Processor { : EXECUTION_STATUS.RESOLVED; this.logger.info(`execution (${this.execution.id}) all nodes finished, finishing execution...`); await this.execution.update({ status }, { transaction: this.transaction }); + await this.commit(); return null; } 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 de9f409602..10b968dcac 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts @@ -11,6 +11,7 @@ describe('workflow > instructions > manual', () => { let userAgents; let db: Database; let PostRepo; + let CommentRepo; let WorkflowModel; let workflow; let UserModel; @@ -25,6 +26,7 @@ describe('workflow > instructions > manual', () => { db = app.db; WorkflowModel = db.getCollection('workflows').model; PostRepo = db.getCollection('posts').repository; + CommentRepo = db.getCollection('comments').repository; UserModel = db.getCollection('users').model; UserJobModel = db.getModel('users_jobs'); @@ -612,5 +614,247 @@ describe('workflow > instructions > manual', () => { expect(j2.status).toBe(JOB_STATUS.RESOLVED); expect(j2.result).toBe(2); }); + + it('save all forms, only reserve submitted ones', async () => { + const n1 = await workflow.createNode({ + type: '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] }, + }, + }, + }); + + 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.PENDING, + result: { f1: { number: 1 } }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(500); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.STARTED); + const [j1] = await e1.getJobs({ order: [['createdAt', 'ASC']] }); + expect(j1.status).toBe(JOB_STATUS.PENDING); + expect(j1.result).toMatchObject({ f1: { number: 1 } }); + + const res2 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + status: JOB_STATUS.PENDING, + result: { f2: { number: 2 } }, + }, + }); + expect(res2.status).toBe(202); + + await sleep(500); + + const [e2] = await workflow.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.STARTED); + const [j2] = await e2.getJobs({ order: [['createdAt', 'ASC']] }); + expect(j2.status).toBe(JOB_STATUS.PENDING); + expect(j2.result).toMatchObject({ + f1: { number: 1 }, + f2: { number: 2 }, + }); + + const res3 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + status: JOB_STATUS.RESOLVED, + result: { f2: { number: 3 } }, + }, + }); + expect(res3.status).toBe(202); + + await sleep(500); + + const [e3] = await workflow.getExecutions(); + expect(e3.status).toBe(EXECUTION_STATUS.RESOLVED); + const [j3] = await e3.getJobs({ order: [['createdAt', 'ASC']] }); + expect(j3.status).toBe(JOB_STATUS.RESOLVED); + expect(j3.result).toMatchObject({ f2: { number: 3 } }); + }); + }); + + describe('forms', () => { + describe('create', () => { + it('create as configured', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + type: 'create', + actions: [JOB_STATUS.RESOLVED], + collection: 'comments', + }, + }, + }, + }); + + 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(1); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + status: JOB_STATUS.RESOLVED, + result: { f1: { status: 1 } }, + }, + }); + 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).toMatchObject({ f1: { status: 1 } }); + + const comments = await CommentRepo.find(); + expect(comments.length).toBe(1); + expect(comments[0]).toMatchObject({ status: 1 }); + }); + + it('save first and then commit', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + type: 'create', + actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING], + collection: 'comments', + }, + }, + }, + }); + + 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(1); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + status: JOB_STATUS.PENDING, + result: { f1: { status: 1 } }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(500); + + 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).toMatchObject({ f1: { status: 1 } }); + + const c1 = await CommentRepo.find(); + expect(c1.length).toBe(0); + + const res2 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + status: JOB_STATUS.RESOLVED, + result: { f1: { status: 1 } }, + }, + }); + + await sleep(500); + + 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).toMatchObject({ f1: { status: 1 } }); + + const c2 = await CommentRepo.find(); + expect(c2.length).toBe(1); + }); + }); + + describe('update', () => { + it('update as configured', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + type: 'update', + actions: [JOB_STATUS.RESOLVED], + collection: 'posts', + }, + }, + }, + }); + + 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(1); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + status: JOB_STATUS.RESOLVED, + result: { f1: { title: 't2' } }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(1000); + + const [e2] = await workflow.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); + const [j1] = await e2.getJobs(); + expect(j1.status).toBe(JOB_STATUS.RESOLVED); + expect(j1.result).toMatchObject({ f1: { title: 't2' } }); + + const postsAfter = await PostRepo.find(); + expect(postsAfter.length).toBe(1); + expect(postsAfter[0]).toMatchObject({ title: 't2' }); + }); + }); }); }); diff --git a/packages/plugins/workflow/src/server/instructions/manual/actions.ts b/packages/plugins/workflow/src/server/instructions/manual/actions.ts index edd1916cb3..16804fe5e8 100644 --- a/packages/plugins/workflow/src/server/instructions/manual/actions.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/actions.ts @@ -1,6 +1,8 @@ import { Context, utils } from '@nocobase/actions'; + import Plugin from '../..'; import { EXECUTION_STATUS, JOB_STATUS } from '../../constants'; +import ManualInstruction from '.'; export async function submit(context: Context, next) { const repository = utils.getRepositoryFromParams(context); @@ -12,60 +14,58 @@ export async function submit(context: Context, next) { } const plugin: Plugin = context.app.pm.get('workflow') as Plugin; + const instruction = plugin.instructions.get('manual') as ManualInstruction; - 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 { forms = {} } = instance.node.config; - const [form] = Object.keys(values.result ?? {}); - - // NOTE: validate status - if ( - instance.status !== JOB_STATUS.PENDING || - instance.job.status !== JOB_STATUS.PENDING || - instance.execution.status !== EXECUTION_STATUS.STARTED || - !instance.workflow.enabled || - !forms[form]?.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; + const userJob = await repository.findOne({ + filterByTk, + // filter: { + // userId: currentUser?.id + // }, + appends: ['job', 'node', 'execution', 'workflow'], + context, }); - // await transaction.commit(); + if (!userJob) { + return context.throw(404); + } + + const { forms = {} } = userJob.node.config; + const [formKey] = Object.keys(values.result ?? {}); + + // 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) + ) { + return context.throw(400); + } + + userJob.execution.workflow = userJob.workflow; + const processor = plugin.createProcessor(userJob.execution); + await processor.prepare(); + + // NOTE: validate assignee + const assignees = processor.getParsedValue(userJob.node.config.assignees ?? []); + if (!assignees.includes(currentUser.id) || userJob.userId !== currentUser.id) { + return context.throw(403); + } + + userJob.set({ + status: values.status, + result: values.status ? values.result : Object.assign(userJob.result ?? {}, values.result), + }); + + const handler = instruction.formTypes.get(forms[formKey].type); + if (handler && userJob.status) { + await handler.call(instruction, userJob, forms[formKey], processor); + } + + await userJob.save({ transaction: processor.transaction }); + + await processor.exit(userJob.job); context.body = userJob; context.status = 202; diff --git a/packages/plugins/workflow/src/server/instructions/manual/forms/create.ts b/packages/plugins/workflow/src/server/instructions/manual/forms/create.ts new file mode 100644 index 0000000000..0b77bf393d --- /dev/null +++ b/packages/plugins/workflow/src/server/instructions/manual/forms/create.ts @@ -0,0 +1,22 @@ +import { Processor } from '../../..'; +import ManualInstruction from '..'; + +export default async function (this: ManualInstruction, instance, { collection }, processor: Processor) { + const repo = this.plugin.db.getRepository(collection); + if (!repo) { + throw new Error(`collection ${collection} for create data on manual node not found`); + } + + const [values] = Object.values(instance.result); + await repo.create({ + values: { + ...((values as { [key: string]: any }) ?? {}), + createdBy: instance.userId, + updatedBy: instance.userId, + }, + context: { + executionId: processor.execution.id, + }, + transaction: processor.transaction, + }); +} diff --git a/packages/plugins/workflow/src/server/instructions/manual/forms/index.ts b/packages/plugins/workflow/src/server/instructions/manual/forms/index.ts new file mode 100644 index 0000000000..676c8c864e --- /dev/null +++ b/packages/plugins/workflow/src/server/instructions/manual/forms/index.ts @@ -0,0 +1,12 @@ +import { Processor } from '../../..'; +import ManualInstruction from '..'; + +import create from './create'; +import update from './update'; + +export type FormHandler = (this: ManualInstruction, instance, formConfig, processor: Processor) => Promise; + +export default function({ formTypes }) { + formTypes.register('create', create); + formTypes.register('update', update); +} diff --git a/packages/plugins/workflow/src/server/instructions/manual/forms/update.ts b/packages/plugins/workflow/src/server/instructions/manual/forms/update.ts new file mode 100644 index 0000000000..9764e46722 --- /dev/null +++ b/packages/plugins/workflow/src/server/instructions/manual/forms/update.ts @@ -0,0 +1,22 @@ +import { Processor } from '../../..'; +import ManualInstruction from '..'; + +export default async function (this: ManualInstruction, instance, { collection, filter = {} }, processor: Processor) { + const repo = this.plugin.db.getRepository(collection); + if (!repo) { + 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 } }); + await repo.update({ + filter: processor.getParsedValue(filter), + values: { + ...(values ?? {}), + updatedBy: instance.userId, + }, + context: { + executionId: processor.execution.id, + }, + transaction: processor.transaction, + }); +} diff --git a/packages/plugins/workflow/src/server/instructions/manual/index.ts b/packages/plugins/workflow/src/server/instructions/manual/index.ts index b034ae5586..43be4f2240 100644 --- a/packages/plugins/workflow/src/server/instructions/manual/index.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/index.ts @@ -1,5 +1,6 @@ import actions from '@nocobase/actions'; import { HandlerType } from '@nocobase/resourcer'; +import { Registry } from '@nocobase/utils'; import Plugin from '../..'; import { JOB_STATUS } from '../../constants'; @@ -8,6 +9,7 @@ import jobsCollection from './collecions/jobs'; import usersCollection from './collecions/users'; import usersJobsCollection from './collecions/users_jobs'; import { submit } from './actions'; +import initFormTypes, { FormHandler } from './forms'; type FormType = { type: 'custom' | 'create' | 'update'; @@ -89,6 +91,8 @@ function getMode(mode) { } export default class implements Instruction { + formTypes = new Registry(); + constructor(protected plugin: Plugin) { plugin.db.collection(usersJobsCollection); plugin.db.extendCollection(usersCollection); @@ -116,6 +120,8 @@ export default class implements Instruction { submit, }, }); + + initFormTypes(this); } async run(node, prevJob, processor) { @@ -158,15 +164,18 @@ export default class implements Instruction { jobId: job.id, }, group: ['status'], + transaction: processor.transaction, }); const submitted = distribution.reduce( (count, item) => (item.status !== JOB_STATUS.PENDING ? count + item.count : count), 0, ); + const status = job.status || (getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING); const result = mode ? (submitted || 0) / assignees.length : job.latestUserJob?.result ?? job.result; + processor.logger.debug(`manual resume job and next status: ${status}`); job.set({ - status: job.status || (getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING), + status, result, });