feat(form-block): data templates (#1704)

* feat(Form): support to select existing data as template

* refactor: extract useDataTemplates

* feat(Form): support to use template

* fix: template switch

* fix: fix association field

* fix: filter fields

* fix: fix unselected default value

* fix: avoid errors

* refactor: remove useless code

* refactor: move templateSelect to FormBlockProvider

* feat: add a checkbox to toggle template selector

* feat: change the options order

* feat: hide Collection option when no inherit

* fix: optimize the label text

* fix: should empty form

* fix: should hide configuration when is not added

* chore: change text

* fix: template selector not displayed

* feat: optimize template

* feat: data template middleware

* fix: template select

* fix: default

* fix: fields

* feat: field delete button changed from hidden to disabled

* fix: improve code

* fix: prefix error

* fix: items

* feat: use Tree

* fix: maxDepth

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
被雨水过滤的空气-Rairn 2023-04-16 14:22:46 +08:00 committed by GitHub
parent 67dc21baf7
commit 69bc18e166
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1074 additions and 76 deletions

View File

@ -1,11 +1,12 @@
import { createForm } from '@formily/core';
import { useField } from '@formily/react';
import { RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import { Spin } from 'antd';
import isEmpty from 'lodash/isEmpty';
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { useCollection } from '../collection-manager';
import { RecordProvider, useRecord } from '../record-provider';
import { useActionContext, useDesignable } from '../schema-component';
import { Templates as DataTemplateSelect } from '../schema-component/antd/form-v2/Templates';
import { BlockProvider, useBlockRequestContext } from './BlockProvider';
export const FormBlockContext = createContext<any>({});
@ -40,10 +41,14 @@ const InternalFormBlockProvider = (props) => {
>
{readPretty ? (
<RecordProvider parent={isEmpty(record?.__parent) ? record : record?.__parent} record={service?.data?.data}>
<div ref={formBlockRef}>{props.children}</div>
<div ref={formBlockRef}>
<RenderChildrenWithDataTemplates form={form} />
</div>
</RecordProvider>
) : (
<div ref={formBlockRef}>{props.children}</div>
<div ref={formBlockRef}>
<RenderChildrenWithDataTemplates form={form} />
</div>
)}
</FormBlockContext.Provider>
);
@ -108,3 +113,27 @@ export const useFormBlockProps = () => {
form: ctx.form,
};
};
const RenderChildrenWithDataTemplates = ({ form }) => {
const FieldSchema = useFieldSchema();
const { findComponent } = useDesignable();
const field = useField();
const Component = findComponent(field.component?.[0]) || React.Fragment;
return (
<Component {...field.componentProps}>
<DataTemplateSelect style={{ marginBottom: 18 }} form={form} />
<RecursionField schema={FieldSchema} onlyRenderProperties />
</Component>
);
};
export const findFormBlock = (schema: Schema) => {
while (schema) {
if (schema['x-decorator'] === 'FormBlockProvider') {
return schema;
}
schema = schema.parent;
}
return null;
};

View File

@ -194,7 +194,7 @@ export const collectionFieldSchema: ISchema = {
delete: {
type: 'void',
title: '{{ t("Delete") }}',
'x-visible': '{{cm.useDeleteButtonVisible()}}',
'x-disabled': '{{cm.useDeleteButtonDisabled()}}',
'x-component': 'Action.Link',
'x-component-props': {
confirm: {

View File

@ -3,9 +3,9 @@ import { message } from 'antd';
import omit from 'lodash/omit';
import { useEffect } from 'react';
import { useCollection, useCollectionManager } from '.';
import { useCompile } from '..';
import { useRequest } from '../api-client';
import { useRecord } from '../record-provider';
import { useCompile } from '..';
import { useActionContext } from '../schema-component';
import { useFilterFieldOptions, useFilterFieldProps } from '../schema-component/antd/filter/useFilterActionProps';
import { useResourceActionContext, useResourceContext } from './ResourceActionProvider';
@ -377,10 +377,10 @@ export const useDestroyActionAndRefreshCM = () => {
};
};
export const useDeleteButtonVisible = () => {
export const useDeleteButtonDisabled = () => {
const { interface: i, deletable = true } = useRecord();
return deletable && i !== 'id';
return !deletable || i === 'id';
};
export const useBulkDestroyActionAndRefreshCM = () => {

View File

@ -1,7 +1,6 @@
import { clone } from '@formily/shared';
import { CascaderProps } from 'antd';
import _ from 'lodash';
import { reduce, unionBy, uniq, uniqBy } from 'lodash';
import _, { reduce, unionBy, uniq, uniqBy } from 'lodash';
import { useContext } from 'react';
import { useCompile } from '../../schema-component';
import { CollectionManagerContext } from '../context';
@ -108,9 +107,31 @@ export const useCollectionManager = () => {
* Max depth of recursion
*/
maxDepth?: number;
allowAllTypes?: boolean;
/**
*
*/
exceptInterfaces?: string[];
/**
* field value . a.b.c
*/
prefixFieldValue?: string;
/**
* 使 prefixFieldValue field value
*/
usePrefix?: boolean;
},
) => {
const { association = false, cached = {}, collectionNames = [collectionName], maxDepth = 1 } = opts || {};
const {
association = false,
cached = {},
collectionNames = [collectionName],
maxDepth = 1,
allowAllTypes = false,
exceptInterfaces = [],
prefixFieldValue = '',
usePrefix = false,
} = opts || {};
if (collectionNames.length - 1 > maxDepth) {
return;
@ -129,14 +150,16 @@ export const useCollectionManager = () => {
?.filter(
(field) =>
field.interface &&
(type.includes(field.type) ||
!exceptInterfaces.includes(field.interface) &&
(allowAllTypes ||
type.includes(field.type) ||
(association && field.target && field.target !== collectionName && Array.isArray(association)
? association.includes(field.interface)
: false)),
)
?.map((field) => {
const result: CascaderProps<any>['options'][0] = {
value: field.name,
value: usePrefix && prefixFieldValue ? `${prefixFieldValue}.${field.name}` : field.name,
label: compile(field?.uiSchema?.title) || field.name,
...field,
};
@ -147,6 +170,12 @@ export const useCollectionManager = () => {
...opts,
cached,
collectionNames: [...collectionNames, field.target],
prefixFieldValue: usePrefix
? prefixFieldValue
? `${prefixFieldValue}.${field.name}`
: field.name
: '',
usePrefix,
});
if (!result.children?.length) {
return null;
@ -161,6 +190,50 @@ export const useCollectionManager = () => {
return options;
};
const getCollection = (name: any) => {
if (typeof name !== 'string') {
return name;
}
return collections?.find((collection) => collection.name === name);
};
// 获取当前 collection 继承链路上的所有 collection
const getAllCollectionsInheritChain = (collectionName: string) => {
const collectionsInheritChain = [collectionName];
const getInheritChain = (name: string) => {
const collection = getCollection(name);
if (collection) {
const { inherits } = collection;
const children = getChildrenCollections(name);
// 搜寻祖先表
if (inherits) {
for (let index = 0; index < inherits.length; index++) {
const collectionKey = inherits[index];
if (collectionsInheritChain.includes(collectionKey)) {
continue;
}
collectionsInheritChain.push(collectionKey);
getInheritChain(collectionKey);
}
}
// 搜寻后代表
if (children) {
for (let index = 0; index < children.length; index++) {
const collectionKey = children[index].name;
if (collectionsInheritChain.includes(collectionKey)) {
continue;
}
collectionsInheritChain.push(collectionKey);
getInheritChain(collectionKey);
}
}
}
return collectionsInheritChain;
};
return getInheritChain(collectionName);
};
return {
service,
interfaces,
@ -176,12 +249,7 @@ export const useCollectionManager = () => {
getCollectionFields,
getCollectionFieldsOptions,
getCurrentCollectionFields,
getCollection(name: any) {
if (typeof name !== 'string') {
return name;
}
return collections?.find((collection) => collection.name === name);
},
getCollection,
getCollectionJoinField(name: string) {
if (!name) {
return;
@ -227,5 +295,6 @@ export const useCollectionManager = () => {
});
});
},
getAllCollectionsInheritChain,
};
};

View File

@ -454,6 +454,7 @@ export default {
"Turn pages": "Turn pages",
"Others": "Others",
"Save as template": "Save as template",
"Save as block template": "Save as block template",
"Block templates": "Block templates",
"Convert reference to duplicate": "Convert reference to duplicate",
"Template name": "Template name",

View File

@ -383,6 +383,7 @@ export default {
"Turn pages": "ページをめくる",
"Others": "Others",
"Save as template": "テンプレートとして保存",
"Save as block template": "ブロックテンプレートとして保存",
"Block templates": "ブロックテンプレート",
"Convert reference to duplicate": "参照を複製に変換",
"Template name": "テンプレート名",

View File

@ -416,6 +416,7 @@ export default {
"Turn pages": "Virar páginas",
"Others": "Outros",
"Save as template": "Salvar como modelo",
"Save as block template": "Salvar como modelo de bloco",
"Block templates": "Modelos de bloco",
"Convert reference to duplicate": "Converter referência em duplicado",
"Template name": "Nome do modelo",

View File

@ -327,6 +327,7 @@ export default {
"Turn pages": "Перелистывать страницы",
"Others": "Другие",
"Save as template": "Сохранить как шаблон",
"Save as block template": "Сохранить как шаблон Блока",
"Block templates": "Шаблоны Блока",
"Convert reference to duplicate": "Преобразовать ссылку в дубликат",
"Template name": "Имя Шаблона",

View File

@ -326,6 +326,7 @@ export default {
"Turn pages": "Sayfaları çevir",
"Others": "Diğerleri",
"Save as template": "Şablon olarak kaydet",
"Save as block template": "Blok şablonu olarak kaydet",
"Block templates": "Blok şablonları",
"Convert reference to duplicate": "Referansı kopyaya dönüştür",
"Template name": "Şablon adı",

View File

@ -490,6 +490,7 @@ export default {
"Turn pages": "翻页",
"Others": "其他",
"Save as template": "保存为模板",
"Save as block template": "保存为区块模板",
"Block templates": "区块模板",
"Convert reference to duplicate": "模板引用转为复制",
"Template name": "模板名称",

View File

@ -1,6 +1,5 @@
import { useFieldSchema } from '@formily/react';
import { useCallback, useMemo } from 'react';
import { useFormBlockContext } from '../../../block-provider';
import { mergeFilter } from '../../../block-provider/SharedFilterProvider';
import { useCollection, useCollectionManager } from '../../../collection-manager';
import { useRecord } from '../../../record-provider';

View File

@ -81,12 +81,12 @@ FormItem.Designer = () => {
const { dn, refresh, insertAdjacent } = useDesignable();
const compile = useCompile();
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
const targetCollection = getCollection(collectionField.target);
const targetCollection = getCollection(collectionField?.target);
const interfaceConfig = getInterface(collectionField?.interface);
const validateSchema = interfaceConfig?.['validateSchema']?.(fieldSchema);
const originalTitle = collectionField?.uiSchema?.title;
const targetFields = collectionField?.target
? getCollectionFields(collectionField.target)
? getCollectionFields(collectionField?.target)
: getCollectionFields(collectionField?.targetCollection) ?? [];
const fieldComponentOptions = useFieldComponentOptions();
const isSubFormAssociationField = field.address.segments.includes('__form_grid');
@ -402,11 +402,11 @@ FormItem.Designer = () => {
title: t('Set default value'),
properties: {
default: {
...collectionField.uiSchema,
...collectionField?.uiSchema,
name: 'default',
title: t('Default value'),
'x-decorator': 'FormItem',
default: fieldSchema.default || collectionField.defaultValue,
default: fieldSchema.default || collectionField?.defaultValue,
},
},
} as ISchema
@ -434,7 +434,7 @@ FormItem.Designer = () => {
value={fieldSchema['x-component']}
onChange={(type) => {
const schema: ISchema = {
name: collectionField.name,
name: collectionField?.name,
type: 'void',
required: fieldSchema['required'],
description: fieldSchema['description'],

View File

@ -1,10 +1,12 @@
import { ArrayItems } from '@formily/antd';
import { ISchema, useField, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDetailsBlockContext } from '../../../block-provider/DetailsBlockProvider';
import { useCollection } from '../../../collection-manager';
import { useCollectionFilterOptions, useSortFields } from '../../../collection-manager/action-hooks';
import { useRecord } from '../../../record-provider';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useSchemaTemplate } from '../../../schema-templates';
import { useDesignable } from '../../hooks';
@ -16,17 +18,20 @@ export const FormDesigner = () => {
const template = useSchemaTemplate();
const fieldSchema = useFieldSchema();
const defaultResource = fieldSchema?.['x-decorator-props']?.resource;
const record = useRecord();
return (
<GeneralSchemaDesigner template={template} title={title || name}>
{/* <SchemaSettings.Template componentName={'FormItem'} collectionName={name} /> */}
<SchemaSettings.BlockTitleItem />
<SchemaSettings.LinkageRules collectionName={name} />
{_.isEmpty(record) ? <SchemaSettings.DataTemplates collectionName={name} /> : null}
<SchemaSettings.Divider />
<SchemaSettings.FormItemTemplate
componentName={'FormItem'}
collectionName={name}
resourceName={defaultResource}
/>
<SchemaSettings.LinkageRules collectionName={name} />
<SchemaSettings.Divider />
<SchemaSettings.Remove
removeParentsIfNoChildren

View File

@ -1,12 +1,11 @@
import { FormLayout } from '@formily/antd';
import { createForm, Field, onFormInputChange, onFieldReact, onFieldInit, onFieldChange } from '@formily/core';
import { FieldContext, FormContext, observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { Field, createForm, onFieldChange, onFieldInit, onFieldReact, onFormInputChange } from '@formily/core';
import { FieldContext, FormContext, RecursionField, observer, useField, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import { ConfigProvider, Spin } from 'antd';
import React, { useEffect, useMemo } from 'react';
import { useActionContext } from '..';
import { useAttach, useComponent } from '../..';
import { gridRowColWrap } from '../../../schema-initializer';
import { useProps } from '../../hooks/useProps';
import { linkageMergeAction } from './utils';
@ -167,23 +166,26 @@ const WithoutForm = (props) => {
);
};
export const Form: React.FC<FormProps> & { Designer?: any; FilterDesigner?: any; ReadPrettyDesigner?: any } = observer(
(props) => {
const field = useField<Field>();
const { form, disabled, ...others } = useProps(props);
const formDisabled = disabled || field.disabled;
return (
<ConfigProvider componentDisabled={formDisabled}>
<form>
<Spin spinning={field.loading || false}>
{form ? (
<WithForm form={form} {...others} disabled={formDisabled} />
) : (
<WithoutForm {...others} disabled={formDisabled} />
)}
</Spin>
</form>
</ConfigProvider>
);
},
);
export const Form: React.FC<FormProps> & {
Designer?: any;
FilterDesigner?: any;
ReadPrettyDesigner?: any;
Templates?: any;
} = observer((props) => {
const field = useField<Field>();
const { form, disabled, ...others } = useProps(props);
const formDisabled = disabled || field.disabled;
return (
<ConfigProvider componentDisabled={formDisabled}>
<form>
<Spin spinning={field.loading || false}>
{form ? (
<WithForm form={form} {...others} disabled={formDisabled} />
) : (
<WithoutForm {...others} disabled={formDisabled} />
)}
</Spin>
</form>
</ConfigProvider>
);
});

View File

@ -0,0 +1,107 @@
import { useFieldSchema } from '@formily/react';
import { Select } from 'antd';
import _ from 'lodash';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../api-client';
import { findFormBlock } from '../../../block-provider';
interface ITemplate {
items: {
key: string;
title: string;
collection: string;
dataId: number;
fields: string[];
default?: boolean;
}[];
/** 是否在 Form 区块显示模板选择器 */
display: boolean;
}
const useDataTemplates = () => {
const fieldSchema = useFieldSchema();
const { t } = useTranslation();
const { items = [], display = true } = findDataTemplates(fieldSchema);
const templates: any = [
{
key: 'none',
title: t('None'),
},
].concat(items.map<any>((t, i) => ({ key: i, ...t })));
const defaultTemplate = items.find((item) => item.default);
return {
templates,
display,
defaultTemplate,
enabled: items.length > 0,
};
};
export const Templates = ({ style = {}, form }) => {
const { templates, display, enabled, defaultTemplate } = useDataTemplates();
const [value, setValue] = React.useState(defaultTemplate?.key || 'none');
const api = useAPIClient();
const { t } = useTranslation();
useEffect(() => {
if (defaultTemplate) {
fetchTemplateData(api, defaultTemplate).then((data) => {
if (form) {
form.values = data;
}
});
}
}, []);
const handleChange = useCallback(async (value, option) => {
setValue(value);
if (option.key !== 'none') {
if (form) {
form.values = await fetchTemplateData(api, option);
}
} else {
form?.reset();
}
}, []);
if (!enabled || !display) {
return null;
}
return (
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: '#f8f8f8', padding: '1em', ...style }}>
<label style={{ fontSize: 14, fontWeight: 'bold', whiteSpace: 'nowrap', marginRight: 8 }}>
{t('Data template')}:{' '}
</label>
<Select
// style={{ width: '8em' }}
options={templates}
fieldNames={{ label: 'title', value: 'key' }}
value={value}
onChange={handleChange}
/>
</div>
);
};
function findDataTemplates(fieldSchema): ITemplate {
const formSchema = findFormBlock(fieldSchema);
if (formSchema) {
return _.cloneDeep(formSchema['x-data-templates']) || {};
}
return {} as ITemplate;
}
async function fetchTemplateData(api, template: { collection: string; dataId: number; fields: string[] }) {
return api
.resource(template.collection)
.get({
filterByTk: template.dataId,
fields: template.fields,
isTemplate: true,
})
.then((data) => {
return data.data?.data;
});
}

View File

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

View File

@ -1,12 +1,11 @@
import { LoadingOutlined } from '@ant-design/icons';
import { connect, mapProps, mapReadPretty } from '@formily/react';
import { SelectProps } from 'antd';
import Item from 'antd/lib/list/Item';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { ResourceActionOptions, useRequest } from '../../../api-client';
import { mergeFilter } from '../../../block-provider/SharedFilterProvider';
import { useCompile } from '../../hooks';
import { defaultFieldNames, Select } from '../select';
import { Select, defaultFieldNames } from '../select';
import { ReadPretty } from './ReadPretty';
export type RemoteSelectProps<P = any> = SelectProps<P, any> & {
@ -15,12 +14,22 @@ export type RemoteSelectProps<P = any> = SelectProps<P, any> & {
target: string;
wait?: number;
manual?: boolean;
mapOptions?: (data: any) => RemoteSelectProps['fieldNames'];
service: ResourceActionOptions<P>;
};
const InternalRemoteSelect = connect(
(props: RemoteSelectProps) => {
const { fieldNames = {}, service = {}, wait = 300, value, objectValue, manual = true, ...others } = props;
const {
fieldNames = {},
service = {},
wait = 300,
value,
objectValue,
manual = true,
mapOptions,
...others
} = props;
const compile = useCompile();
const firstRun = useRef(false);
@ -73,6 +82,9 @@ const InternalRemoteSelect = connect(
const getOptionsByFieldNames = useCallback(
(item) => {
if (mapOptions) {
return mapOptions(item);
}
return Object.keys(fieldNames).reduce((obj, key) => {
const value = item[fieldNames[key]];
if (value) {

View File

@ -1,5 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCompile } from '../../schema-component';
import { SchemaInitializer } from '../SchemaInitializer';
import {
gridRowColWrap,
@ -10,7 +11,6 @@ import {
useFormItemInitializerFields,
useInheritsFormItemInitializerFields,
} from '../utils';
import { useCompile } from '../../schema-component';
// 表单里配置字段
export const FormItemInitializers = (props: any) => {

View File

@ -1,9 +1,9 @@
import React from "react";
import { ISchema } from "@formily/react";
import { ISchema } from '@formily/react';
import React from 'react';
import { InitializerWithSwitch } from "./InitializerWithSwitch";
import { InitializerWithSwitch } from './InitializerWithSwitch';
export const CollectionFieldInitializer = (props) => {
const schema: ISchema = {};
return <InitializerWithSwitch {...props} schema={schema} type={'x-collection-field'} />;
};
const schema: ISchema = {};
return <InitializerWithSwitch {...props} schema={schema} type={'x-collection-field'} />;
};

View File

@ -1,5 +1,5 @@
import React from 'react';
import { merge } from '@formily/shared';
import React from 'react';
import { SchemaInitializer } from '..';
import { useCurrentSchema } from '../utils';

View File

@ -0,0 +1,178 @@
import { connect, mapProps, observer } from '@formily/react';
import { Tree as AntdTree } from 'antd';
import React from 'react';
import { AssociationSelect, SchemaComponent } from '../../schema-component';
import { AsDefaultTemplate } from './components/AsDefaultTemplate';
import { ArrayCollapse } from './components/DataTemplateTitle';
import { useCollectionState } from './hooks/useCollectionState';
const Tree = connect(
AntdTree,
mapProps({
value: 'checkedKeys',
dataSource: 'treeData',
onInput: 'onCheck',
}),
);
export const FormDataTemplates = observer((props: any) => {
const { useProps } = props;
const { defaultValues, collectionName } = useProps();
const { collectionList, getEnableFieldTree } = useCollectionState(collectionName);
return (
<SchemaComponent
components={{ ArrayCollapse }}
scope={{ getEnableFieldTree }}
schema={{
type: 'object',
properties: {
items: {
type: 'array',
default: defaultValues?.items,
'x-component': 'ArrayCollapse',
'x-decorator': 'FormItem',
'x-component-props': {
accordion: true,
},
items: {
type: 'object',
'x-component': 'ArrayCollapse.CollapsePanel',
'x-component-props': {
extra: [<AsDefaultTemplate />],
},
properties: {
layout: {
type: 'void',
'x-component': 'FormLayout',
'x-component-props': {
layout: 'vertical',
},
// TODO: 翻译
properties: {
collection: {
type: 'string',
title: '{{ t("Collection") }}',
required: true,
description: '当前表有继承关系时,可选择继承链路上的表作为模板来源',
default: collectionName,
'x-display': collectionList.length > 1 ? 'visible' : 'hidden',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
options: collectionList,
},
},
dataId: {
type: 'number',
title: '{{ t("Template Data") }}',
required: true,
description: '选择一条已有的数据作为表单的初始化数据',
'x-decorator': 'FormItem',
'x-component': AssociationSelect,
'x-component-props': {
service: {
resource: collectionName,
},
action: 'list',
multiple: false,
objectValue: false,
manual: false,
mapOptions: (item) => {
// TODO: 应该使用 item.title 字段的值作为 label
return {
label: item.id,
value: item.id,
};
},
fieldNames: {
label: 'label',
value: 'value',
},
},
'x-reactions': [
{
dependencies: ['.collection'],
fulfill: {
state: {
disabled: '{{ !$deps[0] }}',
componentProps: {
service: {
resource: '{{ $deps[0] }}',
},
},
},
},
},
],
},
fields: {
type: 'array',
title: '{{ t("Data fields") }}',
required: true,
description: '仅选择的字段才会作为表单的初始化数据',
'x-decorator': 'FormItem',
'x-component': Tree,
'x-component-props': {
treeData: [],
checkable: true,
selectable: false,
rootStyle: {
padding: '8px 0',
border: '1px solid #d9d9d9',
borderRadius: '2px',
maxHeight: '30vh',
overflow: 'auto',
margin: '2px 0',
},
},
'x-reactions': [
{
dependencies: ['.collection'],
fulfill: {
state: {
disabled: '{{ !$deps[0] }}',
componentProps: {
treeData: '{{ getEnableFieldTree($deps[0]) }}',
},
},
},
},
],
},
},
},
remove: {
type: 'void',
'x-component': 'ArrayCollapse.Remove',
},
moveUp: {
type: 'void',
'x-component': 'ArrayCollapse.MoveUp',
},
moveDown: {
type: 'void',
'x-component': 'ArrayCollapse.MoveDown',
},
},
},
properties: {
add: {
type: 'void',
title: '{{ t("Add template") }}',
'x-component': 'ArrayCollapse.Addition',
},
},
},
display: {
type: 'boolean',
'x-content': '{{ t("Display data template selector") }}',
default: defaultValues?.display !== false,
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
},
},
}}
/>
);
});

View File

@ -0,0 +1,23 @@
import { Tag } from 'antd';
import React from 'react';
export const TreeNode = (props) => {
const { tag, type } = props;
const text = {
reference: 'Reference',
duplicate: 'Duplicate',
preloading: 'Preloading',
};
const colors = {
reference: 'blue',
duplicate: 'green',
preloading: 'cyan',
};
return (
<div>
<Tag color={colors[type]}>
<span>{tag}</span> ({text[type]})
</Tag>
</div>
);
};

View File

@ -0,0 +1,38 @@
import { ArrayBase } from '@formily/antd';
import { Switch } from 'antd';
import React from 'react';
import { useTranslation } from 'react-i18next';
export const AsDefaultTemplate = React.forwardRef((props: any) => {
const array = ArrayBase.useArray();
const index = ArrayBase.useIndex(props.index);
const { t } = useTranslation();
return (
<Switch
{...props}
checkedChildren={t('Default')}
unCheckedChildren={t('Default')}
checked={array?.field?.value[index].default}
style={{
transition: 'all 0.25s ease-in-out',
color: 'rgba(0, 0, 0, 0.8)',
fontSize: 16,
marginLeft: 6,
marginBottom: 3,
}}
onChange={(checked, e) => {
e.stopPropagation();
array.field.value.splice(index, 1, { ...array?.field?.value[index], default: checked });
array.field.value.forEach((item, i) => {
if (i !== index) {
array.field.value.splice(i, 1, { ...array?.field?.value[i], default: false });
}
});
}}
onClick={(checked, e) => {
e.stopPropagation();
}}
/>
);
});

View File

@ -0,0 +1,271 @@
import { CopyOutlined } from '@ant-design/icons';
import { ArrayBase, ArrayBaseMixins } from '@formily/antd';
import { ArrayField } from '@formily/core';
import { ISchema, RecursionField, observer, useField, useFieldSchema } from '@formily/react';
import { toArr, uid } from '@formily/shared';
import { Badge, Card, Collapse, CollapsePanelProps, CollapseProps, Empty, Input } from 'antd';
import cls from 'classnames';
import { clone } from 'lodash';
import React, { Fragment, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const DataTemplateTitle = (props) => {
const array = ArrayBase.useArray();
const index = ArrayBase.useIndex(props.index);
const { t } = useTranslation();
const value = array?.field?.value[index];
useEffect(() => {
if (!value.key) {
value.key = uid();
}
if (!value.title) {
value.title = `${t('Template name')} ${array?.field?.value?.length}`;
}
}, []);
return (
<Input.TextArea
value={value.title}
placeholder={t('Template name')}
onChange={(ev) => {
ev.stopPropagation();
array.field.value.splice(index, 1, { ...value, title: ev.target.value });
}}
onBlur={(ev) => {
ev.stopPropagation();
array.field.value.splice(index, 1, { ...value, title: ev.target.value });
}}
autoSize
style={{ width: '70%', border: 'none' }}
onClick={(e) => {
e.stopPropagation();
}}
/>
);
};
export interface IArrayCollapseProps extends CollapseProps {
defaultOpenPanelCount?: number;
}
type ComposedArrayCollapse = React.FC<React.PropsWithChildren<IArrayCollapseProps>> &
ArrayBaseMixins & {
CollapsePanel?: React.FC<React.PropsWithChildren<CollapsePanelProps>>;
};
const isAdditionComponent = (schema: ISchema) => {
return schema['x-component']?.indexOf?.('Addition') > -1;
};
const isIndexComponent = (schema: ISchema) => {
return schema['x-component']?.indexOf?.('Index') > -1;
};
const isRemoveComponent = (schema: ISchema) => {
return schema['x-component']?.indexOf?.('Remove') > -1;
};
const isMoveUpComponent = (schema: ISchema) => {
return schema['x-component']?.indexOf?.('MoveUp') > -1;
};
const isMoveDownComponent = (schema: ISchema) => {
return schema['x-component']?.indexOf?.('MoveDown') > -1;
};
const isCopyComponent = (schema: ISchema) => {
return schema['x-component']?.indexOf?.('Copy') > -1;
};
const isOperationComponent = (schema: ISchema) => {
return (
isAdditionComponent(schema) ||
isRemoveComponent(schema) ||
isMoveDownComponent(schema) ||
isMoveUpComponent(schema) ||
isCopyComponent(schema)
);
};
const range = (count: number) => Array.from({ length: count }).map((_, i) => i);
const takeDefaultActiveKeys = (dataSourceLength: number, defaultOpenPanelCount: number) => {
if (dataSourceLength < defaultOpenPanelCount) return range(dataSourceLength);
return range(defaultOpenPanelCount);
};
const insertActiveKeys = (activeKeys: number[], index: number) => {
if (activeKeys.length <= index) return activeKeys.concat(index);
return activeKeys.reduce((buf, key) => {
if (key < index) return buf.concat(key);
if (key === index) return buf.concat([key, key + 1]);
return buf.concat(key + 1);
}, []);
};
export const ArrayCollapse: ComposedArrayCollapse = observer((props: IArrayCollapseProps) => {
const field = useField<ArrayField>();
const dataSource = Array.isArray(field.value) ? field.value : [];
const [activeKeys, setActiveKeys] = useState<number[]>(
takeDefaultActiveKeys(dataSource.length, props.defaultOpenPanelCount),
);
const schema = useFieldSchema();
const prefixCls = 'ant-formily-array-collapse';
useEffect(() => {
if (!field.modified && dataSource.length) {
setActiveKeys(takeDefaultActiveKeys(dataSource.length, props.defaultOpenPanelCount));
}
}, [dataSource.length, field]);
if (!schema) throw new Error('can not found schema object');
const renderAddition = () => {
return schema.reduceProperties((addition, schema, key) => {
if (isAdditionComponent(schema)) {
return <RecursionField schema={schema} name={key} />;
}
return addition;
}, null);
};
const renderEmpty = () => {
if (dataSource.length) return;
return (
<Card className={cls(`${prefixCls}-item`, props.className)}>
<Empty />
</Card>
);
};
const renderItems = () => {
return (
<Collapse
{...props}
activeKey={activeKeys}
onChange={(keys: string[]) => setActiveKeys(toArr(keys).map(Number))}
className={cls(`${prefixCls}-item`, props.className)}
>
{dataSource.map((item, index) => {
const items = Array.isArray(schema.items) ? schema.items[index] || schema.items[0] : schema.items;
const panelProps = field.query(`${field.address}.${index}`).get('componentProps');
const props: CollapsePanelProps = items['x-component-props'];
const header = () => {
const header = `${panelProps?.header || props.header || field.title}`;
const path = field.address.concat(index);
const errors = field.form.queryFeedbacks({
type: 'error',
address: `${path}.**`,
});
return (
<ArrayBase.Item index={index} record={() => field.value?.[index]}>
<RecursionField
schema={items}
name={index}
filterProperties={(schema) => {
if (!isIndexComponent(schema)) return false;
return true;
}}
onlyRenderProperties
/>
{errors.length ? (
<Badge size="small" className="errors-badge" count={errors.length}>
{header}
</Badge>
) : (
<DataTemplateTitle item={item.initialValue || item} index={index} />
)}
</ArrayBase.Item>
);
};
const extra = (
<ArrayBase.Item index={index} record={item}>
<RecursionField
schema={items}
name={index}
filterProperties={(schema) => {
if (!isOperationComponent(schema)) return false;
return true;
}}
onlyRenderProperties
/>
{panelProps?.extra}
</ArrayBase.Item>
);
const content = (
<RecursionField
schema={items}
name={index}
filterProperties={(schema) => {
if (isIndexComponent(schema)) return false;
if (isOperationComponent(schema)) return false;
return true;
}}
/>
);
return (
<Collapse.Panel {...props} {...panelProps} forceRender key={index} header={header()} extra={extra}>
<ArrayBase.Item index={index} key={index} record={item}>
{content}
</ArrayBase.Item>
</Collapse.Panel>
);
})}
</Collapse>
);
};
return (
<ArrayBase
onAdd={(index) => {
setActiveKeys(insertActiveKeys(activeKeys, index));
}}
>
{renderEmpty()}
{renderItems()}
{renderAddition()}
</ArrayBase>
);
});
const CollapsePanel: React.FC<React.PropsWithChildren<CollapsePanelProps>> = ({ children }) => {
return <Fragment>{children}</Fragment>;
};
CollapsePanel.displayName = 'CollapsePanel';
ArrayCollapse.defaultProps = {
defaultOpenPanelCount: 5,
};
ArrayCollapse.displayName = 'ArrayCollapse';
ArrayCollapse.CollapsePanel = CollapsePanel;
ArrayBase.mixin(ArrayCollapse);
export default ArrayCollapse;
//@ts-ignore
ArrayCollapse.Copy = React.forwardRef((props: any, ref) => {
const self = useField();
const array = ArrayBase.useArray();
const index = ArrayBase.useIndex(props.index);
if (!array) return null;
if (array.field?.pattern !== 'editable') return null;
return (
<CopyOutlined
{...props}
style={{
transition: 'all 0.25s ease-in-out',
color: 'rgba(0, 0, 0, 0.8)',
fontSize: '16px',
marginLeft: 6,
}}
ref={ref}
onClick={(e) => {
if (self?.disabled) return;
e.stopPropagation();
if (array.props?.disabled) return;
const value = clone(array?.field?.value[index]);
array.field.push(value);
if (props.onClick) {
props.onClick(e);
}
}}
/>
);
});

View File

@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { useCollectionManager } from '../../../collection-manager';
import { useCompile } from '../../../schema-component';
import { TreeNode } from '../TreeLabel';
export const useCollectionState = (currentCollectionName: string) => {
const { getCollectionFields, getAllCollectionsInheritChain, getCollection, getCollectionFieldsOptions } =
useCollectionManager();
const [collectionList] = useState(getCollectionList);
const compile = useCompile();
function getCollectionList() {
const collections = getAllCollectionsInheritChain(currentCollectionName);
return collections.map((name) => ({ label: getCollection(name).title, value: name }));
}
const getEnableFieldTree = (collectionName: string) => {
if (!collectionName) {
return [];
}
// 过滤掉系统字段
const systemKeys = [
// 'id',
'sort',
'createdById',
'createdBy',
'createdAt',
'updatedById',
'updatedBy',
'updatedAt',
];
const traverseAssociations = (collectionName, { prefix, maxDepth, depth, exclude = [] }) => {
if (depth > maxDepth) {
return [];
}
return getCollectionFields(collectionName)
.map((field) => {
if (!field.target || !field.interface) {
return;
}
if (exclude.includes(field.name)) {
return;
}
const option = {
type: 'preloading',
tag: compile(field.uiSchema?.title) || field.name,
};
const value = prefix ? `${prefix}.${field.name}` : field.name;
return {
title: React.createElement(TreeNode, option),
key: value,
children: traverseAssociations(getCollectionFields(field.target), {
prefix: value,
depth: depth + 1,
maxDepth,
exclude,
}),
};
})
.filter(Boolean);
};
const traverseFields = (collectionName, { exclude = [], depth = 0, maxDepth, prefix = '' }) => {
if (depth > maxDepth) {
return [];
}
return getCollectionFields(collectionName)
.map((field) => {
if (exclude.includes(field.name)) {
return;
}
if (!field.interface) {
return;
}
const node = {
type: 'duplicate',
tag: compile(field.uiSchema?.title) || field.name,
};
const option = {
title: React.createElement(TreeNode, node),
key: prefix ? `${prefix}.${field.name}` : field.name,
};
// 多对多的只展示关系字段
if (['belongsTo', 'belongsToMany'].includes(field.type)) {
option['type'] = 'reference';
option['label'] = React.createElement(TreeNode, { ...node, type: 'reference' });
option['children'] = traverseAssociations(field.target, {
depth: depth + 1,
maxDepth,
prefix: option.key,
exclude: systemKeys,
});
} else if (['hasOne', 'hasMany'].includes(field.type)) {
option['children'] = traverseFields(field.target, {
depth: depth + 1,
maxDepth,
prefix: option.key,
exclude: ['id', ...systemKeys],
});
}
return option;
})
.filter(Boolean);
};
try {
return traverseFields(collectionName, { exclude: ['id', ...systemKeys], maxDepth: 3 });
} catch (error) {
console.error(error);
return [];
}
};
return {
collectionList,
getEnableFieldTree,
};
};

View File

@ -0,0 +1 @@
export * from './FormDataTemplates';

View File

@ -1,9 +1,7 @@
import { css } from '@emotion/css';
import { ArrayItems } from '@formily/antd';
import { FormDialog, FormItem, FormLayout, Input, ArrayCollapse } from '@formily/antd';
import { createForm, Field, GeneralField } from '@formily/core';
import { ArrayCollapse, ArrayItems, FormDialog, FormItem, FormLayout, Input } from '@formily/antd';
import { Field, GeneralField, createForm } from '@formily/core';
import { ISchema, Schema, SchemaOptionsContext, useField, useFieldSchema, useForm } from '@formily/react';
import _ from 'lodash';
import { uid } from '@formily/shared';
import {
Alert,
@ -20,35 +18,37 @@ import {
Switch,
} from 'antd';
import classNames from 'classnames';
import { cloneDeep } from 'lodash';
import _, { cloneDeep } from 'lodash';
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
APIClientProvider,
ActionContext,
CollectionManagerContext,
createDesignable,
Designable,
FormProvider,
RemoteSchemaComponent,
SchemaComponent,
SchemaComponentOptions,
useActionContext,
createDesignable,
findFormBlock,
useAPIClient,
useCollection,
useCollectionFilterOptions,
useCollectionManager,
useCompile,
useDesignable,
useCollectionFilterOptions,
} from '..';
import { findFilterTargets, updateFilterTargets } from '../block-provider/hooks';
import { FilterBlockType, isSameCollection, useSupportedBlocks } from '../filter-provider/utils';
import { getTargetKey } from '../schema-component/antd/association-filter/utilts';
import { useSchemaTemplateManager } from '../schema-templates';
import { useBlockTemplateContext } from '../schema-templates/BlockTemplate';
import { FormDataTemplates } from './DataTemplates';
import { EnableChildCollections } from './EnableChildCollections';
import { FormLinkageRules } from './LinkageRules';
import { useLinkageCollectionFieldOptions } from './LinkageRules/action-hooks';
import { FilterBlockType, isSameCollection, useSupportedBlocks } from '../filter-provider/utils';
import { findFilterTargets, updateFilterTargets } from '../block-provider/hooks';
import { EnableChildCollections } from './EnableChildCollections';
import { getTargetKey } from '../schema-component/antd/association-filter/utilts';
interface SchemaSettingsProps {
title?: any;
@ -376,7 +376,7 @@ SchemaSettings.FormItemTemplate = (props) => {
});
}}
>
{t('Save as template')}
{t('Save as block template')}
</SchemaSettings.Item>
);
};
@ -780,6 +780,7 @@ SchemaSettings.ModalItem = (props) => {
} = props;
const options = useContext(SchemaOptionsContext);
const cm = useContext(CollectionManagerContext);
const apiClient = useAPIClient();
if (hidden) {
return null;
}
@ -793,7 +794,9 @@ SchemaSettings.ModalItem = (props) => {
<CollectionManagerContext.Provider value={cm}>
<SchemaComponentOptions scope={options.scope} components={options.components}>
<FormLayout layout={'vertical'} style={{ minWidth: 520 }}>
<SchemaComponent components={components} scope={scope} schema={schema} />
<APIClientProvider apiClient={apiClient}>
<SchemaComponent components={components} scope={scope} schema={schema} />
</APIClientProvider>
</FormLayout>
</SchemaComponentOptions>
</CollectionManagerContext.Provider>
@ -998,6 +1001,62 @@ SchemaSettings.LinkageRules = (props) => {
);
};
export const useDataTemplates = () => {
const fieldSchema = useFieldSchema();
const formSchema = findFormBlock(fieldSchema) || fieldSchema;
return {
templateData: _.cloneDeep(formSchema?.['x-data-templates']),
};
};
SchemaSettings.DataTemplates = (props) => {
const { collectionName } = props;
const fieldSchema = useFieldSchema();
const { dn } = useDesignable();
const { t } = useTranslation();
const formSchema = findFormBlock(fieldSchema) || fieldSchema;
const { templateData } = useDataTemplates();
return (
<SchemaSettings.ModalItem
title={t('Form data templates')}
components={{ ArrayCollapse, FormLayout }}
width={770}
schema={
{
type: 'object',
title: t('Form data templates'),
properties: {
fieldReaction: {
'x-component': FormDataTemplates,
'x-component-props': {
useProps: () => {
return {
defaultValues: templateData,
collectionName,
};
},
},
},
},
} as ISchema
}
onSubmit={(v) => {
const data = v.fieldReaction || {};
const schema = {
['x-uid']: formSchema['x-uid'],
['x-data-templates']: data,
};
formSchema['x-data-templates'] = data;
dn.emit('patch', {
schema,
});
dn.refresh();
}}
/>
);
};
SchemaSettings.EnableChildCollections = (props) => {
const { collectionName } = props;
const fieldSchema = useFieldSchema();

View File

@ -5,6 +5,7 @@ import i18next from 'i18next';
import bodyParser from 'koa-bodyparser';
import Application, { ApplicationOptions } from './application';
import { parseVariables } from './middlewares';
import { dateTemplate } from './middlewares/data-template';
import { dataWrapping } from './middlewares/data-wrapping';
import { db2resource } from './middlewares/db2resource';
import { i18n } from './middlewares/i18n';
@ -72,7 +73,9 @@ export function registerMiddlewares(app: Application, options: ApplicationOption
}
app.resourcer.use(parseVariables, { tag: 'parseVariables', after: 'acl' });
app.resourcer.use(dateTemplate, { tag: 'dateTemplate', after: 'acl' });
app.use(db2resource, { tag: 'db2resource', after: 'dataWrapping' });
app.use(app.resourcer.restApiMiddleware(), { tag: 'restApi', after: 'db2resource' });
}

View File

@ -0,0 +1,76 @@
import { Context } from '@nocobase/actions';
import { Collection } from '@nocobase/database';
export const dateTemplate = async (ctx: Context, next) => {
const { resourceName, actionName } = ctx.action;
const { isTemplate, fields } = ctx.action.params;
await next();
if (isTemplate && actionName === 'get' && fields.length > 0) {
ctx.body = traverseJSON(ctx.body?.toJSON(), ctx.db.getCollection(resourceName));
}
};
const traverseHasMany = (arr: any[], collection: Collection, exclude = []) => {
if (!arr) {
return arr;
}
return arr.map((item) => traverseJSON(item, collection, exclude));
};
const traverseBelongsToMany = (arr: any[], collection: Collection, exclude = [], through) => {
if (!arr) {
return arr;
}
const throughCollection = collection.db.getCollection(through);
return arr.map((item) => {
const data = traverseJSON(item[through], throughCollection, exclude);
if (Object.keys(data).length) {
item[through] = data;
} else {
delete item[through];
}
return item;
});
};
const traverseJSON = (data, collection: Collection, exclude = []) => {
const result = {};
for (const key of Object.keys(data)) {
if (exclude.includes(key)) {
continue;
}
if (['createdAt', 'updatedAt', 'createdBy', 'createdById', 'updatedById', 'updatedBy'].includes(key)) {
continue;
}
const field = collection.getField(key);
if (!field) {
result[key] = data[key];
continue;
}
if (field.options.primaryKey) {
continue;
}
if (field.type === 'sort') {
continue;
}
if (field.type === 'hasOne') {
result[key] = traverseJSON(data[key], collection.db.getCollection(field.target), [field.foreignKey]);
} else if (field.type === 'hasMany') {
result[key] = traverseHasMany(data[key], collection.db.getCollection(field.target), [field.foreignKey]);
} else if (field.type === 'belongsTo') {
result[key] = data[key];
} else if (field.type === 'belongsToMany') {
result[key] = traverseBelongsToMany(
data[key],
collection.db.getCollection(field.target),
[field.foreignKey, field.otherKey],
field.through,
);
} else {
result[key] = data[key];
}
}
return result;
};

View File

@ -400,6 +400,7 @@
"Turn pages": "Turn pages",
"Others": "Others",
"Save as template": "Save as template",
"Save as block template": "Save as block template",
"Block templates": "Block templates",
"Convert reference to duplicate": "Convert reference to duplicate",
"Template name": "Template name",

View File

@ -384,6 +384,7 @@
"Turn pages": "Virar páginas",
"Others": "Outros",
"Save as template": "Salvar como modelo",
"Save as block template": "Salvar como modelo de bloco",
"Block templates": "Modelos de bloco",
"Convert reference to duplicate": "Converter referência em duplicado",
"Template name": "Nome do modelo",
@ -1377,4 +1378,4 @@
]
}
}
}
}

View File

@ -400,6 +400,7 @@
"Turn pages": "翻页",
"Others": "其他",
"Save as template": "保存为模板",
"Save as block template": "保存为区块模板",
"Block templates": "区块模板",
"Convert reference to duplicate": "模板引用转为复制",
"Template name": "模板名称",