refactor: form data template support data scope config (#2229)

* refactor: data template support data scope config

* refactor: data template support data scope config

* refactor: locale improve

* refactor: code improve

* refactor: data template config data scope and title field should  linkage with collection field

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: locale improve

* refactor: locale improve

* refactor: code improve
This commit is contained in:
katherinehhh 2023-07-25 14:42:30 +08:00 committed by GitHub
parent 2c8e7b163e
commit b42e3b4042
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 203 additions and 115 deletions

View File

@ -708,5 +708,6 @@ export default {
"Current object":"Current object", "Current object":"Current object",
"Linkage with form fields":"Linkage with form fields", "Linkage with form fields":"Linkage with form fields",
"Allow add new, update and delete actions":"Allow add new, update and delete actions", "Allow add new, update and delete actions":"Allow add new, update and delete actions",
"Date display format":"Date display format" "Date display format":"Date display format",
"Assign data scope for the template":"Assign data scope for the template",
}; };

View File

@ -619,5 +619,6 @@ export default {
"Current object":"現在のオブジェクト", "Current object":"現在のオブジェクト",
"Linkage with form fields":"フォームデータから連動", "Linkage with form fields":"フォームデータから連動",
"Allow add new, update and delete actions":"削除変更操作の許可", "Allow add new, update and delete actions":"削除変更操作の許可",
"Date display format":"日付表示形式" "Date display format":"日付表示形式",
"Assign data scope for the template":"テンプレートのデータ範囲の指定",
} }

View File

@ -793,5 +793,6 @@ export default {
"Linkage with form fields":"从表单字段联动", "Linkage with form fields":"从表单字段联动",
"Failed to load plugin": "插件加载失败", "Failed to load plugin": "插件加载失败",
"Allow add new, update and delete actions":"允许增删改操作", "Allow add new, update and delete actions":"允许增删改操作",
"Date display format":"日期显示格式" "Date display format":"日期显示格式",
"Assign data scope for the template":"为模板指定数据范围",
} }

View File

@ -1,14 +1,16 @@
import { useFieldSchema } from '@formily/react'; import { useFieldSchema } from '@formily/react';
import { error, forEach } from '@nocobase/utils/client'; import { error, forEach } from '@nocobase/utils/client';
import { Select } from 'antd'; import { Select, Space } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useCallback, useEffect, useMemo } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../api-client'; import { useAPIClient } from '../../../api-client';
import { findFormBlock } from '../../../block-provider'; import { findFormBlock } from '../../../block-provider';
import { useCollectionManager } from '../../../collection-manager'; import { useCollectionManager } from '../../../collection-manager';
import { useDuplicatefieldsContext } from '../../../schema-initializer/components'; import { useDuplicatefieldsContext } from '../../../schema-initializer/components';
import { compatibleDataId } from '../../../schema-settings/DataTemplates/FormDataTemplates';
import { useToken } from '../__builtins__'; import { useToken } from '../__builtins__';
import { RemoteSelect } from '../remote-select';
export interface ITemplate { export interface ITemplate {
config?: { config?: {
@ -23,9 +25,11 @@ export interface ITemplate {
key: string; key: string;
title: string; title: string;
collection: string; collection: string;
dataId: number; dataId?: number;
fields: string[]; fields: string[];
default?: boolean; default?: boolean;
dataScope?: object;
titleField?: string;
}[]; }[];
/** 是否在 Form 区块显示模板选择器 */ /** 是否在 Form 区块显示模板选择器 */
display: boolean; display: boolean;
@ -63,49 +67,38 @@ const useDataTemplates = () => {
key: 'none', key: 'none',
title: t('None'), title: t('None'),
}, },
].concat(items.map<any>((item, i) => ({ key: i, ...item }))); ].concat(
items.map<any>((t, i) => ({
key: i,
...t,
isLeaf: t.dataId !== null && t.dataId !== undefined,
titleCollectionField: t?.titleField && getCollectionJoinField(`${t.collection}.${t.titleField}`),
})),
);
const defaultTemplate = items.find((item) => item.default); const defaultTemplate = items.find((item) => item.default);
return { return {
templates, templates,
display, display,
defaultTemplate, defaultTemplate,
enabled: items.length > 0 && items.every((item) => item.dataId !== undefined), enabled: items.length > 0 && items.every((item) => item.dataId || item.dataScope),
}; };
}; };
function filterReferences(obj) {
const filteredObj = {};
for (const key in obj) {
if (typeof obj[key] !== 'object') {
filteredObj[key] = obj[key];
}
}
return filteredObj;
}
export const Templates = ({ style = {}, form }) => { export const Templates = ({ style = {}, form }) => {
const { token } = useToken(); const { token } = useToken();
const { templates, display, enabled, defaultTemplate } = useDataTemplates(); const { templates, display, enabled, defaultTemplate } = useDataTemplates();
const [value, setValue] = React.useState(defaultTemplate?.key || 'none'); const { getCollectionJoinField } = useCollectionManager();
const templateOptions = compatibleDataId(templates);
const [targetTemplate, setTargetTemplate] = useState(defaultTemplate?.key || 'none');
const [targetTemplateData, setTemplateData] = useState(null);
const api = useAPIClient(); const api = useAPIClient();
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
if (enabled && defaultTemplate) { if (enabled && defaultTemplate) {
form.__template = true; form.__template = true;
fetchTemplateData(api, defaultTemplate, t) if (defaultTemplate.key === 'duplicate') {
.then((data) => { handleTemplateDataChange(defaultTemplate.dataId, defaultTemplate);
if (form && data) { }
forEach(data, (value, key) => {
if (value) {
form.values[key] = value;
}
});
}
return data;
})
.catch((err) => {
console.error(err);
});
} }
}, []); }, []);
@ -122,46 +115,69 @@ export const Templates = ({ style = {}, form }) => {
return { fontSize: token.fontSize, fontWeight: 'bold', whiteSpace: 'nowrap', marginRight: token.marginXS }; return { fontSize: token.fontSize, fontWeight: 'bold', whiteSpace: 'nowrap', marginRight: token.marginXS };
}, [token.fontSize, token.marginXS]); }, [token.fontSize, token.marginXS]);
const handleChange = useCallback(async (value, option) => { const handleTemplateChange = useCallback(async (value, option) => {
setValue(value); setTargetTemplate(value);
if (option.key !== 'none') { setTemplateData(null);
fetchTemplateData(api, option, t) form?.reset();
.then((data) => { }, []);
if (form && data) {
// 切换之前先把之前的数据清空
form.reset();
form.__template = true;
forEach(data, (value, key) => { const handleTemplateDataChange: any = useCallback(async (value, option) => {
if (value) { const template = { ...option, dataId: value };
form.values[key] = value; setTemplateData(option);
} fetchTemplateData(api, template, t)
}); .then((data) => {
} if (form && data) {
return data; // 切换之前先把之前的数据清空
}) form.reset();
.catch((err) => { form.__template = true;
console.error(err);
}); forEach(data, (value, key) => {
} else { if (value) {
form?.reset(); form.values[key] = value;
} }
});
}
return data;
})
.catch((err) => {
console.error(err);
});
}, []); }, []);
if (!enabled || !display) { if (!enabled || !display) {
return null; return null;
} }
const template = templateOptions?.find((v) => v.key === targetTemplate);
return ( return (
<div style={wrapperStyle}> <div style={wrapperStyle}>
<label style={labelStyle}>{t('Data template')}: </label> <Space wrap>
<Select <label style={labelStyle}>{t('Data template')}: </label>
popupMatchSelectWidth={false} <Select
options={templates} popupMatchSelectWidth={false}
fieldNames={{ label: 'title', value: 'key' }} options={templateOptions}
value={value} fieldNames={{ label: 'title', value: 'key' }}
onChange={handleChange} value={targetTemplate}
/> onChange={handleTemplateChange}
/>
{targetTemplate !== 'none' && (
<RemoteSelect
style={{ width: 220 }}
fieldNames={{ label: template.titleField, value: 'id' }}
target={template?.collection}
value={targetTemplateData}
objectValue
service={{
resource: template?.collection,
params: {
filter: template?.dataScope,
},
}}
onChange={(value) => handleTemplateDataChange(value.id, { ...value, ...template })}
targetField={getCollectionJoinField(`${template?.collection}.${template.titleField}`)}
/>
)}
</Space>
</div> </div>
); );
}; };
@ -175,7 +191,7 @@ function findDataTemplates(fieldSchema): ITemplate {
} }
export async function fetchTemplateData(api, template: { collection: string; dataId: number; fields: string[] }, t) { export async function fetchTemplateData(api, template: { collection: string; dataId: number; fields: string[] }, t) {
if (template.fields.length === 0) { if (template.fields.length === 0 || !template.dataId) {
return; return;
} }
return api return api

View File

@ -7,16 +7,11 @@ import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { mergeFilter } from '../../block-provider'; import { mergeFilter } from '../../block-provider';
import { useCollectionManager } from '../../collection-manager'; import { useCollectionManager } from '../../collection-manager';
import { import { SchemaComponent, SchemaComponentContext, removeNullCondition } from '../../schema-component';
AssociationSelect,
SchemaComponent,
SchemaComponentContext,
removeNullCondition,
} from '../../schema-component';
import { ITemplate } from '../../schema-component/antd/form-v2/Templates'; import { ITemplate } from '../../schema-component/antd/form-v2/Templates';
import { AsDefaultTemplate } from './components/AsDefaultTemplate'; import { AsDefaultTemplate } from './components/AsDefaultTemplate';
import { ArrayCollapse } from './components/DataTemplateTitle'; import { ArrayCollapse } from './components/DataTemplateTitle';
import { Designer, getSelectedIdFilter } from './components/Designer'; import { getSelectedIdFilter } from './components/Designer';
import { useCollectionState } from './hooks/useCollectionState'; import { useCollectionState } from './hooks/useCollectionState';
const Tree = connect( const Tree = connect(
@ -27,11 +22,30 @@ const Tree = connect(
}), }),
); );
export const compatibleDataId = (data, config?) => {
return data?.map((v) => {
const { dataId, ...others } = v;
const obj = { ...others };
if (dataId) {
obj.dataScope = { $and: [{ id: { $eq: dataId } }] };
obj.titleField = obj?.titleField || config?.[v.collection]?.['titleField'] || 'id';
}
return obj;
});
};
export const FormDataTemplates = observer( export const FormDataTemplates = observer(
(props: any) => { (props: any) => {
const { useProps, formSchema, designerCtx } = props; const { useProps, formSchema, designerCtx } = props;
const { defaultValues, collectionName } = useProps(); const { defaultValues, collectionName } = useProps();
const { collectionList, getEnableFieldTree, getOnLoadData, getOnCheck } = useCollectionState(collectionName); const {
collectionList,
getEnableFieldTree,
getOnLoadData,
getOnCheck,
getScopeDataSource,
useTitleFieldDataSource,
} = useCollectionState(collectionName);
const { getCollection, getCollectionField } = useCollectionManager(); const { getCollection, getCollectionField } = useCollectionManager();
const { t } = useTranslation(); const { t } = useTranslation();
@ -39,11 +53,15 @@ export const FormDataTemplates = observer(
const activeData = useMemo<ITemplate>( const activeData = useMemo<ITemplate>(
() => () =>
observable( observable(
defaultValues || { items: [], display: true, config: { [collectionName]: { titleField: '', filter: {} } } }, { ...defaultValues, items: compatibleDataId(defaultValues?.items || [], defaultValues?.config) } || {
items: [],
display: true,
config: { [collectionName]: { titleField: '', filter: {} } },
},
), ),
[], [],
); );
console.log(activeData);
const getTargetField = (collectionName: string) => { const getTargetField = (collectionName: string) => {
const collection = getCollection(collectionName); const collection = getCollection(collectionName);
return getCollectionField( return getCollectionField(
@ -63,8 +81,8 @@ export const FormDataTemplates = observer(
const filter = activeData.config?.[collectionName]?.filter; const filter = activeData.config?.[collectionName]?.filter;
return _.isEmpty(filter) ? {} : removeNullCondition(mergeFilter([filter, getSelectedIdFilter(value)], '$or')); return _.isEmpty(filter) ? {} : removeNullCondition(mergeFilter([filter, getSelectedIdFilter(value)], '$or'));
}; };
const components = useMemo(() => ({ ArrayCollapse }), []); const components = useMemo(() => ({ ArrayCollapse }), []);
const scope = useMemo( const scope = useMemo(
() => ({ () => ({
getEnableFieldTree, getEnableFieldTree,
@ -75,6 +93,8 @@ export const FormDataTemplates = observer(
getOnLoadData, getOnLoadData,
getOnCheck, getOnCheck,
collectionName, collectionName,
getScopeDataSource,
useTitleFieldDataSource,
}), }),
[], [],
); );
@ -117,49 +137,39 @@ export const FormDataTemplates = observer(
options: collectionList, options: collectionList,
}, },
}, },
dataId: { dataScope: {
type: 'number', type: 'object',
title: '{{ t("Template Data") }}', title: '{{ t("Assign data scope for the template") }}',
required: true,
description: t('Select an existing piece of data as the initialization data for the form'),
'x-designer': Designer,
'x-designer-props': {
formSchema,
data: activeData,
},
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': AssociationSelect, 'x-component': 'Filter',
'x-component-props': { 'x-decorator-props': {
service: { style: {
resource: '{{ $record.collection || collectionName }}', marginBottom: '0px',
params: {
filter: '{{ getFilter($self.componentProps.service.resource, $self.value) }}',
},
}, },
action: 'list',
multiple: false,
objectValue: false,
manual: false,
targetField: '{{ getTargetField($self.componentProps.service.resource) }}',
mapOptions: getMapOptions(),
fieldNames: '{{ getFieldNames($self.componentProps.service.resource) }}',
}, },
required: true,
'x-reactions': [ 'x-reactions': [
{ {
dependencies: ['.collection'], dependencies: ['.collection'],
fulfill: { fulfill: {
state: { state: {
disabled: '{{ !$deps[0] }}', disabled: '{{ !$deps[0] }}',
componentProps: { },
service: { schema: {
resource: '{{ getResource($deps[0], $self) }}', enum: '{{ getScopeDataSource($deps[0]) }}',
},
},
}, },
}, },
}, },
], ],
}, },
titleField: {
type: 'string',
'x-decorator': 'FormItem',
title: '{{ t("Title field") }}',
'x-component': 'Select',
required: true,
'x-reactions': '{{useTitleFieldDataSource}}',
},
fields: { fields: {
type: 'array', type: 'array',
title: '{{ t("Data fields") }}', title: '{{ t("Data fields") }}',
@ -246,15 +256,6 @@ export function getLabel(titleField) {
return titleField || 'label'; return titleField || 'label';
} }
function getMapOptions() {
return (option) => {
if (option?.id === undefined) {
return null;
}
return option;
};
}
function getResource(resource: string, field: Field) { function getResource(resource: string, field: Field) {
if (resource !== field.componentProps.service.resource) { if (resource !== field.componentProps.service.resource) {
// 切换 collection 后,之前选中的其它 collection 的数据就没有意义了,需要清空 // 切换 collection 后,之前选中的其它 collection 的数据就没有意义了,需要清空

View File

@ -1,11 +1,12 @@
import { ArrayField } from '@formily/core'; import { ArrayField } from '@formily/core';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useCollectionManager } from '../../../collection-manager'; import { useCollectionManager } from '../../../collection-manager';
import { isTitleField } from '../../../collection-manager/Configuration/CollectionFields';
import { useCompile } from '../../../schema-component'; import { useCompile } from '../../../schema-component';
import { TreeNode } from '../TreeLabel'; import { TreeNode } from '../TreeLabel';
export const useCollectionState = (currentCollectionName: string) => { export const useCollectionState = (currentCollectionName: string) => {
const { getCollectionFields, getAllCollectionsInheritChain, getCollection } = useCollectionManager(); const { getCollectionFields, getAllCollectionsInheritChain, getCollection, getInterface } = useCollectionManager();
const [collectionList] = useState(getCollectionList); const [collectionList] = useState(getCollectionList);
const compile = useCompile(); const compile = useCompile();
@ -150,11 +151,78 @@ export const useCollectionState = (currentCollectionName: string) => {
}; };
}, []); }, []);
const getScopeDataSource = (resource: string) => {
const fields = getCollectionFields(resource);
const field2option = (field, depth) => {
if (!field.interface) {
return;
}
const fieldInterface = getInterface(field.interface);
if (!fieldInterface?.filterable) {
return;
}
const { nested, children, operators } = fieldInterface.filterable;
const option = {
name: field.name,
title: field?.uiSchema?.title || field.name,
schema: field?.uiSchema,
operators:
operators?.filter?.((operator) => {
return !operator?.visible || operator.visible(field);
}) || [],
interface: field.interface,
};
if (field.target && depth > 2) {
return;
}
if (depth > 2) {
return option;
}
if (children?.length) {
option['children'] = children;
}
if (nested) {
const targetFields = getCollectionFields(field.target);
const options = getOptions(targetFields, depth + 1).filter(Boolean);
option['children'] = option['children'] || [];
option['children'].push(...options);
}
return option;
};
const getOptions = (fields, depth) => {
const options = [];
fields.forEach((field) => {
const option = field2option(field, depth);
if (option) {
options.push(option);
}
});
return options;
};
const options = getOptions(fields, 1);
return options;
};
const useTitleFieldDataSource = (field) => {
const fieldPath = field.path.entire.replace('titleField', 'collection');
const collectionName = field.query(fieldPath).get('value');
const targetFields = getCollectionFields(collectionName);
const options = targetFields
.filter((field) => {
return !field.isForeignKey && getInterface(field.interface)?.titleUsable;
})
.map((field) => ({
value: field?.name,
label: compile(field?.uiSchema?.title) || field?.name,
}));
field.dataSource = options;
};
return { return {
collectionList, collectionList,
getEnableFieldTree, getEnableFieldTree,
getOnLoadData, getOnLoadData,
getOnCheck, getOnCheck,
getScopeDataSource,
useTitleFieldDataSource,
}; };
}; };