From 3e65111bc1ca18d3248e31c2fff955d04b5163ce Mon Sep 17 00:00:00 2001 From: Patrik <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:54:32 -0400 Subject: [PATCH] fix(plugin-import-export): csv export & preview showing full documents for hasMany monomorphic relationships instead of just ID (#13465) ### What? Fixes an issue where CSV exports and the preview table displayed all fields of documents in hasMany monomorphic relationships instead of only their IDs. ### Why? This caused cluttered output and inconsistent CSV formats, since only IDs should be exported for hasMany monomorphic relationships. ### How? Added explicit `toCSV` handling for all relationship types in `getCustomFieldFunctions`, updated `flattenObject` to delegate to these handlers, and adjusted `getFlattenedFieldKeys` to generate the correct headers. --- .../src/export/flattenObject.ts | 72 ++++++++++++------- .../src/export/getCustomFieldFunctions.ts | 18 +++-- .../src/utilities/getFlattenedFieldKeys.ts | 2 +- .../plugin-import-export/collections/Pages.ts | 6 ++ test/plugin-import-export/int.spec.ts | 29 ++++++++ test/plugin-import-export/payload-types.ts | 2 + test/plugin-import-export/seed/index.ts | 10 +++ 7 files changed, 106 insertions(+), 33 deletions(-) diff --git a/packages/plugin-import-export/src/export/flattenObject.ts b/packages/plugin-import-export/src/export/flattenObject.ts index 0801a2e5ef..b18774fcd0 100644 --- a/packages/plugin-import-export/src/export/flattenObject.ts +++ b/packages/plugin-import-export/src/export/flattenObject.ts @@ -22,10 +22,44 @@ export const flattenObject = ({ const newKey = prefix ? `${prefix}_${key}` : key if (Array.isArray(value)) { + // If a custom toCSV function exists for this array field, run it first. + // If it produces output, skip per-item handling; otherwise, fall back. + if (toCSVFunctions?.[newKey]) { + try { + const result = toCSVFunctions[newKey]({ + columnName: newKey, + data: row, + doc, + row, + siblingDoc, + value, // whole array + }) + + if (typeof result !== 'undefined') { + // Custom function returned a single value for this array field. + row[newKey] = result + return + } + + // If the custom function wrote any keys for this field, consider it handled. + for (const k in row) { + if (k === newKey || k.startsWith(`${newKey}_`)) { + return + } + } + // Otherwise, fall through to per-item handling. + } catch (error) { + throw new Error( + `Error in toCSVFunction for array "${newKey}": ${JSON.stringify(value)}\n${ + (error as Error).message + }`, + ) + } + } + value.forEach((item, index) => { if (typeof item === 'object' && item !== null) { const blockType = typeof item.blockType === 'string' ? item.blockType : undefined - const itemPrefix = blockType ? `${newKey}_${index}_${blockType}` : `${newKey}_${index}` // Case: hasMany polymorphic relationships @@ -40,35 +74,15 @@ export const flattenObject = ({ return } + // Fallback: deep-flatten nested objects flatten(item, itemPrefix) } else { - if (toCSVFunctions?.[newKey]) { - const columnName = `${newKey}_${index}` - try { - const result = toCSVFunctions[newKey]({ - columnName, - data: row, - doc, - row, - siblingDoc, - value: item, - }) - if (typeof result !== 'undefined') { - row[columnName] = result - } - } catch (error) { - throw new Error( - `Error in toCSVFunction for array item "${columnName}": ${JSON.stringify(item)}\n${ - (error as Error).message - }`, - ) - } - } else { - row[`${newKey}_${index}`] = item - } + // Primitive array item. + row[`${newKey}_${index}`] = item } }) } else if (typeof value === 'object' && value !== null) { + // Object field: use custom toCSV if present, else recurse. if (!toCSVFunctions?.[newKey]) { flatten(value, newKey) } else { @@ -86,7 +100,9 @@ export const flattenObject = ({ } } catch (error) { throw new Error( - `Error in toCSVFunction for nested object "${newKey}": ${JSON.stringify(value)}\n${(error as Error).message}`, + `Error in toCSVFunction for nested object "${newKey}": ${JSON.stringify(value)}\n${ + (error as Error).message + }`, ) } } @@ -106,7 +122,9 @@ export const flattenObject = ({ } } catch (error) { throw new Error( - `Error in toCSVFunction for field "${newKey}": ${JSON.stringify(value)}\n${(error as Error).message}`, + `Error in toCSVFunction for field "${newKey}": ${JSON.stringify(value)}\n${ + (error as Error).message + }`, ) } } else { diff --git a/packages/plugin-import-export/src/export/getCustomFieldFunctions.ts b/packages/plugin-import-export/src/export/getCustomFieldFunctions.ts index 931c4bcf04..b9ded93831 100644 --- a/packages/plugin-import-export/src/export/getCustomFieldFunctions.ts +++ b/packages/plugin-import-export/src/export/getCustomFieldFunctions.ts @@ -53,13 +53,21 @@ export const getCustomFieldFunctions = ({ fields }: Args): Record[] - }) => - value.map((val: number | Record | string) => - typeof val === 'object' ? val.id : val, - ) + data: Record + value: Array | string> | undefined + }) => { + if (Array.isArray(value)) { + value.forEach((val, i) => { + const id = typeof val === 'object' && val ? val.id : val + // @ts-expect-error ref is untyped + data[`${ref.prefix}${field.name}_${i}_id`] = id + }) + } + return undefined // prevents further flattening + } } else { // polymorphic many // @ts-expect-error ref is untyped diff --git a/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts b/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts index db25206b8b..99145d240a 100644 --- a/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts +++ b/packages/plugin-import-export/src/utilities/getFlattenedFieldKeys.ts @@ -52,7 +52,7 @@ export const getFlattenedFieldKeys = (fields: FieldWithPresentational[], prefix keys.push(`${fullKey}_0_relationTo`, `${fullKey}_0_id`) } else { // hasMany monomorphic - keys.push(`${fullKey}_0`) + keys.push(`${fullKey}_0_id`) } } else { if (Array.isArray(field.relationTo)) { diff --git a/test/plugin-import-export/collections/Pages.ts b/test/plugin-import-export/collections/Pages.ts index 818978b15e..edb57d7125 100644 --- a/test/plugin-import-export/collections/Pages.ts +++ b/test/plugin-import-export/collections/Pages.ts @@ -221,6 +221,12 @@ export const Pages: CollectionConfig = { relationTo: ['users', 'posts'], hasMany: true, }, + { + name: 'hasManyMonomorphic', + type: 'relationship', + relationTo: 'posts', + hasMany: true, + }, { type: 'collapsible', label: 'Collapsible Field', diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index caa57f5d2e..b8d4cc0bb7 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -598,6 +598,35 @@ describe('@payloadcms/plugin-import-export', () => { expect(data[0].hasManyPolymorphic_1_relationTo).toBe('posts') }) + it('should export hasMany monomorphic relationship fields to CSV', async () => { + const doc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'hasManyMonomorphic'], + format: 'csv', + where: { + title: { contains: 'Monomorphic' }, + }, + }, + }) + + const exportDoc = await payload.findByID({ + collection: 'exports', + id: doc.id, + }) + + expect(exportDoc.filename).toBeDefined() + const expectedPath = path.join(dirname, './uploads', exportDoc.filename as string) + const data = await readCSV(expectedPath) + + // hasManyMonomorphic + expect(data[0].hasManyMonomorphic_0_id).toBeDefined() + expect(data[0].hasManyMonomorphic_0_relationTo).toBeUndefined() + expect(data[0].hasManyMonomorphic_0_title).toBeUndefined() + }) + // disabled so we don't always run a massive test it.skip('should create a file from a large set of collection documents', async () => { const allPromises = [] diff --git a/test/plugin-import-export/payload-types.ts b/test/plugin-import-export/payload-types.ts index c598e3b25e..b5f8288d14 100644 --- a/test/plugin-import-export/payload-types.ts +++ b/test/plugin-import-export/payload-types.ts @@ -242,6 +242,7 @@ export interface Page { } )[] | null; + hasManyMonomorphic?: (string | Post)[] | null; textFieldInCollapsible?: string | null; updatedAt: string; createdAt: string; @@ -580,6 +581,7 @@ export interface PagesSelect { excerpt?: T; hasOnePolymorphic?: T; hasManyPolymorphic?: T; + hasManyMonomorphic?: T; textFieldInCollapsible?: T; updatedAt?: T; createdAt?: T; diff --git a/test/plugin-import-export/seed/index.ts b/test/plugin-import-export/seed/index.ts index 4002d6a337..30474c76d1 100644 --- a/test/plugin-import-export/seed/index.ts +++ b/test/plugin-import-export/seed/index.ts @@ -159,6 +159,16 @@ export const seed = async (payload: Payload): Promise => { }) } + for (let i = 0; i < 2; i++) { + await payload.create({ + collection: 'pages', + data: { + title: `Monomorphic ${i}`, + hasManyMonomorphic: [posts[1]?.id ?? ''], + }, + }) + } + for (let i = 0; i < 5; i++) { await payload.create({ collection: 'pages',