feat: association support select cascade for tree collection field (#2514)

* feat: association field support cascade select

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: locale improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: cascadeSelect support m2m association field

* refactor: cascadeSelect support m2m association field

* refactor: code improve

* feat(database): append with options

* feat: recursively load parent instances

* chore: test

* refactor: code improve

* fix: load with appends

* refactor: code improve

* chore: test

* refactor: code improve

* refactor: code improve

* refactor: code improve

* chore: load with belongs to many

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

---------

Co-authored-by: ChengLei Shao <chareice@live.com>
Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
katherinehhh 2023-09-25 18:18:14 +08:00 committed by GitHub
parent 505c23b4e1
commit 7dd7a65a38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 420 additions and 29 deletions

View File

@ -1114,7 +1114,7 @@ export function getAssociationPath(str) {
} }
export const useAssociationNames = () => { export const useAssociationNames = () => {
const { getCollectionJoinField } = useCollectionManager(); const { getCollectionJoinField, getCollection } = useCollectionManager();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const updateAssociationValues = new Set([]); const updateAssociationValues = new Set([]);
const appends = new Set([]); const appends = new Set([]);
@ -1125,10 +1125,16 @@ export const useAssociationNames = () => {
const isAssociationSubfield = s.name.includes('.'); const isAssociationSubfield = s.name.includes('.');
const isAssociationField = const isAssociationField =
collectionfield && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(collectionfield.type); collectionfield && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(collectionfield.type);
const isTreeCollection = isAssociationField && getCollection(collectionfield.target).template === 'tree';
if (collectionfield && (isAssociationField || isAssociationSubfield) && s['x-component'] !== 'TableField') { if (collectionfield && (isAssociationField || isAssociationSubfield) && s['x-component'] !== 'TableField') {
const fieldPath = !isAssociationField && isAssociationSubfield ? getAssociationPath(s.name) : s.name; const fieldPath = !isAssociationField && isAssociationSubfield ? getAssociationPath(s.name) : s.name;
const path = prefix === '' || !prefix ? fieldPath : prefix + '.' + fieldPath; const path = prefix === '' || !prefix ? fieldPath : prefix + '.' + fieldPath;
appends.add(path); if (isTreeCollection) {
appends.add(path);
appends.add(`${path}.parent` + '(recursively=true)');
} else {
appends.add(path);
}
if (['Nester', 'SubTable', 'PopoverNester'].includes(s['x-component-props']?.mode)) { if (['Nester', 'SubTable', 'PopoverNester'].includes(s['x-component-props']?.mode)) {
updateAssociationValues.add(path); updateAssociationValues.add(path);
const bufPrefix = prefix && prefix !== '' ? prefix + '.' + s.name : s.name; const bufPrefix = prefix && prefix !== '' ? prefix + '.' + s.name : s.name;

View File

@ -778,6 +778,7 @@ export default {
"Select all":"Select all", "Select all":"Select all",
"Restart": "Restart", "Restart": "Restart",
"Restart application": "Restart application", "Restart application": "Restart application",
"Cascade Select":"Cascade Select",
Execute: 'Execute', Execute: 'Execute',
'Please use a valid SELECT or WITH AS statement': 'Please use a valid SELECT or WITH AS statement', 'Please use a valid SELECT or WITH AS statement': 'Please use a valid SELECT or WITH AS statement',
'Please confirm the SQL statement first': 'Please confirm the SQL statement first', 'Please confirm the SQL statement first': 'Please confirm the SQL statement first',

View File

@ -627,6 +627,7 @@ export default {
"Sync successfully":"同期成功", "Sync successfully":"同期成功",
"Sync from form fields":"フォームフィールドの同期", "Sync from form fields":"フォームフィールドの同期",
"Select all": "すべて選択", "Select all": "すべて選択",
"Cascade Select":"カスケード選択",
"New plugin": "新しいプラグイン", "New plugin": "新しいプラグイン",
"Upgrade": "アップグレード", "Upgrade": "アップグレード",
"Dependencies check failed": "依存関係のチェックに失敗しました", "Dependencies check failed": "依存関係のチェックに失敗しました",

View File

@ -872,6 +872,7 @@ export default {
'Sync from form fields': '同步表单字段', 'Sync from form fields': '同步表单字段',
'Select all': '全选', 'Select all': '全选',
'Determine whether a record exists by the following fields': '通过以下字段判断记录是否存在', 'Determine whether a record exists by the following fields': '通过以下字段判断记录是否存在',
'Cascade Select': '级联选择',
Execute: '执行', Execute: '执行',
'Please use a valid SELECT or WITH AS statement': '请使用有效的 SELECT 或 WITH AS 语句', 'Please use a valid SELECT or WITH AS statement': '请使用有效的 SELECT 或 WITH AS 语句',
'Please confirm the SQL statement first': '请先确认 SQL 语句', 'Please confirm the SQL statement first': '请先确认 SQL 语句',

View File

@ -109,27 +109,4 @@ interface AssociationSelectInterface {
FilterDesigner: React.FC; FilterDesigner: React.FC;
} }
export const AssociationSelect = (InternalAssociationSelect as unknown) as AssociationSelectInterface; export const AssociationSelect = InternalAssociationSelect as unknown as AssociationSelectInterface;
export const AssociationSelectReadPretty = connect(
(props: any) => {
const service = useServiceOptions(props);
if (props.fieldNames) {
return <RemoteSelect.ReadPretty {...props} service={service}></RemoteSelect.ReadPretty>;
}
return null;
},
mapProps(
{
dataSource: 'options',
loading: true,
},
(props, field) => {
return {
...props,
fieldNames: props.fieldNames && { ...props.fieldNames, ...field.componentProps.fieldNames },
suffixIcon: field?.['loading'] || field?.['validating'] ? <LoadingOutlined /> : props.suffixIcon,
};
},
),
);

View File

@ -10,6 +10,7 @@ import { InternalNester } from './InternalNester';
import { InternalPicker } from './InternalPicker'; import { InternalPicker } from './InternalPicker';
import { InternalSubTable } from './InternalSubTable'; import { InternalSubTable } from './InternalSubTable';
import { InternaPopoverNester } from './InternalPopoverNester'; import { InternaPopoverNester } from './InternalPopoverNester';
import { InternalCascadeSelect } from './InternalCascadeSelect';
import { CreateRecordAction } from './components/CreateRecordAction'; import { CreateRecordAction } from './components/CreateRecordAction';
import { useAssociationFieldContext } from './hooks'; import { useAssociationFieldContext } from './hooks';
import { useCollection } from '../../../collection-manager'; import { useCollection } from '../../../collection-manager';
@ -55,6 +56,7 @@ const EditableAssociationField = observer(
{currentMode === 'Select' && <AssociationSelect {...props} />} {currentMode === 'Select' && <AssociationSelect {...props} />}
{currentMode === 'SubTable' && <InternalSubTable {...props} />} {currentMode === 'SubTable' && <InternalSubTable {...props} />}
{currentMode === 'FileManager' && <InternalFileManager {...props} />} {currentMode === 'FileManager' && <InternalFileManager {...props} />}
{currentMode === 'CascadeSelect' && <InternalCascadeSelect {...props} />}
</SchemaComponentOptions> </SchemaComponentOptions>
); );
}, },

View File

@ -0,0 +1,379 @@
import { observer, useField, connect, createSchemaField, FormProvider, useFieldSchema, Field } from '@formily/react';
import { createForm, onFormValuesChange } from '@formily/core';
import { uid } from '@formily/shared';
import { Space, Tag, Spin, Select as AntdSelect, Input } from 'antd';
import { ArrayItems, FormItem } from '@formily/antd-v5';
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { useCompile, SchemaComponent } from '../../../schema-component';
import { useAPIClient, useCollectionManager } from '../../../';
import { mergeFilter } from '../../../block-provider/SharedFilterProvider';
import useServiceOptions, { useAssociationFieldContext } from './hooks';
const EMPTY = 'N/A';
const SchemaField = createSchemaField({
components: {
Space,
Input,
ArrayItems,
FormItem,
},
});
const CascadeSelect = connect((props) => {
const { data, mapOptions, onChange } = props;
const [selectedOptions, setSelectedOptions] = useState<{ key: string; children: any; value?: any }[]>([
{ key: undefined, children: [], value: null },
]);
const [options, setOptions] = useState(data);
const [loading, setLoading] = useState(false);
const compile = useCompile();
const api = useAPIClient();
const service = useServiceOptions(props);
const { options: collectionField, field: associationField } = useAssociationFieldContext<any>();
const resource = api.resource(collectionField.target);
const { getCollectionJoinField, getInterface } = useCollectionManager();
const fieldNames = associationField?.componentProps?.fieldNames;
const targetField =
collectionField?.target &&
fieldNames?.label &&
getCollectionJoinField(`${collectionField.target}.${fieldNames.label}`);
const operator = useMemo(() => {
if (targetField?.interface) {
return getInterface(targetField.interface)?.filterable?.operators[0].value || '$includes';
}
return '$includes';
}, [targetField]);
const field: any = useField();
useEffect(() => {
if (props.value) {
const values = Array.isArray(props.value)
? extractLastNonNullValueObjects(props.value?.filter((v) => v.value), true)
: transformNestedData(props.value);
const options = values?.map?.((v) => {
return {
key: v.parentId,
children: [],
value: v,
};
});
setSelectedOptions(options);
}
}, []);
const mapOptionsToTags = useCallback(
(options) => {
try {
return options
?.filter((v) => ['number', 'string'].includes(typeof v[fieldNames.value]))
.map((option) => {
let label = compile(option[fieldNames.label]);
if (targetField?.uiSchema?.enum) {
if (Array.isArray(label)) {
label = label
.map((item, index) => {
const option = targetField.uiSchema.enum.find((i) => i.value === item);
if (option) {
return (
<Tag key={index} color={option.color} style={{ marginRight: 3 }}>
{option?.label || item}
</Tag>
);
} else {
return <Tag key={item}>{item}</Tag>;
}
})
.reverse();
} else {
const item = targetField.uiSchema.enum.find((i) => i.value === label);
if (item) {
label = <Tag color={item.color}>{item.label}</Tag>;
}
}
}
if (targetField?.type === 'date') {
label = dayjs(label).format('YYYY-MM-DD');
}
if (mapOptions) {
return mapOptions({
[fieldNames.label]: label || EMPTY,
[fieldNames.value]: option[fieldNames.value],
});
}
return {
...option,
[fieldNames.label]: label || EMPTY,
[fieldNames.value]: option[fieldNames.value],
};
})
.filter(Boolean);
} catch (err) {
console.error(err);
return options;
}
},
[targetField?.uiSchema, fieldNames],
);
const handleGetOptions = async (filter) => {
const response = await resource.list({
pageSize: 200,
params: service?.params,
filter: mergeFilter([service?.params?.filter, filter]),
tree: !filter.parentId ? true : undefined,
});
return response?.data?.data;
};
const handleSelect = async (value, option, index) => {
const data = await handleGetOptions({ parentId: option?.id });
const options = [...selectedOptions];
options.splice(index + 1);
if (value) {
options[index] = { ...options[index], value: option };
options[index + 1] = { key: option?.id, children: data?.length > 0 ? data : null };
}
setSelectedOptions(options);
if (['o2m', 'm2m'].includes(collectionField.interface)) {
const fieldValue = Array.isArray(associationField.fieldValue) ? associationField.fieldValue : [];
fieldValue[field.index] = option;
associationField.fieldValue = fieldValue;
} else {
associationField.value = option;
}
onChange?.(options);
};
const onDropdownVisibleChange = async (visible, selectedValue, index) => {
if (visible) {
setLoading(true);
const result = await handleGetOptions({ parentId: selectedValue?.key });
setLoading(false);
setOptions(result);
if (index === selectedOptions?.length - 1 && selectedValue?.value?.id) {
const data = await handleGetOptions({ parentId: selectedValue?.value?.id });
const options = [...selectedOptions];
options.splice(index + 1);
options[index] = { ...options[index], value: selectedValue?.value };
options[index + 1] = { key: selectedValue?.value?.id, children: data?.length > 0 ? data : null };
setSelectedOptions(options);
onChange?.(options);
}
}
};
const onSearch = async (search, selectedValue) => {
const serachParam = search
? {
[fieldNames.label]: {
[operator]: search,
},
}
: {};
setLoading(true);
const result = await handleGetOptions({
...serachParam,
parentId: selectedValue?.key,
});
setLoading(false);
setOptions(result);
};
return (
<Space wrap>
{selectedOptions.map((value, index) => {
return (
value.children && (
<AntdSelect
disabled={associationField.disabled}
key={`${value.value?.id}+ ${value.key} + ${fieldNames.label}`}
allowClear
showSearch
autoClearSearchValue
filterOption={false}
filterSort={null}
defaultValue={{
label: value?.value?.[fieldNames.label],
value: value?.value?.[fieldNames.value],
}}
labelInValue
onSearch={(search) => onSearch(search, value)}
fieldNames={fieldNames}
style={{ minWidth: 150 }}
onChange={((value, option) => handleSelect(value, option, index)) as any}
options={!loading ? mapOptionsToTags(options) : []}
onDropdownVisibleChange={(open) => onDropdownVisibleChange(open, value, index)}
notFoundContent={loading ? <Spin size="small" /> : null}
/>
)
);
})}
</Space>
);
});
const AssociationCascadeSelect = connect((props: any) => {
return (
<div>
<CascadeSelect {...props} />
</div>
);
});
export const InternalCascadeSelect = observer(
(props: any) => {
const { options: collectionField } = useAssociationFieldContext();
const selectForm = useMemo(() => createForm(), []);
const { t } = useTranslation();
const field: any = useField();
const fieldSchema = useFieldSchema();
useEffect(() => {
const id = uid();
selectForm.addEffects(id, () => {
onFormValuesChange((form) => {
if (collectionField.interface === 'm2o') {
const value = extractLastNonNullValueObjects(form.values?.[fieldSchema.name]);
setTimeout(() => {
form.setValuesIn(fieldSchema.name, value);
props.onChange(value);
field.value = value;
});
} else {
const value = extractLastNonNullValueObjects(form.values?.select_array).filter(
(v) => v && Object.keys(v).length > 0,
);
setTimeout(() => {
field.value = value;
props.onChange(value);
});
}
});
});
return () => {
selectForm.removeEffects(id);
};
}, []);
const toValue = () => {
if (Array.isArray(field.value) && field.value.length > 0) {
return field.value;
}
return [{}];
};
const defaultValue = toValue();
const schema = {
type: 'object',
properties: {
select_array: {
type: 'array',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
default: defaultValue,
items: {
type: 'void',
'x-component': 'Space',
properties: {
sort: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.SortHandle',
},
select: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': AssociationCascadeSelect,
'x-component-props': {
...props,
},
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
properties: {
add: {
type: 'void',
title: t('Add new'),
'x-component': 'ArrayItems.Addition',
},
},
},
},
};
return (
<FormProvider form={selectForm}>
{collectionField.interface === 'm2o' ? (
<SchemaComponent
components={{ FormItem }}
schema={{
...fieldSchema,
default: field.value,
'x-component': AssociationCascadeSelect,
'x-component-props': {
...props,
},
}}
/>
) : (
<SchemaField schema={schema} />
)}
</FormProvider>
);
},
{ displayName: 'InternalCascadeSelect' },
);
function extractLastNonNullValueObjects(data, flag?) {
let result = [];
if (!Array.isArray(data)) {
return data;
}
for (const sublist of data) {
let lastNonNullValue = null;
if (Array.isArray(sublist)) {
for (let i = sublist?.length - 1; i >= 0; i--) {
if (sublist[i].value) {
lastNonNullValue = sublist[i].value;
break;
}
}
if (lastNonNullValue) {
result.push(lastNonNullValue);
}
} else {
if (sublist?.value) {
lastNonNullValue = sublist.value;
} else {
lastNonNullValue = null;
}
if (lastNonNullValue) {
if (flag) {
result?.push?.(lastNonNullValue);
} else {
result = lastNonNullValue;
}
} else {
result?.push?.(sublist);
}
}
}
return result;
}
export function transformNestedData(inputData) {
const resultArray = [];
function recursiveTransform(data) {
if (data?.parent) {
const { parent } = data;
recursiveTransform(parent);
}
const { parent, ...other } = data;
resultArray.push(other);
}
if (inputData) {
recursiveTransform(inputData);
}
return resultArray;
}

View File

@ -3,7 +3,7 @@ import { toArr } from '@formily/shared';
import React, { Fragment, useRef, useState } from 'react'; import React, { Fragment, useRef, useState } from 'react';
import { useDesignable } from '../../'; import { useDesignable } from '../../';
import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider'; import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider';
import { CollectionProvider } from '../../../collection-manager'; import { CollectionProvider, useCollectionManager } from '../../../collection-manager';
import { RecordProvider, useRecord } from '../../../record-provider'; import { RecordProvider, useRecord } from '../../../record-provider';
import { FormProvider } from '../../core'; import { FormProvider } from '../../core';
import { useCompile } from '../../hooks'; import { useCompile } from '../../hooks';
@ -12,6 +12,7 @@ import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip';
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks'; import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';
import schema from './schema'; import schema from './schema';
import { getLabelFormatValue, useLabelUiSchema } from './util'; import { getLabelFormatValue, useLabelUiSchema } from './util';
import { transformNestedData } from './InternalCascadeSelect';
interface IEllipsisWithTooltipRef { interface IEllipsisWithTooltipRef {
setPopoverVisible: (boolean) => void; setPopoverVisible: (boolean) => void;
@ -27,6 +28,7 @@ export const ReadPrettyInternalViewer: React.FC = observer(
(props: any) => { (props: any) => {
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const recordCtx = useRecord(); const recordCtx = useRecord();
const { getCollection } = useCollectionManager();
const { enableLink } = fieldSchema['x-component-props'] || {}; const { enableLink } = fieldSchema['x-component-props'] || {};
// value 做了转换,但 props.value 和原来 useField().value 的值不一致 // value 做了转换,但 props.value 和原来 useField().value 的值不一致
const field = useField(); const field = useField();
@ -38,10 +40,17 @@ export const ReadPrettyInternalViewer: React.FC = observer(
const compile = useCompile(); const compile = useCompile();
const { designable } = useDesignable(); const { designable } = useDesignable();
const { snapshot } = useActionContext(); const { snapshot } = useActionContext();
const targetCollection = getCollection(collectionField?.target);
const isTreeCollection = targetCollection.template === 'tree';
const ellipsisWithTooltipRef = useRef<IEllipsisWithTooltipRef>(); const ellipsisWithTooltipRef = useRef<IEllipsisWithTooltipRef>();
const renderRecords = () => const renderRecords = () =>
toArr(props.value).map((record, index, arr) => { toArr(props.value).map((record, index, arr) => {
const val = toValue(compile(record?.[fieldNames?.label || 'label']), 'N/A'); const label = isTreeCollection
? transformNestedData(record)
.map((o) => o?.[fieldNames?.label || 'label'])
.join(' / ')
: record?.[fieldNames?.label || 'label'];
const val = toValue(compile(label), 'N/A');
const labelUiSchema = useLabelUiSchema( const labelUiSchema = useLabelUiSchema(
record?.__collection || collectionField?.target, record?.__collection || collectionField?.target,
fieldNames?.label || 'label', fieldNames?.label || 'label',

View File

@ -14,7 +14,7 @@ const ReadPrettyAssociationField = observer(
return ( return (
<> <>
{['Select', 'Picker'].includes(currentMode) && <ReadPrettyInternalViewer {...props} />} {['Select', 'Picker', 'CascadeSelect'].includes(currentMode) && <ReadPrettyInternalViewer {...props} />}
{currentMode === 'Tag' && <ReadPrettyInternalTag {...props} />} {currentMode === 'Tag' && <ReadPrettyInternalTag {...props} />}
{currentMode === 'Nester' && <InternalNester {...props} />} {currentMode === 'Nester' && <InternalNester {...props} />}
{currentMode === 'SubTable' && <InternalSubTable {...props} />} {currentMode === 'SubTable' && <InternalSubTable {...props} />}

View File

@ -34,6 +34,21 @@ export const useFieldModeOptions = (props?) => {
!isSubTableField && { label: t('File manager'), value: 'FileManager' }, !isSubTableField && { label: t('File manager'), value: 'FileManager' },
]; ];
} }
if (collection?.template === 'tree' && ['m2m', 'o2m', 'm2o'].includes(collectionField.interface)) {
return isReadPretty
? [
{ label: t('Title'), value: 'Select' },
{ label: t('Tag'), value: 'Tag' },
]
: [
{ label: t('Select'), value: 'Select' },
{ label: t('Record picker'), value: 'Picker' },
{ label: t('Sub-table'), value: 'SubTable' },
{ label: t('Cascade Select'), value: 'CascadeSelect' },
!isSubTableField && { label: t('Sub-form'), value: 'Nester' },
{ label: t('Sub-form(Popover)'), value: 'PopoverNester' },
];
}
switch (collectionField.interface) { switch (collectionField.interface) {
case 'o2m': case 'o2m':
return isReadPretty return isReadPretty