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 collectionFields = getCollectionFields(collection);
|
||||
const associationFields = new Set();
|
||||
|
@ -11,3 +11,4 @@ export * from './TableSelectorProvider';
|
||||
export * from './FormFieldProvider';
|
||||
export * from './GanttBlockProvider';
|
||||
export * from './SharedFilterProvider';
|
||||
export * from './hooks';
|
||||
|
@ -112,6 +112,7 @@ export default {
|
||||
"Enable SMS authentication": "启用短信登录和注册",
|
||||
"Sign out": "注销",
|
||||
"Cancel": "取消",
|
||||
"Confirm": "确定",
|
||||
"Submit": "提交",
|
||||
"Close": "关闭",
|
||||
"Set the data scope": "设置数据范围",
|
||||
|
@ -832,7 +832,7 @@ export function isFileCollection(collection: Collection) {
|
||||
FormItem.FilterFormDesigner = FilterFormDesigner;
|
||||
|
||||
export function getFieldDefaultValue(fieldSchema: ISchema, collectionField: CollectionFieldOptions) {
|
||||
const result = fieldSchema?.default || collectionField?.defaultValue;
|
||||
const result = fieldSchema?.default ?? collectionField?.defaultValue;
|
||||
if (collectionField?.uiSchema?.['x-component'] === 'DatePicker' && result) {
|
||||
return moment(result);
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { Form as FormV2 } from './Form';
|
||||
import { DetailsDesigner, FormDesigner, ReadPrettyFormDesigner } from './Form.Designer';
|
||||
import { FilterDesigner } from './Form.FilterDesigner';
|
||||
import { Templates } from './Templates';
|
||||
|
||||
FormV2.Designer = FormDesigner;
|
||||
FormV2.FilterDesigner = FilterDesigner;
|
||||
FormV2.ReadPrettyDesigner = ReadPrettyFormDesigner;
|
||||
FormV2.Templates = Templates;
|
||||
|
||||
export * from './FormField';
|
||||
export { FormV2, DetailsDesigner };
|
||||
|
@ -107,7 +107,7 @@ export class Designable {
|
||||
}
|
||||
|
||||
loadAPIClientEvents() {
|
||||
const { refresh, api, t = translate } = this.options;
|
||||
const { api, t = translate } = this.options;
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
@ -140,7 +140,7 @@ export class Designable {
|
||||
if (removed?.['x-component'] === 'Grid.Col') {
|
||||
schemas = schemas.concat(updateColumnSize(removed.parent));
|
||||
}
|
||||
refresh();
|
||||
this.refresh();
|
||||
if (!current['x-uid']) {
|
||||
return;
|
||||
}
|
||||
@ -169,7 +169,7 @@ export class Designable {
|
||||
message.success(t('Saved successfully'), 0.2);
|
||||
});
|
||||
this.on('patch', async ({ schema }) => {
|
||||
refresh();
|
||||
this.refresh();
|
||||
if (!schema?.['x-uid']) {
|
||||
return;
|
||||
}
|
||||
@ -183,7 +183,7 @@ export class Designable {
|
||||
message.success(t('Saved successfully'), 0.2);
|
||||
});
|
||||
this.on('batchPatch', async ({ schemas }) => {
|
||||
refresh();
|
||||
this.refresh();
|
||||
await api.request({
|
||||
url: `/uiSchemas:batchPatch`,
|
||||
method: 'post',
|
||||
@ -196,7 +196,7 @@ export class Designable {
|
||||
if (removed?.['x-component'] === 'Grid.Col') {
|
||||
schemas = updateColumnSize(removed.parent);
|
||||
}
|
||||
refresh();
|
||||
this.refresh();
|
||||
if (!removed?.['x-uid']) {
|
||||
return;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Form } from '@formily/core';
|
||||
import { IRecursionFieldProps, ISchemaFieldProps, SchemaReactComponents } from '@formily/react';
|
||||
import React from 'react';
|
||||
import { Designable } from './hooks';
|
||||
|
||||
export interface ISchemaComponentContext {
|
||||
scope?: any;
|
||||
|
@ -7,6 +7,7 @@ export {
|
||||
useRecordCollectionDataSourceItems,
|
||||
createTableBlockSchema,
|
||||
createFilterFormBlockSchema,
|
||||
createFormBlockSchema,
|
||||
useAssociatedTableColumnInitializerFields,
|
||||
useInheritsTableColumnInitializerFields,
|
||||
useTableColumnInitializerFields,
|
||||
|
@ -946,7 +946,6 @@ export const createDetailsBlockSchema = (options) => {
|
||||
},
|
||||
},
|
||||
};
|
||||
console.log(JSON.stringify(schema, null, 2));
|
||||
return schema;
|
||||
};
|
||||
|
||||
@ -1129,7 +1128,10 @@ export const createFormBlockSchema = (options) => {
|
||||
resource,
|
||||
association,
|
||||
action,
|
||||
actions = {},
|
||||
'x-designer': designer = 'FormV2.Designer',
|
||||
template,
|
||||
title,
|
||||
...others
|
||||
} = options;
|
||||
const resourceName = resource || association || collection;
|
||||
@ -1149,8 +1151,11 @@ export const createFormBlockSchema = (options) => {
|
||||
// action: 'get',
|
||||
// useParams: '{{ useParamsFromRecord }}',
|
||||
},
|
||||
'x-designer': 'FormV2.Designer',
|
||||
'x-designer': designer,
|
||||
'x-component': 'CardItem',
|
||||
'x-component-props': {
|
||||
title,
|
||||
},
|
||||
properties: {
|
||||
[uid()]: {
|
||||
type: 'void',
|
||||
@ -1175,7 +1180,7 @@ export const createFormBlockSchema = (options) => {
|
||||
marginTop: 24,
|
||||
},
|
||||
},
|
||||
properties: {},
|
||||
properties: actions,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -161,6 +161,9 @@ export default {
|
||||
'Field name existed in form': '表单中已有对应标识的字段',
|
||||
'Custom form': '自定义表单',
|
||||
'Data record': '数据记录',
|
||||
'Create record form': '新增数据表单',
|
||||
'Update record form': '更新数据表单',
|
||||
'Filter settings': '筛选设置',
|
||||
|
||||
'Create record': '新增数据',
|
||||
'Add new record to a collection. You can use variables from upstream nodes to assign values to fields.':
|
||||
|
@ -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 { get } from 'lodash';
|
||||
@ -10,19 +10,66 @@ import {
|
||||
SchemaInitializerItemOptions,
|
||||
InitializerWithSwitch,
|
||||
SchemaInitializerProvider,
|
||||
useSchemaComponentContext,
|
||||
gridRowColWrap,
|
||||
ActionContext,
|
||||
GeneralSchemaDesigner,
|
||||
SchemaSettings,
|
||||
useCompile,
|
||||
} from '@nocobase/client';
|
||||
import { Registry } from '@nocobase/utils/client';
|
||||
|
||||
import { useTrigger } from '../../triggers';
|
||||
import { instructions, useAvailableUpstreams, useNodeContext } from '..';
|
||||
import { useFlowContext } from '../../FlowContext';
|
||||
import { lang, NAMESPACE } from '../../locale';
|
||||
import { JOB_STATUS } from '../../constants';
|
||||
import customForm from './forms/customForm';
|
||||
import customForm from './forms/custom';
|
||||
import createForm from './forms/create';
|
||||
import updateForm from './forms/update';
|
||||
import { FormBlockProvider } from './FormBlockProvider';
|
||||
|
||||
type ValueOf<T> = 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 {
|
||||
const { workflow } = useFlowContext();
|
||||
@ -90,21 +137,10 @@ function AddBlockButton(props: any) {
|
||||
{
|
||||
type: 'itemGroup',
|
||||
title: '{{t("Form")}}',
|
||||
children: [
|
||||
customForm.config.initializer,
|
||||
// {
|
||||
// key: 'createForm',
|
||||
// type: 'item',
|
||||
// title: '{{t("Create record form")}}',
|
||||
// component: CustomFormBlockInitializer,
|
||||
// },
|
||||
// {
|
||||
// key: 'updateForm',
|
||||
// type: 'item',
|
||||
// title: '{{t("Update record form")}}',
|
||||
// component: CustomFormBlockInitializer,
|
||||
// }
|
||||
],
|
||||
children: Array.from(manualFormTypes.getValues()).map((item) => {
|
||||
const { useInitializer: getInitializer } = item.config;
|
||||
return getInitializer();
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'itemGroup',
|
||||
@ -122,43 +158,6 @@ function AddBlockButton(props: any) {
|
||||
return <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 }) {
|
||||
return (
|
||||
<InitializerWithSwitch
|
||||
@ -250,7 +249,9 @@ export function SchemaConfig({ value, onChange }) {
|
||||
Object.assign(nodeComponents, instruction.components);
|
||||
});
|
||||
|
||||
const schema = new Schema({
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
new Schema({
|
||||
properties: {
|
||||
drawer: {
|
||||
type: 'void',
|
||||
@ -289,53 +290,49 @@ export function SchemaConfig({ value, onChange }) {
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
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
|
||||
initializers={{
|
||||
AddBlockButton,
|
||||
AddActionButton,
|
||||
...trigger.initializers,
|
||||
...nodeInitializers,
|
||||
...customForm.config.initializers,
|
||||
}}
|
||||
>
|
||||
<SchemaComponentRefreshProvider
|
||||
onRefresh={() => {
|
||||
const forms = {};
|
||||
const { tabs } = get(schema.toJSON(), 'properties.drawer.properties') as { tabs: ISchema };
|
||||
const formBlocks: any[] = findSchema(tabs, (item) => item['x-decorator'] === 'FormCollectionProvider');
|
||||
formBlocks.forEach((formBlock) => {
|
||||
const [formKey] = Object.keys(formBlock.properties);
|
||||
const formSchema = formBlock.properties[formKey];
|
||||
const fields = findSchema(
|
||||
formSchema.properties.grid,
|
||||
(item) => item['x-component'] === 'CollectionField',
|
||||
true,
|
||||
);
|
||||
formBlock['x-decorator-props'].collection.fields = fields.map((field) => field['x-interface-options']);
|
||||
forms[formKey] = {
|
||||
type: 'custom',
|
||||
title: formBlock['x-component-props']?.title || formKey,
|
||||
actions: findSchema(formSchema.properties.actions, (item) => item['x-component'] === 'Action').map(
|
||||
(item) => item['x-decorator-props'].value,
|
||||
...Array.from(manualFormTypes.getValues()).reduce(
|
||||
(result, item) => Object.assign(result, item.config.initializers),
|
||||
{},
|
||||
),
|
||||
collection: formBlock['x-decorator-props'].collection,
|
||||
};
|
||||
});
|
||||
|
||||
form.setValuesIn('forms', forms);
|
||||
|
||||
onChange(tabs.properties);
|
||||
}}
|
||||
>
|
||||
<SchemaComponent
|
||||
schema={schema}
|
||||
components={{
|
||||
...nodeComponents,
|
||||
...customForm.config.components,
|
||||
...Array.from(manualFormTypes.getValues()).reduce(
|
||||
(result, item) => Object.assign(result, item.config.components),
|
||||
{},
|
||||
),
|
||||
FormBlockProvider,
|
||||
// NOTE: fake provider component
|
||||
ManualActionStatusProvider(props) {
|
||||
return props.children;
|
||||
@ -350,7 +347,6 @@ export function SchemaConfig({ value, onChange }) {
|
||||
useFlowRecordFromBlock,
|
||||
}}
|
||||
/>
|
||||
</SchemaComponentRefreshProvider>
|
||||
</SchemaInitializerProvider>
|
||||
</SchemaComponentContext.Provider>
|
||||
);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useContext, createContext, useEffect, useState } from 'react';
|
||||
import { observer, useForm, useField, useFieldSchema } from '@formily/react';
|
||||
import { Tag } from 'antd';
|
||||
import { Spin, Tag } from 'antd';
|
||||
import { css } from '@emotion/css';
|
||||
import moment from 'moment';
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
CollectionManagerProvider,
|
||||
SchemaComponent,
|
||||
SchemaComponentContext,
|
||||
SchemaComponentOptions,
|
||||
TableBlockProvider,
|
||||
useActionContext,
|
||||
useAPIClient,
|
||||
@ -17,15 +16,18 @@ import {
|
||||
useRecord,
|
||||
useRequest,
|
||||
useTableBlockContext,
|
||||
FormBlockContext,
|
||||
useFormBlockContext,
|
||||
} from '@nocobase/client';
|
||||
import { uid, parse } from '@nocobase/utils/client';
|
||||
|
||||
import { JobStatusOptions, JobStatusOptionsMap, JOB_STATUS } from '../../constants';
|
||||
import { JobStatusOptions, JobStatusOptionsMap } from '../../constants';
|
||||
import { NAMESPACE } from '../../locale';
|
||||
import { FlowContext, useFlowContext } from '../../FlowContext';
|
||||
import { instructions, useAvailableUpstreams } from '..';
|
||||
import { linkNodes } from '../../utils';
|
||||
import customForm from './forms/customForm';
|
||||
import { manualFormTypes } from './SchemaConfig';
|
||||
import { FormBlockProvider } from './FormBlockProvider';
|
||||
|
||||
const nodeCollection = {
|
||||
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
|
||||
@ -197,7 +199,7 @@ const UserColumn = observer(() => {
|
||||
return field?.value?.nickname ?? field.value?.id;
|
||||
});
|
||||
|
||||
export function WorkflowTodo() {
|
||||
export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC } = () => {
|
||||
return (
|
||||
<SchemaComponent
|
||||
components={{
|
||||
@ -337,7 +339,7 @@ export function WorkflowTodo() {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function ActionBarProvider(props) {
|
||||
// * status is done:
|
||||
@ -348,7 +350,8 @@ function ActionBarProvider(props) {
|
||||
// 2. not current user: disabled action bar
|
||||
|
||||
const { data: user } = useCurrentUserContext();
|
||||
const { status, result, userId } = useRecord();
|
||||
const { userJob } = useFlowContext();
|
||||
const { status, result, userId } = userJob;
|
||||
const buttonSchema = useFieldSchema();
|
||||
const { name } = buttonSchema.parent.toJSON();
|
||||
|
||||
@ -369,15 +372,15 @@ function ActionBarProvider(props) {
|
||||
const ManualActionStatusContext = createContext<number | null>(null);
|
||||
|
||||
function ManualActionStatusProvider({ value, children }) {
|
||||
const { status } = useRecord();
|
||||
const { userJob } = useFlowContext();
|
||||
const button = useField();
|
||||
|
||||
useEffect(() => {
|
||||
if (status) {
|
||||
if (userJob.status) {
|
||||
button.disabled = true;
|
||||
button.visible = status === value;
|
||||
button.visible = userJob.status === value;
|
||||
}
|
||||
}, [status, value]);
|
||||
}, [userJob.status, value, button]);
|
||||
|
||||
return <ManualActionStatusContext.Provider value={value}>{children}</ManualActionStatusContext.Provider>;
|
||||
}
|
||||
@ -389,17 +392,19 @@ function useSubmit() {
|
||||
const buttonSchema = useFieldSchema();
|
||||
const nextStatus = useContext(ManualActionStatusContext);
|
||||
const { service } = useTableBlockContext();
|
||||
const { id } = useRecord();
|
||||
const { userJob } = useFlowContext();
|
||||
const { updateAssociationValues } = useContext(FormBlockContext);
|
||||
return {
|
||||
async run() {
|
||||
await submit();
|
||||
const { name } = buttonSchema.parent.parent.toJSON();
|
||||
await api.resource('users_jobs').submit({
|
||||
filterByTk: id,
|
||||
filterByTk: userJob.id,
|
||||
values: {
|
||||
status: nextStatus,
|
||||
result: { [name]: values },
|
||||
},
|
||||
updateAssociationValues,
|
||||
});
|
||||
setVisible(false);
|
||||
service.refresh();
|
||||
@ -407,6 +412,7 @@ function useSubmit() {
|
||||
};
|
||||
}
|
||||
|
||||
// parse datasource block from execution context
|
||||
function useFlowRecordFromBlock(opts) {
|
||||
const { ['x-context-datasource']: dataSource } = useFieldSchema();
|
||||
const { execution } = useFlowContext();
|
||||
@ -425,8 +431,9 @@ function useFlowRecordFromBlock(opts) {
|
||||
|
||||
function FlowContextProvider(props) {
|
||||
const api = useAPIClient();
|
||||
const { id, node } = useRecord();
|
||||
const { id } = useRecord();
|
||||
const [flowContext, setFlowContext] = useState<any>(null);
|
||||
const [node, setNode] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
@ -436,42 +443,87 @@ function FlowContextProvider(props) {
|
||||
.resource('users_jobs')
|
||||
.get?.({
|
||||
filterByTk: id,
|
||||
appends: ['workflow', 'workflow.nodes', 'execution', 'execution.jobs'],
|
||||
appends: ['node', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'],
|
||||
})
|
||||
.then(({ data }) => {
|
||||
const { workflow: { nodes = [], ...workflow } = {}, execution } = data?.data ?? {};
|
||||
const { node, workflow: { nodes = [], ...workflow } = {}, execution, ...userJob } = data?.data ?? {};
|
||||
linkNodes(nodes);
|
||||
setNode(node);
|
||||
setFlowContext({
|
||||
userJob,
|
||||
workflow,
|
||||
nodes,
|
||||
execution,
|
||||
});
|
||||
return;
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
if (!flowContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const upstreams = useAvailableUpstreams(flowContext.nodes.find((item) => item.id === node.id));
|
||||
const upstreams = useAvailableUpstreams(flowContext?.nodes.find((item) => item.id === node.id));
|
||||
const nodeComponents = upstreams.reduce(
|
||||
(components, { type }) => Object.assign(components, instructions.get(type).components),
|
||||
{},
|
||||
);
|
||||
|
||||
return (
|
||||
return node && flowContext ? (
|
||||
<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>
|
||||
) : (
|
||||
<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 { id, node, workflow, status, updatedAt } = useRecord();
|
||||
|
||||
const { schema } = node.config ?? {};
|
||||
|
||||
const statusOption = JobStatusOptionsMap[status];
|
||||
const footerSchema = status
|
||||
? {
|
||||
@ -498,14 +550,11 @@ WorkflowTodo.Drawer = function () {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SchemaComponentContext.Provider value={{ ...ctx, designable: false }}>
|
||||
<SchemaComponentContext.Provider value={{ ...ctx, reset() {}, designable: false }}>
|
||||
<SchemaComponent
|
||||
components={{
|
||||
Tag,
|
||||
ActionBarProvider,
|
||||
ManualActionStatusProvider,
|
||||
FlowContextProvider,
|
||||
...customForm.block.components,
|
||||
}}
|
||||
schema={{
|
||||
type: 'void',
|
||||
@ -518,9 +567,7 @@ WorkflowTodo.Drawer = function () {
|
||||
properties: {
|
||||
tabs: {
|
||||
type: 'void',
|
||||
'x-decorator': 'FlowContextProvider',
|
||||
'x-component': 'Tabs',
|
||||
properties: schema,
|
||||
'x-component': 'FlowContextProvider',
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
@ -530,16 +577,14 @@ WorkflowTodo.Drawer = function () {
|
||||
},
|
||||
}}
|
||||
scope={{
|
||||
useSubmit,
|
||||
useFlowRecordFromBlock,
|
||||
...customForm.block.scope,
|
||||
}}
|
||||
/>
|
||||
</SchemaComponentContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
WorkflowTodo.Decorator = function ({ children }) {
|
||||
function Decorator({ children }) {
|
||||
const { collections, ...cm } = useCollectionManager();
|
||||
const blockProps = {
|
||||
collection: 'users_jobs',
|
||||
@ -549,7 +594,7 @@ WorkflowTodo.Decorator = function ({ children }) {
|
||||
pageSize: 20,
|
||||
sort: ['-createdAt'],
|
||||
appends: ['user', 'node', 'workflow'],
|
||||
except: ['workflow.config'],
|
||||
except: ['node.config', 'workflow.config'],
|
||||
},
|
||||
rowKey: 'id',
|
||||
showIndex: true,
|
||||
@ -564,4 +609,7 @@ WorkflowTodo.Decorator = function ({ children }) {
|
||||
<TableBlockProvider {...blockProps}>{children}</TableBlockProvider>
|
||||
</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 { Field, createForm } from '@formily/core';
|
||||
import { useForm, useFieldSchema } from '@formily/react';
|
||||
import { Field } from '@formily/core';
|
||||
import { useForm } from '@formily/react';
|
||||
import { ArrayTable } from '@formily/antd';
|
||||
|
||||
import {
|
||||
@ -14,13 +14,13 @@ import {
|
||||
SchemaInitializer,
|
||||
SchemaInitializerItemOptions,
|
||||
useCollectionManager,
|
||||
useCurrentUserContext,
|
||||
useRecord,
|
||||
} from '@nocobase/client';
|
||||
import { merge, uid } from '@nocobase/utils/client';
|
||||
|
||||
import { JOB_STATUS } from '../../../constants';
|
||||
import { lang, NAMESPACE } from '../../../locale';
|
||||
import { findSchema } from '../utils';
|
||||
import { ManualFormType } from '../SchemaConfig';
|
||||
|
||||
const FormCollectionContext = React.createContext<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 {
|
||||
title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`,
|
||||
config: {
|
||||
initializer: {
|
||||
useInitializer() {
|
||||
return {
|
||||
key: 'customForm',
|
||||
type: 'item',
|
||||
title: `{{t("Custom form", { ns: "${NAMESPACE}" })}}`,
|
||||
component: CustomFormBlockInitializer,
|
||||
};
|
||||
},
|
||||
initializers: {
|
||||
AddCustomFormField,
|
||||
@ -353,13 +331,34 @@ export default {
|
||||
components: {
|
||||
FormCollectionProvider,
|
||||
},
|
||||
parseFormOptions(root) {
|
||||
const forms = {};
|
||||
const formBlocks: any[] = findSchema(root, (item) => item['x-decorator'] === 'FormCollectionProvider');
|
||||
formBlocks.forEach((formBlock) => {
|
||||
const [formKey] = Object.keys(formBlock.properties);
|
||||
const formSchema = formBlock.properties[formKey];
|
||||
const fields = findSchema(
|
||||
formSchema.properties.grid,
|
||||
(item) => item['x-component'] === 'CollectionField',
|
||||
true,
|
||||
);
|
||||
formBlock['x-decorator-props'].collection.fields = fields.map((field) => field['x-interface-options']);
|
||||
forms[formKey] = {
|
||||
type: 'custom',
|
||||
title: formBlock['x-component-props']?.title || formKey,
|
||||
actions: findSchema(formSchema.properties.actions, (item) => item['x-component'] === 'Action').map(
|
||||
(item) => item['x-decorator-props'].value,
|
||||
),
|
||||
collection: formBlock['x-decorator-props'].collection,
|
||||
};
|
||||
});
|
||||
return forms;
|
||||
},
|
||||
},
|
||||
block: {
|
||||
scope: {
|
||||
useFormBlockProps,
|
||||
},
|
||||
scope: {},
|
||||
components: {
|
||||
FormCollectionProvider: CollectionProvider,
|
||||
},
|
||||
},
|
||||
};
|
||||
} as ManualFormType;
|
@ -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 { CollectionFieldInitializers } from '../../components/CollectionFieldInitializers';
|
||||
import { filterTypedFields } from '../../variable';
|
||||
import { filterTypedFields, useCollectionFieldOptions } from '../../variable';
|
||||
import { NAMESPACE } from '../../locale';
|
||||
import { SchemaConfig, SchemaConfigButton } from './SchemaConfig';
|
||||
import { ModeConfig } from './ModeConfig';
|
||||
import { AssigneesSelect } from './AssigneesSelect';
|
||||
import { JOB_STATUS } from '../../constants';
|
||||
|
||||
const MULTIPLE_ASSIGNED_MODE = {
|
||||
SINGLE: Symbol('single'),
|
||||
@ -97,20 +96,19 @@ export default {
|
||||
.map((formKey) => {
|
||||
const form = config.forms[formKey];
|
||||
|
||||
const fields = (form.collection?.fields ?? []).map((field) => ({
|
||||
key: field.name,
|
||||
value: field.name,
|
||||
label: field.uiSchema.title,
|
||||
title: field.uiSchema.title,
|
||||
}));
|
||||
const filteredFields = filterTypedFields(fields, types);
|
||||
return filteredFields.length
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const options = useCollectionFieldOptions({
|
||||
fields: form.collection?.fields,
|
||||
collection: form.collection,
|
||||
types,
|
||||
});
|
||||
return options.length
|
||||
? {
|
||||
key: formKey,
|
||||
value: formKey,
|
||||
label: form.title || formKey,
|
||||
title: form.title || formKey,
|
||||
children: filteredFields,
|
||||
children: options,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
@ -119,6 +117,7 @@ export default {
|
||||
return options.length ? options : null;
|
||||
},
|
||||
useInitializers(node): SchemaInitializerItemOptions | null {
|
||||
const { getCollection } = useCollectionManager();
|
||||
const formKeys = Object.keys(node.config.forms ?? {});
|
||||
if (!formKeys.length || node.config.mode) {
|
||||
return null;
|
||||
@ -127,8 +126,9 @@ export default {
|
||||
const forms = formKeys
|
||||
.map((formKey) => {
|
||||
const form = node.config.forms[formKey];
|
||||
const { fields = [] } = getCollection(form.collection);
|
||||
|
||||
return form.collection?.fields?.length
|
||||
return fields.length
|
||||
? ({
|
||||
type: 'item',
|
||||
title: form.title ?? formKey,
|
||||
|
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 {
|
||||
await this.exit(null);
|
||||
}
|
||||
await this.commit();
|
||||
}
|
||||
|
||||
public async resume(job: JobModel) {
|
||||
@ -122,7 +121,6 @@ export default class Processor {
|
||||
await this.prepare();
|
||||
const node = this.nodesMap.get(job.nodeId);
|
||||
await this.recall(node, job);
|
||||
await this.commit();
|
||||
}
|
||||
|
||||
private async commit() {
|
||||
@ -225,6 +223,7 @@ export default class Processor {
|
||||
: EXECUTION_STATUS.RESOLVED;
|
||||
this.logger.info(`execution (${this.execution.id}) all nodes finished, finishing execution...`);
|
||||
await this.execution.update({ status }, { transaction: this.transaction });
|
||||
await this.commit();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,7 @@ describe('workflow > instructions > manual', () => {
|
||||
let userAgents;
|
||||
let db: Database;
|
||||
let PostRepo;
|
||||
let CommentRepo;
|
||||
let WorkflowModel;
|
||||
let workflow;
|
||||
let UserModel;
|
||||
@ -25,6 +26,7 @@ describe('workflow > instructions > manual', () => {
|
||||
db = app.db;
|
||||
WorkflowModel = db.getCollection('workflows').model;
|
||||
PostRepo = db.getCollection('posts').repository;
|
||||
CommentRepo = db.getCollection('comments').repository;
|
||||
UserModel = db.getCollection('users').model;
|
||||
UserJobModel = db.getModel('users_jobs');
|
||||
|
||||
@ -612,5 +614,247 @@ describe('workflow > instructions > manual', () => {
|
||||
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(j2.result).toBe(2);
|
||||
});
|
||||
|
||||
it('save all forms, only reserve submitted ones', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'manual',
|
||||
config: {
|
||||
assignees: [users[0].id, users[1].id],
|
||||
forms: {
|
||||
f1: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING] },
|
||||
f2: { actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const UserJobModel = db.getModel('users_jobs');
|
||||
const pendingJobs = await UserJobModel.findAll({
|
||||
order: [['userId', 'ASC']],
|
||||
});
|
||||
expect(pendingJobs.length).toBe(2);
|
||||
|
||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||
filterByTk: pendingJobs[0].get('id'),
|
||||
values: {
|
||||
status: JOB_STATUS.PENDING,
|
||||
result: { f1: { number: 1 } },
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(202);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const [e1] = await workflow.getExecutions();
|
||||
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
||||
const [j1] = await e1.getJobs({ order: [['createdAt', 'ASC']] });
|
||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
||||
expect(j1.result).toMatchObject({ f1: { number: 1 } });
|
||||
|
||||
const res2 = await userAgents[0].resource('users_jobs').submit({
|
||||
filterByTk: pendingJobs[0].get('id'),
|
||||
values: {
|
||||
status: JOB_STATUS.PENDING,
|
||||
result: { f2: { number: 2 } },
|
||||
},
|
||||
});
|
||||
expect(res2.status).toBe(202);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const [e2] = await workflow.getExecutions();
|
||||
expect(e2.status).toBe(EXECUTION_STATUS.STARTED);
|
||||
const [j2] = await e2.getJobs({ order: [['createdAt', 'ASC']] });
|
||||
expect(j2.status).toBe(JOB_STATUS.PENDING);
|
||||
expect(j2.result).toMatchObject({
|
||||
f1: { number: 1 },
|
||||
f2: { number: 2 },
|
||||
});
|
||||
|
||||
const res3 = await userAgents[0].resource('users_jobs').submit({
|
||||
filterByTk: pendingJobs[0].get('id'),
|
||||
values: {
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: { f2: { number: 3 } },
|
||||
},
|
||||
});
|
||||
expect(res3.status).toBe(202);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const [e3] = await workflow.getExecutions();
|
||||
expect(e3.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||
const [j3] = await e3.getJobs({ order: [['createdAt', 'ASC']] });
|
||||
expect(j3.status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(j3.result).toMatchObject({ f2: { number: 3 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('forms', () => {
|
||||
describe('create', () => {
|
||||
it('create as configured', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'manual',
|
||||
config: {
|
||||
assignees: [users[0].id],
|
||||
forms: {
|
||||
f1: {
|
||||
type: 'create',
|
||||
actions: [JOB_STATUS.RESOLVED],
|
||||
collection: 'comments',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const UserJobModel = db.getModel('users_jobs');
|
||||
const pendingJobs = await UserJobModel.findAll({
|
||||
order: [['userId', 'ASC']],
|
||||
});
|
||||
expect(pendingJobs.length).toBe(1);
|
||||
|
||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||
filterByTk: pendingJobs[0].get('id'),
|
||||
values: {
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: { f1: { status: 1 } },
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(202);
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
const [e1] = await workflow.getExecutions();
|
||||
expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||
const [j1] = await e1.getJobs();
|
||||
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(j1.result).toMatchObject({ f1: { status: 1 } });
|
||||
|
||||
const comments = await CommentRepo.find();
|
||||
expect(comments.length).toBe(1);
|
||||
expect(comments[0]).toMatchObject({ status: 1 });
|
||||
});
|
||||
|
||||
it('save first and then commit', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'manual',
|
||||
config: {
|
||||
assignees: [users[0].id],
|
||||
forms: {
|
||||
f1: {
|
||||
type: 'create',
|
||||
actions: [JOB_STATUS.RESOLVED, JOB_STATUS.PENDING],
|
||||
collection: 'comments',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const UserJobModel = db.getModel('users_jobs');
|
||||
const pendingJobs = await UserJobModel.findAll({
|
||||
order: [['userId', 'ASC']],
|
||||
});
|
||||
expect(pendingJobs.length).toBe(1);
|
||||
|
||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||
filterByTk: pendingJobs[0].get('id'),
|
||||
values: {
|
||||
status: JOB_STATUS.PENDING,
|
||||
result: { f1: { status: 1 } },
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(202);
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const [e1] = await workflow.getExecutions();
|
||||
expect(e1.status).toBe(EXECUTION_STATUS.STARTED);
|
||||
const [j1] = await e1.getJobs();
|
||||
expect(j1.status).toBe(JOB_STATUS.PENDING);
|
||||
expect(j1.result).toMatchObject({ f1: { status: 1 } });
|
||||
|
||||
const c1 = await CommentRepo.find();
|
||||
expect(c1.length).toBe(0);
|
||||
|
||||
const res2 = await userAgents[0].resource('users_jobs').submit({
|
||||
filterByTk: pendingJobs[0].get('id'),
|
||||
values: {
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: { f1: { status: 1 } },
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const [e2] = await workflow.getExecutions();
|
||||
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||
const [j2] = await e2.getJobs();
|
||||
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(j2.result).toMatchObject({ f1: { status: 1 } });
|
||||
|
||||
const c2 = await CommentRepo.find();
|
||||
expect(c2.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('update as configured', async () => {
|
||||
const n1 = await workflow.createNode({
|
||||
type: 'manual',
|
||||
config: {
|
||||
assignees: [users[0].id],
|
||||
forms: {
|
||||
f1: {
|
||||
type: 'update',
|
||||
actions: [JOB_STATUS.RESOLVED],
|
||||
collection: 'posts',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||
|
||||
await sleep(500);
|
||||
|
||||
const UserJobModel = db.getModel('users_jobs');
|
||||
const pendingJobs = await UserJobModel.findAll({
|
||||
order: [['userId', 'ASC']],
|
||||
});
|
||||
expect(pendingJobs.length).toBe(1);
|
||||
|
||||
const res1 = await userAgents[0].resource('users_jobs').submit({
|
||||
filterByTk: pendingJobs[0].get('id'),
|
||||
values: {
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: { f1: { title: 't2' } },
|
||||
},
|
||||
});
|
||||
expect(res1.status).toBe(202);
|
||||
|
||||
await sleep(1000);
|
||||
|
||||
const [e2] = await workflow.getExecutions();
|
||||
expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||
const [j1] = await e2.getJobs();
|
||||
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(j1.result).toMatchObject({ f1: { title: 't2' } });
|
||||
|
||||
const postsAfter = await PostRepo.find();
|
||||
expect(postsAfter.length).toBe(1);
|
||||
expect(postsAfter[0]).toMatchObject({ title: 't2' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { Context, utils } from '@nocobase/actions';
|
||||
|
||||
import Plugin from '../..';
|
||||
import { EXECUTION_STATUS, JOB_STATUS } from '../../constants';
|
||||
import ManualInstruction from '.';
|
||||
|
||||
export async function submit(context: Context, next) {
|
||||
const repository = utils.getRepositoryFromParams(context);
|
||||
@ -12,60 +14,58 @@ export async function submit(context: Context, next) {
|
||||
}
|
||||
|
||||
const plugin: Plugin = context.app.pm.get('workflow') as Plugin;
|
||||
const instruction = plugin.instructions.get('manual') as ManualInstruction;
|
||||
|
||||
const userJob = await context.db.sequelize.transaction(async (transaction) => {
|
||||
const instance = await repository.findOne({
|
||||
const userJob = await repository.findOne({
|
||||
filterByTk,
|
||||
// filter: {
|
||||
// userId: currentUser?.id
|
||||
// },
|
||||
appends: ['job', 'node', 'execution', 'workflow'],
|
||||
context,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!instance) {
|
||||
if (!userJob) {
|
||||
return context.throw(404);
|
||||
}
|
||||
|
||||
const { forms = {} } = instance.node.config;
|
||||
const [form] = Object.keys(values.result ?? {});
|
||||
const { forms = {} } = userJob.node.config;
|
||||
const [formKey] = 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)
|
||||
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);
|
||||
}
|
||||
|
||||
instance.execution.workflow = instance.workflow;
|
||||
const processor = plugin.createProcessor(instance.execution, { transaction });
|
||||
userJob.execution.workflow = userJob.workflow;
|
||||
const processor = plugin.createProcessor(userJob.execution);
|
||||
await processor.prepare();
|
||||
|
||||
const assignees = processor.getParsedValue(instance.node.config.assignees ?? []);
|
||||
if (!assignees.includes(currentUser.id) || instance.userId !== currentUser.id) {
|
||||
// NOTE: validate assignee
|
||||
const assignees = processor.getParsedValue(userJob.node.config.assignees ?? []);
|
||||
if (!assignees.includes(currentUser.id) || userJob.userId !== currentUser.id) {
|
||||
return context.throw(403);
|
||||
}
|
||||
|
||||
// NOTE: validate assignee
|
||||
await instance.update(
|
||||
{
|
||||
userJob.set({
|
||||
status: values.status,
|
||||
result: values.result,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
return instance;
|
||||
result: values.status ? values.result : Object.assign(userJob.result ?? {}, values.result),
|
||||
});
|
||||
|
||||
// await transaction.commit();
|
||||
const handler = instruction.formTypes.get(forms[formKey].type);
|
||||
if (handler && userJob.status) {
|
||||
await handler.call(instruction, userJob, forms[formKey], processor);
|
||||
}
|
||||
|
||||
await userJob.save({ transaction: processor.transaction });
|
||||
|
||||
await processor.exit(userJob.job);
|
||||
|
||||
context.body = userJob;
|
||||
context.status = 202;
|
||||
|
@ -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 { HandlerType } from '@nocobase/resourcer';
|
||||
import { Registry } from '@nocobase/utils';
|
||||
|
||||
import Plugin from '../..';
|
||||
import { JOB_STATUS } from '../../constants';
|
||||
@ -8,6 +9,7 @@ import jobsCollection from './collecions/jobs';
|
||||
import usersCollection from './collecions/users';
|
||||
import usersJobsCollection from './collecions/users_jobs';
|
||||
import { submit } from './actions';
|
||||
import initFormTypes, { FormHandler } from './forms';
|
||||
|
||||
type FormType = {
|
||||
type: 'custom' | 'create' | 'update';
|
||||
@ -89,6 +91,8 @@ function getMode(mode) {
|
||||
}
|
||||
|
||||
export default class implements Instruction {
|
||||
formTypes = new Registry<FormHandler>();
|
||||
|
||||
constructor(protected plugin: Plugin) {
|
||||
plugin.db.collection(usersJobsCollection);
|
||||
plugin.db.extendCollection(usersCollection);
|
||||
@ -116,6 +120,8 @@ export default class implements Instruction {
|
||||
submit,
|
||||
},
|
||||
});
|
||||
|
||||
initFormTypes(this);
|
||||
}
|
||||
|
||||
async run(node, prevJob, processor) {
|
||||
@ -158,15 +164,18 @@ export default class implements Instruction {
|
||||
jobId: job.id,
|
||||
},
|
||||
group: ['status'],
|
||||
transaction: processor.transaction,
|
||||
});
|
||||
|
||||
const submitted = distribution.reduce(
|
||||
(count, item) => (item.status !== JOB_STATUS.PENDING ? count + item.count : count),
|
||||
0,
|
||||
);
|
||||
const status = job.status || (getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING);
|
||||
const result = mode ? (submitted || 0) / assignees.length : job.latestUserJob?.result ?? job.result;
|
||||
processor.logger.debug(`manual resume job and next status: ${status}`);
|
||||
job.set({
|
||||
status: job.status || (getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING),
|
||||
status,
|
||||
result,
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user