Skip to content

Commit 3d4114b

Browse files
committed
add documentation and test case for hooks to handle
1 parent 41781a5 commit 3d4114b

File tree

7 files changed

+768
-3
lines changed

7 files changed

+768
-3
lines changed

docs/plugins/import-export.mdx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,109 @@ export: {
483483
},
484484
```
485485

486+
### Column name mapping for foreign systems
487+
488+
When importing from or exporting to a foreign system (a third-party CRM, an analytics dump, a legacy CSV), column names rarely match your Payload field names. Both directions are handled with the existing batch hooks — no new API is required.
489+
490+
#### Exporting with renamed CSV columns
491+
492+
Use `export.hooks.before` to rewrite every row's keys to the foreign system's column names. The returned shape is what gets written to the file.
493+
494+
```ts
495+
import { importExportPlugin } from '@payloadcms/plugin-import-export'
496+
497+
const exportRenameMap: Record<string, string> = {
498+
title: 'Post Title',
499+
excerpt: 'Summary',
500+
count: 'View Count',
501+
}
502+
503+
importExportPlugin({
504+
collections: [
505+
{
506+
slug: 'posts',
507+
export: {
508+
hooks: {
509+
before: ({ data }) =>
510+
data.map((row) => {
511+
const renamed: Record<string, unknown> = {}
512+
for (const [key, value] of Object.entries(row)) {
513+
renamed[exportRenameMap[key] ?? key] = value
514+
}
515+
return renamed
516+
}),
517+
},
518+
},
519+
},
520+
],
521+
})
522+
```
523+
524+
JSON exports work the same way — the hook receives the fully-transformed batch, and the returned keys become the top-level JSON keys.
525+
526+
#### Importing from foreign CSV columns
527+
528+
Use `import.hooks.before` to rewrite incoming keys back to Payload field names. The hook receives already-unflattened documents, so foreign top-level keys like `Post Title` are still present and can be remapped to `title` before the DB write. Keys you don't include in the returned object are dropped.
529+
530+
```ts
531+
const importRenameMap: Record<string, string> = {
532+
'Post Title': 'title',
533+
Summary: 'excerpt',
534+
'View Count': 'count',
535+
}
536+
537+
importExportPlugin({
538+
collections: [
539+
{
540+
slug: 'posts',
541+
import: {
542+
hooks: {
543+
before: ({ data }) =>
544+
data.map((doc) => {
545+
const renamed: Record<string, unknown> = {}
546+
for (const [key, value] of Object.entries(doc)) {
547+
const payloadKey = importRenameMap[key]
548+
if (payloadKey) {
549+
renamed[payloadKey] = value
550+
}
551+
// Foreign keys not present in the rename map are dropped.
552+
}
553+
return renamed
554+
}),
555+
},
556+
},
557+
},
558+
],
559+
})
560+
```
561+
562+
CSV cell values arrive as strings. Payload coerces basic scalar types automatically on write, but if a target field has stricter validation you can coerce inside the hook (`renamed.count = Number(value)`).
563+
564+
#### Field-level export rename for shared fields
565+
566+
When a field definition is shared across collections and the rename should travel with the field, use the field's own `hooks.beforeExport`. Mutate `siblingData` to add the renamed key and return `undefined` so the original field key is dropped from the output:
567+
568+
```ts
569+
import type { FieldBeforeExportHook } from '@payloadcms/plugin-import-export'
570+
571+
const sharedNameField = {
572+
name: 'sharedName',
573+
type: 'text' as const,
574+
custom: {
575+
'plugin-import-export': {
576+
hooks: {
577+
beforeExport: (({ siblingData, value }) => {
578+
siblingData['Display Name'] = value
579+
return undefined
580+
}) satisfies FieldBeforeExportHook,
581+
},
582+
},
583+
},
584+
}
585+
```
586+
587+
This pattern is export-only. Field-level `hooks.beforeImport` does **not** fire for foreign columns — it's keyed by the incoming column name, so it can't pick up a column whose name doesn't already match a Payload field. For the reverse direction, use the collection-level `import.hooks.before` recipe above.
588+
486589
## Field Options
487590

488591
In addition to collection-level hooks, you can configure field-level export and import behavior directly on any field's `custom['plugin-import-export']` config. This is especially useful when:
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { FieldBeforeExportHook } from '@payloadcms/plugin-import-export/types'
2+
import type { CollectionConfig } from 'payload'
3+
4+
import { postsWithColumnMapSlug } from '../shared.js'
5+
6+
// Payload field name -> foreign system column name.
7+
// Used by collection-level export.hooks.before in config.ts and mirrored
8+
// in import.hooks.before.
9+
export const exportRenameMap: Record<string, string> = {
10+
title: 'Post Title',
11+
excerpt: 'Summary',
12+
count: 'View Count',
13+
}
14+
15+
export const importRenameMap: Record<string, string> = {
16+
'Post Title': 'title',
17+
Summary: 'excerpt',
18+
'View Count': 'count',
19+
}
20+
21+
export const PostsWithColumnMap: CollectionConfig = {
22+
slug: postsWithColumnMapSlug,
23+
admin: { useAsTitle: 'title' },
24+
fields: [
25+
{ name: 'title', type: 'text', required: true },
26+
{ name: 'excerpt', type: 'text' },
27+
{ name: 'count', type: 'number' },
28+
{
29+
name: 'sharedName',
30+
type: 'text',
31+
custom: {
32+
'plugin-import-export': {
33+
hooks: {
34+
beforeExport: (({ siblingData, value }) => {
35+
siblingData['Display Name'] = value
36+
return undefined
37+
}) satisfies FieldBeforeExportHook,
38+
},
39+
},
40+
},
41+
},
42+
],
43+
}

test/plugin-import-export/config.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ import { Posts } from './collections/Posts.js'
1717
import { PostsExportsOnly } from './collections/PostsExportsOnly.js'
1818
import { PostsImportsOnly } from './collections/PostsImportsOnly.js'
1919
import { PostsNoJobsQueue } from './collections/PostsNoJobsQueue.js'
20+
import {
21+
exportRenameMap,
22+
importRenameMap,
23+
PostsWithColumnMap,
24+
} from './collections/PostsWithColumnMap.js'
2025
import { PostsWithFieldHooks } from './collections/PostsWithFieldHooks.js'
2126
import { PostsWithHooks } from './collections/PostsWithHooks.js'
2227
import { PostsWithLimits } from './collections/PostsWithLimits.js'
@@ -31,6 +36,7 @@ import {
3136
import { seed } from './seed/index.js'
3237
import {
3338
customIdPagesSlug,
39+
postsWithColumnMapSlug,
3440
postsWithFieldHooksSlug,
3541
postsWithHooksSlug,
3642
postsWithS3Slug,
@@ -64,6 +70,7 @@ export default buildConfigWithDefaults({
6470
PostsWithS3,
6571
PostsWithHooks,
6672
PostsWithFieldHooks,
73+
PostsWithColumnMap,
6774
Media,
6875
CustomIdPages,
6976
],
@@ -282,6 +289,50 @@ export default buildConfigWithDefaults({
282289
},
283290
},
284291
},
292+
{
293+
slug: postsWithColumnMapSlug,
294+
export: {
295+
disableJobsQueue: true,
296+
hooks: {
297+
before: ({ data }) => {
298+
return data.map((row) => {
299+
const renamed: Record<string, unknown> = {}
300+
for (const [key, value] of Object.entries(row)) {
301+
renamed[exportRenameMap[key] ?? key] = value
302+
}
303+
return renamed
304+
})
305+
},
306+
},
307+
overrideCollection: ({ collection }) => {
308+
collection.slug = 'posts-with-column-map-export'
309+
collection.upload.staticDir = path.resolve(dirname, 'uploads')
310+
return collection
311+
},
312+
},
313+
import: {
314+
disableJobsQueue: true,
315+
hooks: {
316+
before: ({ data }) => {
317+
return data.map((doc) => {
318+
const renamed: Record<string, unknown> = {}
319+
for (const [key, value] of Object.entries(doc)) {
320+
const payloadKey = importRenameMap[key]
321+
if (payloadKey) {
322+
renamed[payloadKey] = value
323+
}
324+
}
325+
return renamed
326+
})
327+
},
328+
},
329+
overrideCollection: ({ collection }) => {
330+
collection.slug = 'posts-with-column-map-import'
331+
collection.upload.staticDir = path.resolve(dirname, 'uploads')
332+
return collection
333+
},
334+
},
335+
},
285336
],
286337
}),
287338
s3Storage({

test/plugin-import-export/e2e.spec.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ import {
2121
import { AdminUrlUtil } from '../__helpers/shared/adminUrlUtil.js'
2222
import { initPayloadE2ENoConfig } from '../__helpers/shared/initPayloadE2ENoConfig.js'
2323
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
24-
import { postsWithS3ExportSlug, postsWithS3ImportSlug, postsWithS3Slug } from './shared.js'
24+
import { readCSV } from './helpers.js'
25+
import {
26+
postsWithColumnMapSlug,
27+
postsWithS3ExportSlug,
28+
postsWithS3ImportSlug,
29+
postsWithS3Slug,
30+
} from './shared.js'
2531

2632
test.describe('Import Export Plugin', () => {
2733
let page: Page
@@ -1634,4 +1640,113 @@ test.describe('Import Export Plugin', () => {
16341640
await expect(importCount).toContainText('10 documents to import')
16351641
})
16361642
})
1643+
1644+
test.describe('column mapping e2e', () => {
1645+
const tempFiles: string[] = []
1646+
const createdTitles: string[] = []
1647+
1648+
test.afterEach(async () => {
1649+
for (const filePath of tempFiles) {
1650+
if (fs.existsSync(filePath)) {
1651+
fs.unlinkSync(filePath)
1652+
}
1653+
}
1654+
tempFiles.length = 0
1655+
1656+
for (const title of createdTitles) {
1657+
await payload.delete({
1658+
collection: postsWithColumnMapSlug,
1659+
where: { title: { equals: title } },
1660+
})
1661+
}
1662+
createdTitles.length = 0
1663+
})
1664+
1665+
test('should import a CSV with foreign column headers through the admin UI', async () => {
1666+
const csvContent =
1667+
'"Post Title","Summary","View Count"\n' +
1668+
'"E2E Foreign A","e2e summary a","11"\n' +
1669+
'"E2E Foreign B","e2e summary b","22"\n'
1670+
const csvPath = path.join(__dirname, 'uploads', 'e2e-column-map-import.csv')
1671+
fs.writeFileSync(csvPath, csvContent)
1672+
tempFiles.push(csvPath)
1673+
createdTitles.push('E2E Foreign A', 'E2E Foreign B')
1674+
1675+
const columnMapImportsURL = new AdminUrlUtil(serverURL, 'posts-with-column-map-import')
1676+
await page.goto(columnMapImportsURL.create)
1677+
await expect(page.locator('.collection-edit')).toBeVisible()
1678+
1679+
await page.setInputFiles('input[type="file"]', csvPath)
1680+
await expect(page.locator('.file-field__filename')).toHaveValue('e2e-column-map-import.csv')
1681+
1682+
const importModeField = page.locator('#field-importMode')
1683+
await importModeField.click()
1684+
await page.locator('.rs__option:has-text("create")').first().click()
1685+
1686+
await saveDocAndAssert(page)
1687+
1688+
await expect(async () => {
1689+
const { docs } = await payload.find({
1690+
collection: postsWithColumnMapSlug,
1691+
where: { title: { in: ['E2E Foreign A', 'E2E Foreign B'] } },
1692+
})
1693+
expect(docs).toHaveLength(2)
1694+
}).toPass({ timeout: POLL_TOPASS_TIMEOUT })
1695+
1696+
const imported = await payload.find({
1697+
collection: postsWithColumnMapSlug,
1698+
sort: 'title',
1699+
where: { title: { in: ['E2E Foreign A', 'E2E Foreign B'] } },
1700+
})
1701+
1702+
expect(imported.docs[0]!.title).toBe('E2E Foreign A')
1703+
expect(imported.docs[0]!.excerpt).toBe('e2e summary a')
1704+
expect(imported.docs[0]!.count).toBe(11)
1705+
expect(imported.docs[1]!.title).toBe('E2E Foreign B')
1706+
expect(imported.docs[1]!.count).toBe(22)
1707+
})
1708+
1709+
test('should export CSV with renamed column headers via admin save', async () => {
1710+
await payload.create({
1711+
collection: postsWithColumnMapSlug,
1712+
data: { title: 'E2E Export Rename', excerpt: 'exported summary', count: 99 },
1713+
})
1714+
createdTitles.push('E2E Export Rename')
1715+
1716+
const columnMapExportsURL = new AdminUrlUtil(serverURL, 'posts-with-column-map-export')
1717+
await page.goto(columnMapExportsURL.create)
1718+
await expect(page.locator('.collection-edit')).toBeVisible()
1719+
1720+
await saveDocAndAssert(page, '#action-save')
1721+
1722+
await expect(async () => {
1723+
await page.reload()
1724+
const exportFilename = page.locator('.file-details__main-detail')
1725+
await expect(exportFilename).toBeVisible()
1726+
await expect(exportFilename).toContainText('.csv')
1727+
}).toPass({ timeout: POLL_TOPASS_TIMEOUT })
1728+
1729+
const exports = await payload.find({
1730+
collection: 'posts-with-column-map-export',
1731+
sort: '-createdAt',
1732+
limit: 1,
1733+
})
1734+
1735+
expect(exports.docs).toHaveLength(1)
1736+
const exportDoc = exports.docs[0]! as unknown as { filename: string; id: number | string }
1737+
const csvPath = path.join(__dirname, 'uploads', exportDoc.filename)
1738+
const rows = await readCSV(csvPath)
1739+
1740+
const matching = rows.find((row) => row['Post Title'] === 'E2E Export Rename')
1741+
expect(matching).toBeDefined()
1742+
expect(matching!.Summary).toBe('exported summary')
1743+
expect(matching!['View Count']).toBe('99')
1744+
expect(matching!.title).toBeUndefined()
1745+
1746+
await payload.delete({
1747+
collection: 'posts-with-column-map-export',
1748+
id: exportDoc.id,
1749+
})
1750+
})
1751+
})
16371752
})

0 commit comments

Comments
 (0)