feat(plugin-workflow): manual forms (#1748)

* feat(plugin-workflow): add create record form for manual node

* feat(plugin-workflow): add update form for manual node

* fix(plugin-workflow): fix antd version and menu

* refactor(plugin-workflow): change collection config modal to initializer sub-menu

* test(plugin-workflow): add test case

* fix(plugin-workflow): fix todo components based on forms

* fix(plugin-workflow): fix cycling trigger when create or update

* fix(plugin-workflow): fix transaction in manual processor

* refactor(plugin-workflow): optimize todo list data loading

* fix(plugin-workflow): fix uncommitted manual action effects

* fix(plugin-workflow): fix save multiple forms and only submit one

* chore(plugin-workflow): fix lint

* fix(plugin-workflow): fix lint error

* refactor(plugin-workflow): abstract workflow scoped FormBlockProvider

* fix(plugin-workflow): adjust designable api to use current schema in refresh

* fix(plugin-workflow): fix schema config and support block template

* fix(plugin-workflow): fix lint and build error

* fix(plugin-workflow): adjust components and scope to inner schema component

* fix(plugin-workflow): fix ref template in todo drawer

* fix(plugin-workflow): fix todo form undefined

* fix(plugin-workflow): fix manual form schema

* fix(plugin-workflow): fix manual createdBy/updatedBy user

* fix(plugin-workflow): disable save to template on create form

* fix(plugin-workflow): fix manual form variables

* fix(plugin-workflow): fix FormBlockProvider for default field value

* fix(plugin-workflow): fix manual node variables
This commit is contained in:
Junyi 2023-06-05 15:52:43 +07:00 committed by GitHub
parent ecb2af8fd6
commit 2c9ce09304
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1039 additions and 288 deletions

View File

@ -46,7 +46,7 @@ const InternalTableBlockProvider = (props: Props) => {
); );
}; };
export const useAssociationNames = (collection) => { const useAssociationNames = (collection) => {
const { getCollectionFields } = useCollectionManager(); const { getCollectionFields } = useCollectionManager();
const collectionFields = getCollectionFields(collection); const collectionFields = getCollectionFields(collection);
const associationFields = new Set(); const associationFields = new Set();

View File

@ -11,3 +11,4 @@ export * from './TableSelectorProvider';
export * from './FormFieldProvider'; export * from './FormFieldProvider';
export * from './GanttBlockProvider'; export * from './GanttBlockProvider';
export * from './SharedFilterProvider'; export * from './SharedFilterProvider';
export * from './hooks';

View File

@ -112,6 +112,7 @@ export default {
"Enable SMS authentication": "启用短信登录和注册", "Enable SMS authentication": "启用短信登录和注册",
"Sign out": "注销", "Sign out": "注销",
"Cancel": "取消", "Cancel": "取消",
"Confirm": "确定",
"Submit": "提交", "Submit": "提交",
"Close": "关闭", "Close": "关闭",
"Set the data scope": "设置数据范围", "Set the data scope": "设置数据范围",

View File

@ -832,7 +832,7 @@ export function isFileCollection(collection: Collection) {
FormItem.FilterFormDesigner = FilterFormDesigner; FormItem.FilterFormDesigner = FilterFormDesigner;
export function getFieldDefaultValue(fieldSchema: ISchema, collectionField: CollectionFieldOptions) { 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) { if (collectionField?.uiSchema?.['x-component'] === 'DatePicker' && result) {
return moment(result); return moment(result);
} }

View File

@ -1,10 +1,12 @@
import { Form as FormV2 } from './Form'; import { Form as FormV2 } from './Form';
import { DetailsDesigner, FormDesigner, ReadPrettyFormDesigner } from './Form.Designer'; import { DetailsDesigner, FormDesigner, ReadPrettyFormDesigner } from './Form.Designer';
import { FilterDesigner } from './Form.FilterDesigner'; import { FilterDesigner } from './Form.FilterDesigner';
import { Templates } from './Templates';
FormV2.Designer = FormDesigner; FormV2.Designer = FormDesigner;
FormV2.FilterDesigner = FilterDesigner; FormV2.FilterDesigner = FilterDesigner;
FormV2.ReadPrettyDesigner = ReadPrettyFormDesigner; FormV2.ReadPrettyDesigner = ReadPrettyFormDesigner;
FormV2.Templates = Templates;
export * from './FormField'; export * from './FormField';
export { FormV2, DetailsDesigner }; export { FormV2, DetailsDesigner };

View File

@ -107,7 +107,7 @@ export class Designable {
} }
loadAPIClientEvents() { loadAPIClientEvents() {
const { refresh, api, t = translate } = this.options; const { api, t = translate } = this.options;
if (!api) { if (!api) {
return; return;
} }
@ -140,7 +140,7 @@ export class Designable {
if (removed?.['x-component'] === 'Grid.Col') { if (removed?.['x-component'] === 'Grid.Col') {
schemas = schemas.concat(updateColumnSize(removed.parent)); schemas = schemas.concat(updateColumnSize(removed.parent));
} }
refresh(); this.refresh();
if (!current['x-uid']) { if (!current['x-uid']) {
return; return;
} }
@ -169,7 +169,7 @@ export class Designable {
message.success(t('Saved successfully'), 0.2); message.success(t('Saved successfully'), 0.2);
}); });
this.on('patch', async ({ schema }) => { this.on('patch', async ({ schema }) => {
refresh(); this.refresh();
if (!schema?.['x-uid']) { if (!schema?.['x-uid']) {
return; return;
} }
@ -183,7 +183,7 @@ export class Designable {
message.success(t('Saved successfully'), 0.2); message.success(t('Saved successfully'), 0.2);
}); });
this.on('batchPatch', async ({ schemas }) => { this.on('batchPatch', async ({ schemas }) => {
refresh(); this.refresh();
await api.request({ await api.request({
url: `/uiSchemas:batchPatch`, url: `/uiSchemas:batchPatch`,
method: 'post', method: 'post',
@ -196,7 +196,7 @@ export class Designable {
if (removed?.['x-component'] === 'Grid.Col') { if (removed?.['x-component'] === 'Grid.Col') {
schemas = updateColumnSize(removed.parent); schemas = updateColumnSize(removed.parent);
} }
refresh(); this.refresh();
if (!removed?.['x-uid']) { if (!removed?.['x-uid']) {
return; return;
} }

View File

@ -1,6 +1,7 @@
import { Form } from '@formily/core'; import { Form } from '@formily/core';
import { IRecursionFieldProps, ISchemaFieldProps, SchemaReactComponents } from '@formily/react'; import { IRecursionFieldProps, ISchemaFieldProps, SchemaReactComponents } from '@formily/react';
import React from 'react'; import React from 'react';
import { Designable } from './hooks';
export interface ISchemaComponentContext { export interface ISchemaComponentContext {
scope?: any; scope?: any;

View File

@ -7,6 +7,7 @@ export {
useRecordCollectionDataSourceItems, useRecordCollectionDataSourceItems,
createTableBlockSchema, createTableBlockSchema,
createFilterFormBlockSchema, createFilterFormBlockSchema,
createFormBlockSchema,
useAssociatedTableColumnInitializerFields, useAssociatedTableColumnInitializerFields,
useInheritsTableColumnInitializerFields, useInheritsTableColumnInitializerFields,
useTableColumnInitializerFields, useTableColumnInitializerFields,

View File

@ -946,7 +946,6 @@ export const createDetailsBlockSchema = (options) => {
}, },
}, },
}; };
console.log(JSON.stringify(schema, null, 2));
return schema; return schema;
}; };
@ -1129,7 +1128,10 @@ export const createFormBlockSchema = (options) => {
resource, resource,
association, association,
action, action,
actions = {},
'x-designer': designer = 'FormV2.Designer',
template, template,
title,
...others ...others
} = options; } = options;
const resourceName = resource || association || collection; const resourceName = resource || association || collection;
@ -1149,8 +1151,11 @@ export const createFormBlockSchema = (options) => {
// action: 'get', // action: 'get',
// useParams: '{{ useParamsFromRecord }}', // useParams: '{{ useParamsFromRecord }}',
}, },
'x-designer': 'FormV2.Designer', 'x-designer': designer,
'x-component': 'CardItem', 'x-component': 'CardItem',
'x-component-props': {
title,
},
properties: { properties: {
[uid()]: { [uid()]: {
type: 'void', type: 'void',
@ -1175,7 +1180,7 @@ export const createFormBlockSchema = (options) => {
marginTop: 24, marginTop: 24,
}, },
}, },
properties: {}, properties: actions,
}, },
}, },
}, },

View File

@ -161,6 +161,9 @@ export default {
'Field name existed in form': '表单中已有对应标识的字段', 'Field name existed in form': '表单中已有对应标识的字段',
'Custom form': '自定义表单', 'Custom form': '自定义表单',
'Data record': '数据记录', 'Data record': '数据记录',
'Create record form': '新增数据表单',
'Update record form': '更新数据表单',
'Filter settings': '筛选设置',
'Create record': '新增数据', 'Create record': '新增数据',
'Add new record to a collection. You can use variables from upstream nodes to assign values to fields.': 'Add new record to a collection. You can use variables from upstream nodes to assign values to fields.':

View File

@ -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 <SchemaInitializer.Item {...others} onClick={onConfirm} items={items} />;
}
export function FormBlockInitializer(props) {
return (
<CollectionProvider collection={props.schema?.collection}>
<InternalFormBlockInitializer {...props} />
</CollectionProvider>
);
}

View File

@ -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 (
<CollectionProvider collection={props.collection}>
<RecordProvider record={values} parent={false}>
<BlockRequestContext.Provider value={{ block: 'form', props, field, service, resource, __parent }}>
<FormBlockContext.Provider
value={{
params,
form,
field,
service,
updateAssociationValues,
formBlockRef,
}}
>
<Component {...field.componentProps}>
<FormV2.Templates style={{ marginBottom: 18 }} form={form} />
<div ref={formBlockRef}>
<RecursionField schema={fieldSchema} onlyRenderProperties />
</div>
</Component>
</FormBlockContext.Provider>
</BlockRequestContext.Provider>
</RecordProvider>
</CollectionProvider>
);
}

View File

@ -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 { useForm, ISchema, Schema, useFieldSchema } from '@formily/react';
import { get } from 'lodash'; import { get } from 'lodash';
@ -10,19 +10,66 @@ import {
SchemaInitializerItemOptions, SchemaInitializerItemOptions,
InitializerWithSwitch, InitializerWithSwitch,
SchemaInitializerProvider, SchemaInitializerProvider,
useSchemaComponentContext,
gridRowColWrap, gridRowColWrap,
ActionContext, ActionContext,
GeneralSchemaDesigner, GeneralSchemaDesigner,
SchemaSettings, SchemaSettings,
useCompile, useCompile,
} from '@nocobase/client'; } from '@nocobase/client';
import { Registry } from '@nocobase/utils/client';
import { useTrigger } from '../../triggers'; import { useTrigger } from '../../triggers';
import { instructions, useAvailableUpstreams, useNodeContext } from '..'; import { instructions, useAvailableUpstreams, useNodeContext } from '..';
import { useFlowContext } from '../../FlowContext'; import { useFlowContext } from '../../FlowContext';
import { lang, NAMESPACE } from '../../locale'; import { lang, NAMESPACE } from '../../locale';
import { JOB_STATUS } from '../../constants'; 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> = T[keyof T];
export type FormType = {
type: 'create' | 'update' | 'custom';
title: string;
actions: ValueOf<typeof JOB_STATUS>[];
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<ManualFormType>();
manualFormTypes.register('customForm', customForm);
manualFormTypes.register('createForm', createForm);
manualFormTypes.register('updateForm', updateForm);
function useTriggerInitializers(): SchemaInitializerItemOptions | null { function useTriggerInitializers(): SchemaInitializerItemOptions | null {
const { workflow } = useFlowContext(); const { workflow } = useFlowContext();
@ -90,21 +137,10 @@ function AddBlockButton(props: any) {
{ {
type: 'itemGroup', type: 'itemGroup',
title: '{{t("Form")}}', title: '{{t("Form")}}',
children: [ children: Array.from(manualFormTypes.getValues()).map((item) => {
customForm.config.initializer, const { useInitializer: getInitializer } = item.config;
// { return getInitializer();
// key: 'createForm', }),
// type: 'item',
// title: '{{t("Create record form")}}',
// component: CustomFormBlockInitializer,
// },
// {
// key: 'updateForm',
// type: 'item',
// title: '{{t("Update record form")}}',
// component: CustomFormBlockInitializer,
// }
],
}, },
{ {
type: 'itemGroup', type: 'itemGroup',
@ -122,43 +158,6 @@ function AddBlockButton(props: any) {
return <SchemaInitializer.Button {...props} wrap={gridRowColWrap} items={items} title="{{t('Add block')}}" />; return <SchemaInitializer.Button {...props} wrap={gridRowColWrap} items={items} title="{{t('Add block')}}" />;
} }
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 (
<SchemaComponentContext.Provider
value={{
...ctx,
refresh() {
ctx?.refresh?.();
props?.onRefresh?.();
},
}}
>
{props.children}
</SchemaComponentContext.Provider>
);
}
function ActionInitializer({ action, actionProps, ...props }) { function ActionInitializer({ action, actionProps, ...props }) {
return ( return (
<InitializerWithSwitch <InitializerWithSwitch
@ -250,107 +249,104 @@ export function SchemaConfig({ value, onChange }) {
Object.assign(nodeComponents, instruction.components); Object.assign(nodeComponents, instruction.components);
}); });
const schema = new Schema({ const schema = useMemo(
properties: { () =>
drawer: { new Schema({
type: 'void',
title: '{{t("Configure form")}}',
'x-decorator': 'Form',
'x-component': 'Action.Drawer',
'x-component-props': {
className: 'nb-action-popup',
},
properties: { properties: {
tabs: { drawer: {
type: 'void', type: 'void',
'x-component': 'Tabs', title: '{{t("Configure form")}}',
'x-component-props': {}, 'x-decorator': 'Form',
'x-initializer': 'TabPaneInitializers', 'x-component': 'Action.Drawer',
'x-initializer-props': { 'x-component-props': {
gridInitializer: 'AddBlockButton', className: 'nb-action-popup',
}, },
properties: value ?? { properties: {
tab1: { tabs: {
type: 'void', type: 'void',
title: `{{t("Manual", { ns: "${NAMESPACE}" })}}`, 'x-component': 'Tabs',
'x-component': 'Tabs.TabPane', 'x-component-props': {},
'x-designer': 'Tabs.Designer', 'x-initializer': 'TabPaneInitializers',
properties: { 'x-initializer-props': {
grid: { gridInitializer: 'AddBlockButton',
},
properties: value ?? {
tab1: {
type: 'void', type: 'void',
'x-component': 'Grid', title: `{{t("Manual", { ns: "${NAMESPACE}" })}}`,
'x-initializer': 'AddBlockButton', 'x-component': 'Tabs.TabPane',
properties: {}, 'x-designer': 'Tabs.Designer',
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'AddBlockButton',
properties: {},
},
},
}, },
}, },
}, },
}, },
}, },
}, },
}, }),
}, [],
}); );
return ( return (
<SchemaComponentContext.Provider value={{ ...ctx, designable: !workflow.executed }}> <SchemaComponentContext.Provider
value={{
...ctx,
designable: !workflow.executed,
refresh() {
ctx.refresh?.();
const { tabs } = get(schema.toJSON(), 'properties.drawer.properties') as { tabs: ISchema };
const forms = Array.from(manualFormTypes.getValues()).reduce(
(result, item) => Object.assign(result, item.config.parseFormOptions(tabs)),
{},
);
form.setValuesIn('forms', forms);
onChange(tabs.properties);
},
}}
>
<SchemaInitializerProvider <SchemaInitializerProvider
initializers={{ initializers={{
AddBlockButton, AddBlockButton,
AddActionButton, AddActionButton,
...trigger.initializers, ...trigger.initializers,
...nodeInitializers, ...nodeInitializers,
...customForm.config.initializers, ...Array.from(manualFormTypes.getValues()).reduce(
(result, item) => Object.assign(result, item.config.initializers),
{},
),
}} }}
> >
<SchemaComponentRefreshProvider <SchemaComponent
onRefresh={() => { schema={schema}
const forms = {}; components={{
const { tabs } = get(schema.toJSON(), 'properties.drawer.properties') as { tabs: ISchema }; ...nodeComponents,
const formBlocks: any[] = findSchema(tabs, (item) => item['x-decorator'] === 'FormCollectionProvider'); ...Array.from(manualFormTypes.getValues()).reduce(
formBlocks.forEach((formBlock) => { (result, item) => Object.assign(result, item.config.components),
const [formKey] = Object.keys(formBlock.properties); {},
const formSchema = formBlock.properties[formKey]; ),
const fields = findSchema( FormBlockProvider,
formSchema.properties.grid, // NOTE: fake provider component
(item) => item['x-component'] === 'CollectionField', ManualActionStatusProvider(props) {
true, return props.children;
); },
formBlock['x-decorator-props'].collection.fields = fields.map((field) => field['x-interface-options']); ActionBarProvider(props) {
forms[formKey] = { return props.children;
type: 'custom', },
title: formBlock['x-component-props']?.title || formKey, SimpleDesigner,
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);
}} }}
> scope={{
<SchemaComponent useSubmit,
schema={schema} useFlowRecordFromBlock,
components={{ }}
...nodeComponents, />
...customForm.config.components,
// NOTE: fake provider component
ManualActionStatusProvider(props) {
return props.children;
},
ActionBarProvider(props) {
return props.children;
},
SimpleDesigner,
}}
scope={{
useSubmit,
useFlowRecordFromBlock,
}}
/>
</SchemaComponentRefreshProvider>
</SchemaInitializerProvider> </SchemaInitializerProvider>
</SchemaComponentContext.Provider> </SchemaComponentContext.Provider>
); );

View File

@ -1,6 +1,6 @@
import React, { useContext, createContext, useEffect, useState } from 'react'; import React, { useContext, createContext, useEffect, useState } from 'react';
import { observer, useForm, useField, useFieldSchema } from '@formily/react'; import { observer, useForm, useField, useFieldSchema } from '@formily/react';
import { Tag } from 'antd'; import { Spin, Tag } from 'antd';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import moment from 'moment'; import moment from 'moment';
@ -8,7 +8,6 @@ import {
CollectionManagerProvider, CollectionManagerProvider,
SchemaComponent, SchemaComponent,
SchemaComponentContext, SchemaComponentContext,
SchemaComponentOptions,
TableBlockProvider, TableBlockProvider,
useActionContext, useActionContext,
useAPIClient, useAPIClient,
@ -17,15 +16,18 @@ import {
useRecord, useRecord,
useRequest, useRequest,
useTableBlockContext, useTableBlockContext,
FormBlockContext,
useFormBlockContext,
} from '@nocobase/client'; } from '@nocobase/client';
import { uid, parse } from '@nocobase/utils/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 { NAMESPACE } from '../../locale';
import { FlowContext, useFlowContext } from '../../FlowContext'; import { FlowContext, useFlowContext } from '../../FlowContext';
import { instructions, useAvailableUpstreams } from '..'; import { instructions, useAvailableUpstreams } from '..';
import { linkNodes } from '../../utils'; import { linkNodes } from '../../utils';
import customForm from './forms/customForm'; import { manualFormTypes } from './SchemaConfig';
import { FormBlockProvider } from './FormBlockProvider';
const nodeCollection = { const nodeCollection = {
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`, title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
@ -197,7 +199,7 @@ const UserColumn = observer(() => {
return field?.value?.nickname ?? field.value?.id; return field?.value?.nickname ?? field.value?.id;
}); });
export function WorkflowTodo() { export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC } = () => {
return ( return (
<SchemaComponent <SchemaComponent
components={{ components={{
@ -337,7 +339,7 @@ export function WorkflowTodo() {
}} }}
/> />
); );
} };
function ActionBarProvider(props) { function ActionBarProvider(props) {
// * status is done: // * status is done:
@ -348,7 +350,8 @@ function ActionBarProvider(props) {
// 2. not current user: disabled action bar // 2. not current user: disabled action bar
const { data: user } = useCurrentUserContext(); const { data: user } = useCurrentUserContext();
const { status, result, userId } = useRecord(); const { userJob } = useFlowContext();
const { status, result, userId } = userJob;
const buttonSchema = useFieldSchema(); const buttonSchema = useFieldSchema();
const { name } = buttonSchema.parent.toJSON(); const { name } = buttonSchema.parent.toJSON();
@ -369,15 +372,15 @@ function ActionBarProvider(props) {
const ManualActionStatusContext = createContext<number | null>(null); const ManualActionStatusContext = createContext<number | null>(null);
function ManualActionStatusProvider({ value, children }) { function ManualActionStatusProvider({ value, children }) {
const { status } = useRecord(); const { userJob } = useFlowContext();
const button = useField(); const button = useField();
useEffect(() => { useEffect(() => {
if (status) { if (userJob.status) {
button.disabled = true; button.disabled = true;
button.visible = status === value; button.visible = userJob.status === value;
} }
}, [status, value]); }, [userJob.status, value, button]);
return <ManualActionStatusContext.Provider value={value}>{children}</ManualActionStatusContext.Provider>; return <ManualActionStatusContext.Provider value={value}>{children}</ManualActionStatusContext.Provider>;
} }
@ -389,17 +392,19 @@ function useSubmit() {
const buttonSchema = useFieldSchema(); const buttonSchema = useFieldSchema();
const nextStatus = useContext(ManualActionStatusContext); const nextStatus = useContext(ManualActionStatusContext);
const { service } = useTableBlockContext(); const { service } = useTableBlockContext();
const { id } = useRecord(); const { userJob } = useFlowContext();
const { updateAssociationValues } = useContext(FormBlockContext);
return { return {
async run() { async run() {
await submit(); await submit();
const { name } = buttonSchema.parent.parent.toJSON(); const { name } = buttonSchema.parent.parent.toJSON();
await api.resource('users_jobs').submit({ await api.resource('users_jobs').submit({
filterByTk: id, filterByTk: userJob.id,
values: { values: {
status: nextStatus, status: nextStatus,
result: { [name]: values }, result: { [name]: values },
}, },
updateAssociationValues,
}); });
setVisible(false); setVisible(false);
service.refresh(); service.refresh();
@ -407,6 +412,7 @@ function useSubmit() {
}; };
} }
// parse datasource block from execution context
function useFlowRecordFromBlock(opts) { function useFlowRecordFromBlock(opts) {
const { ['x-context-datasource']: dataSource } = useFieldSchema(); const { ['x-context-datasource']: dataSource } = useFieldSchema();
const { execution } = useFlowContext(); const { execution } = useFlowContext();
@ -425,8 +431,9 @@ function useFlowRecordFromBlock(opts) {
function FlowContextProvider(props) { function FlowContextProvider(props) {
const api = useAPIClient(); const api = useAPIClient();
const { id, node } = useRecord(); const { id } = useRecord();
const [flowContext, setFlowContext] = useState<any>(null); const [flowContext, setFlowContext] = useState<any>(null);
const [node, setNode] = useState<any>(null);
useEffect(() => { useEffect(() => {
if (!id) { if (!id) {
@ -436,42 +443,87 @@ function FlowContextProvider(props) {
.resource('users_jobs') .resource('users_jobs')
.get?.({ .get?.({
filterByTk: id, filterByTk: id,
appends: ['workflow', 'workflow.nodes', 'execution', 'execution.jobs'], appends: ['node', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'],
}) })
.then(({ data }) => { .then(({ data }) => {
const { workflow: { nodes = [], ...workflow } = {}, execution } = data?.data ?? {}; const { node, workflow: { nodes = [], ...workflow } = {}, execution, ...userJob } = data?.data ?? {};
linkNodes(nodes); linkNodes(nodes);
setNode(node);
setFlowContext({ setFlowContext({
userJob,
workflow, workflow,
nodes, nodes,
execution, execution,
}); });
return;
}); });
}, [id]); }, [id]);
if (!flowContext) { const upstreams = useAvailableUpstreams(flowContext?.nodes.find((item) => item.id === node.id));
return null;
}
const upstreams = useAvailableUpstreams(flowContext.nodes.find((item) => item.id === node.id));
const nodeComponents = upstreams.reduce( const nodeComponents = upstreams.reduce(
(components, { type }) => Object.assign(components, instructions.get(type).components), (components, { type }) => Object.assign(components, instructions.get(type).components),
{}, {},
); );
return ( return node && flowContext ? (
<FlowContext.Provider value={flowContext}> <FlowContext.Provider value={flowContext}>
<SchemaComponentOptions components={{ ...nodeComponents }}>{props.children}</SchemaComponentOptions> <SchemaComponent
components={{
FormBlockProvider,
ActionBarProvider,
ManualActionStatusProvider,
...Array.from(manualFormTypes.getValues()).reduce(
(result, item) => 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,
}}
/>
</FlowContext.Provider> </FlowContext.Provider>
) : (
<Spin />
); );
} }
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 ctx = useContext(SchemaComponentContext);
const { id, node, workflow, status, updatedAt } = useRecord(); const { id, node, workflow, status, updatedAt } = useRecord();
const { schema } = node.config ?? {};
const statusOption = JobStatusOptionsMap[status]; const statusOption = JobStatusOptionsMap[status];
const footerSchema = status const footerSchema = status
? { ? {
@ -498,14 +550,11 @@ WorkflowTodo.Drawer = function () {
: null; : null;
return ( return (
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}> <SchemaComponentContext.Provider value={{ ...ctx, reset() {}, designable: false }}>
<SchemaComponent <SchemaComponent
components={{ components={{
Tag, Tag,
ActionBarProvider,
ManualActionStatusProvider,
FlowContextProvider, FlowContextProvider,
...customForm.block.components,
}} }}
schema={{ schema={{
type: 'void', type: 'void',
@ -518,9 +567,7 @@ WorkflowTodo.Drawer = function () {
properties: { properties: {
tabs: { tabs: {
type: 'void', type: 'void',
'x-decorator': 'FlowContextProvider', 'x-component': 'FlowContextProvider',
'x-component': 'Tabs',
properties: schema,
}, },
footer: { footer: {
type: 'void', type: 'void',
@ -530,16 +577,14 @@ WorkflowTodo.Drawer = function () {
}, },
}} }}
scope={{ scope={{
useSubmit,
useFlowRecordFromBlock, useFlowRecordFromBlock,
...customForm.block.scope,
}} }}
/> />
</SchemaComponentContext.Provider> </SchemaComponentContext.Provider>
); );
}; }
WorkflowTodo.Decorator = function ({ children }) { function Decorator({ children }) {
const { collections, ...cm } = useCollectionManager(); const { collections, ...cm } = useCollectionManager();
const blockProps = { const blockProps = {
collection: 'users_jobs', collection: 'users_jobs',
@ -549,7 +594,7 @@ WorkflowTodo.Decorator = function ({ children }) {
pageSize: 20, pageSize: 20,
sort: ['-createdAt'], sort: ['-createdAt'],
appends: ['user', 'node', 'workflow'], appends: ['user', 'node', 'workflow'],
except: ['workflow.config'], except: ['node.config', 'workflow.config'],
}, },
rowKey: 'id', rowKey: 'id',
showIndex: true, showIndex: true,
@ -564,4 +609,7 @@ WorkflowTodo.Decorator = function ({ children }) {
<TableBlockProvider {...blockProps}>{children}</TableBlockProvider> <TableBlockProvider {...blockProps}>{children}</TableBlockProvider>
</CollectionManagerProvider> </CollectionManagerProvider>
); );
}; }
WorkflowTodo.Drawer = Drawer;
WorkflowTodo.Decorator = Decorator;

View File

@ -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 (
<GeneralSchemaDesigner title={title || name}>
<SchemaSettings.BlockTitleItem />
<SchemaSettings.LinkageRules collectionName={name} />
<SchemaSettings.DataTemplates collectionName={name} />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{
'x-component': 'Grid',
}}
/>
</GeneralSchemaDesigner>
);
}
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;

View File

@ -1,8 +1,8 @@
import React, { useState, useContext, useMemo } from 'react'; import React, { useState, useContext } from 'react';
import { cloneDeep, set } from 'lodash'; import { cloneDeep, set } from 'lodash';
import { Field, createForm } from '@formily/core'; import { Field } from '@formily/core';
import { useForm, useFieldSchema } from '@formily/react'; import { useForm } from '@formily/react';
import { ArrayTable } from '@formily/antd'; import { ArrayTable } from '@formily/antd';
import { import {
@ -14,13 +14,13 @@ import {
SchemaInitializer, SchemaInitializer,
SchemaInitializerItemOptions, SchemaInitializerItemOptions,
useCollectionManager, useCollectionManager,
useCurrentUserContext,
useRecord,
} from '@nocobase/client'; } from '@nocobase/client';
import { merge, uid } from '@nocobase/utils/client'; import { merge, uid } from '@nocobase/utils/client';
import { JOB_STATUS } from '../../../constants'; import { JOB_STATUS } from '../../../constants';
import { lang, NAMESPACE } from '../../../locale'; import { lang, NAMESPACE } from '../../../locale';
import { findSchema } from '../utils';
import { ManualFormType } from '../SchemaConfig';
const FormCollectionContext = React.createContext<any>(null); const FormCollectionContext = React.createContext<any>(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 { export default {
title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`, title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`,
config: { config: {
initializer: { useInitializer() {
key: 'customForm', return {
type: 'item', key: 'customForm',
title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`, type: 'item',
component: CustomFormBlockInitializer, title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`,
component: CustomFormBlockInitializer,
};
}, },
initializers: { initializers: {
AddCustomFormField, AddCustomFormField,
@ -353,13 +331,34 @@ export default {
components: { components: {
FormCollectionProvider, 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: { block: {
scope: { scope: {},
useFormBlockProps,
},
components: { components: {
FormCollectionProvider: CollectionProvider, FormCollectionProvider: CollectionProvider,
}, },
}, },
}; } as ManualFormType;

View File

@ -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 (
<GeneralSchemaDesigner title={title || name}>
<SchemaSettings.BlockTitleItem />
<SchemaSettings.ActionModalItem
title={t('Filter settings', { ns: NAMESPACE })}
schema={{
name: 'filter',
type: 'object',
title: `{{t("Filter")}}`,
// 'x-decorator': 'FormItem',
'x-component': 'Filter',
'x-component-props': {
useProps() {
const options = useCollectionFilterOptions(fieldSchema?.['x-decorator-props']?.collection);
return {
options,
};
},
dynamicComponent: 'FilterDynamicComponent',
},
}}
initialValues={fieldSchema?.['x-decorator-props']}
onSubmit={({ filter }) => {
fieldSchema['x-decorator-props'].filter = filter;
dn.emit('patch', {
schema: {
// ['x-uid']: fieldSchema['x-uid'],
'x-decorator-props': fieldSchema['x-decorator-props'],
},
});
dn.refresh();
}}
/>
<SchemaSettings.LinkageRules collectionName={name} />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{
'x-component': 'Grid',
}}
/>
</GeneralSchemaDesigner>
);
}
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;

View File

@ -1,13 +1,12 @@
import { BlockInitializers, SchemaInitializerItemOptions } from '@nocobase/client'; import { BlockInitializers, SchemaInitializerItemOptions, useCollectionManager } from '@nocobase/client';
import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer'; import { CollectionBlockInitializer } from '../../components/CollectionBlockInitializer';
import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers'; import { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers';
import { filterTypedFields } from '../../variable'; import { filterTypedFields, useCollectionFieldOptions } from '../../variable';
import { NAMESPACE } from '../../locale'; import { NAMESPACE } from '../../locale';
import { SchemaConfig, SchemaConfigButton } from './SchemaConfig'; import { SchemaConfig, SchemaConfigButton } from './SchemaConfig';
import { ModeConfig } from './ModeConfig'; import { ModeConfig } from './ModeConfig';
import { AssigneesSelect } from './AssigneesSelect'; import { AssigneesSelect } from './AssigneesSelect';
import { JOB_STATUS } from '../../constants';
const MULTIPLE_ASSIGNED_MODE = { const MULTIPLE_ASSIGNED_MODE = {
SINGLE: Symbol('single'), SINGLE: Symbol('single'),
@ -97,20 +96,19 @@ export default {
.map((formKey) => { .map((formKey) => {
const form = config.forms[formKey]; const form = config.forms[formKey];
const fields = (form.collection?.fields ?? []).map((field) => ({ // eslint-disable-next-line react-hooks/rules-of-hooks
key: field.name, const options = useCollectionFieldOptions({
value: field.name, fields: form.collection?.fields,
label: field.uiSchema.title, collection: form.collection,
title: field.uiSchema.title, types,
})); });
const filteredFields = filterTypedFields(fields, types); return options.length
return filteredFields.length
? { ? {
key: formKey, key: formKey,
value: formKey, value: formKey,
label: form.title || formKey, label: form.title || formKey,
title: form.title || formKey, title: form.title || formKey,
children: filteredFields, children: options,
} }
: null; : null;
}) })
@ -119,6 +117,7 @@ export default {
return options.length ? options : null; return options.length ? options : null;
}, },
useInitializers(node): SchemaInitializerItemOptions | null { useInitializers(node): SchemaInitializerItemOptions | null {
const { getCollection } = useCollectionManager();
const formKeys = Object.keys(node.config.forms ?? {}); const formKeys = Object.keys(node.config.forms ?? {});
if (!formKeys.length || node.config.mode) { if (!formKeys.length || node.config.mode) {
return null; return null;
@ -127,8 +126,9 @@ export default {
const forms = formKeys const forms = formKeys
.map((formKey) => { .map((formKey) => {
const form = node.config.forms[formKey]; const form = node.config.forms[formKey];
const { fields = [] } = getCollection(form.collection);
return form.collection?.fields?.length return fields.length
? ({ ? ({
type: 'item', type: 'item',
title: form.title ?? formKey, title: form.title ?? formKey,

View File

@ -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;
}

View File

@ -111,7 +111,6 @@ export default class Processor {
} else { } else {
await this.exit(null); await this.exit(null);
} }
await this.commit();
} }
public async resume(job: JobModel) { public async resume(job: JobModel) {
@ -122,7 +121,6 @@ export default class Processor {
await this.prepare(); await this.prepare();
const node = this.nodesMap.get(job.nodeId); const node = this.nodesMap.get(job.nodeId);
await this.recall(node, job); await this.recall(node, job);
await this.commit();
} }
private async commit() { private async commit() {
@ -225,6 +223,7 @@ export default class Processor {
: EXECUTION_STATUS.RESOLVED; : EXECUTION_STATUS.RESOLVED;
this.logger.info(`execution (${this.execution.id}) all nodes finished, finishing execution...`); this.logger.info(`execution (${this.execution.id}) all nodes finished, finishing execution...`);
await this.execution.update({ status }, { transaction: this.transaction }); await this.execution.update({ status }, { transaction: this.transaction });
await this.commit();
return null; return null;
} }

View File

@ -11,6 +11,7 @@ describe('workflow > instructions > manual', () => {
let userAgents; let userAgents;
let db: Database; let db: Database;
let PostRepo; let PostRepo;
let CommentRepo;
let WorkflowModel; let WorkflowModel;
let workflow; let workflow;
let UserModel; let UserModel;
@ -25,6 +26,7 @@ describe('workflow > instructions > manual', () => {
db = app.db; db = app.db;
WorkflowModel = db.getCollection('workflows').model; WorkflowModel = db.getCollection('workflows').model;
PostRepo = db.getCollection('posts').repository; PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository;
UserModel = db.getCollection('users').model; UserModel = db.getCollection('users').model;
UserJobModel = db.getModel('users_jobs'); UserJobModel = db.getModel('users_jobs');
@ -612,5 +614,247 @@ describe('workflow > instructions > manual', () => {
expect(j2.status).toBe(JOB_STATUS.RESOLVED); expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toBe(2); 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' });
});
});
}); });
}); });

View File

@ -1,6 +1,8 @@
import { Context, utils } from '@nocobase/actions'; import { Context, utils } from '@nocobase/actions';
import Plugin from '../..'; import Plugin from '../..';
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants'; import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
import ManualInstruction from '.';
export async function submit(context: Context, next) { export async function submit(context: Context, next) {
const repository = utils.getRepositoryFromParams(context); 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 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 userJob = await repository.findOne({
const instance = await repository.findOne({ filterByTk,
filterByTk, // filter: {
// filter: { // userId: currentUser?.id
// userId: currentUser?.id // },
// }, appends: ['job', 'node', 'execution', 'workflow'],
appends: ['job', 'node', 'execution', 'workflow'], context,
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;
}); });
// 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.body = userJob;
context.status = 202; context.status = 202;

View File

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

View File

@ -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<void>;
export default function({ formTypes }) {
formTypes.register('create', create);
formTypes.register('update', update);
}

View File

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

View File

@ -1,5 +1,6 @@
import actions from '@nocobase/actions'; import actions from '@nocobase/actions';
import { HandlerType } from '@nocobase/resourcer'; import { HandlerType } from '@nocobase/resourcer';
import { Registry } from '@nocobase/utils';
import Plugin from '../..'; import Plugin from '../..';
import { JOB_STATUS } from '../../constants'; import { JOB_STATUS } from '../../constants';
@ -8,6 +9,7 @@ import jobsCollection from './collecions/jobs';
import usersCollection from './collecions/users'; import usersCollection from './collecions/users';
import usersJobsCollection from './collecions/users_jobs'; import usersJobsCollection from './collecions/users_jobs';
import { submit } from './actions'; import { submit } from './actions';
import initFormTypes, { FormHandler } from './forms';
type FormType = { type FormType = {
type: 'custom' | 'create' | 'update'; type: 'custom' | 'create' | 'update';
@ -89,6 +91,8 @@ function getMode(mode) {
} }
export default class implements Instruction { export default class implements Instruction {
formTypes = new Registry<FormHandler>();
constructor(protected plugin: Plugin) { constructor(protected plugin: Plugin) {
plugin.db.collection(usersJobsCollection); plugin.db.collection(usersJobsCollection);
plugin.db.extendCollection(usersCollection); plugin.db.extendCollection(usersCollection);
@ -116,6 +120,8 @@ export default class implements Instruction {
submit, submit,
}, },
}); });
initFormTypes(this);
} }
async run(node, prevJob, processor) { async run(node, prevJob, processor) {
@ -158,15 +164,18 @@ export default class implements Instruction {
jobId: job.id, jobId: job.id,
}, },
group: ['status'], group: ['status'],
transaction: processor.transaction,
}); });
const submitted = distribution.reduce( const submitted = distribution.reduce(
(count, item) => (item.status !== JOB_STATUS.PENDING ? count + item.count : count), (count, item) => (item.status !== JOB_STATUS.PENDING ? count + item.count : count),
0, 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; const result = mode ? (submitted || 0) / assignees.length : job.latestUserJob?.result ?? job.result;
processor.logger.debug(`manual resume job and next status: ${status}`);
job.set({ job.set({
status: job.status || (getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING), status,
result, result,
}); });