feat(data-template): support for unlimited levels of fields selection (#1910)

* refactor: use useMemo

* refactor: use useCallback

* feat(Data-template): change maxDepth from 3 to 10

* feat(Data-template): support for unlimited levels of fields selection

* fix: fix invalid selection

* fix: should be updated when data changes

* feat: parent treeNode and children treeNodes are not associated

* fix: avoid error

* fix: fix error

* chore: remove notification

* fix: parant node should be selected when child is selected

* fix: auto select all ancester nodes
This commit is contained in:
被雨水过滤的空气-Rairn 2023-05-25 15:23:32 +08:00 committed by GitHub
parent 948d39bab4
commit c722ca41d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 405 additions and 297 deletions

View File

@ -709,5 +709,4 @@ export default {
"Feedback": "Feedback",
"Try again": "Try again",
"Data template": "Data template",
"Template fields have been removed and need to be reconfigured": "Template fields have been removed and need to be reconfigured",
};

View File

@ -688,5 +688,4 @@ export default {
"Sortable": "Clasificable",
"Enable link": "Activar enlace",
"Data template": "Plantilla de datos",
"Template fields have been removed and need to be reconfigured": "Los campos de la plantilla se han eliminado y deben reconfigurarse",
};

View File

@ -618,5 +618,4 @@ export default {
'Display data template selector': 'データテンプレートセレクターを表示',
'Form data templates': 'フォームデータテンプレート',
"Data template": "データテンプレート",
"Template fields have been removed and need to be reconfigured": "テンプレートフィールドが削除されました。再設定する必要があります",
}

View File

@ -668,5 +668,4 @@ export default {
'Display data template selector': 'Exibir seletor de modelo de dados',
'Form data templates': 'Modelos de dados do formulário',
"Data template": "Modelo de dados",
"Template fields have been removed and need to be reconfigured": "Os campos do modelo foram removidos e precisam ser reconfigurados",
};

View File

@ -522,5 +522,4 @@ export default {
'Display data template selector': "Отображать селектор шаблона данных",
'Form data templates': "Шаблоны данных формы",
"Data template": "Шаблон данных",
"Template fields have been removed and need to be reconfigured": "Поля шаблона были удалены и требуют повторной настройки",
}

View File

@ -521,5 +521,4 @@ export default {
'Display data template selector': 'Veri şablonu seçicisini görüntüle',
'Form data templates': 'Form veri şablonları',
"Data template": "Veri şablonu",
"Template fields have been removed and need to be reconfigured": "Şablon alanları kaldırıldı ve yeniden yapılandırılması gerekiyor",
}

View File

@ -786,6 +786,5 @@ export default {
"Allows to clear cache, reboot application": "允许清除缓存,重启应用",
'The will interrupt service, it may take a few seconds to restart. Are you sure to continue?': '重启将会中断当前服务,这个过程可能需要一点时间,确定要继续吗?',
'Reboot': '重启',
"Template fields have been removed and need to be reconfigured": "模板字段已被删除,需要重新配置",
'Clear cache': '清除缓存',
}

View File

@ -1,5 +1,5 @@
import { useFieldSchema } from '@formily/react';
import { error, forEach, showToast } from '@nocobase/utils/client';
import { error, forEach } from '@nocobase/utils/client';
import { Select } from 'antd';
import _ from 'lodash';
import React, { useCallback, useEffect } from 'react';
@ -71,7 +71,7 @@ export const Templates = ({ style = {}, form }) => {
if (defaultTemplate) {
fetchTemplateData(api, defaultTemplate, t)
.then((data) => {
if (form) {
if (form && data) {
forEach(data, (value, key) => {
if (value) {
form.values[key] = value;
@ -91,7 +91,7 @@ export const Templates = ({ style = {}, form }) => {
if (option.key !== 'none') {
fetchTemplateData(api, option, t)
.then((data) => {
if (form) {
if (form && data) {
forEach(data, (value, key) => {
if (value) {
form.values[key] = value;
@ -138,7 +138,7 @@ function findDataTemplates(fieldSchema): ITemplate {
async function fetchTemplateData(api, template: { collection: string; dataId: number; fields: string[] }, t) {
if (template.fields.length === 0) {
return showToast(t('Template fields have been removed and need to be reconfigured'));
return;
}
return api
.resource(template.collection)

View File

@ -1,6 +1,6 @@
import { connect, mapProps, observer } from '@formily/react';
import { Tree as AntdTree } from 'antd';
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useCollectionManager } from '../../collection-manager';
import { AssociationSelect, SchemaComponent } from '../../schema-component';
@ -13,183 +13,185 @@ const Tree = connect(
mapProps({
value: 'checkedKeys',
dataSource: 'treeData',
onInput: 'onCheck',
// onInput: 'onCheck',
}),
);
export const FormDataTemplates = observer((props: any) => {
const { useProps } = props;
const { defaultValues, collectionName } = useProps();
const { collectionList, getEnableFieldTree } = useCollectionState(collectionName);
const { collectionList, getEnableFieldTree, onLoadData, onCheck } = useCollectionState(collectionName);
const { getCollection, getCollectionField } = useCollectionManager();
const collection = getCollection(collectionName);
const { t } = useTranslation();
const field = getCollectionField(`${collectionName}.${collection?.titleField || 'id'}`);
return (
<SchemaComponent
components={{ ArrayCollapse }}
scope={{ getEnableFieldTree }}
schema={{
type: 'object',
properties: {
const components = useMemo(() => ({ ArrayCollapse }), []);
const scope = useMemo(() => ({ getEnableFieldTree }), []);
const schema = useMemo(
() => ({
type: 'object',
properties: {
items: {
type: 'array',
default: defaultValues?.items,
'x-component': 'ArrayCollapse',
'x-decorator': 'FormItem',
'x-component-props': {
accordion: true,
},
items: {
type: 'array',
default: defaultValues?.items,
'x-component': 'ArrayCollapse',
'x-decorator': 'FormItem',
type: 'object',
'x-component': 'ArrayCollapse.CollapsePanel',
'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: t('If collection inherits, choose inherited collections as templates'),
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: t('Select an existing piece of data as the initialization data for the form'),
'x-decorator': 'FormItem',
'x-component': AssociationSelect,
'x-component-props': {
service: {
resource: collectionName,
},
action: 'list',
multiple: false,
objectValue: false,
manual: false,
targetField: field,
mapOptions(option) {
try {
const label = getLabel(collection);
option[label] = (
<>
#{option.id} {option[label]}
</>
);
return option;
} catch (error) {
console.error(error);
return option;
}
},
fieldNames: {
label: getLabel(collection),
value: 'id',
},
},
'x-reactions': [
{
dependencies: ['.collection'],
fulfill: {
state: {
disabled: '{{ !$deps[0] }}',
componentProps: {
service: {
resource: '{{ $deps[0] }}',
},
},
},
},
},
],
},
fields: {
type: 'array',
title: '{{ t("Data fields") }}',
required: true,
description: t('Only the selected fields will be used as the initialization data for the form'),
'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',
},
},
extra: [<AsDefaultTemplate key="0" />],
},
properties: {
add: {
layout: {
type: 'void',
title: '{{ t("Add template") }}',
'x-component': 'ArrayCollapse.Addition',
'x-component': 'FormLayout',
'x-component-props': {
layout: 'vertical',
},
properties: {
collection: {
type: 'string',
title: '{{ t("Collection") }}',
required: true,
description: t('If collection inherits, choose inherited collections as templates'),
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: t('Select an existing piece of data as the initialization data for the form'),
'x-decorator': 'FormItem',
'x-component': AssociationSelect,
'x-component-props': {
service: {
resource: collectionName,
},
action: 'list',
multiple: false,
objectValue: false,
manual: false,
targetField: field,
mapOptions(option) {
try {
const label = getLabel(collection);
option[label] = (
<>
#{option.id} {option[label]}
</>
);
return option;
} catch (error) {
console.error(error);
return option;
}
},
fieldNames: {
label: getLabel(collection),
value: 'id',
},
},
'x-reactions': [
{
dependencies: ['.collection'],
fulfill: {
state: {
disabled: '{{ !$deps[0] }}',
componentProps: {
service: {
resource: '{{ $deps[0] }}',
},
},
},
},
},
],
},
fields: {
type: 'array',
title: '{{ t("Data fields") }}',
required: true,
description: t('Only the selected fields will be used as the initialization data for the form'),
'x-decorator': 'FormItem',
'x-component': Tree,
'x-component-props': {
treeData: [],
checkable: true,
checkStrictly: true,
selectable: false,
loadData: onLoadData,
onCheck,
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], $self) }}',
},
},
},
},
],
},
},
},
remove: {
type: 'void',
'x-component': 'ArrayCollapse.Remove',
},
moveUp: {
type: 'void',
'x-component': 'ArrayCollapse.MoveUp',
},
moveDown: {
type: 'void',
'x-component': 'ArrayCollapse.MoveDown',
},
},
},
display: {
type: 'boolean',
'x-content': '{{ t("Display data template selector") }}',
default: defaultValues?.display !== false,
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
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',
},
},
}),
[],
);
return <SchemaComponent components={components} scope={scope} schema={schema} />;
});
function getLabel(collection: any) {

View File

@ -1,4 +1,7 @@
import React, { useState } from 'react';
import { ArrayField } from '@formily/core';
import { error } from '@nocobase/utils/client';
import _ from 'lodash';
import React, { useCallback, useState } from 'react';
import { useCollectionManager } from '../../../collection-manager';
import { useCompile } from '../../../schema-component';
import { TreeNode } from '../TreeLabel';
@ -9,113 +12,213 @@ export const useCollectionState = (currentCollectionName: string) => {
const [collectionList] = useState(getCollectionList);
const compile = useCompile();
let dataFields: ArrayField = {} as any;
function getCollectionList() {
const collections = getAllCollectionsInheritChain(currentCollectionName);
return collections.map((name) => ({ label: getCollection(name)?.title, value: name }));
}
const getEnableFieldTree = (collectionName: string) => {
// 过滤掉系统字段
const systemKeys = [
// 'id',
'sort',
'createdById',
'createdBy',
'createdAt',
'updatedById',
'updatedBy',
'updatedAt',
];
/**
* maxDepth: 0 0 1
*/
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;
}
if (['sort', 'password', 'sequence'].includes(field.type)) {
return;
}
const node = {
type: 'duplicate',
tag: compile(field.uiSchema?.title) || field.name,
};
const option = {
...node,
title: React.createElement(TreeNode, node),
key: prefix ? `${prefix}.${field.name}` : field.name,
isLeaf: true,
field,
};
// 多对多和多对一只展示关系字段
if (['belongsTo', 'belongsToMany'].includes(field.type)) {
node['type'] = 'reference';
option['type'] = 'reference';
option['title'] = React.createElement(TreeNode, { ...node, type: 'reference' });
option.isLeaf = false;
option['children'] = traverseAssociations(field.target, {
depth: depth + 1,
maxDepth,
prefix: option.key,
exclude: systemKeys,
});
} else if (['hasOne', 'hasMany'].includes(field.type)) {
option.isLeaf = false;
option['children'] = traverseFields(field.target, {
depth: depth + 1,
maxDepth,
prefix: option.key,
exclude: ['id', ...systemKeys],
});
}
return option;
})
.filter(Boolean);
};
const traverseAssociations = (collectionName, { prefix, maxDepth, depth = 0, 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,
isLeaf: false,
field,
children: traverseAssociations(field.target, {
prefix: value,
depth: depth + 1,
maxDepth,
exclude,
}),
};
})
.filter(Boolean);
};
/**
* fields: 通过在 x-reactions $self useField
*/
const getEnableFieldTree = useCallback((collectionName: string, fields: ArrayField) => {
dataFields = fields;
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;
}
if (['sort', 'password', 'sequence'].includes(field.type)) {
return;
}
const node = {
type: 'duplicate',
tag: compile(field.uiSchema?.title) || field.name,
};
const option = {
...node,
title: React.createElement(TreeNode, node),
key: prefix ? `${prefix}.${field.name}` : field.name,
};
// 多对多的只展示关系字段
if (['belongsTo', 'belongsToMany'].includes(field.type)) {
node['type'] = 'reference';
option['type'] = 'reference';
option['title'] = 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 });
return traverseFields(collectionName, { exclude: ['id', ...systemKeys], maxDepth: 1 });
} catch (error) {
console.error(error);
return [];
}
};
}, []);
const onLoadData = useCallback((node) => {
return new Promise((resolve) => {
if (node.children.length) {
node.children.forEach((child) => {
loadChildren({ node: child, traverseAssociations, traverseFields, systemKeys, dataFields });
});
return resolve(void 0);
}
loadChildren({ node, traverseAssociations, traverseFields, systemKeys, dataFields });
resolve(void 0);
});
}, []);
const onCheck = useCallback((checkedKeys, { node, checked }) => {
if (checked) {
let parentKey = node.key.split('.').slice(0, -1).join('.');
try {
// 当子节点被选中时,也选中所有祖先节点,提高用户辨识度
while (parentKey) {
if (parentKey) {
checkedKeys.checked = _.uniq([...checkedKeys.checked, parentKey]);
}
parentKey = parentKey.split('.').slice(0, -1).join('.');
}
} catch (err) {
error(err);
}
}
dataFields.value = checkedKeys;
}, []);
return {
collectionList,
getEnableFieldTree,
onLoadData,
onCheck,
};
};
// 广度优先遍历查找一个 key 相等的节点并返回
function findNode(treeData, item) {
const queue = [...treeData];
while (queue.length) {
const node = queue.shift();
if (node.key === item.key) {
return node;
}
if (node.children) {
queue.push(...node.children);
}
}
}
function loadChildren({ node, traverseAssociations, traverseFields, systemKeys, dataFields }) {
const activeNode = findNode(dataFields.componentProps.treeData, node);
let children = [];
// 多对多和多对一只展示关系字段
if (['belongsTo', 'belongsToMany'].includes(node.field.type)) {
children = traverseAssociations(node.field.target, {
exclude: systemKeys,
prefix: node.key,
maxDepth: 1,
});
} else if (['hasOne', 'hasMany'].includes(node.field.type)) {
children = traverseFields(node.field.target, {
exclude: ['id', ...systemKeys],
prefix: node.key,
maxDepth: 1,
});
}
if (children.length) {
activeNode.children = children;
} else {
activeNode.isLeaf = true;
}
return children;
}

View File

@ -47,9 +47,9 @@ 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 { EnableChildCollections } from './EnableChildCollections';
interface SchemaSettingsProps {
title?: any;
@ -815,6 +815,10 @@ SchemaSettings.ModalItem = function ModalItem(props) {
})
.then((values) => {
onSubmit(values);
return values;
})
.catch((err) => {
console.error(err);
});
}}
>
@ -897,7 +901,7 @@ SchemaSettings.DefaultSortingRules = function DefaultSortingRules(props) {
field: {
type: 'string',
enum: sortFields,
required:true,
required: true,
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
@ -1023,43 +1027,49 @@ SchemaSettings.DataTemplates = function DataTemplates(props) {
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,
};
},
},
const schema = useMemo(
() => ({
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();
}}
/>
},
},
}),
[templateData],
);
const onSubmit = useCallback((v) => {
const data = v.fieldReaction || {};
// 当 Tree 组件开启 checkStrictly 属性时,会导致 checkedKeys 的值是一个对象,而不是数组,所以这里需要转换一下以支持旧版本
data.items.forEach((item) => {
item.fields = Array.isArray(item.fields) ? item.fields : item.fields.checked;
});
const schema = {
['x-uid']: formSchema['x-uid'],
['x-data-templates']: data,
};
formSchema['x-data-templates'] = data;
dn.emit('patch', {
schema,
});
dn.refresh();
}, []);
const title = useMemo(() => t('Form data templates'), []);
const components = useMemo(() => ({ ArrayCollapse, FormLayout }), []);
return (
<SchemaSettings.ModalItem title={title} components={components} width={770} schema={schema} onSubmit={onSubmit} />
);
};