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