From 7dd7a65a38bf0b8ff57a7aeb24d9083f106451c0 Mon Sep 17 00:00:00 2001 From: katherinehhh Date: Mon, 25 Sep 2023 18:18:14 +0800 Subject: [PATCH] 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 Co-authored-by: chenos --- .../client/src/block-provider/hooks/index.ts | 10 +- packages/core/client/src/locale/en_US.ts | 1 + packages/core/client/src/locale/ja_JP.ts | 1 + packages/core/client/src/locale/zh_CN.ts | 1 + .../association-field/AssociationSelect.tsx | 25 +- .../antd/association-field/Editable.tsx | 2 + .../InternalCascadeSelect.tsx | 379 ++++++++++++++++++ .../antd/association-field/InternalViewer.tsx | 13 +- .../antd/association-field/ReadPretty.tsx | 2 +- .../hooks/useFieldModeOptions.tsx | 15 + 10 files changed, 420 insertions(+), 29 deletions(-) create mode 100644 packages/core/client/src/schema-component/antd/association-field/InternalCascadeSelect.tsx 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