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",
"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"
"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":"現在のオブジェクト",
"Linkage with form fields":"フォームデータから連動",
"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":"从表单字段联动",
"Failed to load plugin": "插件加载失败",
"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 { error, forEach } from '@nocobase/utils/client';
import { Select } from 'antd';
import { Select, Space } from 'antd';
import _ from 'lodash';
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../api-client';
import { findFormBlock } from '../../../block-provider';
import { useCollectionManager } from '../../../collection-manager';
import { useDuplicatefieldsContext } from '../../../schema-initializer/components';
import { compatibleDataId } from '../../../schema-settings/DataTemplates/FormDataTemplates';
import { useToken } from '../__builtins__';
import { RemoteSelect } from '../remote-select';
export interface ITemplate {
config?: {
@ -23,9 +25,11 @@ export interface ITemplate {
key: string;
title: string;
collection: string;
dataId: number;
dataId?: number;
fields: string[];
default?: boolean;
dataScope?: object;
titleField?: string;
}[];
/** 是否在 Form 区块显示模板选择器 */
display: boolean;
@ -63,49 +67,38 @@ const useDataTemplates = () => {
key: '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);
return {
templates,
display,
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 }) => {
const { token } = useToken();
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 { t } = useTranslation();
useEffect(() => {
if (enabled && defaultTemplate) {
form.__template = true;
fetchTemplateData(api, defaultTemplate, t)
.then((data) => {
if (form && data) {
forEach(data, (value, key) => {
if (value) {
form.values[key] = value;
}
});
}
return data;
})
.catch((err) => {
console.error(err);
});
if (defaultTemplate.key === 'duplicate') {
handleTemplateDataChange(defaultTemplate.dataId, defaultTemplate);
}
}
}, []);
@ -122,46 +115,69 @@ export const Templates = ({ style = {}, form }) => {
return { fontSize: token.fontSize, fontWeight: 'bold', whiteSpace: 'nowrap', marginRight: token.marginXS };
}, [token.fontSize, token.marginXS]);
const handleChange = useCallback(async (value, option) => {
setValue(value);
if (option.key !== 'none') {
fetchTemplateData(api, option, t)
.then((data) => {
if (form && data) {
// 切换之前先把之前的数据清空
form.reset();
form.__template = true;
const handleTemplateChange = useCallback(async (value, option) => {
setTargetTemplate(value);
setTemplateData(null);
form?.reset();
}, []);
forEach(data, (value, key) => {
if (value) {
form.values[key] = value;
}
});
}
return data;
})
.catch((err) => {
console.error(err);
});
} else {
form?.reset();
}
const handleTemplateDataChange: any = useCallback(async (value, option) => {
const template = { ...option, dataId: value };
setTemplateData(option);
fetchTemplateData(api, template, t)
.then((data) => {
if (form && data) {
// 切换之前先把之前的数据清空
form.reset();
form.__template = true;
forEach(data, (value, key) => {
if (value) {
form.values[key] = value;
}
});
}
return data;
})
.catch((err) => {
console.error(err);
});
}, []);
if (!enabled || !display) {
return null;
}
const template = templateOptions?.find((v) => v.key === targetTemplate);
return (
<div style={wrapperStyle}>
<label style={labelStyle}>{t('Data template')}: </label>
<Select
popupMatchSelectWidth={false}
options={templates}
fieldNames={{ label: 'title', value: 'key' }}
value={value}
onChange={handleChange}
/>
<Space wrap>
<label style={labelStyle}>{t('Data template')}: </label>
<Select
popupMatchSelectWidth={false}
options={templateOptions}
fieldNames={{ label: 'title', value: 'key' }}
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>
);
};
@ -175,7 +191,7 @@ function findDataTemplates(fieldSchema): ITemplate {
}
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 api

View File

@ -7,16 +7,11 @@ import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { mergeFilter } from '../../block-provider';
import { useCollectionManager } from '../../collection-manager';
import {
AssociationSelect,
SchemaComponent,
SchemaComponentContext,
removeNullCondition,
} from '../../schema-component';
import { SchemaComponent, SchemaComponentContext, removeNullCondition } from '../../schema-component';
import { ITemplate } from '../../schema-component/antd/form-v2/Templates';
import { AsDefaultTemplate } from './components/AsDefaultTemplate';
import { ArrayCollapse } from './components/DataTemplateTitle';
import { Designer, getSelectedIdFilter } from './components/Designer';
import { getSelectedIdFilter } from './components/Designer';
import { useCollectionState } from './hooks/useCollectionState';
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(
(props: any) => {
const { useProps, formSchema, designerCtx } = props;
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 { t } = useTranslation();
@ -39,11 +53,15 @@ export const FormDataTemplates = observer(
const activeData = useMemo<ITemplate>(
() =>
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 collection = getCollection(collectionName);
return getCollectionField(
@ -63,8 +81,8 @@ export const FormDataTemplates = observer(
const filter = activeData.config?.[collectionName]?.filter;
return _.isEmpty(filter) ? {} : removeNullCondition(mergeFilter([filter, getSelectedIdFilter(value)], '$or'));
};
const components = useMemo(() => ({ ArrayCollapse }), []);
const scope = useMemo(
() => ({
getEnableFieldTree,
@ -75,6 +93,8 @@ export const FormDataTemplates = observer(
getOnLoadData,
getOnCheck,
collectionName,
getScopeDataSource,
useTitleFieldDataSource,
}),
[],
);
@ -117,49 +137,39 @@ export const FormDataTemplates = observer(
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-designer': Designer,
'x-designer-props': {
formSchema,
data: activeData,
},
dataScope: {
type: 'object',
title: '{{ t("Assign data scope for the template") }}',
'x-decorator': 'FormItem',
'x-component': AssociationSelect,
'x-component-props': {
service: {
resource: '{{ $record.collection || collectionName }}',
params: {
filter: '{{ getFilter($self.componentProps.service.resource, $self.value) }}',
},
'x-component': 'Filter',
'x-decorator-props': {
style: {
marginBottom: '0px',
},
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': [
{
dependencies: ['.collection'],
fulfill: {
state: {
disabled: '{{ !$deps[0] }}',
componentProps: {
service: {
resource: '{{ getResource($deps[0], $self) }}',
},
},
},
schema: {
enum: '{{ getScopeDataSource($deps[0]) }}',
},
},
},
],
},
titleField: {
type: 'string',
'x-decorator': 'FormItem',
title: '{{ t("Title field") }}',
'x-component': 'Select',
required: true,
'x-reactions': '{{useTitleFieldDataSource}}',
},
fields: {
type: 'array',
title: '{{ t("Data fields") }}',
@ -246,15 +256,6 @@ export function getLabel(titleField) {
return titleField || 'label';
}
function getMapOptions() {
return (option) => {
if (option?.id === undefined) {
return null;
}
return option;
};
}
function getResource(resource: string, field: Field) {
if (resource !== field.componentProps.service.resource) {
// 切换 collection 后,之前选中的其它 collection 的数据就没有意义了,需要清空

View File

@ -1,11 +1,12 @@
import { ArrayField } from '@formily/core';
import React, { useCallback, useState } from 'react';
import { useCollectionManager } from '../../../collection-manager';
import { isTitleField } from '../../../collection-manager/Configuration/CollectionFields';
import { useCompile } from '../../../schema-component';
import { TreeNode } from '../TreeLabel';
export const useCollectionState = (currentCollectionName: string) => {
const { getCollectionFields, getAllCollectionsInheritChain, getCollection } = useCollectionManager();
const { getCollectionFields, getAllCollectionsInheritChain, getCollection, getInterface } = useCollectionManager();
const [collectionList] = useState(getCollectionList);
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 {
collectionList,
getEnableFieldTree,
getOnLoadData,
getOnCheck,
getScopeDataSource,
useTitleFieldDataSource,
};
};