Skip to content

Commit 7e2a3ab

Browse files
fix(plugin-import-export): export and import issues when using custom IDs (#15518)
When using custom IDs on collections for both imports and exports there were bugs as we were not correctly checking the collection config for custom IDs being used so this flow never worked. Instead documents would end up being imported and created with new IDs which could also error if the collection was not set up for it. This PR fixes those problems and also adds test coverage for custom IDs with this plugin. Closes #15231 --------- Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
1 parent 0935824 commit 7e2a3ab

File tree

29 files changed

+1626
-284
lines changed

29 files changed

+1626
-284
lines changed
Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,8 @@
11
'use client'
2-
import type React from 'react'
2+
import type { TextFieldClientComponent } from 'payload'
33

4-
import { useDocumentInfo, useField } from '@payloadcms/ui'
5-
import { useEffect } from 'react'
4+
import { CollectionSelectField } from '../CollectionSelectField/index.js'
65

7-
import { useImportExport } from '../ImportExportProvider/index.js'
8-
9-
export const CollectionField: React.FC = () => {
10-
const { id, collectionSlug, docConfig } = useDocumentInfo()
11-
const { setValue } = useField({ path: 'collectionSlug' })
12-
const { collection } = useImportExport()
13-
14-
const defaultCollectionSlug = docConfig?.admin?.custom?.defaultCollectionSlug as
15-
| string
16-
| undefined
17-
18-
useEffect(() => {
19-
if (id) {
20-
return
21-
}
22-
if (collection) {
23-
setValue(collection)
24-
} else if (defaultCollectionSlug) {
25-
setValue(defaultCollectionSlug)
26-
} else if (collectionSlug) {
27-
setValue(collectionSlug)
28-
}
29-
}, [id, collection, setValue, collectionSlug, defaultCollectionSlug])
30-
31-
return null
6+
export const CollectionField: TextFieldClientComponent = (props) => {
7+
return <CollectionSelectField textFieldProps={props} />
328
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
'use client'
2+
import type { ReactSelectOption } from '@payloadcms/ui'
3+
import type { TextFieldClientProps } from 'payload'
4+
5+
import { getTranslation } from '@payloadcms/translations'
6+
import {
7+
FieldDescription,
8+
FieldError,
9+
FieldLabel,
10+
ReactSelect,
11+
useConfig,
12+
useDocumentInfo,
13+
useField,
14+
useTranslation,
15+
} from '@payloadcms/ui'
16+
import { useCallback, useEffect, useMemo } from 'react'
17+
18+
import { useImportExport } from '../ImportExportProvider/index.js'
19+
20+
type CollectionSelectFieldProps = {
21+
textFieldProps: TextFieldClientProps
22+
}
23+
24+
/**
25+
* Custom component for rendering a collection slug selector.
26+
* Uses FieldLabel and ReactSelect directly for better control over the field.
27+
*
28+
* Used by both export and import collection field components.
29+
*/
30+
export function CollectionSelectField({ textFieldProps }: CollectionSelectFieldProps) {
31+
const { field, path, readOnly: readOnlyFromProps } = textFieldProps
32+
const { label, required } = field
33+
34+
const { id: docId, docConfig, initialData } = useDocumentInfo()
35+
const { config } = useConfig()
36+
const { setValue, showError, value } = useField<string>({ path })
37+
const { collection: collectionFromContext } = useImportExport()
38+
const { i18n } = useTranslation()
39+
40+
const fieldId = path ? `field-${path.replace(/\./g, '__')}` : undefined
41+
42+
const options = useMemo(() => {
43+
const validSlugs = (docConfig?.admin?.custom?.['plugin-import-export']?.collectionSlugs ||
44+
[]) as string[]
45+
46+
const slugsToUse = validSlugs.length > 0 ? validSlugs : config.collections.map((c) => c.slug)
47+
48+
return slugsToUse.map((slug) => {
49+
const collectionConfig = config.collections.find((c) => c.slug === slug)
50+
const labelSource =
51+
collectionConfig?.labels?.plural || collectionConfig?.labels?.singular || slug
52+
return {
53+
label: getTranslation(labelSource, i18n),
54+
value: slug,
55+
}
56+
})
57+
}, [docConfig?.admin?.custom, config.collections, i18n])
58+
59+
const presetValue = useMemo(() => {
60+
if (initialData?.collectionSlug) {
61+
return initialData.collectionSlug as string
62+
}
63+
if (collectionFromContext) {
64+
return collectionFromContext
65+
}
66+
return null
67+
}, [initialData?.collectionSlug, collectionFromContext])
68+
69+
// Determine if field should be readonly:
70+
// - Explicit readOnly from props
71+
// - Existing document (has ID)
72+
// - Only one option available
73+
// - Preset value from drawer/context
74+
const isReadOnly = useMemo(() => {
75+
if (readOnlyFromProps) {
76+
return true
77+
}
78+
if (docId) {
79+
return true
80+
}
81+
if (options.length === 1) {
82+
return true
83+
}
84+
if (presetValue) {
85+
return true
86+
}
87+
return false
88+
}, [readOnlyFromProps, docId, options.length, presetValue])
89+
90+
useEffect(() => {
91+
if (docId) {
92+
return
93+
}
94+
95+
if (presetValue && value !== presetValue) {
96+
setValue(presetValue)
97+
return
98+
}
99+
100+
if (!value && options.length > 0 && options[0]?.value) {
101+
setValue(options[0].value)
102+
}
103+
}, [docId, presetValue, value, options, setValue])
104+
105+
const selectedOption = useMemo((): ReactSelectOption | undefined => {
106+
if (!value) {
107+
return undefined
108+
}
109+
110+
const found = options.find((opt) => opt.value === value)
111+
if (found) {
112+
return found
113+
}
114+
115+
return { label: value, value }
116+
}, [value, options])
117+
118+
const handleChange = useCallback(
119+
(selected: ReactSelectOption | ReactSelectOption[]) => {
120+
if (Array.isArray(selected)) {
121+
setValue(selected[0]?.value ?? '')
122+
} else {
123+
setValue(selected?.value ?? '')
124+
}
125+
},
126+
[setValue],
127+
)
128+
129+
return (
130+
<div
131+
className={['field-type', 'select', showError && 'error', isReadOnly && 'read-only']
132+
.filter(Boolean)
133+
.join(' ')}
134+
id={fieldId}
135+
>
136+
<FieldLabel label={label} path={path} required={required} />
137+
<div className="field-type__wrap">
138+
<FieldError path={path} showError={showError} />
139+
<ReactSelect
140+
disabled={isReadOnly}
141+
isClearable={false}
142+
isSearchable={!isReadOnly && options.length > 5}
143+
onChange={handleChange}
144+
options={options}
145+
showError={showError}
146+
value={selectedOption}
147+
/>
148+
</div>
149+
<FieldDescription description={field.admin?.description} path={path} />
150+
</div>
151+
)
152+
}

packages/plugin-import-export/src/components/ExportListMenuItem/index.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
Translation,
77
useConfig,
88
useDocumentDrawer,
9-
useDocumentInfo,
109
useTranslation,
1110
} from '@payloadcms/ui'
1211
import React, { useEffect } from 'react'
@@ -56,7 +55,7 @@ export const ExportListMenuItem: React.FC<{
5655
}}
5756
/>
5857
</DocumentDrawerToggler>
59-
<DocumentDrawer />
58+
<DocumentDrawer initialData={{ collectionSlug }} />
6059
</PopupList.Button>
6160
)
6261
}

packages/plugin-import-export/src/components/FieldsToExport/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import React, { useEffect } from 'react'
1616
import { useImportExport } from '../ImportExportProvider/index.js'
1717
import { reduceFields } from './reduceFields.js'
1818

19-
const baseClass = 'fields-to-export'
19+
const baseClass = 'field-type fields-to-export'
2020

2121
export const FieldsToExport: SelectFieldClientComponent = (props) => {
2222
const { id } = useDocumentInfo()

packages/plugin-import-export/src/components/ImportCollectionField/index.tsx

Lines changed: 0 additions & 17 deletions
This file was deleted.

packages/plugin-import-export/src/components/ImportPreview/index.tsx

Lines changed: 64 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22
import type { Column } from '@payloadcms/ui'
3-
import type { ClientField, ConditionalDateProps, PaginatedDocs } from 'payload'
3+
import type { ClientField, PaginatedDocs } from 'payload'
44

55
import { getTranslation } from '@payloadcms/translations'
66
import {
@@ -290,17 +290,8 @@ export const ImportPreview: React.FC = () => {
290290
// Just an ID
291291
return String(value)
292292
} else if (field.type === 'date') {
293-
// Format dates
294-
const dateFormat =
295-
(field.admin &&
296-
'date' in field.admin &&
297-
(field.admin.date as ConditionalDateProps)?.displayFormat) ||
298-
config.admin.dateFormat
299-
300-
return new Date(value as string).toLocaleString(i18n.language, {
301-
dateStyle: 'medium',
302-
timeStyle: 'short',
303-
})
293+
// Display date as string to avoid wrong locale/timezone conversion
294+
return String(value)
304295
} else if (field.type === 'checkbox') {
305296
return value ? '✓' : '✗'
306297
} else if (field.type === 'select' || field.type === 'radio') {
@@ -387,40 +378,73 @@ export const ImportPreview: React.FC = () => {
387378
return cols
388379
}
389380

390-
// Add default meta fields at the end
391381
const fieldColumns = buildColumnsFromFields(collectionConfig.fields)
392-
const metaFields = ['id', 'createdAt', 'updatedAt', '_status']
393382

394-
metaFields.forEach((metaField) => {
395-
const hasData = docs.some((doc) => doc[metaField] !== undefined)
396-
if (!hasData) {
397-
return
398-
}
383+
const existingAccessors = new Set(fieldColumns.map((column) => column.accessor))
399384

400-
fieldColumns.push({
401-
accessor: metaField,
402-
active: true,
403-
field: { name: metaField } as ClientField,
404-
Heading: getTranslation(metaField, i18n),
405-
renderedCells: docs.map((doc) => {
406-
const value = doc[metaField]
407-
if (value === undefined || value === null) {
408-
return null
409-
}
410-
411-
if (metaField === 'createdAt' || metaField === 'updatedAt') {
412-
return new Date(value as string).toLocaleString(i18n.language, {
413-
dateStyle: 'medium',
414-
timeStyle: 'short',
415-
})
416-
}
417-
418-
return String(value)
419-
}),
385+
// Discover all fields from document data to determine column order
386+
// Respect the order fields appear in the data (e.g., CSV column order)
387+
const dataFieldOrder: string[] = []
388+
const dataFieldsSet = new Set<string>()
389+
390+
// Collect all fields from all docs to get comprehensive field list
391+
docs.forEach((doc) => {
392+
Object.keys(doc).forEach((key) => {
393+
if (!dataFieldsSet.has(key) && doc[key] !== undefined && doc[key] !== null) {
394+
dataFieldsSet.add(key)
395+
dataFieldOrder.push(key)
396+
}
420397
})
421398
})
422399

423-
setColumns(fieldColumns)
400+
// Helper to create a column for a field
401+
const createColumnForField = (fieldName: string): Column => ({
402+
accessor: fieldName,
403+
active: true,
404+
field: { name: fieldName } as ClientField,
405+
Heading: getTranslation(fieldName, i18n),
406+
renderedCells: docs.map((doc) => {
407+
const value = doc[fieldName]
408+
if (value === undefined || value === null) {
409+
return null
410+
}
411+
412+
return String(value)
413+
}),
414+
})
415+
416+
// Build columns respecting data order for fields not in config
417+
// For fields in config, use their natural order from fieldColumns
418+
const finalColumns: Column[] = []
419+
const addedAccessors = new Set<string>()
420+
421+
// Process fields in the order they appear in the data
422+
dataFieldOrder.forEach((fieldName) => {
423+
if (existingAccessors.has(fieldName)) {
424+
// This field is from the collection config
425+
const configColumn = fieldColumns.find((col) => col.accessor === fieldName)
426+
if (configColumn && !addedAccessors.has(fieldName)) {
427+
finalColumns.push(configColumn)
428+
addedAccessors.add(fieldName)
429+
}
430+
} else {
431+
// This is an additional field (system field or extra data field)
432+
if (!addedAccessors.has(fieldName)) {
433+
finalColumns.push(createColumnForField(fieldName))
434+
addedAccessors.add(fieldName)
435+
}
436+
}
437+
})
438+
439+
// Add any remaining config fields that weren't in the data
440+
fieldColumns.forEach((col) => {
441+
if (!addedAccessors.has(col.accessor)) {
442+
finalColumns.push(col)
443+
addedAccessors.add(col.accessor)
444+
}
445+
})
446+
447+
setColumns(finalColumns)
424448
setDataToRender(docs)
425449
} catch (err) {
426450
console.error('Error processing file data:', err)

packages/plugin-import-export/src/export/createExport.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ export type Export = {
4040
maxLimit?: number
4141
name: string
4242
page?: number
43-
slug: string
44-
sort: Sort
43+
sort?: Sort
4544
userCollection: string
4645
userID: number | string
4746
where?: Where
@@ -124,7 +123,8 @@ export const createExport = async (args: CreateExportArgs) => {
124123
and: [whereFromInput, draft ? {} : publishedWhere],
125124
}
126125

127-
const name = `${nameArg ?? `${getFilename()}-${collectionSlug}`}.${format}`
126+
const baseName = nameArg ?? getFilename()
127+
const name = `${baseName}-${collectionSlug}.${format}`
128128
const isCSV = format === 'csv'
129129
const select = Array.isArray(fields) && fields.length > 0 ? getSelect(fields) : undefined
130130

0 commit comments

Comments
 (0)