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