From 320b16701354b891d1be36b85882a3ec1db19225 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 27 Jul 2026 11:58:42 -0400 Subject: [PATCH 1/8] fix(plugin-import-export): export relationship fields natively for JSON format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasMany and polymorphic relationship fields were flattened into field_N_id / field_N_relationTo sibling keys for every export format, but only CSV import reverses that flattening. Importing a collection's own JSON export therefore dropped every hasMany and polymorphic relationship. The export hooks now check the format and return the relationship value as-is for JSON — an id, an array of ids, or relationTo/value pairs. CSV output is unchanged. --- .../utilities/getExportFieldFunctions.spec.ts | 75 ++++ .../src/utilities/getExportFieldFunctions.ts | 80 ++-- .../src/utilities/unflattenObject.spec.ts | 38 ++ test/plugin-import-export/int.spec.ts | 383 ++++++++++++++++++ 4 files changed, 548 insertions(+), 28 deletions(-) diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts index 0b03cb4448a..2541ca96482 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts @@ -37,3 +37,78 @@ describe('getExportFieldFunctions registration', () => { expect(result['topLevelData']).toBeDefined() }) }) + +describe('relationship export handlers', () => { + const buildField = (overrides: Record): FlattenedField[] => [ + { name: 'rel', type: 'relationship', ...overrides } as unknown as FlattenedField, + ] + + /** + * Invokes the registered handler the way the export pipelines do and returns both + * the hook's return value and whatever it wrote into the flat row. + */ + const invoke = ({ + fields, + format, + value, + }: { + fields: FlattenedField[] + format: 'csv' | 'json' + value: unknown + }) => { + const entry = getExportFieldFunctions({ fields })['rel'] + const siblingData: Record = {} + + const returned = (entry as { fn: (args: Record) => unknown }).fn({ + columnName: 'rel', + data: {}, + format, + siblingData, + siblingDoc: {}, + value, + }) + + return { returned, siblingData } + } + + it('should leave an unresolvable single polymorphic value untouched for json but suppress it for csv', () => { + const fields = buildField({ relationTo: ['posts', 'users'] }) + const value = { relationTo: 'posts', value: null } + + expect(invoke({ fields, format: 'json', value }).returned).toBeUndefined() + expect(invoke({ fields, format: 'csv', value }).returned).toBeNull() + }) + + it('should drop unresolvable ids from a hasMany monomorphic json export', () => { + const { returned } = invoke({ + fields: buildField({ hasMany: true, relationTo: 'posts' }), + format: 'json', + value: [{ id: 'p1' }, {}, 'p2'], + }) + + expect(returned).toEqual(['p1', 'p2']) + }) + + describe('hasMany polymorphic with an unresolvable entry', () => { + const fields = buildField({ hasMany: true, relationTo: ['posts', 'users'] }) + const value = [ + { relationTo: 'users', value: null }, + { relationTo: 'posts', value: 'p1' }, + ] + + it('should keep csv columns pinned to the source index', () => { + // The surviving entry stays at index 1 — shifting it to 0 would silently + // rewrite column names for every consumer of the CSV. + expect(invoke({ fields, format: 'csv', value }).siblingData).toEqual({ + rel_1_id: 'p1', + rel_1_relationTo: 'posts', + }) + }) + + it('should drop the entry for json rather than leaving a hole', () => { + expect(invoke({ fields, format: 'json', value }).returned).toEqual([ + { relationTo: 'posts', value: 'p1' }, + ]) + }) + }) +}) diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts index 5f635d40dda..65d0f95a972 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts @@ -68,46 +68,70 @@ 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 }) => { + // An unresolvable relationship is suppressed for CSV, whose id/relationTo would + // otherwise land in sibling columns, but left untouched for JSON, which holds the + // source value verbatim. + const unresolved = format === 'json' ? undefined : null + + if (!isPolymorphicRelValue(value)) { + return unresolved + } + const id = getPolymorphicRelId(value) + if (id === undefined) { + return unresolved + } + + 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, + ) + if (format === 'json') { + return ids.filter((id) => id !== undefined) + } + 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 - } - } - }) - return null + registerHandler(({ format, siblingData, value }) => { + if (!Array.isArray(value)) { + return undefined } - 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') { + return rels.map((rel) => ({ relationTo: rel.relationTo, value: rel.id })) + } + 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..b3afd036920 100644 --- a/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts +++ b/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts @@ -405,6 +405,44 @@ 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 skip polymorphic relationships with undefined values', () => { const data = { polymorphic_id: undefined, diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index 97ab2f1348d..45a186a2c3d 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -4698,6 +4698,389 @@ describe('@payloadcms/plugin-import-export', () => { }) }) + it('should roundtrip a hasMany monomorphic relationship through CSV export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { + title: 'hasMany Monomorphic Post 1', + }, + }) + const post2 = await payload.create({ + collection: 'posts', + data: { + title: 'hasMany Monomorphic Post 2', + }, + }) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasMany Monomorphic Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyMonomorphic'], + format: 'csv', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const csvPath = path.join(dirname, './uploads', exportedDoc.filename as string) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(csvPath), + mimetype: 'text/csv', + name: 'hasmany-monomorphic-roundtrip.csv', + size: fs.statSync(csvPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasMany Monomorphic Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + await payload.delete({ + collection: 'pages', + where: { + title: { equals: 'hasMany Monomorphic Roundtrip' }, + }, + }) + await payload.delete({ collection: 'posts', id: post1.id }) + await payload.delete({ collection: 'posts', id: post2.id }) + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.hasManyMonomorphic).toHaveLength(2) + expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) + }) + + it('should roundtrip a hasMany monomorphic relationship through JSON export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasMany JSON Post 1' }, + }) + const post2 = await payload.create({ + collection: 'posts', + data: { title: 'hasMany JSON Post 2' }, + }) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasMany JSON Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyMonomorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasmany-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasMany JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + await payload.delete({ + collection: 'pages', + where: { + title: { equals: 'hasMany JSON Roundtrip' }, + }, + }) + await payload.delete({ collection: 'posts', id: post1.id }) + await payload.delete({ collection: 'posts', id: post2.id }) + + expect(importDoc.status).toBe('completed') + expect(importedPages.docs).toHaveLength(1) + expect(imported?.hasManyMonomorphic).toHaveLength(2) + expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) + }) + + it('should roundtrip a single polymorphic relationship through JSON export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasOnePolymorphic JSON Post' }, + }) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasOnePolymorphic JSON Roundtrip', + hasOnePolymorphic: { + relationTo: 'posts', + value: post1.id, + }, + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasOnePolymorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasone-polymorphic-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasOnePolymorphic JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + await payload.delete({ + collection: 'pages', + where: { + title: { equals: 'hasOnePolymorphic JSON Roundtrip' }, + }, + }) + await payload.delete({ collection: 'posts', id: post1.id }) + + expect(importDoc.status).toBe('completed') + expect(importedPages.docs).toHaveLength(1) + expect(imported?.hasOnePolymorphic).toEqual({ + relationTo: 'posts', + value: post1.id, + }) + }) + + it('should roundtrip a hasMany polymorphic relationship through JSON export/import', async () => { + const testUser = await payload.find({ + collection: 'users', + limit: 1, + }) + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasManyPolymorphic JSON Post' }, + }) + + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasManyPolymorphic JSON Roundtrip', + hasManyPolymorphic: [ + { relationTo: 'users', value: testUser.docs[0]?.id }, + { relationTo: 'posts', value: post1.id }, + ], + _status: 'published', + }, + }) + + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyPolymorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) + + await payload.jobs.run() + + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) + + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) + + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasmany-polymorphic-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasManyPolymorphic JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + await payload.delete({ + collection: 'pages', + where: { + title: { equals: 'hasManyPolymorphic JSON Roundtrip' }, + }, + }) + await payload.delete({ collection: 'posts', id: post1.id }) + + expect(importDoc.status).toBe('completed') + expect(importedPages.docs).toHaveLength(1) + expect(imported?.hasManyPolymorphic).toHaveLength(2) + expect(imported?.hasManyPolymorphic?.[0]).toEqual({ + relationTo: 'users', + value: testUser.docs[0]?.id, + }) + expect(imported?.hasManyPolymorphic?.[1]).toEqual({ + relationTo: 'posts', + value: post1.id, + }) + }) + describe('batch processing', () => { it('should process large imports in batches', async () => { const rows = ['title,excerpt'] From b52647384ba0bd2292b16ecbbb0886a41a51fe11 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 27 Jul 2026 12:45:38 -0400 Subject: [PATCH 2/8] chore(plugin-import-export): trim relationship export hook unit tests Four of the five relationship tests either paraphrased the implementation's null guards or duplicated the JSON roundtrip coverage already in test/plugin-import-export/int.spec.ts, and all five went through a local helper that hand-rolled the beforeExport arg object behind an untyped cast. Kept the one case nothing else covers: an orphaned entry must leave a gap in the CSV columns so surviving relationships keep their source index. It now runs through applyFieldHooks, the real caller, so the arg shape comes from production code rather than the test. --- .../utilities/getExportFieldFunctions.spec.ts | 110 +++++++----------- 1 file changed, 41 insertions(+), 69 deletions(-) diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts index 2541ca96482..634fea65e16 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts @@ -1,9 +1,18 @@ -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 { 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[] = [ @@ -38,77 +47,40 @@ describe('getExportFieldFunctions registration', () => { }) }) -describe('relationship export handlers', () => { - const buildField = (overrides: Record): FlattenedField[] => [ - { name: 'rel', type: 'relationship', ...overrides } as unknown as FlattenedField, - ] - - /** - * Invokes the registered handler the way the export pipelines do and returns both - * the hook's return value and whatever it wrote into the flat row. - */ - const invoke = ({ - fields, - format, - value, - }: { - fields: FlattenedField[] - format: 'csv' | 'json' - value: unknown - }) => { - const entry = getExportFieldFunctions({ fields })['rel'] - const siblingData: Record = {} - - const returned = (entry as { fn: (args: Record) => unknown }).fn({ - columnName: 'rel', - data: {}, - format, - siblingData, - siblingDoc: {}, - value, - }) - - return { returned, siblingData } - } - - it('should leave an unresolvable single polymorphic value untouched for json but suppress it for csv', () => { - const fields = buildField({ relationTo: ['posts', 'users'] }) - const value = { relationTo: 'posts', value: null } - - expect(invoke({ fields, format: 'json', value }).returned).toBeUndefined() - expect(invoke({ fields, format: 'csv', value }).returned).toBeNull() - }) - - it('should drop unresolvable ids from a hasMany monomorphic json export', () => { - const { returned } = invoke({ - fields: buildField({ hasMany: true, relationTo: 'posts' }), - format: 'json', - value: [{ id: 'p1' }, {}, 'p2'], - }) - - expect(returned).toEqual(['p1', 'p2']) - }) - - describe('hasMany polymorphic with an unresolvable entry', () => { - const fields = buildField({ hasMany: true, relationTo: ['posts', 'users'] }) - const value = [ - { relationTo: 'users', value: null }, - { relationTo: 'posts', value: 'p1' }, +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, ] - it('should keep csv columns pinned to the source index', () => { - // The surviving entry stays at index 1 — shifting it to 0 would silently - // rewrite column names for every consumer of the CSV. - expect(invoke({ fields, format: 'csv', value }).siblingData).toEqual({ - rel_1_id: 'p1', - rel_1_relationTo: 'posts', - }) + const result = applyFieldHooks({ + type: 'beforeExport', + // 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' }, + ], + }, + fieldHooks: getExportFieldFunctions({ fields }), + fields, + format: 'csv', + operation: 'export', + req: mockReq, }) - it('should drop the entry for json rather than leaving a hole', () => { - expect(invoke({ fields, format: 'json', value }).returned).toEqual([ - { relationTo: 'posts', value: 'p1' }, - ]) + // The surviving entry stays at index 1 — shifting it to 0 would silently + // rewrite column names for every consumer of the CSV. + expect(result).toEqual({ + rel: null, + rel_1_id: 'p1', + rel_1_relationTo: 'posts', }) }) }) From 48b47dcd7aa2b700ab409be18d4b614b57ee674d Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Mon, 27 Jul 2026 16:59:52 -0400 Subject: [PATCH 3/8] add better preview --- .../getRelationshipGroups.spec.ts | 203 ++++++ .../RelationshipCell/getRelationshipGroups.ts | 146 ++++ .../ImportPreview/RelationshipCell/index.css | 33 + .../ImportPreview/RelationshipCell/index.tsx | 64 ++ .../src/components/ImportPreview/index.tsx | 70 +- packages/ui/src/exports/client/index.ts | 4 + test/plugin-import-export/int.spec.ts | 680 ++++++++++-------- test/plugin-import-export/payload-types.ts | 148 +++- 8 files changed, 967 insertions(+), 381 deletions(-) create mode 100644 packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.spec.ts create mode 100644 packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.ts create mode 100644 packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.css create mode 100644 packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.tsx diff --git a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.spec.ts b/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.spec.ts new file mode 100644 index 00000000000..aa5cce85f29 --- /dev/null +++ b/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.spec.ts @@ -0,0 +1,203 @@ +import type { ClientCollectionConfig } from 'payload' + +import { describe, expect, it } from 'vitest' + +import { getRelationshipGroups } from './getRelationshipGroups.js' + +const collections = [ + { + slug: 'posts', + admin: { useAsTitle: 'title' }, + fields: [{ name: 'title', type: 'text' }], + labels: { plural: 'Posts', singular: 'Post' }, + }, + { + slug: 'users', + admin: { useAsTitle: 'email' }, + fields: [{ name: 'email', type: 'email' }], + labels: { plural: 'Users', singular: 'User' }, + }, +] as unknown as ClientCollectionConfig[] + +const i18n = { t: (key: string) => key } as any + +const group = ({ relationTo, value }: { relationTo: string | string[]; value: unknown }) => + getRelationshipGroups({ collections, dateFormat: 'MMMM do yyyy', i18n, relationTo, value }) + +/** Collapses each group to `Label: value, value (+N)` so assertions stay readable. */ +const summarize = (groups: ReturnType) => + groups.map(({ label, options, remaining }) => { + const values = options.map((o) => o.label).join(', ') + + return remaining ? `${label}: ${values} (+${remaining})` : `${label}: ${values}` + }) + +describe('getRelationshipGroups', () => { + describe('monomorphic', () => { + it('should build a single group labeled with the plural collection label', () => { + expect(group({ relationTo: 'posts', value: 3 })).toEqual([ + { + label: 'Posts', + options: [{ allowEdit: false, label: '3', relationTo: 'posts', value: 3 }], + remaining: 0, + }, + ]) + }) + + it('should collect a list of bare IDs into one group', () => { + expect(summarize(group({ relationTo: 'posts', value: [1, 2] }))).toEqual(['Posts: 1, 2']) + }) + + it('should title a populated document using useAsTitle', () => { + expect( + summarize(group({ relationTo: 'posts', value: { id: 3, title: 'The Wall' } })), + ).toEqual(['Posts: The Wall']) + }) + + it('should fall back to untitled and ID for a document with no useAsTitle value', () => { + expect(summarize(group({ relationTo: 'posts', value: { id: 3 } }))).toEqual([ + 'Posts: general:untitled - ID: 3', + ]) + }) + }) + + describe('polymorphic', () => { + it('should label a wrapped ID with its collection', () => { + expect( + summarize( + group({ relationTo: ['posts', 'users'], value: { relationTo: 'posts', value: 3 } }), + ), + ).toEqual(['Posts: 3']) + }) + + it('should title a wrapped populated document using useAsTitle', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: { relationTo: 'posts', value: { id: 3, title: 'The Wall' } }, + }), + ), + ).toEqual(['Posts: The Wall']) + }) + + it('should group entries that share a collection', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [ + { relationTo: 'users', value: 7 }, + { relationTo: 'posts', value: 3 }, + { relationTo: 'users', value: 9 }, + ], + }), + ), + ).toEqual(['Users: 7, 9', 'Posts: 3']) + }) + + it('should order groups by where their collection first appears', () => { + expect( + group({ + relationTo: ['posts', 'users'], + value: [ + { relationTo: 'posts', value: 3 }, + { relationTo: 'users', value: 7 }, + ], + }).map(({ label }) => label), + ).toEqual(['Posts', 'Users']) + }) + + it('should fall back to the collection slug when the collection has no labels', () => { + expect( + summarize(group({ relationTo: ['pages'], value: { relationTo: 'pages', value: 4 } })), + ).toEqual(['pages: 4']) + }) + + it('should keep entries whose collection is unknown in their own unlabeled group', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [{ relationTo: 'posts', value: 3 }, 4], + }), + ), + ).toEqual(['Posts: 3', ': 4']) + }) + }) + + describe('capping', () => { + it('should not report remaining options when the group fits', () => { + expect(summarize(group({ relationTo: 'posts', value: [1, 2, 3] }))).toEqual([ + 'Posts: 1, 2, 3', + ]) + }) + + it('should cap a group at three options and count the rest', () => { + expect(summarize(group({ relationTo: 'posts', value: [1, 2, 3, 4, 5] }))).toEqual([ + 'Posts: 1, 2, 3 (+2)', + ]) + }) + + it('should cap each collection independently rather than across the cell', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [ + { relationTo: 'users', value: 7 }, + { relationTo: 'posts', value: 3 }, + { relationTo: 'users', value: 9 }, + { relationTo: 'posts', value: 4 }, + ], + }), + ), + ).toEqual(['Users: 7, 9', 'Posts: 3, 4']) + }) + + it('should count overflow per collection', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [ + ...[1, 2, 3, 4, 5].map((value) => ({ relationTo: 'posts', value })), + ...[7, 8].map((value) => ({ relationTo: 'users', value })), + ], + }), + ), + ).toEqual(['Posts: 1, 2, 3 (+2)', 'Users: 7, 8']) + }) + + it('should not let a leading collection crowd out a later one', () => { + expect( + summarize( + group({ + relationTo: ['posts', 'users'], + value: [ + ...[1, 2, 3, 4].map((value) => ({ relationTo: 'posts', value })), + { relationTo: 'users', value: 7 }, + ], + }), + ), + ).toEqual(['Posts: 1, 2, 3 (+1)', 'Users: 7']) + }) + }) + + describe('unresolvable values', () => { + it('should render an object carrying no ID as JSON rather than [object Object]', () => { + expect(summarize(group({ relationTo: 'posts', value: { slug: 'no-id-here' } }))).toEqual([ + 'Posts: {"slug":"no-id-here"}', + ]) + }) + + it('should never render [object Object] for a wrapped value', () => { + const groups = group({ + relationTo: ['posts', 'users'], + value: { relationTo: 'posts', value: { title: 'No ID' } }, + }) + + expect(groups[0]?.options[0]?.label).not.toContain('[object Object]') + }) + }) +}) diff --git a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.ts b/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.ts new file mode 100644 index 00000000000..82691d10272 --- /dev/null +++ b/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.ts @@ -0,0 +1,146 @@ +import type { I18n } from '@payloadcms/translations' +import type { RelationshipOption, RelationshipOptionGroup } from '@payloadcms/ui' +import type { ClientCollectionConfig, SanitizedConfig } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import { formatDocTitle } from '@payloadcms/ui/shared' + +/** + * Options rendered per collection before the rest collapse into `and N more`, + * matching `totalToShow` in the list view's relationship cell. + */ +export const TOTAL_TO_SHOW = 3 + +type Args = { + collections: ClientCollectionConfig[] + dateFormat: SanitizedConfig['admin']['dateFormat'] + i18n: I18n + /** `field.relationTo` — an array when the field is polymorphic. */ + relationTo: string | string[] + value: unknown +} + +export type RelationshipGroup = { + /** Options past `TOTAL_TO_SHOW` dropped from this group, to render as `and N more`. */ + remaining: number +} & RelationshipOptionGroup + +/** + * Groups a relationship or upload value by target collection, mirroring the option + * groups the Relationship field builds in `optionsReducer` — one group per + * collection, labeled with its plural label, holding one option per document. + * + * Import files carry relationships in whichever shape produced them, so every + * entry is one of: a bare ID, a populated document, or the polymorphic + * `{ relationTo, value }` pair — whose `value` is itself an ID or a document. + * + * Each group caps at `TOTAL_TO_SHOW` options and counts the rest into `remaining`, + * so a polymorphic field shows the same depth per collection rather than spending + * one cell's worth of rows on whichever collection happens to come first. Groups are + * returned in the order their collection first appears, and entries whose collection + * cannot be determined collect into a single unlabeled group. Options keep their order + * in the file rather than being sorted, so the preview reflects what will be imported. + */ +export const getRelationshipGroups = ({ + collections, + dateFormat, + i18n, + relationTo, + value, +}: Args): RelationshipGroup[] => { + const entries = Array.isArray(value) ? value : [value] + + const groups: RelationshipGroup[] = [] + const groupsBySlug = new Map() + + entries.forEach((entry) => { + const key = getEntrySlug({ entry, relationTo }) ?? '' + + let group = groupsBySlug.get(key) + + if (!group) { + const relatedConfig = collections.find((collection) => collection.slug === key) + + group = { + label: key ? getTranslation(relatedConfig?.labels?.plural || key, i18n) : '', + options: [], + remaining: 0, + } + + groupsBySlug.set(key, group) + groups.push(group) + } + + // Past the cap only the count matters, so skip the cost of titling the document. + if (group.options.length < TOTAL_TO_SHOW) { + group.options.push(buildOption({ collections, dateFormat, entry, i18n, relationTo })) + } else { + group.remaining++ + } + }) + + return groups +} + +/** The collection an entry targets, or `undefined` when it cannot be determined. */ +const getEntrySlug = ({ + entry, + relationTo, +}: { entry: unknown } & Pick): string | undefined => { + if (isPolymorphicRelationship(entry)) { + return entry.relationTo + } + + return Array.isArray(relationTo) ? undefined : relationTo +} + +type BuildOptionArgs = { entry: unknown } & Omit + +const buildOption = ({ + collections, + dateFormat, + entry, + i18n, + relationTo, +}: BuildOptionArgs): RelationshipOption => { + const entrySlug = getEntrySlug({ entry, relationTo }) + const target = isPolymorphicRelationship(entry) ? entry.value : entry + + const doc = isRecord(target) ? target : undefined + const id = doc ? doc.id : target + + // Fall back to JSON for anything that did not resolve to a usable ID. Interpolating an + // object into a template is the `[object Object]` this function exists to avoid. + if (typeof id !== 'string' && typeof id !== 'number') { + const json = JSON.stringify(entry) ?? '' + + return { allowEdit: false, label: json, relationTo: entrySlug, value: json } + } + + if (!doc) { + // A bare ID carries no document to title. Showing the ID alone is more honest than + // the field's `Untitled` fallback, which would assert the document has no title. + return { allowEdit: false, label: `${id}`, relationTo: entrySlug, value: id } + } + + return { + allowEdit: false, + label: formatDocTitle({ + collectionConfig: collections.find((collection) => collection.slug === entrySlug), + data: doc as Parameters[0]['data'], + dateFormat, + fallback: `${i18n.t('general:untitled')} - ID: ${id}`, + i18n, + }), + relationTo: entrySlug, + value: id, + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isPolymorphicRelationship = ( + value: unknown, +): value is { relationTo: string; value: unknown } => + isRecord(value) && typeof value.relationTo === 'string' && 'value' in value diff --git a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.css b/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.css new file mode 100644 index 00000000000..6273236078e --- /dev/null +++ b/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.css @@ -0,0 +1,33 @@ +@layer payload-default { + .import-preview-relationship { + display: flex; + flex-direction: column; + gap: var(--spacer-1); + } + + .import-preview-relationship__group { + display: flex; + align-items: baseline; + gap: var(--spacer-2); + } + + .import-preview-relationship__collection { + color: var(--field-color-label); + font-family: var(--text-body-small-strong-font-family); + font-size: var(--text-body-small-strong-font-size); + font-weight: var(--text-body-small-strong-font-weight); + letter-spacing: var(--text-body-small-strong-letter-spacing); + line-height: var(--text-body-small-strong-line-height); + text-transform: uppercase; + white-space: nowrap; + } + + .import-preview-relationship__more { + display: block; + color: var(--color-text-secondary); + font-family: var(--text-body-small-font-family); + font-size: var(--text-body-small-font-size); + font-weight: var(--text-body-small-font-weight); + line-height: var(--text-body-small-line-height); + } +} diff --git a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.tsx b/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.tsx new file mode 100644 index 00000000000..56606508526 --- /dev/null +++ b/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.tsx @@ -0,0 +1,64 @@ +'use client' +import type { I18n } from '@payloadcms/translations' +import type { ClientCollectionConfig, SanitizedConfig } from 'payload' + +import React from 'react' + +import { getRelationshipGroups } from './getRelationshipGroups.js' +import './index.css' + +const baseClass = 'import-preview-relationship' + +type Props = { + collections: ClientCollectionConfig[] + dateFormat: SanitizedConfig['admin']['dateFormat'] + i18n: I18n + /** `field.relationTo` — an array when the field is polymorphic. */ + relationTo: string | string[] + value: unknown +} + +/** + * Renders a relationship or upload value for the import preview table, stacking + * one row per target collection. + */ +export const RelationshipCell: React.FC = ({ + collections, + dateFormat, + i18n, + relationTo, + value, +}) => { + const groups = React.useMemo( + () => getRelationshipGroups({ collections, dateFormat, i18n, relationTo, value }), + [collections, dateFormat, i18n, relationTo, value], + ) + + // The column heading already names a monomorphic field, whose collection never varies. + const showCollectionLabels = Array.isArray(relationTo) + + if (!groups.length) { + return null + } + + return ( +
+ {groups.map(({ label, options, remaining }, index) => ( +
+ {/* A `label` element would be unassociated here — the cell holds no control */} + {showCollectionLabels && label && ( + {label} + )} +
+ {options.map((option) => option.label).join(', ')} + {remaining > 0 && ( + + {i18n.t('fields:itemsAndMore', { count: remaining, items: '' }).trim()} + + )} +
+
+ ))} +
+ ) +} diff --git a/packages/plugin-import-export/src/components/ImportPreview/index.tsx b/packages/plugin-import-export/src/components/ImportPreview/index.tsx index 412417b4159..c2a6dc9634c 100644 --- a/packages/plugin-import-export/src/components/ImportPreview/index.tsx +++ b/packages/plugin-import-export/src/components/ImportPreview/index.tsx @@ -14,7 +14,6 @@ import { useFormFields, useTranslation, } from '@payloadcms/ui' -import { formatDocTitle } from '@payloadcms/ui/shared' import { fieldAffectsData, getObjectDotNotation } from 'payload/shared' import React, { useState, useTransition } from 'react' @@ -25,6 +24,7 @@ import type { import type { ImportPreviewResponse } from '../../types.js' import { DEFAULT_PREVIEW_LIMIT, PREVIEW_LIMIT_OPTIONS } from '../../constants.js' +import { RelationshipCell } from './RelationshipCell/index.js' import './index.css' const baseClass = 'import-preview' @@ -239,7 +239,7 @@ export const ImportPreview: React.FC = () => { active: true, field, Heading: label, - renderedCells: docs.map((doc) => { + renderedCells: docs.map((doc, rowIndex) => { const value = getObjectDotNotation(doc, fieldPath) if (value === undefined || value === null) { @@ -248,62 +248,16 @@ export const ImportPreview: React.FC = () => { // Format based on field type if (field.type === 'relationship' || field.type === 'upload') { - // Handle relationships - if (typeof value === 'object' && !Array.isArray(value)) { - // Single relationship - const relationTo = Array.isArray(field.relationTo) - ? (value as any).relationTo - : field.relationTo - - const relatedConfig = config.collections.find((c) => c.slug === relationTo) - if (relatedConfig && relatedConfig.admin?.useAsTitle) { - const titleValue = (value as any)[relatedConfig.admin.useAsTitle] - if (titleValue) { - return formatDocTitle({ - collectionConfig: relatedConfig, - data: value as any, - dateFormat: config.admin.dateFormat, - i18n, - }) - } - } - - // Fallback to ID - const id = (value as any).id || value - return `${getTranslation(relatedConfig?.labels?.singular || relationTo, i18n)}: ${id}` - } else if (Array.isArray(value)) { - // Multiple relationships - return value - .map((item) => { - if (typeof item === 'object') { - const relationTo = Array.isArray(field.relationTo) - ? item.relationTo - : field.relationTo - const relatedConfig = config.collections.find( - (c) => c.slug === relationTo, - ) - - if (relatedConfig && relatedConfig.admin?.useAsTitle) { - const titleValue = item[relatedConfig.admin.useAsTitle] - if (titleValue) { - return formatDocTitle({ - collectionConfig: relatedConfig, - data: item, - dateFormat: config.admin.dateFormat, - i18n, - }) - } - } - - return item.id || item - } - return item - }) - .join(', ') - } - - // Just an ID - return String(value) + return ( + + ) } else if (field.type === 'date') { // Display date as string to avoid wrong locale/timezone conversion return String(value) diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 82358b1311d..68fe54b0d61 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -297,6 +297,10 @@ export { PasswordField } from '../../fields/Password/index.js' export { PointField } from '../../fields/Point/index.js' export { RadioGroupField } from '../../fields/RadioGroup/index.js' export { RelationshipField, RelationshipInput } from '../../fields/Relationship/index.js' +export type { + Option as RelationshipOption, + OptionGroup as RelationshipOptionGroup, +} from '../../fields/Relationship/types.js' export { RichTextField } from '../../fields/RichText/index.js' export { RowField } from '../../fields/Row/index.js' export { formatOptions, SelectField, SelectInput } from '../../fields/Select/index.js' diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index 45a186a2c3d..ed0a1ef0b38 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -4698,386 +4698,428 @@ describe('@payloadcms/plugin-import-export', () => { }) }) - it('should roundtrip a hasMany monomorphic relationship through CSV export/import', async () => { - const post1 = await payload.create({ - collection: 'posts', - data: { - title: 'hasMany Monomorphic Post 1', - }, - }) - const post2 = await payload.create({ - collection: 'posts', - data: { - title: 'hasMany Monomorphic Post 2', - }, - }) + describe('relationship roundtrips', () => { + const createdPostIDs: (number | string)[] = [] - const testPage = await payload.create({ - collection: 'pages', - data: { - title: 'hasMany Monomorphic Roundtrip', - hasManyMonomorphic: [post1.id, post2.id], - _status: 'published', - }, + afterEach(async () => { + for (const id of createdPostIDs) { + await payload.delete({ collection: 'posts', id }) + } + createdPostIDs.length = 0 }) - const exportDoc = await payload.create({ - collection: 'exports', - user, - data: { - collectionSlug: 'pages', - fields: ['id', 'title', 'hasManyMonomorphic'], - format: 'csv', - where: { - id: { equals: testPage.id }, + it('should roundtrip a hasMany monomorphic relationship through CSV export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { + title: 'hasMany Monomorphic Post 1', }, - }, - }) - - await payload.jobs.run() + }) + const post2 = await payload.create({ + collection: 'posts', + data: { + title: 'hasMany Monomorphic Post 2', + }, + }) - const exportedDoc = await payload.findByID({ - collection: 'exports', - id: exportDoc.id, - }) + createdPostIDs.push(post1.id, post2.id) - const csvPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasMany Monomorphic Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + _status: 'published', + }, + }) - await payload.delete({ - collection: 'pages', - id: testPage.id, - }) + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyMonomorphic'], + format: 'csv', + where: { + id: { equals: testPage.id }, + }, + }, + }) - let importDoc = await payload.create({ - collection: 'imports', - user, - data: { - collectionSlug: 'pages', - importMode: 'create', - }, - file: { - data: fs.readFileSync(csvPath), - mimetype: 'text/csv', - name: 'hasmany-monomorphic-roundtrip.csv', - size: fs.statSync(csvPath).size, - }, - }) + await payload.jobs.run() - await payload.jobs.run() + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) - importDoc = await payload.findByID({ - collection: 'imports', - id: importDoc.id, - }) + const csvPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const exportedRows = await readCSV(csvPath) - const importedPages = await payload.find({ - collection: 'pages', - where: { - title: { equals: 'hasMany Monomorphic Roundtrip' }, - }, - depth: 0, - }) - const imported = importedPages.docs[0] + // CSV keeps each relationship flattened into indexed sibling columns + expect(exportedRows).toEqual([ + { + id: String(testPage.id), + title: 'hasMany Monomorphic Roundtrip', + hasManyMonomorphic_0_id: String(post1.id), + hasManyMonomorphic_1_id: String(post2.id), + }, + ]) - await payload.delete({ - collection: 'pages', - where: { - title: { equals: 'hasMany Monomorphic Roundtrip' }, - }, - }) - await payload.delete({ collection: 'posts', id: post1.id }) - await payload.delete({ collection: 'posts', id: post2.id }) + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) - expect(importDoc.status).toBe('completed') - expect(importDoc.summary?.imported).toBe(1) - expect(importDoc.summary?.issues).toBe(0) - expect(importedPages.docs).toHaveLength(1) - expect(imported?.hasManyMonomorphic).toHaveLength(2) - expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) - }) + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(csvPath), + mimetype: 'text/csv', + name: 'hasmany-monomorphic-roundtrip.csv', + size: fs.statSync(csvPath).size, + }, + }) - it('should roundtrip a hasMany monomorphic relationship through JSON export/import', async () => { - const post1 = await payload.create({ - collection: 'posts', - data: { title: 'hasMany JSON Post 1' }, - }) - const post2 = await payload.create({ - collection: 'posts', - data: { title: 'hasMany JSON Post 2' }, - }) + await payload.jobs.run() - const testPage = await payload.create({ - collection: 'pages', - data: { - title: 'hasMany JSON Roundtrip', - hasManyMonomorphic: [post1.id, post2.id], - _status: 'published', - }, - }) + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) - const exportDoc = await payload.create({ - collection: 'exports', - user, - data: { - collectionSlug: 'pages', - fields: ['id', 'title', 'hasManyMonomorphic'], - format: 'json', + const importedPages = await payload.find({ + collection: 'pages', where: { - id: { equals: testPage.id }, + title: { equals: 'hasMany Monomorphic Roundtrip' }, }, - }, - }) - - await payload.jobs.run() + depth: 0, + }) + const imported = importedPages.docs[0] - const exportedDoc = await payload.findByID({ - collection: 'exports', - id: exportDoc.id, + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.title).toBe('hasMany Monomorphic Roundtrip') + expect(imported?.hasManyMonomorphic).toHaveLength(2) + expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) + // The flattened columns are consumed by the import, not carried onto the document + expect(imported).not.toHaveProperty('hasManyMonomorphic_0_id') + expect(imported).not.toHaveProperty('hasManyMonomorphic_1_id') }) - const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + it('should roundtrip a hasMany monomorphic relationship through JSON export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasMany JSON Post 1' }, + }) + const post2 = await payload.create({ + collection: 'posts', + data: { title: 'hasMany JSON Post 2' }, + }) - await payload.delete({ - collection: 'pages', - id: testPage.id, - }) + createdPostIDs.push(post1.id, post2.id) - let importDoc = await payload.create({ - collection: 'imports', - user, - data: { - collectionSlug: 'pages', - importMode: 'create', - }, - file: { - data: fs.readFileSync(jsonPath), - mimetype: 'application/json', - name: 'hasmany-json-roundtrip.json', - size: fs.statSync(jsonPath).size, - }, - }) + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasMany JSON Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + _status: 'published', + }, + }) - await payload.jobs.run() + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyMonomorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) - importDoc = await payload.findByID({ - collection: 'imports', - id: importDoc.id, - }) + await payload.jobs.run() - const importedPages = await payload.find({ - collection: 'pages', - where: { - title: { equals: 'hasMany JSON Roundtrip' }, - }, - depth: 0, - }) - const imported = importedPages.docs[0] + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) - await payload.delete({ - collection: 'pages', - where: { - title: { equals: 'hasMany JSON Roundtrip' }, - }, - }) - await payload.delete({ collection: 'posts', id: post1.id }) - await payload.delete({ collection: 'posts', id: post2.id }) + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const exportedDocs = await readJSON(jsonPath) - expect(importDoc.status).toBe('completed') - expect(importedPages.docs).toHaveLength(1) - expect(imported?.hasManyMonomorphic).toHaveLength(2) - expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) - }) + // JSON holds the relationship natively — an array of IDs, with no flattened siblings + expect(exportedDocs).toEqual([ + { + id: testPage.id, + title: 'hasMany JSON Roundtrip', + hasManyMonomorphic: [post1.id, post2.id], + }, + ]) - it('should roundtrip a single polymorphic relationship through JSON export/import', async () => { - const post1 = await payload.create({ - collection: 'posts', - data: { title: 'hasOnePolymorphic JSON Post' }, - }) + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) - const testPage = await payload.create({ - collection: 'pages', - data: { - title: 'hasOnePolymorphic JSON Roundtrip', - hasOnePolymorphic: { - relationTo: 'posts', - value: post1.id, + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', }, - _status: 'published', - }, - }) + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasmany-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) - const exportDoc = await payload.create({ - collection: 'exports', - user, - data: { - collectionSlug: 'pages', - fields: ['id', 'title', 'hasOnePolymorphic'], - format: 'json', + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', where: { - id: { equals: testPage.id }, + title: { equals: 'hasMany JSON Roundtrip' }, }, - }, + depth: 0, + }) + const imported = importedPages.docs[0] + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.title).toBe('hasMany JSON Roundtrip') + expect(imported?.hasManyMonomorphic).toHaveLength(2) + expect((imported?.hasManyMonomorphic ?? []).map(extractID)).toEqual([post1.id, post2.id]) }) - await payload.jobs.run() + it('should roundtrip a single polymorphic relationship through JSON export/import', async () => { + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasOnePolymorphic JSON Post' }, + }) - const exportedDoc = await payload.findByID({ - collection: 'exports', - id: exportDoc.id, - }) + createdPostIDs.push(post1.id) - const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasOnePolymorphic JSON Roundtrip', + hasOnePolymorphic: { + relationTo: 'posts', + value: post1.id, + }, + _status: 'published', + }, + }) - await payload.delete({ - collection: 'pages', - id: testPage.id, - }) + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasOnePolymorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) - let importDoc = await payload.create({ - collection: 'imports', - user, - data: { - collectionSlug: 'pages', - importMode: 'create', - }, - file: { - data: fs.readFileSync(jsonPath), - mimetype: 'application/json', - name: 'hasone-polymorphic-json-roundtrip.json', - size: fs.statSync(jsonPath).size, - }, - }) + await payload.jobs.run() - await payload.jobs.run() + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) - importDoc = await payload.findByID({ - collection: 'imports', - id: importDoc.id, - }) + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const exportedDocs = await readJSON(jsonPath) - const importedPages = await payload.find({ - collection: 'pages', - where: { - title: { equals: 'hasOnePolymorphic JSON Roundtrip' }, - }, - depth: 0, - }) - const imported = importedPages.docs[0] + // The `relationTo`/`value` pair survives intact rather than splitting into siblings + expect(exportedDocs).toEqual([ + { + id: testPage.id, + title: 'hasOnePolymorphic JSON Roundtrip', + hasOnePolymorphic: { relationTo: 'posts', value: post1.id }, + }, + ]) - await payload.delete({ - collection: 'pages', - where: { - title: { equals: 'hasOnePolymorphic JSON Roundtrip' }, - }, - }) - await payload.delete({ collection: 'posts', id: post1.id }) + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) - expect(importDoc.status).toBe('completed') - expect(importedPages.docs).toHaveLength(1) - expect(imported?.hasOnePolymorphic).toEqual({ - relationTo: 'posts', - value: post1.id, - }) - }) + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasone-polymorphic-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) - it('should roundtrip a hasMany polymorphic relationship through JSON export/import', async () => { - const testUser = await payload.find({ - collection: 'users', - limit: 1, - }) - const post1 = await payload.create({ - collection: 'posts', - data: { title: 'hasManyPolymorphic JSON Post' }, - }) + await payload.jobs.run() - const testPage = await payload.create({ - collection: 'pages', - data: { - title: 'hasManyPolymorphic JSON Roundtrip', - hasManyPolymorphic: [ - { relationTo: 'users', value: testUser.docs[0]?.id }, - { relationTo: 'posts', value: post1.id }, - ], - _status: 'published', - }, - }) + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) - const exportDoc = await payload.create({ - collection: 'exports', - user, - data: { - collectionSlug: 'pages', - fields: ['id', 'title', 'hasManyPolymorphic'], - format: 'json', + const importedPages = await payload.find({ + collection: 'pages', where: { - id: { equals: testPage.id }, + title: { equals: 'hasOnePolymorphic JSON Roundtrip' }, }, - }, + depth: 0, + }) + const imported = importedPages.docs[0] + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.title).toBe('hasOnePolymorphic JSON Roundtrip') + expect(imported?.hasOnePolymorphic).toEqual({ + relationTo: 'posts', + value: post1.id, + }) }) - await payload.jobs.run() + it('should roundtrip a hasMany polymorphic relationship through JSON export/import', async () => { + const testUser = await payload.find({ + collection: 'users', + limit: 1, + }) + const post1 = await payload.create({ + collection: 'posts', + data: { title: 'hasManyPolymorphic JSON Post' }, + }) - const exportedDoc = await payload.findByID({ - collection: 'exports', - id: exportDoc.id, - }) + createdPostIDs.push(post1.id) - const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const testPage = await payload.create({ + collection: 'pages', + data: { + title: 'hasManyPolymorphic JSON Roundtrip', + hasManyPolymorphic: [ + { relationTo: 'users', value: testUser.docs[0]?.id }, + { relationTo: 'posts', value: post1.id }, + ], + _status: 'published', + }, + }) - await payload.delete({ - collection: 'pages', - id: testPage.id, - }) + const exportDoc = await payload.create({ + collection: 'exports', + user, + data: { + collectionSlug: 'pages', + fields: ['id', 'title', 'hasManyPolymorphic'], + format: 'json', + where: { + id: { equals: testPage.id }, + }, + }, + }) - let importDoc = await payload.create({ - collection: 'imports', - user, - data: { - collectionSlug: 'pages', - importMode: 'create', - }, - file: { - data: fs.readFileSync(jsonPath), - mimetype: 'application/json', - name: 'hasmany-polymorphic-json-roundtrip.json', - size: fs.statSync(jsonPath).size, - }, - }) + await payload.jobs.run() - await payload.jobs.run() + const exportedDoc = await payload.findByID({ + collection: 'exports', + id: exportDoc.id, + }) - importDoc = await payload.findByID({ - collection: 'imports', - id: importDoc.id, - }) + const jsonPath = path.join(dirname, './uploads', exportedDoc.filename as string) + const exportedDocs = await readJSON(jsonPath) - const importedPages = await payload.find({ - collection: 'pages', - where: { - title: { equals: 'hasManyPolymorphic JSON Roundtrip' }, - }, - depth: 0, - }) - const imported = importedPages.docs[0] + // Each entry keeps its own `relationTo`, so a mixed list stays unambiguous + expect(exportedDocs).toEqual([ + { + id: testPage.id, + title: 'hasManyPolymorphic JSON Roundtrip', + hasManyPolymorphic: [ + { relationTo: 'users', value: testUser.docs[0]?.id }, + { relationTo: 'posts', value: post1.id }, + ], + }, + ]) - await payload.delete({ - collection: 'pages', - where: { - title: { equals: 'hasManyPolymorphic JSON Roundtrip' }, - }, - }) - await payload.delete({ collection: 'posts', id: post1.id }) + await payload.delete({ + collection: 'pages', + id: testPage.id, + }) - expect(importDoc.status).toBe('completed') - expect(importedPages.docs).toHaveLength(1) - expect(imported?.hasManyPolymorphic).toHaveLength(2) - expect(imported?.hasManyPolymorphic?.[0]).toEqual({ - relationTo: 'users', - value: testUser.docs[0]?.id, - }) - expect(imported?.hasManyPolymorphic?.[1]).toEqual({ - relationTo: 'posts', - value: post1.id, + let importDoc = await payload.create({ + collection: 'imports', + user, + data: { + collectionSlug: 'pages', + importMode: 'create', + }, + file: { + data: fs.readFileSync(jsonPath), + mimetype: 'application/json', + name: 'hasmany-polymorphic-json-roundtrip.json', + size: fs.statSync(jsonPath).size, + }, + }) + + await payload.jobs.run() + + importDoc = await payload.findByID({ + collection: 'imports', + id: importDoc.id, + }) + + const importedPages = await payload.find({ + collection: 'pages', + where: { + title: { equals: 'hasManyPolymorphic JSON Roundtrip' }, + }, + depth: 0, + }) + const imported = importedPages.docs[0] + + expect(importDoc.status).toBe('completed') + expect(importDoc.summary?.imported).toBe(1) + expect(importDoc.summary?.issues).toBe(0) + expect(importedPages.docs).toHaveLength(1) + expect(imported?.title).toBe('hasManyPolymorphic JSON Roundtrip') + expect(imported?.hasManyPolymorphic).toHaveLength(2) + expect(imported?.hasManyPolymorphic?.[0]).toEqual({ + relationTo: 'users', + value: testUser.docs[0]?.id, + }) + expect(imported?.hasManyPolymorphic?.[1]).toEqual({ + relationTo: 'posts', + value: post1.id, + }) }) }) diff --git a/test/plugin-import-export/payload-types.ts b/test/plugin-import-export/payload-types.ts index fd249a78cd8..24730550bb4 100644 --- a/test/plugin-import-export/payload-types.ts +++ b/test/plugin-import-export/payload-types.ts @@ -198,11 +198,17 @@ export interface Config { | null | ('en' | 'es' | 'de' | 'he') | ('en' | 'es' | 'de' | 'he')[]; - globals: {}; - globalsSelect: {}; + globals: { + 'payload-jobs-stats': PayloadJobsStat; + }; + globalsSelect: { + 'payload-jobs-stats': PayloadJobsStatsSelect | PayloadJobsStatsSelect; + }; locale: 'en' | 'es' | 'de' | 'he'; widgets: { collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; }; user: User; jobs: { @@ -1179,6 +1185,15 @@ export interface PayloadJob { | number | boolean | null; + meta?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; completedAt?: string | null; totalTried?: number | null; /** @@ -1240,7 +1255,8 @@ export interface PayloadJob { taskSlug?: ('inline' | 'createCollectionExport' | 'createCollectionImport') | null; queue?: string | null; waitUntil?: string | null; - processing?: boolean | null; + processingUntil?: string | null; + processingToken?: string | null; updatedAt: string; createdAt: string; } @@ -2089,6 +2105,7 @@ export interface PayloadKvSelect { export interface PayloadJobsSelect { input?: T; taskStatus?: T; + meta?: T; completedAt?: T; totalTried?: T; hasError?: T; @@ -2109,7 +2126,8 @@ export interface PayloadJobsSelect { taskSlug?: T; queue?: T; waitUntil?: T; - processing?: T; + processingUntil?: T; + processingToken?: T; updatedAt?: T; createdAt?: T; } @@ -2145,6 +2163,34 @@ export interface PayloadMigrationsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs-stats". + */ +export interface PayloadJobsStat { + id: string; + stats?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-jobs-stats_select". + */ +export interface PayloadJobsStatsSelect { + stats?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "collections_widget". @@ -2155,6 +2201,100 @@ export interface CollectionsWidget { }; width: 'full'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: + | 'users' + | 'pages' + | 'posts' + | 'posts-exports-only' + | 'posts-imports-only' + | 'posts-no-jobs-queue' + | 'posts-with-limits' + | 'posts-with-s3' + | 'posts-with-hooks' + | 'posts-with-field-hooks' + | 'posts-with-column-map' + | 'media' + | 'custom-id-pages' + | 'exports' + | 'posts-export' + | 'posts-no-jobs-queue-export' + | 'posts-with-s3-export' + | 'posts-with-limits-export' + | 'posts-with-hooks-export' + | 'posts-with-field-hooks-export' + | 'posts-with-column-map-export' + | 'imports' + | 'posts-import' + | 'posts-with-s3-import' + | 'posts-with-limits-import' + | 'posts-with-hooks-import' + | 'posts-with-field-hooks-import' + | 'posts-with-column-map-import' + | 'payload-jobs'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: + | ( + | 'users' + | 'pages' + | 'posts' + | 'posts-exports-only' + | 'posts-imports-only' + | 'posts-no-jobs-queue' + | 'posts-with-limits' + | 'posts-with-s3' + | 'posts-with-hooks' + | 'posts-with-field-hooks' + | 'posts-with-column-map' + | 'media' + | 'custom-id-pages' + | 'exports' + | 'posts-export' + | 'posts-no-jobs-queue-export' + | 'posts-with-s3-export' + | 'posts-with-limits-export' + | 'posts-with-hooks-export' + | 'posts-with-field-hooks-export' + | 'posts-with-column-map-export' + | 'imports' + | 'posts-import' + | 'posts-with-s3-import' + | 'posts-with-limits-import' + | 'posts-with-hooks-import' + | 'posts-with-field-hooks-import' + | 'posts-with-column-map-import' + | 'payload-jobs' + )[] + | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "TaskCreateCollectionExport". From 103024ada5e46c89f0313e49bdafcac539312c26 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Tue, 28 Jul 2026 12:47:51 -0400 Subject: [PATCH 4/8] add e2e test for relationship cell --- .../src/components/ImportPreview/index.tsx | 5 +- .../getRelationshipGroups.spec.ts | 6 +- .../RelationshipCell/getRelationshipGroups.ts | 47 ++--- .../RelationshipCell/index.css | 0 .../RelationshipCell/index.tsx | 31 ++- packages/ui/src/exports/client/index.ts | 4 - test/plugin-import-export/e2e.spec.ts | 179 +++++++++++++++++- test/plugin-import-export/int.spec.ts | 9 +- 8 files changed, 216 insertions(+), 65 deletions(-) rename packages/plugin-import-export/src/components/{ImportPreview => }/RelationshipCell/getRelationshipGroups.spec.ts (96%) rename packages/plugin-import-export/src/components/{ImportPreview => }/RelationshipCell/getRelationshipGroups.ts (75%) rename packages/plugin-import-export/src/components/{ImportPreview => }/RelationshipCell/index.css (100%) rename packages/plugin-import-export/src/components/{ImportPreview => }/RelationshipCell/index.tsx (69%) diff --git a/packages/plugin-import-export/src/components/ImportPreview/index.tsx b/packages/plugin-import-export/src/components/ImportPreview/index.tsx index c2a6dc9634c..3369efb4a31 100644 --- a/packages/plugin-import-export/src/components/ImportPreview/index.tsx +++ b/packages/plugin-import-export/src/components/ImportPreview/index.tsx @@ -24,7 +24,7 @@ import type { import type { ImportPreviewResponse } from '../../types.js' import { DEFAULT_PREVIEW_LIMIT, PREVIEW_LIMIT_OPTIONS } from '../../constants.js' -import { RelationshipCell } from './RelationshipCell/index.js' +import { RelationshipCell } from '../RelationshipCell/index.js' import './index.css' const baseClass = 'import-preview' @@ -250,9 +250,6 @@ export const ImportPreview: React.FC = () => { if (field.type === 'relationship' || field.type === 'upload') { return ( ) => groups.map(({ label, options, remaining }) => { - const values = options.map((o) => o.label).join(', ') + const values = options.join(', ') return remaining ? `${label}: ${values} (+${remaining})` : `${label}: ${values}` }) @@ -38,7 +38,7 @@ describe('getRelationshipGroups', () => { expect(group({ relationTo: 'posts', value: 3 })).toEqual([ { label: 'Posts', - options: [{ allowEdit: false, label: '3', relationTo: 'posts', value: 3 }], + options: ['3'], remaining: 0, }, ]) @@ -197,7 +197,7 @@ describe('getRelationshipGroups', () => { value: { relationTo: 'posts', value: { title: 'No ID' } }, }) - expect(groups[0]?.options[0]?.label).not.toContain('[object Object]') + expect(groups[0]?.options[0]).not.toContain('[object Object]') }) }) }) diff --git a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.ts b/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts similarity index 75% rename from packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.ts rename to packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts index 82691d10272..8999a1b8b00 100644 --- a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/getRelationshipGroups.ts +++ b/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts @@ -1,12 +1,11 @@ import type { I18n } from '@payloadcms/translations' -import type { RelationshipOption, RelationshipOptionGroup } from '@payloadcms/ui' import type { ClientCollectionConfig, SanitizedConfig } from 'payload' import { getTranslation } from '@payloadcms/translations' import { formatDocTitle } from '@payloadcms/ui/shared' /** - * Options rendered per collection before the rest collapse into `and N more`, + * Labels rendered per collection before the rest collapse into `and N more`, * matching `totalToShow` in the list view's relationship cell. */ export const TOTAL_TO_SHOW = 3 @@ -21,14 +20,17 @@ type Args = { } export type RelationshipGroup = { + /** Plural label of the target collection, empty when it cannot be determined. */ + label: string + /** One rendered label per document, capped at `TOTAL_TO_SHOW`. */ + options: string[] /** Options past `TOTAL_TO_SHOW` dropped from this group, to render as `and N more`. */ remaining: number -} & RelationshipOptionGroup +} /** - * Groups a relationship or upload value by target collection, mirroring the option - * groups the Relationship field builds in `optionsReducer` — one group per - * collection, labeled with its plural label, holding one option per document. + * Groups a relationship or upload value by target collection — one group per + * collection, labeled with its plural label, holding one label per document. * * Import files carry relationships in whichever shape produced them, so every * entry is one of: a bare ID, a populated document, or the polymorphic @@ -73,7 +75,7 @@ export const getRelationshipGroups = ({ // Past the cap only the count matters, so skip the cost of titling the document. if (group.options.length < TOTAL_TO_SHOW) { - group.options.push(buildOption({ collections, dateFormat, entry, i18n, relationTo })) + group.options.push(getOptionLabel({ collections, dateFormat, entry, i18n, relationTo })) } else { group.remaining++ } @@ -94,15 +96,15 @@ const getEntrySlug = ({ return Array.isArray(relationTo) ? undefined : relationTo } -type BuildOptionArgs = { entry: unknown } & Omit +type GetOptionLabelArgs = { entry: unknown } & Omit -const buildOption = ({ +const getOptionLabel = ({ collections, dateFormat, entry, i18n, relationTo, -}: BuildOptionArgs): RelationshipOption => { +}: GetOptionLabelArgs): string => { const entrySlug = getEntrySlug({ entry, relationTo }) const target = isPolymorphicRelationship(entry) ? entry.value : entry @@ -112,29 +114,22 @@ const buildOption = ({ // Fall back to JSON for anything that did not resolve to a usable ID. Interpolating an // object into a template is the `[object Object]` this function exists to avoid. if (typeof id !== 'string' && typeof id !== 'number') { - const json = JSON.stringify(entry) ?? '' - - return { allowEdit: false, label: json, relationTo: entrySlug, value: json } + return JSON.stringify(entry) ?? '' } if (!doc) { // A bare ID carries no document to title. Showing the ID alone is more honest than // the field's `Untitled` fallback, which would assert the document has no title. - return { allowEdit: false, label: `${id}`, relationTo: entrySlug, value: id } + return `${id}` } - return { - allowEdit: false, - label: formatDocTitle({ - collectionConfig: collections.find((collection) => collection.slug === entrySlug), - data: doc as Parameters[0]['data'], - dateFormat, - fallback: `${i18n.t('general:untitled')} - ID: ${id}`, - i18n, - }), - relationTo: entrySlug, - value: id, - } + return formatDocTitle({ + collectionConfig: collections.find((collection) => collection.slug === entrySlug), + data: doc as Parameters[0]['data'], + dateFormat, + fallback: `${i18n.t('general:untitled')} - ID: ${id}`, + i18n, + }) } const isRecord = (value: unknown): value is Record => diff --git a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.css b/packages/plugin-import-export/src/components/RelationshipCell/index.css similarity index 100% rename from packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.css rename to packages/plugin-import-export/src/components/RelationshipCell/index.css diff --git a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.tsx b/packages/plugin-import-export/src/components/RelationshipCell/index.tsx similarity index 69% rename from packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.tsx rename to packages/plugin-import-export/src/components/RelationshipCell/index.tsx index 56606508526..403393bac60 100644 --- a/packages/plugin-import-export/src/components/ImportPreview/RelationshipCell/index.tsx +++ b/packages/plugin-import-export/src/components/RelationshipCell/index.tsx @@ -1,7 +1,5 @@ 'use client' -import type { I18n } from '@payloadcms/translations' -import type { ClientCollectionConfig, SanitizedConfig } from 'payload' - +import { useConfig, useTranslation } from '@payloadcms/ui' import React from 'react' import { getRelationshipGroups } from './getRelationshipGroups.js' @@ -10,9 +8,6 @@ import './index.css' const baseClass = 'import-preview-relationship' type Props = { - collections: ClientCollectionConfig[] - dateFormat: SanitizedConfig['admin']['dateFormat'] - i18n: I18n /** `field.relationTo` — an array when the field is polymorphic. */ relationTo: string | string[] value: unknown @@ -22,17 +17,17 @@ type Props = { * Renders a relationship or upload value for the import preview table, stacking * one row per target collection. */ -export const RelationshipCell: React.FC = ({ - collections, - dateFormat, - i18n, - relationTo, - value, -}) => { - const groups = React.useMemo( - () => getRelationshipGroups({ collections, dateFormat, i18n, relationTo, value }), - [collections, dateFormat, i18n, relationTo, value], - ) +export const RelationshipCell: React.FC = ({ relationTo, value }) => { + const { config } = useConfig() + const { i18n } = useTranslation() + + const groups = getRelationshipGroups({ + collections: config.collections, + dateFormat: config.admin.dateFormat, + i18n, + relationTo, + value, + }) // The column heading already names a monomorphic field, whose collection never varies. const showCollectionLabels = Array.isArray(relationTo) @@ -50,7 +45,7 @@ export const RelationshipCell: React.FC = ({ {label} )}
- {options.map((option) => option.label).join(', ')} + {options.join(', ')} {remaining > 0 && ( {i18n.t('fields:itemsAndMore', { count: remaining, items: '' }).trim()} diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 68fe54b0d61..82358b1311d 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -297,10 +297,6 @@ export { PasswordField } from '../../fields/Password/index.js' export { PointField } from '../../fields/Point/index.js' export { RadioGroupField } from '../../fields/RadioGroup/index.js' export { RelationshipField, RelationshipInput } from '../../fields/Relationship/index.js' -export type { - Option as RelationshipOption, - OptionGroup as RelationshipOptionGroup, -} from '../../fields/Relationship/types.js' export { RichTextField } from '../../fields/RichText/index.js' export { RowField } from '../../fields/Row/index.js' export { formatOptions, SelectField, SelectInput } from '../../fields/Select/index.js' 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[] + name: string + }) => { + await page.goto(importsURL.create) + await expect(page.locator('.collection-edit')).toBeVisible() + + const collectionField = page.locator('#field-collectionSlug') + await collectionField.locator('.rs__control').click() + await getSelectMenu({ page }).locator('.rs__option:text-is("Pages")').click() + + await page.locator('input[type="file"]').setInputFiles({ + name, + buffer: Buffer.from(JSON.stringify(docs)), + mimeType: 'application/json', + }) + + await expect(async () => { + await expect(page.locator('.import-preview table')).toBeVisible() + }).toPass({ timeout: POLL_TOPASS_TIMEOUT }) + } + + /** The first row's cell under the given column heading. */ + const getPreviewCell = async ({ heading }: { heading: string }) => { + const previewTable = page.locator('.import-preview table') + const headings = await previewTable.locator('thead th').allTextContents() + const columnIndex = headings.findIndex((text) => text.trim() === heading) + + expect(columnIndex).toBeGreaterThan(-1) + + return previewTable.locator('tbody tr').first().locator('td').nth(columnIndex) + } + + test('should group polymorphic relationship values by collection', async () => { + await uploadPreviewFile({ + docs: [ + { + title: 'Polymorphic Preview', + hasManyPolymorphic: [ + { relationTo: 'users', value: 'preview-user-1' }, + { relationTo: 'posts', value: 'preview-post-1' }, + ], + }, + ], + name: 'polymorphic-preview.json', + }) + + const cell = await getPreviewCell({ heading: 'Has Many Polymorphic' }) + const groups = cell.locator(`.${relationshipBaseClass}__group`) + + await expect(groups).toHaveCount(2) + await expect(groups.nth(0).locator(`.${relationshipBaseClass}__collection`)).toHaveText( + 'Users', + ) + await expect(groups.nth(0).locator(`.${relationshipBaseClass}__values`)).toHaveText( + 'preview-user-1', + ) + await expect(groups.nth(1).locator(`.${relationshipBaseClass}__collection`)).toHaveText( + 'Posts', + ) + await expect(groups.nth(1).locator(`.${relationshipBaseClass}__values`)).toHaveText( + 'preview-post-1', + ) + }) + + test('should not label the collection for monomorphic relationship values', async () => { + await uploadPreviewFile({ + docs: [ + { + title: 'Monomorphic Preview', + hasManyMonomorphic: ['preview-post-1', 'preview-post-2'], + }, + ], + name: 'monomorphic-preview.json', + }) + + const cell = await getPreviewCell({ heading: 'Has Many Monomorphic' }) + + // The column heading already names the only collection this field can target + await expect(cell.locator(`.${relationshipBaseClass}__collection`)).toHaveCount(0) + await expect(cell.locator(`.${relationshipBaseClass}__values`)).toHaveText( + 'preview-post-1, preview-post-2', + ) + }) + + test('should show the document title for populated relationship values', async () => { + await uploadPreviewFile({ + docs: [ + { + title: 'Populated Preview', + hasManyMonomorphic: [{ id: 'preview-post-1', title: 'Populated Post Title' }], + }, + ], + name: 'populated-relationship-preview.json', + }) + + const cell = await getPreviewCell({ heading: 'Has Many Monomorphic' }) + + await expect(cell.locator(`.${relationshipBaseClass}__values`)).toHaveText( + 'Populated Post Title', + ) + }) + + test('should collapse relationship values past the third into a count', async () => { + await uploadPreviewFile({ + docs: [ + { + title: 'Overflow Preview', + hasManyMonomorphic: [ + 'preview-post-1', + 'preview-post-2', + 'preview-post-3', + 'preview-post-4', + 'preview-post-5', + ], + }, + ], + name: 'overflow-relationship-preview.json', + }) + + const cell = await getPreviewCell({ heading: 'Has Many Monomorphic' }) + + await expect(cell.locator(`.${relationshipBaseClass}__values`)).toContainText( + 'preview-post-1, preview-post-2, preview-post-3', + ) + await expect(cell.locator(`.${relationshipBaseClass}__values`)).not.toContainText( + 'preview-post-4', + ) + await expect(cell.locator(`.${relationshipBaseClass}__more`)).toHaveText('and 2 more') + }) + }) }) test.describe('S3 Storage', () => { test('should import CSV file stored in S3 via jobs queue', 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 uniqueId = Date.now() const csvFilename = `s3-e2e-import-${uniqueId}.csv` const csvPath = path.join(__dirname, 'uploads', csvFilename) @@ -1672,7 +1830,10 @@ test.describe('Import Export Plugin', () => { }) test('should import a CSV with foreign column headers through the admin UI', 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 = '"Post Title","Summary","View Count"\n' + '"E2E Foreign A","e2e summary a","11"\n' + diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts index ed0a1ef0b38..57ccc0587a4 100644 --- a/test/plugin-import-export/int.spec.ts +++ b/test/plugin-import-export/int.spec.ts @@ -4703,7 +4703,14 @@ describe('@payloadcms/plugin-import-export', () => { afterEach(async () => { for (const id of createdPostIDs) { - await payload.delete({ collection: 'posts', id }) + try { + await payload.delete({ + collection: 'posts', + id, + }) + } catch { + // Ignore cleanup errors + } } createdPostIDs.length = 0 }) From 9970ca4611de3e162de2fba3875e8a02c14dbae5 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Tue, 28 Jul 2026 12:55:38 -0400 Subject: [PATCH 5/8] simplify relationship groups --- .../RelationshipCell/getRelationshipGroups.ts | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts b/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts index 8999a1b8b00..28ffde12760 100644 --- a/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts +++ b/packages/plugin-import-export/src/components/RelationshipCell/getRelationshipGroups.ts @@ -51,63 +51,75 @@ export const getRelationshipGroups = ({ value, }: Args): RelationshipGroup[] => { const entries = Array.isArray(value) ? value : [value] + const configsBySlug = new Map(collections.map((collection) => [collection.slug, collection])) - const groups: RelationshipGroup[] = [] + // Insertion order is the order each collection first appears, which is the order to render. const groupsBySlug = new Map() entries.forEach((entry) => { - const key = getEntrySlug({ entry, relationTo }) ?? '' + const { slug, target } = resolveEntry({ entry, relationTo }) + const key = slug ?? '' let group = groupsBySlug.get(key) if (!group) { - const relatedConfig = collections.find((collection) => collection.slug === key) - group = { - label: key ? getTranslation(relatedConfig?.labels?.plural || key, i18n) : '', + label: slug ? getTranslation(configsBySlug.get(slug)?.labels?.plural || slug, i18n) : '', options: [], remaining: 0, } groupsBySlug.set(key, group) - groups.push(group) } // Past the cap only the count matters, so skip the cost of titling the document. if (group.options.length < TOTAL_TO_SHOW) { - group.options.push(getOptionLabel({ collections, dateFormat, entry, i18n, relationTo })) + group.options.push( + getOptionLabel({ + collectionConfig: slug ? configsBySlug.get(slug) : undefined, + dateFormat, + entry, + i18n, + target, + }), + ) } else { group.remaining++ } }) - return groups + return [...groupsBySlug.values()] } -/** The collection an entry targets, or `undefined` when it cannot be determined. */ -const getEntrySlug = ({ +/** + * Splits an entry into the collection it targets — `undefined` when that cannot be + * determined — and the ID or document it points at. + */ +const resolveEntry = ({ entry, relationTo, -}: { entry: unknown } & Pick): string | undefined => { +}: { entry: unknown } & Pick): { slug?: string; target: unknown } => { if (isPolymorphicRelationship(entry)) { - return entry.relationTo + return { slug: entry.relationTo, target: entry.value } } - return Array.isArray(relationTo) ? undefined : relationTo + return { slug: Array.isArray(relationTo) ? undefined : relationTo, target: entry } } -type GetOptionLabelArgs = { entry: unknown } & Omit +type GetOptionLabelArgs = { + collectionConfig?: ClientCollectionConfig + /** The original entry, kept for the JSON fallback when `target` holds no usable ID. */ + entry: unknown + target: unknown +} & Pick const getOptionLabel = ({ - collections, + collectionConfig, dateFormat, entry, i18n, - relationTo, + target, }: GetOptionLabelArgs): string => { - const entrySlug = getEntrySlug({ entry, relationTo }) - const target = isPolymorphicRelationship(entry) ? entry.value : entry - const doc = isRecord(target) ? target : undefined const id = doc ? doc.id : target @@ -124,7 +136,7 @@ const getOptionLabel = ({ } return formatDocTitle({ - collectionConfig: collections.find((collection) => collection.slug === entrySlug), + collectionConfig, data: doc as Parameters[0]['data'], dateFormat, fallback: `${i18n.t('general:untitled')} - ID: ${id}`, From e6519618d34e9c8f64dce58ae8b7705cd68b1924 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Tue, 28 Jul 2026 14:31:40 -0400 Subject: [PATCH 6/8] tweak styles --- .../src/components/RelationshipCell/index.css | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/plugin-import-export/src/components/RelationshipCell/index.css b/packages/plugin-import-export/src/components/RelationshipCell/index.css index 6273236078e..98c21a5128f 100644 --- a/packages/plugin-import-export/src/components/RelationshipCell/index.css +++ b/packages/plugin-import-export/src/components/RelationshipCell/index.css @@ -2,23 +2,22 @@ .import-preview-relationship { display: flex; flex-direction: column; - gap: var(--spacer-1); + gap: var(--spacer-2); } .import-preview-relationship__group { display: flex; - align-items: baseline; - gap: var(--spacer-2); + flex-direction: column; + align-items: flex-start; } .import-preview-relationship__collection { - color: var(--field-color-label); + color: var(--color-text-secondary); font-family: var(--text-body-small-strong-font-family); - font-size: var(--text-body-small-strong-font-size); - font-weight: var(--text-body-small-strong-font-weight); + font-size: var(--text-body-medium-strong-font-size); + font-weight: var(--text-body-large-font-weight); letter-spacing: var(--text-body-small-strong-letter-spacing); line-height: var(--text-body-small-strong-line-height); - text-transform: uppercase; white-space: nowrap; } From 6415b5fa6e40b74872cf44ab4d4285e09093f5f2 Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Tue, 28 Jul 2026 15:29:48 -0400 Subject: [PATCH 7/8] add more tests for json --- .../utilities/getExportFieldFunctions.spec.ts | 73 +++++++++++++++++++ .../src/utilities/getExportFieldFunctions.ts | 19 +++-- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts index 634fea65e16..cfbf77b349f 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts @@ -84,3 +84,76 @@ describe('hasMany polymorphic CSV columns', () => { }) }) }) + +describe('dangling references in JSON exports', () => { + it('should drop an orphaned entry from a hasMany array rather than exporting a null', () => { + 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, which import rejects as an + // invalid relationship — failing the whole row rather than the one dead reference. + data: { rel: [null, 'p1'] }, + fieldHooks: getExportFieldFunctions({ fields }), + fields, + format: 'json', + operation: 'export', + req: mockReq, + }) + + expect(result).toEqual({ rel: ['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 65d0f95a972..6f663f76721 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts @@ -69,17 +69,18 @@ const registerExportHandler = ( } registerHandler(({ format, siblingData, value }) => { - // An unresolvable relationship is suppressed for CSV, whose id/relationTo would - // otherwise land in sibling columns, but left untouched for JSON, which holds the - // source value verbatim. - const unresolved = format === 'json' ? undefined : null - + // 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 unresolved + 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 unresolved + return null } if (format === 'json') { @@ -100,8 +101,10 @@ const registerExportHandler = ( const ids = value.map((val) => typeof val === 'object' && val ? (val as { id: unknown }).id : val, ) + // A dangling reference cannot be imported back, so it is dropped rather than + // exported as a null the import would reject as an invalid relationship. if (format === 'json') { - return ids.filter((id) => id !== undefined) + return ids.filter((id) => id !== undefined && id !== null) } ids.forEach((id, i) => { siblingData[`${fullKey}_${i}_id`] = id From 9be4137d772910a39ec3c6ab54ba98dd091e9bbc Mon Sep 17 00:00:00 2001 From: Nate Lentz Date: Wed, 29 Jul 2026 11:48:30 -0400 Subject: [PATCH 8/8] chore(plugin-import-export): preserve nulls for dangling refs in JSON exports Keep a dangling hasMany reference as null at its source index so JSON exports preserve the same length and positions as CSV, which pins each entry to its own column. Test coverage from review: - drive the hasMany polymorphic CSV column test through flattenObject, which is what real CSV export uses instead of applyFieldHooks - cover a gap strictly between two present column indices on import - cover hasMany polymorphic + JSON + dangling reference --- .../utilities/getExportFieldFunctions.spec.ts | 52 +++++++++++++++---- .../src/utilities/getExportFieldFunctions.ts | 14 +++-- .../src/utilities/unflattenObject.spec.ts | 16 ++++++ 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts index cfbf77b349f..1f501e909ad 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.spec.ts @@ -3,6 +3,7 @@ import type { FlattenedField, PayloadRequest } from 'payload' import { describe, expect, it, vi } from 'vitest' import { applyFieldHooks } from './applyFieldHooks.js' +import { flattenObject } from './flattenObject.js' import { getExportFieldFunctions } from './getExportFieldFunctions.js' const mockReq = { @@ -58,8 +59,10 @@ describe('hasMany polymorphic CSV columns', () => { } as unknown as FlattenedField, ] - const result = applyFieldHooks({ - type: 'beforeExport', + // 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: { @@ -68,17 +71,15 @@ describe('hasMany polymorphic CSV columns', () => { { relationTo: 'posts', value: 'p1' }, ], }, - fieldHooks: getExportFieldFunctions({ fields }), - fields, + exportFieldHooks: getExportFieldFunctions({ fields }), format: 'csv', - operation: 'export', req: mockReq, }) // The surviving entry stays at index 1 — shifting it to 0 would silently - // rewrite column names for every consumer of the CSV. + // 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: null, rel_1_id: 'p1', rel_1_relationTo: 'posts', }) @@ -86,7 +87,7 @@ describe('hasMany polymorphic CSV columns', () => { }) describe('dangling references in JSON exports', () => { - it('should drop an orphaned entry from a hasMany array rather than exporting a null', () => { + it('should keep an orphaned hasMany entry as null so the array stays index-aligned with CSV', () => { const fields: FlattenedField[] = [ { name: 'rel', @@ -98,8 +99,7 @@ describe('dangling references in JSON exports', () => { const result = applyFieldHooks({ type: 'beforeExport', - // Population resolves a soft-deleted target to null, which import rejects as an - // invalid relationship — failing the whole row rather than the one dead reference. + // Population resolves a soft-deleted target to null. data: { rel: [null, 'p1'] }, fieldHooks: getExportFieldFunctions({ fields }), fields, @@ -108,7 +108,37 @@ describe('dangling references in JSON exports', () => { req: mockReq, }) - expect(result).toEqual({ rel: ['p1'] }) + 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', () => { diff --git a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts index 6f663f76721..8ddb3cd81f9 100644 --- a/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts +++ b/packages/plugin-import-export/src/utilities/getExportFieldFunctions.ts @@ -101,10 +101,10 @@ const registerExportHandler = ( const ids = value.map((val) => typeof val === 'object' && val ? (val as { id: unknown }).id : val, ) - // A dangling reference cannot be imported back, so it is dropped rather than - // exported as a null the import would reject as an invalid relationship. + // 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.filter((id) => id !== undefined && id !== null) + return ids.map((id) => id ?? null) } ids.forEach((id, i) => { siblingData[`${fullKey}_${i}_id`] = id @@ -129,7 +129,13 @@ const registerExportHandler = ( }) if (format === 'json') { - return rels.map((rel) => ({ relationTo: rel.relationTo, value: rel.id })) + // 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 jsonRels } rels.forEach(({ id, index, relationTo }) => { siblingData[`${fullKey}_${index}_id`] = id diff --git a/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts b/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts index b3afd036920..bb305e4342f 100644 --- a/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts +++ b/packages/plugin-import-export/src/utilities/unflattenObject.spec.ts @@ -441,6 +441,22 @@ describe('unflattenObject', () => { 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', () => {