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:
parent
ecb2af8fd6
commit
2c9ce09304
@ -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();
|
||||||
|
@ -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';
|
||||||
|
@ -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": "设置数据范围",
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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 };
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -7,6 +7,7 @@ export {
|
|||||||
useRecordCollectionDataSourceItems,
|
useRecordCollectionDataSourceItems,
|
||||||
createTableBlockSchema,
|
createTableBlockSchema,
|
||||||
createFilterFormBlockSchema,
|
createFilterFormBlockSchema,
|
||||||
|
createFormBlockSchema,
|
||||||
useAssociatedTableColumnInitializerFields,
|
useAssociatedTableColumnInitializerFields,
|
||||||
useInheritsTableColumnInitializerFields,
|
useInheritsTableColumnInitializerFields,
|
||||||
useTableColumnInitializerFields,
|
useTableColumnInitializerFields,
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -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.':
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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,
|
||||||
|
28
packages/plugins/workflow/src/client/nodes/manual/utils.ts
Normal file
28
packages/plugins/workflow/src/client/nodes/manual/utils.ts
Normal 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;
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user