diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts
index 7b5891a0d1..34a499788b 100644
--- a/packages/core/client/src/block-provider/hooks/index.ts
+++ b/packages/core/client/src/block-provider/hooks/index.ts
@@ -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;
diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts
index de2a473272..c025ca82d1 100644
--- a/packages/core/client/src/locale/en_US.ts
+++ b/packages/core/client/src/locale/en_US.ts
@@ -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',
diff --git a/packages/core/client/src/locale/ja_JP.ts b/packages/core/client/src/locale/ja_JP.ts
index 7ee02626fb..c9fb96d5d7 100644
--- a/packages/core/client/src/locale/ja_JP.ts
+++ b/packages/core/client/src/locale/ja_JP.ts
@@ -627,6 +627,7 @@ export default {
"Sync successfully":"同期成功",
"Sync from form fields":"フォームフィールドの同期",
"Select all": "すべて選択",
+ "Cascade Select":"カスケード選択",
"New plugin": "新しいプラグイン",
"Upgrade": "アップグレード",
"Dependencies check failed": "依存関係のチェックに失敗しました",
diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts
index be748a96be..f01a58a5fd 100644
--- a/packages/core/client/src/locale/zh_CN.ts
+++ b/packages/core/client/src/locale/zh_CN.ts
@@ -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 语句',
diff --git a/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx b/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx
index cd6865befb..deefe452bb 100644
--- a/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx
+++ b/packages/core/client/src/schema-component/antd/association-field/AssociationSelect.tsx
@@ -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 ;
- }
- return null;
- },
- mapProps(
- {
- dataSource: 'options',
- loading: true,
- },
- (props, field) => {
- return {
- ...props,
- fieldNames: props.fieldNames && { ...props.fieldNames, ...field.componentProps.fieldNames },
- suffixIcon: field?.['loading'] || field?.['validating'] ? : props.suffixIcon,
- };
- },
- ),
-);
+export const AssociationSelect = InternalAssociationSelect as unknown as AssociationSelectInterface;
diff --git a/packages/core/client/src/schema-component/antd/association-field/Editable.tsx b/packages/core/client/src/schema-component/antd/association-field/Editable.tsx
index c03e623413..bf2466e5c6 100644
--- a/packages/core/client/src/schema-component/antd/association-field/Editable.tsx
+++ b/packages/core/client/src/schema-component/antd/association-field/Editable.tsx
@@ -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' && }
{currentMode === 'SubTable' && }
{currentMode === 'FileManager' && }
+ {currentMode === 'CascadeSelect' && }
);
},
diff --git a/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx b/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx
new file mode 100644
index 0000000000..a6b8200f1d
--- /dev/null
+++ b/packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx
@@ -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();
+ 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 (
+
+ {option?.label || item}
+
+ );
+ } else {
+ return {item};
+ }
+ })
+ .reverse();
+ } else {
+ const item = targetField.uiSchema.enum.find((i) => i.value === label);
+ if (item) {
+ label = {item.label};
+ }
+ }
+ }
+ 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 (
+
+ {selectedOptions.map((value, index) => {
+ return (
+ value.children && (
+ 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 ? : null}
+ />
+ )
+ );
+ })}
+
+ );
+});
+const AssociationCascadeSelect = connect((props: any) => {
+ return (
+
+
+
+ );
+});
+
+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 (
+
+ {collectionField.interface === 'm2o' ? (
+
+ ) : (
+
+ )}
+
+ );
+ },
+ { 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;
+}
diff --git a/packages/core/client/src/schema-component/antd/association-field/InternalViewer.tsx b/packages/core/client/src/schema-component/antd/association-field/InternalViewer.tsx
index ef039e817e..57bc6db526 100644
--- a/packages/core/client/src/schema-component/antd/association-field/InternalViewer.tsx
+++ b/packages/core/client/src/schema-component/antd/association-field/InternalViewer.tsx
@@ -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();
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',
diff --git a/packages/core/client/src/schema-component/antd/association-field/ReadPretty.tsx b/packages/core/client/src/schema-component/antd/association-field/ReadPretty.tsx
index d68ee7e23e..dcc3ff57e5 100644
--- a/packages/core/client/src/schema-component/antd/association-field/ReadPretty.tsx
+++ b/packages/core/client/src/schema-component/antd/association-field/ReadPretty.tsx
@@ -14,7 +14,7 @@ const ReadPrettyAssociationField = observer(
return (
<>
- {['Select', 'Picker'].includes(currentMode) && }
+ {['Select', 'Picker', 'CascadeSelect'].includes(currentMode) && }
{currentMode === 'Tag' && }
{currentMode === 'Nester' && }
{currentMode === 'SubTable' && }
diff --git a/packages/core/client/src/schema-component/hooks/useFieldModeOptions.tsx b/packages/core/client/src/schema-component/hooks/useFieldModeOptions.tsx
index 777034e85d..5e68e22bd0 100644
--- a/packages/core/client/src/schema-component/hooks/useFieldModeOptions.tsx
+++ b/packages/core/client/src/schema-component/hooks/useFieldModeOptions.tsx
@@ -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