diff --git a/assets/studio/js/src/modules/data-importer/components/tabs/steps/advanced-mapping-modal/hooks/use-auto-recalculate-type.ts b/assets/studio/js/src/modules/data-importer/components/tabs/steps/advanced-mapping-modal/hooks/use-auto-recalculate-type.ts index 4bfb0238..c2b3985b 100644 --- a/assets/studio/js/src/modules/data-importer/components/tabs/steps/advanced-mapping-modal/hooks/use-auto-recalculate-type.ts +++ b/assets/studio/js/src/modules/data-importer/components/tabs/steps/advanced-mapping-modal/hooks/use-auto-recalculate-type.ts @@ -9,7 +9,7 @@ */ import { useEffect, useMemo, useRef } from 'react' -import { type MappingConfigItem, type ClassAttribute, resolveAttrMapKey, DEFAULT_ATTR_MAP_KEY } from '../../../../../types' +import { type MappingConfigItem, type ClassAttribute, resolveAttrMapKey, parseAttrMapKey, DEFAULT_ATTR_MAP_KEY } from '../../../../../types' import { useBundleDataImporterDataTypeLoadClassAttributesQuery } from '../../../../../data-importer-api-slice.gen' import { parseClassAttribute } from '../../mapping-step/hooks/use-mapping-step-loader.types' @@ -75,12 +75,12 @@ export function useAutoRecalculateType ({ }) }, [pipelineKey, dataSourceKey, open, configName, localItemRef, setCalculateTypeRequest]) - const currentAttrMapKey = resolveAttrMapKey(localItem.transformationResultType) + const currentAttrMapKey = resolveAttrMapKey(localItem.transformationResultType, localItem.dataTarget?.type) const needsAttrFetch = currentAttrMapKey !== DEFAULT_ATTR_MAP_KEY && attributesMap[currentAttrMapKey] === undefined const { data: extraAttrData, isFetching: isFetchingExtraAttributes } = useBundleDataImporterDataTypeLoadClassAttributesQuery( { classId: classId ?? '', - transformationResultType: localItem.transformationResultType, + ...parseAttrMapKey(currentAttrMapKey), systemWrite: true }, { skip: !needsAttrFetch || classId === undefined || classId === '' } diff --git a/assets/studio/js/src/modules/data-importer/components/tabs/steps/advanced-mapping-modal/step-target/useClassAttributes.ts b/assets/studio/js/src/modules/data-importer/components/tabs/steps/advanced-mapping-modal/step-target/useClassAttributes.ts index ad8e31b7..531fd83c 100644 --- a/assets/studio/js/src/modules/data-importer/components/tabs/steps/advanced-mapping-modal/step-target/useClassAttributes.ts +++ b/assets/studio/js/src/modules/data-importer/components/tabs/steps/advanced-mapping-modal/step-target/useClassAttributes.ts @@ -21,8 +21,8 @@ export function useClassAttributes ({ }: StepTargetProps): { classFieldOptions: Array<{ value: string, label: string }>, isLocalized: boolean } { const { isFetchingAttributes, calculateTypeError } = useResultPreviewContext() const attributes = useMemo( - () => attributesMap[resolveAttrMapKey(transformationResultType)] ?? [], - [attributesMap, transformationResultType] + () => attributesMap[resolveAttrMapKey(transformationResultType, dataTarget?.type)] ?? [], + [attributesMap, transformationResultType, dataTarget?.type] ) const classFieldOptions = useMemo( () => ((calculateTypeError ?? '') !== '' ? [] : attributes.map((a) => ({ value: a.key, label: a.title }))), diff --git a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/hooks/use-mapping-step-loader.tsx b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/hooks/use-mapping-step-loader.tsx index bf07bb74..fd72d096 100644 --- a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/hooks/use-mapping-step-loader.tsx +++ b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/hooks/use-mapping-step-loader.tsx @@ -17,7 +17,7 @@ import { api, useBundleDataImporterConfigGetQuery } from '../../../../../data-im import { useBundleDataImporterConfigLoadColumnHeadersQuery, useBundleDataImporterConfigLoadPreviewQuery } from '../../../../../data-importer-api-slice.gen' import { transformFormToBackend, type BackendConfiguration } from '../../../../../utils/transformers' import { normalizeDataRow } from '../../../../../utils/normalize-data-row' -import { type DataImporterFormValues, type MappingConfigItem, type ClassAttribute, resolveAttrMapKey, DEFAULT_ATTR_MAP_KEY } from '../../../../../types' +import { type DataImporterFormValues, type MappingConfigItem, type ClassAttribute, resolveAttrMapKey, parseAttrMapKey, DEFAULT_ATTR_MAP_KEY } from '../../../../../types' import { type SourceRow } from '../sources-panel/sources-panel' import { parseClassAttribute, type ColumnHeaderEntry, type UseMappingStepLoaderResult } from './use-mapping-step-loader.types' @@ -39,6 +39,19 @@ export const SUGGESTION_TRANSFORMATION_RESULT_TYPES: string[] = [ 'dataObjectArray' ] +// every attributes map key the mapping step needs for the given mapping items +function collectAttrMapKeys (items: MappingConfigItem[]): string[] { + const mapKeys = new Set([DEFAULT_ATTR_MAP_KEY]) + // the default request only returns DEFAULT+NUMERIC typed attributes — without + // the other result types the autofill suggestions would miss e.g. select, + // relation or asset fields on configs that have no mapping rows yet (#622) + SUGGESTION_TRANSFORMATION_RESULT_TYPES.forEach((trt) => mapKeys.add(resolveAttrMapKey(trt))) + items.forEach((item) => { + mapKeys.add(resolveAttrMapKey(item.transformationResultType, item.dataTarget?.type)) + }) + return Array.from(mapKeys) +} + export function useMappingStepLoader (configName: string, isActive: boolean): UseMappingStepLoaderResult { const form = Form.useFormInstance() const dispatch = useAppDispatch() @@ -49,6 +62,9 @@ export function useMappingStepLoader (configName: string, isActive: boolean): Us const [sourceRows, setSourceRows] = useState([]) const [hasPreviewError, setHasPreviewError] = useState(false) const [attributesMap, setAttributesMap] = useState>({}) + // The class the attributesMap entries belong to. The class can be changed in the resolver + // step without saving, so entries loaded for another class must never be reused. + const attributesMapClassIdRef = useRef(undefined) const [headersRequest, setHeadersRequest] = useState<{ name: string bundleDataImporterCopyPreviewParameters: { @@ -99,13 +115,15 @@ export function useMappingStepLoader (configName: string, isActive: boolean): Us // Serialize to a string so useWatch only triggers a re-render when the TRT // list actually changes, not on every unrelated form field update (returning a // new array reference on every call would always be referentially unequal). - const mappingTrtListJson = Form.useWatch( + const mappingAttrMapKeyListJson = Form.useWatch( (values: { mappingConfig?: MappingConfigItem[] }) => - JSON.stringify((values.mappingConfig ?? []).map((item) => item.transformationResultType ?? '')) + JSON.stringify((values.mappingConfig ?? []).map((item) => + resolveAttrMapKey(item.transformationResultType, item.dataTarget?.type) + )) ) as string | undefined - const mappingTrtList = useMemo( - () => (mappingTrtListJson !== undefined ? JSON.parse(mappingTrtListJson) as string[] : undefined), - [mappingTrtListJson] + const mappingAttrMapKeyList = useMemo( + () => (mappingAttrMapKeyListJson !== undefined ? JSON.parse(mappingAttrMapKeyListJson) as string[] : undefined), + [mappingAttrMapKeyListJson] ) const getMappingConfig = useCallback( @@ -255,29 +273,21 @@ export function useMappingStepLoader (configName: string, isActive: boolean): Us const backendConfig = (configData?.configuration ?? {}) as BackendConfiguration const items: MappingConfigItem[] = (backendConfig.mappingConfig) ?? [] - const uniqueTypes = new Set() - if (effectiveClassId !== undefined && effectiveClassId !== '') { - uniqueTypes.add(undefined) - // the default request only returns DEFAULT+NUMERIC typed attributes — without - // the other result types the autofill suggestions would miss e.g. select, - // relation or asset fields on configs that have no mapping rows yet (#622) - SUGGESTION_TRANSFORMATION_RESULT_TYPES.forEach((trt) => uniqueTypes.add(trt)) - items.forEach((item) => { uniqueTypes.add(item.transformationResultType) }) - } - - const typesArray = Array.from(uniqueTypes) + const mapKeysArray = (effectiveClassId !== undefined && effectiveClassId !== '') + ? collectAttrMapKeys(items) + : [] if (debugEnabled) { console.debug('[DI][Loader] class attributes load start', { cycleId, effectiveClassId, - types: typesArray.map((t) => t ?? DEFAULT_ATTR_MAP_KEY) + types: mapKeysArray }) } - const attrPromises = typesArray.map(async (trt) => + const attrPromises = mapKeysArray.map(async (mapKey) => await dispatch( api.endpoints.bundleDataImporterDataTypeLoadClassAttributes.initiate({ classId: effectiveClassId!, - transformationResultType: trt, + ...parseAttrMapKey(mapKey), systemWrite: true }, { forceRefetch: requestChanged @@ -287,18 +297,19 @@ export function useMappingStepLoader (configName: string, isActive: boolean): Us const attrResults = await Promise.all(attrPromises) - if (typesArray.length > 0) { + const classChanged = attributesMapClassIdRef.current !== effectiveClassId + attributesMapClassIdRef.current = effectiveClassId + + if (mapKeysArray.length > 0) { const newEntries: Record = {} attrResults.forEach((result, i) => { - const trt = typesArray[i] - const mapKey = (trt === undefined || trt === '' || trt === 'default') ? DEFAULT_ATTR_MAP_KEY : trt - const attrs = (result.data?.attributes ?? []).map(parseClassAttribute) - newEntries[mapKey] = attrs + newEntries[mapKeysArray[i]] = (result.data?.attributes ?? []).map(parseClassAttribute) }) // Merge new entries — only update the map object when content actually // changed so consumers with referential equality checks (React.memo, // useMemo) don't re-render when their own TRT's attrs are unchanged. setAttributesMap((prev) => { + if (classChanged) return newEntries let changed = false for (const key of Object.keys(newEntries)) { if (prev[key] !== newEntries[key]) { @@ -386,30 +397,36 @@ export function useMappingStepLoader (configName: string, isActive: boolean): Us if (effectiveClassId === undefined || effectiveClassId === '' || !initialLoadDone) return const items = getMappingConfig() - const missingTypes = new Set() - items.forEach((item) => { - const trt = item.transformationResultType - const mapKey = resolveAttrMapKey(trt) - if (attributesMap[mapKey] === undefined) { - missingTypes.add(mapKey) - } - }) + let missingArray: string[] + if (attributesMapClassIdRef.current !== effectiveClassId) { + // the class was changed (unsaved) in the resolver step: drop the other class's entries + attributesMapClassIdRef.current = effectiveClassId + setAttributesMap({}) + missingArray = collectAttrMapKeys(items) + } else { + missingArray = Array.from(new Set( + items + .map((item) => resolveAttrMapKey(item.transformationResultType, item.dataTarget?.type)) + .filter((mapKey) => attributesMap[mapKey] === undefined) + )) + } - if (missingTypes.size === 0) return + if (missingArray.length === 0) return - const missingArray = Array.from(missingTypes) const promises = missingArray.map(async (mapKey) => { - const trt = mapKey === DEFAULT_ATTR_MAP_KEY ? undefined : mapKey return await dispatch( api.endpoints.bundleDataImporterDataTypeLoadClassAttributes.initiate({ classId: effectiveClassId, - transformationResultType: trt, + ...parseAttrMapKey(mapKey), systemWrite: true }) ) }) void Promise.all(promises).then((results) => { + // the class changed again while loading + if (attributesMapClassIdRef.current !== effectiveClassId) return + setAttributesMap((prev) => { const next: Record = {} results.forEach((result, i) => { @@ -421,7 +438,7 @@ export function useMappingStepLoader (configName: string, isActive: boolean): Us return { ...prev, ...next } }) }) - }, [mappingTrtList, initialLoadDone, classId]) + }, [mappingAttrMapKeyList, initialLoadDone, classId]) return { columnHeaderOptions, @@ -431,7 +448,7 @@ export function useMappingStepLoader (configName: string, isActive: boolean): Us attributesMap, setAttributesMap, classId, - mappingTrtList, + mappingAttrMapKeyList, getMappingConfig } } diff --git a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/hooks/use-mapping-step-loader.types.ts b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/hooks/use-mapping-step-loader.types.ts index bd407303..7d736bc8 100644 --- a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/hooks/use-mapping-step-loader.types.ts +++ b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/hooks/use-mapping-step-loader.types.ts @@ -25,7 +25,7 @@ export interface UseMappingStepLoaderResult { attributesMap: Record setAttributesMap: React.Dispatch>> classId: string | undefined - mappingTrtList: string[] | undefined + mappingAttrMapKeyList: string[] | undefined getMappingConfig: () => MappingConfigItem[] } diff --git a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/mapping-item/mapping-item-with-filter.tsx b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/mapping-item/mapping-item-with-filter.tsx index b0a6cb7d..45b042bb 100644 --- a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/mapping-item/mapping-item-with-filter.tsx +++ b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/mapping-item/mapping-item-with-filter.tsx @@ -106,6 +106,7 @@ export const MappingItemWithFilter = React.memo(({ columnHeaderOptions={ columnHeaderOptions } configName={ configName } dataSourceIndex={ dataSourceIndex } + dataTargetType={ item.dataTarget?.type } expanded={ expanded } fieldIndex={ fieldIndex } itemLabel={ item.label } diff --git a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/mapping-item/mapping-item.tsx b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/mapping-item/mapping-item.tsx index 5fb50c1b..89eec4e7 100644 --- a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/mapping-item/mapping-item.tsx +++ b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/mapping-item/mapping-item.tsx @@ -45,6 +45,7 @@ export interface MappingItemProps { itemLabel: string | undefined dataSourceIndex: string[] | undefined transformationResultType: string | undefined + dataTargetType: string | undefined selectedFieldName: string | undefined language: string | undefined attributesMap: Record @@ -63,6 +64,7 @@ const MappingItemComponent = ({ itemLabel, dataSourceIndex, transformationResultType, + dataTargetType, selectedFieldName, language, attributesMap @@ -123,7 +125,7 @@ const MappingItemComponent = ({ } }, [expanded, columnHeaderOptions, form, getCurrentIndexByMappingId]) - const attrMapKey = resolveAttrMapKey(transformationResultType) + const attrMapKey = resolveAttrMapKey(transformationResultType, dataTargetType) const attributes: ClassAttribute[] = attributesMap[attrMapKey] ?? [] // Only compute props needed by MappingItemContent when the panel is expanded. @@ -275,6 +277,7 @@ function areMappingItemPropsEqual (prev: MappingItemProps, next: MappingItemProp prev.itemLabel === next.itemLabel && dataSourceIndexEqual && prev.transformationResultType === next.transformationResultType && + prev.dataTargetType === next.dataTargetType && prev.selectedFieldName === next.selectedFieldName && prev.language === next.language && prev.attributesMap === next.attributesMap diff --git a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/utils/compute-autofill-suggestions.ts b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/utils/compute-autofill-suggestions.ts index 6bcfcb62..5b33a3ea 100644 --- a/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/utils/compute-autofill-suggestions.ts +++ b/assets/studio/js/src/modules/data-importer/components/tabs/steps/mapping-step/utils/compute-autofill-suggestions.ts @@ -11,7 +11,7 @@ import { uuid } from '@pimcore/studio-ui-bundle/utils' import type { ClassAttribute, MappingConfigItem } from '../../../../../types' import type { SourceRow } from '../sources-panel/sources-panel' -import { DEFAULT_ATTR_MAP_KEY } from '../../../../../types' +import { DEFAULT_ATTR_MAP_KEY, isAdvancedRelationsAttrMapKey } from '../../../../../types' export interface MappingSuggestion { id: string @@ -124,12 +124,14 @@ interface PoolEntry { } // the default map key wins when an attribute is offered by several result types, -// so simple fields keep the default transformation result type +// so simple fields keep the default transformation result type. Advanced relation +// keys are skipped: their key is not a transformation result type and autofill only +// creates direct data targets. function flattenAttributes (attributesMap: Record): PoolEntry[] { const attrsByKey = new Map() const keyOrder = [ DEFAULT_ATTR_MAP_KEY, - ...Object.keys(attributesMap).filter((k) => k !== DEFAULT_ATTR_MAP_KEY) + ...Object.keys(attributesMap).filter((k) => k !== DEFAULT_ATTR_MAP_KEY && !isAdvancedRelationsAttrMapKey(k)) ] for (const mapKey of keyOrder) { for (const attr of (attributesMap[mapKey] ?? [])) { diff --git a/assets/studio/js/src/modules/data-importer/types.ts b/assets/studio/js/src/modules/data-importer/types.ts index 85c078a9..417ab655 100644 --- a/assets/studio/js/src/modules/data-importer/types.ts +++ b/assets/studio/js/src/modules/data-importer/types.ts @@ -102,14 +102,52 @@ export interface ClassAttribute { export const DEFAULT_ATTR_MAP_KEY = '__default__' -export function resolveAttrMapKey (transformationResultType: string | undefined): string { - return ( +// Data targets that write into advanced relation fields. For these the backend only offers +// advancedManyToMany(Object)Relation fields when the attributes are requested with +// loadAdvancedRelations, so their attributes are kept under a separate map key. +const ADVANCED_RELATION_DATA_TARGET_TYPES = new Set(['manyToManyRelation']) +const ADVANCED_RELATION_RESULT_TYPES = new Set(['dataObjectArray', 'assetArray']) +const ADVANCED_RELATIONS_ATTR_MAP_KEY_PREFIX = 'advancedRelations:' + +export function resolveAttrMapKey (transformationResultType: string | undefined, dataTargetType?: string): string { + if ( transformationResultType === undefined || transformationResultType === '' || transformationResultType === 'default' - ) - ? DEFAULT_ATTR_MAP_KEY - : transformationResultType + ) { + return DEFAULT_ATTR_MAP_KEY + } + + if ( + ADVANCED_RELATION_DATA_TARGET_TYPES.has(dataTargetType ?? '') && + ADVANCED_RELATION_RESULT_TYPES.has(transformationResultType) + ) { + return ADVANCED_RELATIONS_ATTR_MAP_KEY_PREFIX + transformationResultType + } + + return transformationResultType +} + +export function isAdvancedRelationsAttrMapKey (mapKey: string): boolean { + return mapKey.startsWith(ADVANCED_RELATIONS_ATTR_MAP_KEY_PREFIX) +} + +/** + * Turns an attributes map key back into the parameters of the class-attributes request. + */ +export function parseAttrMapKey (mapKey: string): { transformationResultType?: string, loadAdvancedRelations?: boolean } { + if (mapKey === DEFAULT_ATTR_MAP_KEY) { + return {} + } + + if (isAdvancedRelationsAttrMapKey(mapKey)) { + return { + transformationResultType: mapKey.slice(ADVANCED_RELATIONS_ATTR_MAP_KEY_PREFIX.length), + loadAdvancedRelations: true + } + } + + return { transformationResultType: mapKey } } export interface MappingConfigItem {