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:
Junyi 2023-04-19 08:41:18 +07:00 committed by GitHub
parent 05fd419729
commit a5b4684a48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 916 additions and 449 deletions

View File

@ -115,6 +115,7 @@ export default {
"Submit": "提交",
"Close": "关闭",
"Set the data scope": "设置数据范围",
"Block": "区块",
"Data blocks": "数据区块",
"Filter blocks": "筛选区块",
"Table": "表格",

View File

@ -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',

View File

@ -135,6 +135,8 @@ export default {
'Any pass': '任意通过',
'Anyone pass': '任何一人通过即通过',
'Field name existed in form': '表单中已有对应标识的字段',
'Custom form': '自定义表单',
'Data record': '数据记录',
'Create record': '新增数据',
'Update record': '更新数据',

View File

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

View File

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

View File

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

View File

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

View File

@ -160,6 +160,7 @@ export default {
return {
type: 'item',
key: 'triggerData',
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
component: CollectionBlockInitializer,
collection: config.collection,

View File

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

View File

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

View File

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

View File

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