mirror of
https://gitee.com/nocobase/nocobase.git
synced 2024-12-05 05:38:23 +08:00
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:
parent
505c23b4e1
commit
7dd7a65a38
@ -1114,7 +1114,7 @@ export function getAssociationPath(str) {
|
||||
}
|
||||
|
||||
export const useAssociationNames = () => {
|
||||
const { getCollectionJoinField } = useCollectionManager();
|
||||
const { getCollectionJoinField, getCollection } = useCollectionManager();
|
||||
const fieldSchema = useFieldSchema();
|
||||
const updateAssociationValues = new Set([]);
|
||||
const appends = new Set([]);
|
||||
@ -1125,10 +1125,16 @@ export const useAssociationNames = () => {
|
||||
const isAssociationSubfield = s.name.includes('.');
|
||||
const isAssociationField =
|
||||
collectionfield && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(collectionfield.type);
|
||||
const isTreeCollection = isAssociationField && getCollection(collectionfield.target).template === 'tree';
|
||||
if (collectionfield && (isAssociationField || isAssociationSubfield) && s['x-component'] !== 'TableField') {
|
||||
const fieldPath = !isAssociationField && isAssociationSubfield ? getAssociationPath(s.name) : s.name;
|
||||
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)) {
|
||||
updateAssociationValues.add(path);
|
||||
const bufPrefix = prefix && prefix !== '' ? prefix + '.' + s.name : s.name;
|
||||
|
@ -778,6 +778,7 @@ export default {
|
||||
"Select all":"Select all",
|
||||
"Restart": "Restart",
|
||||
"Restart application": "Restart application",
|
||||
"Cascade Select":"Cascade Select",
|
||||
Execute: 'Execute',
|
||||
'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',
|
||||
|
@ -627,6 +627,7 @@ export default {
|
||||
"Sync successfully":"同期成功",
|
||||
"Sync from form fields":"フォームフィールドの同期",
|
||||
"Select all": "すべて選択",
|
||||
"Cascade Select":"カスケード選択",
|
||||
"New plugin": "新しいプラグイン",
|
||||
"Upgrade": "アップグレード",
|
||||
"Dependencies check failed": "依存関係のチェックに失敗しました",
|
||||
|
@ -872,6 +872,7 @@ export default {
|
||||
'Sync from form fields': '同步表单字段',
|
||||
'Select all': '全选',
|
||||
'Determine whether a record exists by the following fields': '通过以下字段判断记录是否存在',
|
||||
'Cascade Select': '级联选择',
|
||||
Execute: '执行',
|
||||
'Please use a valid SELECT or WITH AS statement': '请使用有效的 SELECT 或 WITH AS 语句',
|
||||
'Please confirm the SQL statement first': '请先确认 SQL 语句',
|
||||
|
@ -109,27 +109,4 @@ interface AssociationSelectInterface {
|
||||
FilterDesigner: React.FC;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
export const AssociationSelect = InternalAssociationSelect as unknown as AssociationSelectInterface;
|
||||
|
@ -10,6 +10,7 @@ import { InternalNester } from './InternalNester';
|
||||
import { InternalPicker } from './InternalPicker';
|
||||
import { InternalSubTable } from './InternalSubTable';
|
||||
import { InternaPopoverNester } from './InternalPopoverNester';
|
||||
import { InternalCascadeSelect } from './InternalCascadeSelect';
|
||||
import { CreateRecordAction } from './components/CreateRecordAction';
|
||||
import { useAssociationFieldContext } from './hooks';
|
||||
import { useCollection } from '../../../collection-manager';
|
||||
@ -55,6 +56,7 @@ const EditableAssociationField = observer(
|
||||
{currentMode === 'Select' && <AssociationSelect {...props} />}
|
||||
{currentMode === 'SubTable' && <InternalSubTable {...props} />}
|
||||
{currentMode === 'FileManager' && <InternalFileManager {...props} />}
|
||||
{currentMode === 'CascadeSelect' && <InternalCascadeSelect {...props} />}
|
||||
</SchemaComponentOptions>
|
||||
);
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
@ -3,7 +3,7 @@ import { toArr } from '@formily/shared';
|
||||
import React, { Fragment, useRef, useState } from 'react';
|
||||
import { useDesignable } from '../../';
|
||||
import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider';
|
||||
import { CollectionProvider } from '../../../collection-manager';
|
||||
import { CollectionProvider, useCollectionManager } from '../../../collection-manager';
|
||||
import { RecordProvider, useRecord } from '../../../record-provider';
|
||||
import { FormProvider } from '../../core';
|
||||
import { useCompile } from '../../hooks';
|
||||
@ -12,6 +12,7 @@ import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip';
|
||||
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';
|
||||
import schema from './schema';
|
||||
import { getLabelFormatValue, useLabelUiSchema } from './util';
|
||||
import { transformNestedData } from './InternalCascadeSelect';
|
||||
|
||||
interface IEllipsisWithTooltipRef {
|
||||
setPopoverVisible: (boolean) => void;
|
||||
@ -27,6 +28,7 @@ export const ReadPrettyInternalViewer: React.FC = observer(
|
||||
(props: any) => {
|
||||
const fieldSchema = useFieldSchema();
|
||||
const recordCtx = useRecord();
|
||||
const { getCollection } = useCollectionManager();
|
||||
const { enableLink } = fieldSchema['x-component-props'] || {};
|
||||
// value 做了转换,但 props.value 和原来 useField().value 的值不一致
|
||||
const field = useField();
|
||||
@ -38,10 +40,17 @@ export const ReadPrettyInternalViewer: React.FC = observer(
|
||||
const compile = useCompile();
|
||||
const { designable } = useDesignable();
|
||||
const { snapshot } = useActionContext();
|
||||
const targetCollection = getCollection(collectionField?.target);
|
||||
const isTreeCollection = targetCollection.template === 'tree';
|
||||
const ellipsisWithTooltipRef = useRef<IEllipsisWithTooltipRef>();
|
||||
const renderRecords = () =>
|
||||
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(
|
||||
record?.__collection || collectionField?.target,
|
||||
fieldNames?.label || 'label',
|
||||
|
@ -14,7 +14,7 @@ const ReadPrettyAssociationField = observer(
|
||||
|
||||
return (
|
||||
<>
|
||||
{['Select', 'Picker'].includes(currentMode) && <ReadPrettyInternalViewer {...props} />}
|
||||
{['Select', 'Picker', 'CascadeSelect'].includes(currentMode) && <ReadPrettyInternalViewer {...props} />}
|
||||
{currentMode === 'Tag' && <ReadPrettyInternalTag {...props} />}
|
||||
{currentMode === 'Nester' && <InternalNester {...props} />}
|
||||
{currentMode === 'SubTable' && <InternalSubTable {...props} />}
|
||||
|
@ -34,6 +34,21 @@ export const useFieldModeOptions = (props?) => {
|
||||
!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) {
|
||||
case 'o2m':
|
||||
return isReadPretty
|
||||
|
Loading…
Reference in New Issue
Block a user