From a5b4684a486bc13b529f7f9d0a3c96ca923c1388 Mon Sep 17 00:00:00 2001 From: Junyi Date: Wed, 19 Apr 2023 08:41:18 +0700 Subject: [PATCH] refactor(plugin-workflow): change single form to custom form block (#1707) * refactor(plugin-workflow): change single form to custom form block * fix(plugin-workflow): fix migration * fix(plugin-workflow): fix manual node initializer * test(plugin-workflow): migrate test cases * refactor(plugin-workflow): split custom form code * fix(plugin-workflow): fix types * refactor(plugin-workflow): change manual form config data structure * fix(plugin-workflow): fix no options manual node variable * fix(plugin-workflow): fix migration script --- packages/core/client/src/locale/zh_CN.ts | 1 + .../components/CollectionBlockInitializer.tsx | 3 + .../workflow/src/client/locale/zh-CN.ts | 2 + .../src/client/nodes/manual/SchemaConfig.tsx | 414 ++++-------------- .../src/client/nodes/manual/WorkflowTodo.tsx | 144 +++--- .../client/nodes/manual/forms/customForm.tsx | 343 +++++++++++++++ .../src/client/nodes/manual/index.tsx | 76 +++- .../src/client/triggers/collection.tsx | 1 + .../__tests__/instructions/manual.test.ts | 99 +++-- .../src/server/instructions/manual/actions.ts | 5 +- .../src/server/instructions/manual/index.ts | 20 +- .../20230411034722-manual-multi-form.ts | 257 +++++++++++ 12 files changed, 916 insertions(+), 449 deletions(-) create mode 100644 packages/plugins/workflow/src/client/nodes/manual/forms/customForm.tsx create mode 100644 packages/plugins/workflow/src/server/migrations/20230411034722-manual-multi-form.ts diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 6ba9d2690..922d074e4 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -115,6 +115,7 @@ export default { "Submit": "提交", "Close": "关闭", "Set the data scope": "设置数据范围", + "Block": "区块", "Data blocks": "数据区块", "Filter blocks": "筛选区块", "Table": "表格", diff --git a/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx b/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx index d69a4e4a4..14fa7aa0f 100644 --- a/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx +++ b/packages/plugins/workflow/src/client/components/CollectionBlockInitializer.tsx @@ -24,6 +24,9 @@ export function CollectionBlockInitializer({ insert, collection, dataSource, ... // title: props.title }, 'x-designer': 'SimpleDesigner', + 'x-designer-props': { + type: 'record' + }, properties: { grid: { type: 'void', diff --git a/packages/plugins/workflow/src/client/locale/zh-CN.ts b/packages/plugins/workflow/src/client/locale/zh-CN.ts index 69ee6cb58..d3e099b6c 100644 --- a/packages/plugins/workflow/src/client/locale/zh-CN.ts +++ b/packages/plugins/workflow/src/client/locale/zh-CN.ts @@ -135,6 +135,8 @@ export default { 'Any pass': '任意通过', 'Anyone pass': '任何一人通过即通过', 'Field name existed in form': '表单中已有对应标识的字段', + 'Custom form': '自定义表单', + 'Data record': '数据记录', 'Create record': '新增数据', 'Update record': '更新数据', diff --git a/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx b/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx index 1dc48d2c4..e5d10b457 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/SchemaConfig.tsx @@ -1,12 +1,9 @@ import React, { useState, useContext } from 'react'; -import { Field } from '@formily/core'; -import { useForm, Schema } from '@formily/react'; -import { ArrayTable } from '@formily/antd'; -import { cloneDeep, get, set } from 'lodash'; -import { useTranslation } from 'react-i18next'; + +import { useForm, ISchema, Schema, useFieldSchema } from '@formily/react'; +import { get } from 'lodash'; import { - CollectionProvider, SchemaComponent, SchemaComponentContext, SchemaInitializer, @@ -15,18 +12,17 @@ import { SchemaInitializerProvider, useSchemaComponentContext, gridRowColWrap, - useCollectionManager, ActionContext, - CollectionContext, GeneralSchemaDesigner, - SchemaSettings + SchemaSettings, + useCompile } from '@nocobase/client'; -import { merge, uid } from '@nocobase/utils/client'; import { useTrigger } from '../../triggers'; import { instructions, useAvailableUpstreams, useNodeContext } from '..'; import { useFlowContext } from '../../FlowContext'; import { lang, NAMESPACE } from '../../locale'; import { JOB_STATUS } from '../../constants'; +import customForm from './forms/customForm'; @@ -36,10 +32,17 @@ function useTriggerInitializers(): SchemaInitializerItemOptions | null { return trigger.useInitializers? trigger.useInitializers(workflow.config) : null; }; +const blockTypeNames = { + 'customForm': customForm.title, + 'record': `{{t("Data record", { ns: "${NAMESPACE}" })}}`, +}; + function SimpleDesigner() { - const { t } = useTranslation(); + const schema = useFieldSchema(); + const title = blockTypeNames[schema['x-designer-props']?.type] ?? '{{t("Block")}}'; + const compile = useCompile(); return ( - + { - insert({ - type: 'void', - 'x-component': 'CardItem', - 'x-designer': 'SimpleDesigner', - properties: { - grid: { - type: 'void', - 'x-component': 'Grid', - 'x-initializer': 'AddFormField', - } - } - }); - }} - /> - ); -} - function AddBlockButton(props: any) { const current = useNodeContext(); const nodes = useAvailableUpstreams(current); @@ -102,12 +83,19 @@ function AddBlockButton(props: any) { type: 'itemGroup', title: '{{t("Form")}}', children: [ - { - key: 'form', - type: 'item', - title: '{{t("Form")}}', - component: FormBlockInitializer - }, + 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, + // } ], }, { @@ -133,214 +121,24 @@ function AddBlockButton(props: any) { ); }; -const GroupLabels = { - basic: '{{t("Basic")}}', - choices: '{{t("Choices")}}', - media: '{{t("Media")}}', - datetime: '{{t("Date & Time")}}', - relation: '{{t("Relation")}}', - advanced: '{{t("Advanced type")}}', - systemInfo: '{{t("System info")}}', - others: '{{t("Others")}}', -}; +function findSchema(schema, filter, onlyLeaf = false) { + const result = []; -function getOptions(interfaces) { - const fields = {}; - - Object.keys(interfaces).forEach((type) => { - const schema = interfaces[type]; - const { group = 'others' } = schema; - fields[group] = fields[group] || {}; - set(fields, [group, type], schema); - }); - - return Object.keys(GroupLabels) - .filter(groupName => ['basic', 'choices', 'datetime', 'media'].includes(groupName)) - .map((groupName) => ({ - title: GroupLabels[groupName], - children: Object.keys(fields[groupName] || {}) - .map((type) => { - const field = fields[groupName][type]; - return { - value: type, - title: field.title, - name: type, - ...fields[groupName][type], - }; - }) - .sort((a, b) => a.order - b.order), - })); -} - -function useCommonInterfaceInitializers(): SchemaInitializerItemOptions[] { - const { interfaces } = useCollectionManager(); - const options = getOptions(interfaces); - - return options.map(group => ({ - key: group.title, - type: 'itemGroup', - title: group.title, - children: group.children.map(item => ({ - key: item.name, - type: 'item', - title: item.title, - component: FormFieldInitializer, - fieldInterface: item.name, - })) - })); -} - -const AddFormFieldButtonContext = React.createContext({}); - -function AddFormField(props) { - const { insertPosition = 'beforeEnd', component } = props; - const items = useCommonInterfaceInitializers(); - const collection = useContext(CollectionContext); - const [interfaceOptions, setInterface] = useState(null); - const [insert, setCallback] = useState(); - - return ( - - - - {interfaceOptions - ? ( - item.name === options.name); - if (existed) { - const field = query('name').take() as Field; - field.setFeedback({ - type: 'error', - // code: 'FormulaError', - messages: [lang('Field name existed in form')], - }); - return; - } - collection.fields?.push(merge(options, values) as any); - insert({ - name: options.name, - type: options.uiSchema.type, - 'x-decorator': 'FormItem', - 'x-component': 'CollectionField', - 'x-component-props': {}, - 'x-collection-field': `${collection.name}.${options.name}`, - 'x-designer': 'FormItem.Designer', - }); - setCallback(null); - setInterface(null); - } - } - } - } - } - } - } - } - }} - components={{ - ArrayTable - }} - /> - ) - : null} - - - ); -} - -function FormFieldInitializer(props) { - const { item, insert } = props; - const { onAddField, setCallback } = useContext(AddFormFieldButtonContext); - const { getInterface } = useCollectionManager(); - - const interfaceOptions = getInterface(item.fieldInterface); - - return ( - { - setCallback(() => insert); - onAddField(interfaceOptions); - }} - /> - ); -}; - -function findFormFields(formSchema, fields) { - if (!formSchema) { - return; + if (!schema) { + return result; } - if (!formSchema.properties) { - if (formSchema['x-component'] === 'CollectionField') { - fields.push(formSchema); - } - return; + if (filter(schema) && (!onlyLeaf || !schema.properties)) { + result.push(schema); + return result; } - Object.keys(formSchema.properties).forEach(key => { - findFormFields(formSchema.properties[key], fields); - }); + if (schema.properties) { + Object.keys(schema.properties).forEach(key => { + result.push(...findSchema(schema.properties[key], filter)); + }); + } + return result; } function SchemaComponentRefreshProvider(props) { @@ -424,7 +222,12 @@ function AddActionButton(props) { // NOTE: fake useAction for ui configuration function useSubmit() { - return { run() {} } + // const { values, submit, id: formId } = useForm(); + // const formSchema = useFieldSchema(); + return { + run() { + } + }; } function useFlowRecordFromBlock() { @@ -439,11 +242,6 @@ export function SchemaConfig({ value, onChange }) { const form = useForm(); const { workflow } = useFlowContext(); - const { collection = { - name: uid(), - fields: [] - }, blocks, actions } = value ?? {}; - const nodeInitializers = {}; const nodeComponents = {}; nodes.forEach(item => { @@ -471,7 +269,7 @@ export function SchemaConfig({ value, onChange }) { 'x-initializer-props': { gridInitializer: 'AddBlockButton' }, - properties: blocks ?? { + properties: value ?? { tab1: { type: 'void', title: `{{t("Manual", { ns: "${NAMESPACE}" })}}`, @@ -487,39 +285,6 @@ export function SchemaConfig({ value, onChange }) { }, } } - }, - footer: { - type: 'void', - 'x-component': 'Action.Drawer.Footer', - 'x-component-props': { - style: { - background: '#fff' - } - }, - properties: { - actions: { - type: 'void', - 'x-component': 'ActionBar', - 'x-initializer': 'AddActionButton', - properties: actions ?? { - resolve: { - type: 'void', - title: `{{t("Continue the process", { ns: "${NAMESPACE}" })}}`, - 'x-decorator': 'ManualActionStatusProvider', - 'x-decorator-props': { - value: JOB_STATUS.RESOLVED - }, - 'x-component': 'Action', - 'x-component-props': { - type: 'primary', - useAction: '{{ useSubmit }}', - }, - 'x-designer': 'Action.Designer', - 'x-action': `${JOB_STATUS.RESOLVED}`, - } - } - } - } } } } @@ -528,47 +293,58 @@ export function SchemaConfig({ value, onChange }) { return ( - + { - const { tabs, footer } = get(schema.toJSON(), 'properties.drawer.properties') as any; - const fields: any[] = []; - findFormFields(tabs, fields); - - for(let i = collection.fields.length - 1; i >= 0; i--) { - if (!fields.find(field => field.name === collection.fields[i].name)) { - collection.fields.splice(i, 1); - } - } - - const actionKeys = (Object.values(footer.properties.actions.properties ?? {}) as any[]) - .reduce((actions: number[], { ['x-action']: status }) => actions.concat(Number.parseInt(status, 10)), []); - form.setValuesIn('actions', actionKeys); - - onChange({ - collection, - blocks: tabs.properties, - actions: footer.properties.actions.properties + 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); }} > - - - + diff --git a/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx b/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx index 0e0a7c53f..138c4b9f7 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/WorkflowTodo.tsx @@ -1,12 +1,11 @@ -import React, { useContext, createContext, useMemo, useEffect, useState } from "react"; -import { createForm } from '@formily/core'; +import React, { useContext, createContext, useEffect, useState } from "react"; import { observer, useForm, useField, useFieldSchema } from '@formily/react'; import { Tag } from 'antd'; import parse from 'json-templates'; import { css } from "@emotion/css"; import moment from 'moment'; -import { CollectionManagerProvider, CollectionProvider, SchemaComponent, SchemaComponentContext, SchemaComponentOptions, TableBlockProvider, useActionContext, useAPIClient, useCollectionManager, useCurrentUserContext, useRecord, useRequest, useTableBlockContext } from "@nocobase/client"; +import { CollectionManagerProvider, SchemaComponent, SchemaComponentContext, SchemaComponentOptions, TableBlockProvider, useActionContext, useAPIClient, useCollectionManager, useCurrentUserContext, useRecord, useRequest, useTableBlockContext } from "@nocobase/client"; import { uid } from "@nocobase/utils/client"; import { JobStatusOptions, JobStatusOptionsMap, JOB_STATUS } from "../../constants"; @@ -14,6 +13,7 @@ import { NAMESPACE } from "../../locale"; import { FlowContext, useFlowContext } from "../../FlowContext"; import { instructions, useAvailableUpstreams } from '..'; import { linkNodes } from "../../utils"; +import customForm from "./forms/customForm"; const nodeCollection = { title: `{{t("Task", { ns: "${NAMESPACE}" })}}`, @@ -327,35 +327,70 @@ export function WorkflowTodo() { ) } -const ManualActionStatusContext = createContext(null); +function ActionBarProvider(props) { + // * status is done: + // 1. form is this form: show action button, and emphasis used status button + // 2. form is not this form: hide action bar + // * status is not done: + // 1. current user: show action bar + // 2. not current user: disabled action bar -function useManualActionStatusContext() { - return useContext(ManualActionStatusContext); + const { data: user } = useCurrentUserContext(); + const { status, result, userId } = useRecord(); + const buttonSchema = useFieldSchema(); + const { name } = buttonSchema.parent.toJSON(); + + let { children: content } = props; + if (status) { + if (!result[name]) { + content = null; + } + } else { + if (user?.data?.id !== userId) { + content = null; + } + } + + return content; } +const ManualActionStatusContext = createContext(null); + function ManualActionStatusProvider({ value, children }) { + const { status } = useRecord(); + const button = useField(); + + useEffect(() => { + if (status) { + button.disabled = true; + button.visible = status === value; + } + }, [status, value]); + return ( {children} - ) + ); } function useSubmit() { const api = useAPIClient(); const { setVisible } = useActionContext(); const { values, submit } = useForm(); - const nextStatus = useManualActionStatusContext(); + const buttonSchema = useFieldSchema(); + const nextStatus = useContext(ManualActionStatusContext); const { service } = useTableBlockContext(); const { id } = useRecord(); return { async run() { await submit(); + const { name } = buttonSchema.parent.parent.toJSON(); await api.resource('users_jobs').submit({ filterByTk: id, values: { status: nextStatus, - result: values + result: { [name]: values } } }); setVisible(false); @@ -422,24 +457,12 @@ function FlowContextProvider(props) { WorkflowTodo.Drawer = function () { const ctx = useContext(SchemaComponentContext); - const { id, node, workflow, status, result, updatedAt, userId } = useRecord(); - const { data: user } = useCurrentUserContext(); + const { id, node, workflow, status, updatedAt } = useRecord(); - const disabled = Boolean(status) || user?.data?.id !== userId; - const form = useMemo(() => createForm({ - readPretty: disabled, - disabled, - initialValues: result - }), [result]); - - const { blocks, collection, actions } = node.config.schema ?? {}; - const availableActions = Object.keys(actions).reduce((buttons, key) => ({ ...buttons, [key]: { - ...actions[key], - 'x-disabled': disabled - } }), {}); + const { schema } = node.config ?? {}; const statusOption = JobStatusOptionsMap[status]; - const actionSchema = status + const footerSchema = status ? { date: { type: 'void', @@ -461,49 +484,46 @@ WorkflowTodo.Drawer = function () { 'x-content': statusOption.label } } - : availableActions; + : null; return ( - - - + } + }} + scope={{ + useSubmit, + useFlowRecordFromBlock, + ...customForm.block.scope, + }} + /> ) } diff --git a/packages/plugins/workflow/src/client/nodes/manual/forms/customForm.tsx b/packages/plugins/workflow/src/client/nodes/manual/forms/customForm.tsx new file mode 100644 index 000000000..a094acea6 --- /dev/null +++ b/packages/plugins/workflow/src/client/nodes/manual/forms/customForm.tsx @@ -0,0 +1,343 @@ +import React, { useState, useContext, useMemo } from 'react'; + +import { cloneDeep, set } from 'lodash'; +import { Field, createForm } from '@formily/core'; +import { useForm, useFieldSchema } from '@formily/react'; +import { ArrayTable } from '@formily/antd'; + +import { ActionContext, CollectionContext, CollectionProvider, gridRowColWrap, SchemaComponent, 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'; + +const FormCollectionContext = React.createContext(null); + +function FormCollectionProvider(props) { + const [fields, setCollectionFields] = useState(props.collection?.fields ?? []); + + return ( + + + {props.children} + + + ); +} + +function CustomFormBlockInitializer({ insert, ...props }) { + return ( + { + insert({ + type: 'void', + 'x-decorator': 'FormCollectionProvider', + 'x-decorator-props': { + collection: { + name: uid(), + fields: [] + } + }, + 'x-component': 'CardItem', + 'x-component-props': { + title: '{{t("Form")}}', + }, + 'x-designer': 'SimpleDesigner', + 'x-designer-props': { + type: 'customForm' + }, + properties: { + [uid()]: { + type: 'void', + 'x-component': 'FormV2', + 'x-component-props': { + // disabled / read-pretty / initialValues + useProps: '{{ useFormBlockProps }}' + }, + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'AddCustomFormField', + }, + actions: { + type: 'void', + 'x-decorator': 'ActionBarProvider', + 'x-component': 'ActionBar', + 'x-component-props': { + layout: 'one-column', + style: { + marginTop: '1.5em', + }, + }, + 'x-initializer': 'AddActionButton', + properties: { + 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}`, + } + } + } + } + } + } + }); + }} + /> + ); +} + +const GroupLabels = { + basic: '{{t("Basic")}}', + choices: '{{t("Choices")}}', + media: '{{t("Media")}}', + datetime: '{{t("Date & Time")}}', + relation: '{{t("Relation")}}', + advanced: '{{t("Advanced type")}}', + systemInfo: '{{t("System info")}}', + others: '{{t("Others")}}', +}; + +function getOptions(interfaces) { + const fields = {}; + + Object.keys(interfaces).forEach((type) => { + const schema = interfaces[type]; + const { group = 'others' } = schema; + fields[group] = fields[group] || {}; + set(fields, [group, type], schema); + }); + + return Object.keys(GroupLabels) + .filter(groupName => ['basic', 'choices', 'datetime', 'media'].includes(groupName)) + .map((groupName) => ({ + title: GroupLabels[groupName], + children: Object.keys(fields[groupName] || {}) + .map((type) => { + const field = fields[groupName][type]; + return { + value: type, + title: field.title, + name: type, + ...fields[groupName][type], + }; + }) + .sort((a, b) => a.order - b.order), + })); +} + +function useCommonInterfaceInitializers(): SchemaInitializerItemOptions[] { + const { interfaces } = useCollectionManager(); + const options = getOptions(interfaces); + + return options.map(group => ({ + key: group.title, + type: 'itemGroup', + title: group.title, + children: group.children.map(item => ({ + key: item.name, + type: 'item', + title: item.title, + component: CustomFormFieldInitializer, + fieldInterface: item.name, + })) + })); +} + +const AddCustomFormFieldButtonContext = React.createContext({}); + +function AddCustomFormField(props) { + const { insertPosition = 'beforeEnd', component } = props; + const items = useCommonInterfaceInitializers(); + const collection = useContext(CollectionContext); + const [interfaceOptions, setInterface] = useState(null); + const [insert, setCallback] = useState(); + const { setCollectionFields } = useContext(FormCollectionContext); + + return ( + + + + {interfaceOptions + ? ( + item.name === options.name); + if (existed) { + const field = query('name').take() as Field; + field.setFeedback({ + type: 'error', + // code: 'FormulaError', + messages: [lang('Field name existed in form')], + }); + return; + } + const newField = merge(options, values) as any; + setCollectionFields([...collection.fields, newField]); + + insert({ + name: options.name, + type: options.uiSchema.type, + 'x-decorator': 'FormItem', + 'x-component': 'CollectionField', + 'x-interface-options': newField, + 'x-collection-field': `${collection.name}.${options.name}`, + 'x-designer': 'FormItem.Designer', + }); + setCallback(null); + setInterface(null); + } + } + } + } + } + } + } + } + }} + components={{ + ArrayTable + }} + /> + ) + : null} + + + ); +} + +function CustomFormFieldInitializer(props) { + const { item, insert } = props; + const { onAddField, setCallback } = useContext(AddCustomFormFieldButtonContext); + const { getInterface } = useCollectionManager(); + + const interfaceOptions = getInterface(item.fieldInterface); + + return ( + { + setCallback(() => insert); + onAddField(interfaceOptions); + }} + /> + ); +}; + +function useFormBlockProps() { + const { status, result, userId } = useRecord(); + const { data: user } = useCurrentUserContext(); + const { name } = useFieldSchema(); + + const pattern = Boolean(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 + }, + initializers: { + AddCustomFormField + }, + components: { + FormCollectionProvider + } + }, + block: { + scope: { + useFormBlockProps + }, + components: { + FormCollectionProvider: CollectionProvider + } + } +}; diff --git a/packages/plugins/workflow/src/client/nodes/manual/index.tsx b/packages/plugins/workflow/src/client/nodes/manual/index.tsx index 07e8e4f48..015ad219f 100644 --- a/packages/plugins/workflow/src/client/nodes/manual/index.tsx +++ b/packages/plugins/workflow/src/client/nodes/manual/index.tsx @@ -70,12 +70,13 @@ export default { schema: { type: 'object', 'x-component': 'SchemaConfig', + default: null }, } }, - actions: { - type: 'array', - default: [JOB_STATUS.RESOLVED] + forms: { + type: 'object', + default: {} } }, view: { @@ -90,29 +91,60 @@ export default { AssigneesSelect }, getOptions(config, types) { - const fields = (config.schema?.collection?.fields ?? []).map(field => ({ - key: field.name, - value: field.name, - label: field.uiSchema.title, - title: field.uiSchema.title - })); - const filteredFields = filterTypedFields(fields, types); - return filteredFields.length ? filteredFields : null; - }, - useInitializers(node): SchemaInitializerItemOptions | null { - if (!node.config.schema?.collection?.fields?.length - || node.config.mode - ) { + const formKeys = Object.keys(config.forms ?? {}); + if (!formKeys.length) { return null; } - return { - type: 'item', - title: node.title ?? `#${node.id}`, - component: CollectionBlockInitializer, - collection: node.config.schema.collection, - dataSource: `{{$jobsMapByNodeId.${node.id}}}` + const options = formKeys.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 + ? { + key: formKey, + value: formKey, + label: form.title || formKey, + title: form.title || formKey, + children: filteredFields + } + : null; + }).filter(Boolean); + + return options.length ? options : null; + }, + useInitializers(node): SchemaInitializerItemOptions | null { + const formKeys = Object.keys(node.config.forms ?? {}); + if (!formKeys.length || node.config.mode) { + return null; } + + const forms = formKeys.map(formKey => { + const form = node.config.forms[formKey]; + + return form.collection?.fields?.length + ? { + type: 'item', + title: form.title ?? formKey, + component: CollectionBlockInitializer, + collection: form.collection, + dataSource: `{{$jobsMapByNodeId.${node.id}.${formKey}}}` + } as SchemaInitializerItemOptions + : null; + }).filter(Boolean); + + return forms.length ? { + key: 'forms', + type: 'subMenu', + title: node.title, + children: forms + } : null; }, initializers: { CollectionFieldInitializers diff --git a/packages/plugins/workflow/src/client/triggers/collection.tsx b/packages/plugins/workflow/src/client/triggers/collection.tsx index 57505b23e..d383a0452 100644 --- a/packages/plugins/workflow/src/client/triggers/collection.tsx +++ b/packages/plugins/workflow/src/client/triggers/collection.tsx @@ -160,6 +160,7 @@ export default { return { type: 'item', + key: 'triggerData', title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`, component: CollectionBlockInitializer, collection: config.collection, 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 69a92e657..01932cc10 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts @@ -60,7 +60,11 @@ describe('workflow > instructions > manual', () => { type: 'manual', config: { assignees: [users[0].id], - actions: [JOB_STATUS.RESOLVED] + forms: { + f1: { + actions: [JOB_STATUS.RESOLVED] + } + } } }); @@ -88,7 +92,8 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: usersJobs[0].id, values: { - status: JOB_STATUS.RESOLVED + status: JOB_STATUS.RESOLVED, + result: { f1: {} } } }); expect(res2.status).toBe(403); @@ -97,7 +102,7 @@ describe('workflow > instructions > manual', () => { filterByTk: usersJobs[0].id, values: { status: JOB_STATUS.RESOLVED, - result: { a: 1 } + result: { f1: { a: 1 } } } }); expect(res3.status).toBe(202); @@ -106,18 +111,18 @@ describe('workflow > instructions > manual', () => { const [j2] = await pending.getJobs(); expect(j2.status).toBe(JOB_STATUS.RESOLVED); - expect(j2.result).toEqual({ a: 1 }); + expect(j2.result).toEqual({ f1: { a: 1 } }); const usersJobsAfter = await UserJobModel.findAll(); expect(usersJobsAfter.length).toBe(1); expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED); - expect(usersJobsAfter[0].result).toEqual({ a: 1 }); + expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 } }); const res4 = await userAgents[0].resource('users_jobs').submit({ filterByTk: usersJobs[0].id, values: { status: JOB_STATUS.RESOLVED, - result: { a: 2 } + result: { f1: { a: 2 } } } }); expect(res4.status).toBe(400); @@ -128,7 +133,9 @@ describe('workflow > instructions > manual', () => { type: 'manual', config: { assignees: [users[0].id, users[1].id], - actions: [JOB_STATUS.RESOLVED], + forms: { + f1: { actions: [JOB_STATUS.RESOLVED] } + } } }); @@ -147,7 +154,7 @@ describe('workflow > instructions > manual', () => { filterByTk: usersJobs.find(item => item.userId === users[1].id).id, values: { status: JOB_STATUS.RESOLVED, - result: { a: 1 } + result: { f1: { a: 1 } } } }); expect(res1.status).toBe(202); @@ -156,13 +163,13 @@ describe('workflow > instructions > manual', () => { const [j2] = await pending.getJobs(); expect(j2.status).toBe(JOB_STATUS.RESOLVED); - expect(j2.result).toEqual({ a: 1 }); + expect(j2.result).toEqual({ f1: { a: 1 } }); const res2 = await userAgents[0].resource('users_jobs').submit({ filterByTk: usersJobs.find(item => item.userId === users[0].id).id, values: { status: JOB_STATUS.RESOLVED, - result: { a: 2 } + result: { f1: { a: 1 } } } }); expect(res2.status).toBe(400); @@ -173,7 +180,9 @@ describe('workflow > instructions > manual', () => { type: 'manual', config: { assignees: [users[0].id], - actions: [JOB_STATUS.RESOLVED] + forms: { + f1: { actions: [JOB_STATUS.RESOLVED] } + } } }); @@ -191,7 +200,7 @@ describe('workflow > instructions > manual', () => { filterByTk: usersJobs[0].get('id'), values: { status: JOB_STATUS.RESOLVED, - result: { a: 1 } + result: { f1: { a: 1 } } } }); expect(res.status).toBe(202); @@ -202,7 +211,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({ a: 1 }); + expect(job.result).toEqual({ f1: { a: 1 } }); }); }); @@ -213,7 +222,9 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], mode: 1, - actions: [JOB_STATUS.RESOLVED] + forms: { + f1: { actions: [JOB_STATUS.RESOLVED] } + } } }); @@ -231,7 +242,7 @@ describe('workflow > instructions > manual', () => { filterByTk: pendingJobs[0].get('id'), values: { status: JOB_STATUS.RESOLVED, - result: { a: 1 } + result: { f1: { a: 1 } } } }); expect(res1.status).toBe(202); @@ -252,7 +263,7 @@ describe('workflow > instructions > manual', () => { filterByTk: pendingJobs[1].get('id'), values: { status: JOB_STATUS.RESOLVED, - result: { a: 2 } + result: { f1: { a: 2 } } } }); expect(res2.status).toBe(202); @@ -272,7 +283,9 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], mode: 1, - actions: [JOB_STATUS.REJECTED] + forms: { + f1: { actions: [JOB_STATUS.REJECTED] } + } } }); @@ -289,7 +302,8 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.REJECTED + status: JOB_STATUS.REJECTED, + result: { f1: { a: 0 } } } }); expect(res1.status).toBe(202); @@ -310,7 +324,7 @@ describe('workflow > instructions > manual', () => { filterByTk: pendingJobs[1].get('id'), values: { status: JOB_STATUS.REJECTED, - result: { a: 2 } + result: { f1: { a: 0 } } } }); expect(res2.status).toBe(400); @@ -322,7 +336,9 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], mode: 1, - actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] + forms: { + f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] } + } } }); @@ -339,7 +355,8 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED + status: JOB_STATUS.RESOLVED, + result: { f1: { a: 1 } } } }); expect(res1.status).toBe(202); @@ -360,7 +377,7 @@ describe('workflow > instructions > manual', () => { filterByTk: pendingJobs[1].get('id'), values: { status: JOB_STATUS.REJECTED, - result: { a: 2 } + result: { f1: { a: 0 } } } }); expect(res2.status).toBe(202); @@ -382,7 +399,9 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], mode: -1, - actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] + forms: { + f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] } + } } }); @@ -399,7 +418,8 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.RESOLVED + status: JOB_STATUS.RESOLVED, + result: { f1: { a: 1 } } } }); expect(res1.status).toBe(202); @@ -415,7 +435,8 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.REJECTED + status: JOB_STATUS.REJECTED, + result: { f1: { a: 0 } } } }); expect(res2.status).toBe(400); @@ -427,7 +448,9 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], mode: -1, - actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] + forms: { + f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.REJECTED] } + } } }); @@ -444,7 +467,8 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.REJECTED + status: JOB_STATUS.REJECTED, + result: { f1: { a: 0 } } } }); expect(res1.status).toBe(202); @@ -460,7 +484,8 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.RESOLVED + status: JOB_STATUS.RESOLVED, + result: { f1: { a: 1 } } } }); expect(res2.status).toBe(202); @@ -480,7 +505,9 @@ describe('workflow > instructions > manual', () => { config: { assignees: [users[0].id, users[1].id], mode: -1, - actions: [JOB_STATUS.REJECTED] + forms: { + f1: { actions: [JOB_STATUS.REJECTED] } + } } }); @@ -497,7 +524,8 @@ describe('workflow > instructions > manual', () => { const res1 = await userAgents[0].resource('users_jobs').submit({ filterByTk: pendingJobs[0].get('id'), values: { - status: JOB_STATUS.REJECTED + status: JOB_STATUS.REJECTED, + result: { f1: { a: 0 } } } }); expect(res1.status).toBe(202); @@ -513,7 +541,8 @@ describe('workflow > instructions > manual', () => { const res2 = await userAgents[1].resource('users_jobs').submit({ filterByTk: pendingJobs[1].get('id'), values: { - status: JOB_STATUS.REJECTED + status: JOB_STATUS.REJECTED, + result: { f1: { a: 0 } } } }); expect(res2.status).toBe(202); @@ -542,7 +571,9 @@ describe('workflow > instructions > manual', () => { type: 'manual', config: { assignees: [users[0].id, users[1].id], - actions: [JOB_STATUS.RESOLVED], + forms: { + f1: {actions: [JOB_STATUS.RESOLVED] } + } } }); @@ -550,7 +581,7 @@ describe('workflow > instructions > manual', () => { type: 'calculation', config: { engine: 'math.js', - expression: `{{$jobsMapByNodeId.${n1.id}.number}} + 1` + expression: `{{$jobsMapByNodeId.${n1.id}.f1.number}} + 1` }, upstreamId: n1.id }); @@ -571,7 +602,7 @@ describe('workflow > instructions > manual', () => { filterByTk: pendingJobs[0].get('id'), values: { status: JOB_STATUS.RESOLVED, - result: { number: 1 } + result: { f1: { number: 1 } } } }); 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 6ee8b60d2..67cba7a5b 100644 --- a/packages/plugins/workflow/src/server/instructions/manual/actions.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/actions.ts @@ -30,14 +30,15 @@ export async function submit(context: Context, next) { return context.throw(404); } - const { actions = [] } = instance.node.config; + 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 - || !actions.includes(values.status) + || !forms[form]?.actions?.includes(values.status) ) { return context.throw(400); } diff --git a/packages/plugins/workflow/src/server/instructions/manual/index.ts b/packages/plugins/workflow/src/server/instructions/manual/index.ts index 1b933acb1..051f26203 100644 --- a/packages/plugins/workflow/src/server/instructions/manual/index.ts +++ b/packages/plugins/workflow/src/server/instructions/manual/index.ts @@ -10,18 +10,18 @@ import usersJobsCollection from './collecions/users_jobs'; import { submit } from './actions'; +type FormType = { + type: 'custom' | 'create' | 'update'; + actions: number[]; + options: { + [key: string]: any; + } +}; export interface ManualConfig { - schema: { - collection: { - name: string; - fields: any[]; - }; - blocks: { [key: string]: any }, - actions: { [key: string]: any }, - }; - actions: number[]; - assignees?: number[]; + schema: { [key: string]: any }; + forms: { [key: string]: FormType }; + assignees?: (number | string)[]; mode?: number; } diff --git a/packages/plugins/workflow/src/server/migrations/20230411034722-manual-multi-form.ts b/packages/plugins/workflow/src/server/migrations/20230411034722-manual-multi-form.ts new file mode 100644 index 000000000..6c2bbee32 --- /dev/null +++ b/packages/plugins/workflow/src/server/migrations/20230411034722-manual-multi-form.ts @@ -0,0 +1,257 @@ +import { cloneDeep } from 'lodash'; + +import { Migration } from '@nocobase/server'; +import { uid } from '@nocobase/utils'; + + +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 findParent(root, node) { + return findSchema(root, item => item.properties && Object.values(item.properties).includes(node))[0]; +} + +// 1. all form blocks combine into one, at first form block position +// 2. add fields move to the form +// 3. remove all other form blocks except first (should remove ancestors till grid) +// 4. add 'x-interface-options' property to all fields as collection field +// 5. make collection fields decorator as `FormCollectionProvider` for form block +// 6. add form component wrapper for form grid +// 7. move action footer to form block +function migrateConfig({ schema = {}, actions = [] }: { schema: any; actions: number[] }): Object { + const { blocks, collection } = schema; + if (!blocks) { + return { + forms: {} + }; + } + const root = { properties: blocks }; + const formBlocks = findSchema(root, (item) => { + return item['x-component'] === 'CardItem' + && item['x-designer'] === 'SimpleDesigner' + && item.properties.grid['x-initializer'] === 'AddFormField'; + }); + if (!formBlocks.length) { + return { + schema: blocks, + forms: {} + }; + } + + // 1. + formBlocks.forEach((formBlock, i) => { + const formItems = findSchema(formBlock, (item) => { + return item['x-component'] === 'CollectionField' + && item['x-decorator'] === 'FormItem'; + }, true); + // 4. + formItems.forEach(item => { + Object.assign(item, { + 'x-interface-options': collection.fields.find(field => field.name === item.name) + }); + }); + // skip first form block + if (!i) { + return; + } + // 2. + Object.assign(formBlocks[0].properties.grid.properties, formBlock.properties.grid.properties); + + // 3. + const col = findParent(root, formBlock); + const row = findParent(root, col); + delete row.properties[col.name]; + if (!Object.keys(row.properties).length) { + const grid = findParent(root, row); + delete grid.properties[row.name]; + } + }); + + // 5. + const [formBlock] = formBlocks; + Object.assign(formBlock, { + 'x-decorator': 'FormCollectionProvider', + 'x-decorator-props': { + collection + }, + 'x-component-props': { + title: '{{t("Form")}}', + }, + 'x-designer-props': { + type: 'customForm', + } + }); + + const formId = uid(); + // 6. + const newFormBlock = { + [formId]: { + type: 'void', + 'x-component': 'FormV2', + 'x-component-props': { + useProps: '{{ useFormBlockProps }}' + }, + properties: { + grid: Object.assign(formBlock.properties.grid, { + 'x-initializer': 'AddCustomFormField' + }), + // 7. + actions: { + type: 'void', + 'x-decorator': 'ActionBarProvider', + 'x-component': 'ActionBar', + 'x-component-props': { + layout: 'one-column', + style: { + marginTop: '1.5em', + }, + }, + 'x-initializer': 'AddActionButton', + properties: schema.actions + } + } + } + }; + delete formBlock.properties.grid; + Object.assign(formBlock.properties, newFormBlock); + + return { + schema: blocks, + forms: { + [formId]: { + type: 'custom', + title: '{{t("Form")}}', + actions, + collection + } + } + }; +} + +function migrateUsedConfig(config, manualForms) { + Object.keys(config).forEach(key => { + const valueType = typeof config[key]; + if (valueType === 'string') { + config[key] = config[key].replace(/{{\s*\$jobsMapByNodeId\.(\d+)(\.[^}]+)?\s*}}/g, (matched, id, path) => { + if (!manualForms[id]) { + return matched; + } + return `{{$jobsMapByNodeId.${id}.${manualForms[id]}${path || ''}}}`; + }); + } else if (valueType === 'object') { + migrateUsedConfig(config[key], manualForms); + } + }); + return config; +} + + +export default class extends Migration { + async up() { + const match = await this.app.version.satisfies('<0.9.1-alpha.3'); + if (!match) { + return; + } + const { db } = this.context; + const NodeRepo = db.getRepository('flow_nodes'); + const UserJobRepo = db.getRepository('users_jobs'); + 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(() => { + const { forms, schema, actions, ...config } = node.config; + if (forms) { + return; + } + return node.update({ + config: { + ...config, + ...migrateConfig({ schema, actions }) + } + }, { + silent: true, + transaction + }); + }), Promise.resolve()); + + const usersJobs = await UserJobRepo.find({ + filter: { + nodeId: nodes.map(item => item.id) + }, + appends: ['job', 'node'], + transaction + }); + // update all results + await usersJobs.reduce((promise, userJob) => promise.then(async () => { + const { result, job, node } = userJob; + const { forms } = node.config; + const [formId] = Object.keys(forms); + if (result) { + await userJob.update({ + result: { [formId]: result } + }, { + silent: true, + transaction + }); + } + if (job.result) { + await job.update({ + result: { [formId]: result } + }, { + silent: true, + transaction + }); + } + }), Promise.resolve()); + + const usedNodes = await NodeRepo.find({ + filter: { + type: { + $notIn: ['delay', 'parallel'] + } + }, + transaction + }); + + const nodeForms = {}; + nodes.forEach(node => { + const [form] = Object.keys(node.config.forms); + if (form) { + nodeForms[node.id] = form; + } + }); + + await usedNodes.reduce((promise, node) => promise.then(async () => { + await node.update({ + config: migrateUsedConfig(cloneDeep(node.config), nodeForms) + }, { + silent: true, + transaction + }); + }), Promise.resolve()); + }); + } +}