+ {groups.map(({ label, options, remaining }, index) => (
+
+ {/* A `label` element would be unassociated here — the cell holds no control */}
+ {showCollectionLabels && label && (
+
{label}
+ )}
+
+ {options.join(', ')}
+ {remaining > 0 && (
+
+ {i18n.t('fields:itemsAndMore', { count: remaining, items: '' }).trim()}
+
+ )}
+
+
+ ))}
+
+ )
+}
diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts
index 0b03cb4448a..1f501e909ad 100644
--- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts
+++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts
@@ -1,9 +1,19 @@
-import { FlattenedField } from 'payload'
+import type { FlattenedField, PayloadRequest } from 'payload'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
+import { applyFieldHooks } from './applyFieldHooks.js'
+import { flattenObject } from './flattenObject.js'
import { getExportFieldFunctions } from './getExportFieldFunctions.js'
+const mockReq = {
+ payload: {
+ logger: {
+ error: vi.fn(),
+ },
+ },
+} as unknown as PayloadRequest
+
describe('getExportFieldFunctions registration', () => {
it('should not collide bare-key entries when two same-named fields with built-in handlers exist in different positions', () => {
const fields: FlattenedField[] = [
@@ -37,3 +47,143 @@ describe('getExportFieldFunctions registration', () => {
expect(result['topLevelData']).toBeDefined()
})
})
+
+describe('hasMany polymorphic CSV columns', () => {
+ it('should pin columns to the source index when an earlier relationship is orphaned', () => {
+ const fields: FlattenedField[] = [
+ {
+ name: 'rel',
+ type: 'relationship',
+ hasMany: true,
+ relationTo: ['posts', 'users'],
+ } as unknown as FlattenedField,
+ ]
+
+ // Real CSV export never calls applyFieldHooks — it goes through flattenObject,
+ // which (unlike applyFieldHooks) drops the key entirely when an array hook
+ // returns null instead of writing a literal `rel: null`.
+ const result = flattenObject({
+ // Exports populate at depth 1, so `value: null` is an orphaned reference —
+ // the target doc was deleted out from under it.
+ data: {
+ rel: [
+ { relationTo: 'users', value: null },
+ { relationTo: 'posts', value: 'p1' },
+ ],
+ },
+ exportFieldHooks: getExportFieldFunctions({ fields }),
+ format: 'csv',
+ req: mockReq,
+ })
+
+ // The surviving entry stays at index 1 — shifting it to 0 would silently
+ // rewrite column names for every consumer of the CSV. There is no bare `rel`
+ // column: a hasMany field never gets one, orphaned or not.
+ expect(result).toEqual({
+ rel_1_id: 'p1',
+ rel_1_relationTo: 'posts',
+ })
+ })
+})
+
+describe('dangling references in JSON exports', () => {
+ it('should keep an orphaned hasMany entry as null so the array stays index-aligned with CSV', () => {
+ const fields: FlattenedField[] = [
+ {
+ name: 'rel',
+ type: 'relationship',
+ hasMany: true,
+ relationTo: 'posts',
+ } as unknown as FlattenedField,
+ ]
+
+ const result = applyFieldHooks({
+ type: 'beforeExport',
+ // Population resolves a soft-deleted target to null.
+ data: { rel: [null, 'p1'] },
+ fieldHooks: getExportFieldFunctions({ fields }),
+ fields,
+ format: 'json',
+ operation: 'export',
+ req: mockReq,
+ })
+
+ expect(result).toEqual({ rel: [null, 'p1'] })
+ })
+
+ it('should keep an orphaned hasMany polymorphic entry as null at its source index', () => {
+ const fields: FlattenedField[] = [
+ {
+ name: 'rel',
+ type: 'relationship',
+ hasMany: true,
+ relationTo: ['posts', 'users'],
+ } as unknown as FlattenedField,
+ ]
+
+ const result = applyFieldHooks({
+ type: 'beforeExport',
+ data: {
+ rel: [
+ { relationTo: 'users', value: null },
+ { relationTo: 'posts', value: 'p1' },
+ ],
+ },
+ fieldHooks: getExportFieldFunctions({ fields }),
+ fields,
+ format: 'json',
+ operation: 'export',
+ req: mockReq,
+ })
+
+ expect(result).toEqual({
+ rel: [null, { relationTo: 'posts', value: 'p1' }],
+ })
+ })
+
+ it('should clear an orphaned single polymorphic reference', () => {
+ const fields: FlattenedField[] = [
+ {
+ name: 'rel',
+ type: 'relationship',
+ relationTo: ['posts', 'users'],
+ } as unknown as FlattenedField,
+ ]
+
+ const result = applyFieldHooks({
+ type: 'beforeExport',
+ data: { rel: { relationTo: 'posts', value: null } },
+ fieldHooks: getExportFieldFunctions({ fields }),
+ fields,
+ format: 'json',
+ operation: 'export',
+ req: mockReq,
+ })
+
+ expect(result).toEqual({ rel: null })
+ })
+
+ it('should leave a shape it does not recognize untouched', () => {
+ const fields: FlattenedField[] = [
+ {
+ name: 'rel',
+ type: 'relationship',
+ relationTo: ['posts', 'users'],
+ } as unknown as FlattenedField,
+ ]
+
+ const result = applyFieldHooks({
+ type: 'beforeExport',
+ // Destroying a value this handler cannot interpret would lose data that a
+ // custom beforeExport hook or a hand-edited file may depend on.
+ data: { rel: { slug: 'not-a-relationship' } },
+ fieldHooks: getExportFieldFunctions({ fields }),
+ fields,
+ format: 'json',
+ operation: 'export',
+ req: mockReq,
+ })
+
+ expect(result).toEqual({ rel: { slug: 'not-a-relationship' } })
+ })
+})
diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts
index 5f635d40dda..8ddb3cd81f9 100644
--- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts
+++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts
@@ -68,46 +68,79 @@ const registerExportHandler = (
return
}
- registerHandler(({ siblingData, value }) => {
- if (isPolymorphicRelValue(value)) {
- const id = getPolymorphicRelId(value)
- if (id !== undefined) {
- siblingData[`${fullKey}_id`] = id
- siblingData[`${fullKey}_relationTo`] = value.relationTo
- }
+ registerHandler(({ format, siblingData, value }) => {
+ // A shape this handler does not recognize is left untouched for JSON, which holds the
+ // source value verbatim. CSV still clears it, since its id/relationTo would otherwise
+ // land in sibling columns.
+ if (!isPolymorphicRelValue(value)) {
+ return format === 'json' ? undefined : null
}
+
+ // A recognized reference that resolves to no id is dangling, and a dangling reference
+ // cannot be imported back — clear it for both formats.
+ const id = getPolymorphicRelId(value)
+ if (id === undefined) {
+ return null
+ }
+
+ if (format === 'json') {
+ return { relationTo: value.relationTo, value: id }
+ }
+ siblingData[`${fullKey}_id`] = id
+ siblingData[`${fullKey}_relationTo`] = value.relationTo
return null
})
return
}
if (!Array.isArray(field.relationTo)) {
- registerHandler(({ siblingData, value }) => {
- if (Array.isArray(value)) {
- value.forEach((val, i) => {
- const id = typeof val === 'object' && val ? (val as { id: unknown }).id : val
- siblingData[`${fullKey}_${i}_id`] = id
- })
- return null
+ registerHandler(({ format, siblingData, value }) => {
+ if (!Array.isArray(value)) {
+ return undefined
}
- return undefined
+ const ids = value.map((val) =>
+ typeof val === 'object' && val ? (val as { id: unknown }).id : val,
+ )
+ // A dangling reference is kept as null rather than dropped, so JSON preserves the same
+ // length and positions as CSV, which pins each entry to its own column.
+ if (format === 'json') {
+ return ids.map((id) => id ?? null)
+ }
+ ids.forEach((id, i) => {
+ siblingData[`${fullKey}_${i}_id`] = id
+ })
+ return null
})
return
}
- registerHandler(({ siblingData, value }) => {
- if (Array.isArray(value)) {
- value.forEach((val, i) => {
- if (isPolymorphicRelValue(val)) {
- const id = getPolymorphicRelId(val)
- if (id !== undefined) {
- siblingData[`${fullKey}_${i}_id`] = id
- siblingData[`${fullKey}_${i}_relationTo`] = val.relationTo
- }
- }
+ registerHandler(({ format, siblingData, value }) => {
+ if (!Array.isArray(value)) {
+ return undefined
+ }
+ // `index` is carried so CSV columns stay pinned to the source position: an entry
+ // that cannot be resolved to an id leaves a gap rather than shifting its siblings.
+ const rels = value.flatMap((val, index) => {
+ if (!isPolymorphicRelValue(val)) {
+ return []
+ }
+ const id = getPolymorphicRelId(val)
+ return id === undefined ? [] : [{ id, index, relationTo: val.relationTo }]
+ })
+
+ if (format === 'json') {
+ // Each entry stays at its source index, leaving a null where one could not be resolved,
+ // so JSON preserves the same length and positions as CSV.
+ const jsonRels: unknown[] = new Array(value.length).fill(null)
+ rels.forEach(({ id, index, relationTo }) => {
+ jsonRels[index] = { relationTo, value: id }
})
- return null
+ return jsonRels
}
- return undefined
+ rels.forEach(({ id, index, relationTo }) => {
+ siblingData[`${fullKey}_${index}_id`] = id
+ siblingData[`${fullKey}_${index}_relationTo`] = relationTo
+ })
+ return null
})
}
diff --git a/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts b/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts
index 681f1f35359..bb305e4342f 100644
--- a/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts
+++ b/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts
@@ -405,6 +405,60 @@ describe('unflattenObject', () => {
})
})
+ describe('hasMany with gaps in the column indices', () => {
+ // Export pins each column to its source index, so an entry that could not be
+ // resolved to an id leaves a gap. Import must absorb the gap rather than
+ // emitting a null array entry.
+ const hasManyFields: FlattenedField[] = [
+ {
+ name: 'rels',
+ type: 'relationship',
+ hasMany: true,
+ relationTo: ['posts', 'pages'],
+ } as FlattenedField,
+ ]
+
+ const expected = {
+ rels: [{ relationTo: 'posts', value: 'p1' }],
+ }
+
+ it('should absorb a leading gap left by an unresolvable entry', () => {
+ const data = {
+ rels_1_id: 'p1',
+ rels_1_relationTo: 'posts',
+ }
+
+ expect(unflattenObject({ data, fields: hasManyFields, req: mockReq })).toEqual(expected)
+ })
+
+ it('should absorb a gap padded with empty strings by schema columns', () => {
+ const data = {
+ rels_0_id: '',
+ rels_0_relationTo: '',
+ rels_1_id: 'p1',
+ rels_1_relationTo: 'posts',
+ }
+
+ expect(unflattenObject({ data, fields: hasManyFields, req: mockReq })).toEqual(expected)
+ })
+
+ it('should absorb a gap strictly between two present entries', () => {
+ const data = {
+ rels_0_id: 'p1',
+ rels_0_relationTo: 'posts',
+ rels_2_id: 'p2',
+ rels_2_relationTo: 'pages',
+ }
+
+ expect(unflattenObject({ data, fields: hasManyFields, req: mockReq })).toEqual({
+ rels: [
+ { relationTo: 'posts', value: 'p1' },
+ { relationTo: 'pages', value: 'p2' },
+ ],
+ })
+ })
+ })
+
it('should skip polymorphic relationships with undefined values', () => {
const data = {
polymorphic_id: undefined,
diff --git a/test/plugin-import-export/e2e.spec.ts b/test/plugin-import-export/e2e.spec.ts
index e99065a280b..281850defd0 100644
--- a/test/plugin-import-export/e2e.spec.ts
+++ b/test/plugin-import-export/e2e.spec.ts
@@ -666,7 +666,10 @@ test.describe('Import Export Plugin', () => {
})
test('should import a CSV file successfully', async () => {
- test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.')
+ test.skip(
+ process.env.PAYLOAD_FRAMEWORK === 'tanstack-start',
+ 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.',
+ )
const csvContent =
'title,excerpt\n"E2E Import Test 1","Test excerpt 1"\n"E2E Import Test 2","Test excerpt 2"'
const csvPath = path.join(__dirname, 'uploads', 'e2e-test-import.csv')
@@ -704,7 +707,10 @@ test.describe('Import Export Plugin', () => {
})
test('should import a JSON file successfully', async () => {
- test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.')
+ test.skip(
+ process.env.PAYLOAD_FRAMEWORK === 'tanstack-start',
+ 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.',
+ )
const jsonContent = JSON.stringify([
{ excerpt: 'JSON excerpt 1', title: 'E2E JSON Import 1' },
{ excerpt: 'JSON excerpt 2', title: 'E2E JSON Import 2' },
@@ -824,9 +830,12 @@ test.describe('Import Export Plugin', () => {
// one-shot native event with no auto-retry.
await expect(async () => {
await page.setInputFiles('input[type="file"]', csvPath)
- await expect(page.locator('#field-filemanager-filename')).toHaveValue('e2e-update-test.csv', {
- timeout: 2000,
- })
+ await expect(page.locator('#field-filemanager-filename')).toHaveValue(
+ 'e2e-update-test.csv',
+ {
+ timeout: 2000,
+ },
+ )
}).toPass({ timeout: POLL_TOPASS_TIMEOUT })
const collectionField = page.locator('#field-collectionSlug')
@@ -858,7 +867,10 @@ test.describe('Import Export Plugin', () => {
})
test('should import documents as published by default', async () => {
- test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.')
+ test.skip(
+ process.env.PAYLOAD_FRAMEWORK === 'tanstack-start',
+ 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.',
+ )
const csvContent =
'title,excerpt\n"E2E Published Status Test 1","Test excerpt 1"\n"E2E Published Status Test 2","Test excerpt 2"'
const csvPath = path.join(__dirname, 'uploads', 'e2e-published-status-test.csv')
@@ -902,7 +914,10 @@ test.describe('Import Export Plugin', () => {
})
test('should respect explicit _status column values in CSV', async () => {
- test.skip(process.env.PAYLOAD_FRAMEWORK === 'tanstack-start', 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.')
+ test.skip(
+ process.env.PAYLOAD_FRAMEWORK === 'tanstack-start',
+ 'TanStack: known post-hydration RSC view remount detaches the view mid-interaction (see framework adapter notes); re-enable when the TanStack RSC hydration is fixed.',
+ )
const csvContent =
'title,excerpt,_status\n"E2E Explicit Draft Test","Draft excerpt","draft"\n"E2E Explicit Published Test","Published excerpt","published"'
const csvPath = path.join(__dirname, 'uploads', 'e2e-explicit-status-test.csv')
@@ -1074,11 +1089,154 @@ test.describe('Import Export Plugin', () => {
}).toPass({ timeout: POLL_TOPASS_TIMEOUT })
})
})
+
+ test.describe('Relationship Preview', () => {
+ const relationshipBaseClass = 'import-preview-relationship'
+
+ // The preview renders the file as-is without populating relationships, so these
+ // IDs never need to exist — nothing here reaches the database.
+ const uploadPreviewFile = async ({
+ docs,
+ name,
+ }: {
+ docs: Record