Skip to content

Commit a40210c

Browse files
authored
fix(plugin-import-export): update docs on jobs and basic usage as well as visibility (#15695)
This PR adds more to the docs for how to use it as well as how to enable and run jobs with it. Furthermore there are bug fixes as listed below, some have necessitated some restructuring internally for how hooks are included, now they are always run so that config can be dynamically read from the collections. Also does the following: - Adds docs on jobs usage and example configuration - Adds docs on jobs collection visibility, closes #15591 - Fixes docs on basic example, closes #15552 - Fixes a problem with per collection settings not being applied if `overrideCollection` is not provided - Fixes #15627
1 parent c05ace2 commit a40210c

19 files changed

Lines changed: 1153 additions & 414 deletions

File tree

docs/plugins/import-export.mdx

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ import { buildConfig } from 'payload'
4141
import { importExportPlugin } from '@payloadcms/plugin-import-export'
4242

4343
const config = buildConfig({
44-
collections: [Pages, Media],
44+
collections: [Users, Pages],
4545
plugins: [
4646
importExportPlugin({
47-
collections: ['users', 'pages'],
47+
collections: [{ slug: 'users' }, { slug: 'pages' }],
4848
// see below for a list of available options
4949
}),
5050
],
@@ -53,6 +53,51 @@ const config = buildConfig({
5353
export default config
5454
```
5555

56+
## Jobs Queue Requirements
57+
58+
By default, the plugin uses Payload's [Jobs Queue](/docs/jobs-queue/overview) for import and export operations. Queued jobs require a runner to be configured, otherwise imports and exports will stay in "pending" status and never complete.
59+
60+
Configure `jobs.autoRun` in your Payload config so that queued jobs are processed:
61+
62+
```ts
63+
import { buildConfig } from 'payload'
64+
import { importExportPlugin } from '@payloadcms/plugin-import-export'
65+
66+
const config = buildConfig({
67+
collections: [Users, Pages],
68+
jobs: {
69+
autoRun: [
70+
{
71+
cron: '*/5 * * * *', // Check every 5 minutes
72+
queue: 'default',
73+
},
74+
],
75+
},
76+
plugins: [
77+
importExportPlugin({
78+
collections: [{ slug: 'users' }, { slug: 'pages' }],
79+
}),
80+
],
81+
})
82+
83+
export default config
84+
```
85+
86+
Alternatively, use `allQueues: true` to process jobs from all queues:
87+
88+
```ts
89+
jobs: {
90+
autoRun: [
91+
{
92+
allQueues: true,
93+
cron: '*/5 * * * *',
94+
},
95+
],
96+
},
97+
```
98+
99+
For smaller datasets or testing, you can run imports and exports synchronously by setting `disableJobsQueue: true` in the per-collection [ExportConfig](#exportconfig-options) or [ImportConfig](#importconfig-options). This avoids the need for a jobs runner but blocks the request until the operation completes.
100+
56101
## Options
57102

58103
| Property | Type | Description |
@@ -144,6 +189,34 @@ export default buildConfig({
144189
})
145190
```
146191

192+
## Collection Visibility
193+
194+
The `exports` and `imports` collections are hidden from the admin navigation by default (`admin.group: false`). Their routes remain accessible (for example `/admin/collections/exports` and `/admin/collections/imports`), so you can open them directly or link to them from your app. To list them in the sidebar, use `overrideExportCollection` and `overrideImportCollection` to set a `group`:
195+
196+
```ts
197+
import { importExportPlugin } from '@payloadcms/plugin-import-export'
198+
199+
importExportPlugin({
200+
overrideExportCollection: ({ collection }) => ({
201+
...collection,
202+
admin: {
203+
...collection.admin,
204+
group: 'Data Management',
205+
},
206+
}),
207+
overrideImportCollection: ({ collection }) => ({
208+
...collection,
209+
admin: {
210+
...collection.admin,
211+
group: 'Data Management',
212+
},
213+
}),
214+
collections: [{ slug: 'pages' }],
215+
})
216+
```
217+
218+
With a group set, the Exports and Imports collections appear in the admin navigation under "Data Management", and you can open saved exports or import documents from there (including downloading saved export files).
219+
147220
## Limiting Import and Export Size
148221

149222
You can limit the number of documents that can be imported or exported in a single operation. This helps prevent DDOS-style abuse and protects server resources during large data operations.
@@ -432,7 +505,12 @@ There are four possible ways that the plugin allows for exporting documents, the
432505
3. Local API - A create call to the uploads collection: `payload.create({ slug: 'uploads', ...parameters })`
433506
4. Jobs Queue - `payload.jobs.queue({ task: 'createCollectionExport', input: parameters })`
434507

435-
By default, a user can use the Export drawer to create a file download by choosing `Save` or stream a downloadable file directly without persisting it by using the `Download` button. Either option can be disabled to provide the export experience you desire for your use-case.
508+
In the Export drawer you can choose:
509+
510+
- **Download** — Streams the export file directly to the browser without saving. Nothing is stored in the exports collection.
511+
- **Save** — Creates a document in the exports collection (an upload) so the export is stored and can be reused or downloaded later.
512+
513+
To download a saved export, open the exports collection (go to `/admin/collections/exports` or, if you made it visible as in [Collection Visibility](#collection-visibility), use the sidebar), open the export document, and use the file download from there. Either the Download or Save option can be disabled per collection via [ExportConfig](#exportconfig-options) (`disableDownload`, `disableSave`).
436514

437515
The UI for creating exports provides options so that users can be selective about which documents to include and also which columns or fields to include.
438516

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
toast,
77
Translation,
88
useConfig,
9+
useField,
910
useForm,
1011
useFormModified,
1112
useTranslation,
@@ -30,11 +31,19 @@ export const ExportSaveButton: React.FC = () => {
3031
const { getData, setModified } = useForm()
3132
const modified = useFormModified()
3233

34+
const { value: targetCollectionSlug } = useField<string>({ path: 'collectionSlug' })
35+
36+
const targetCollectionConfig = getEntityConfig({ collectionSlug: targetCollectionSlug })
37+
const targetPluginConfig = targetCollectionConfig?.admin?.custom?.['plugin-import-export']
38+
3339
const exportsCollectionConfig = getEntityConfig({ collectionSlug: 'exports' })
3440

35-
const disableSave = exportsCollectionConfig?.admin?.custom?.disableSave === true
41+
const disableSave =
42+
targetPluginConfig?.disableSave ?? exportsCollectionConfig?.admin?.custom?.disableSave === true
3643

37-
const disableDownload = exportsCollectionConfig?.admin?.custom?.disableDownload === true
44+
const disableDownload =
45+
targetPluginConfig?.disableDownload ??
46+
exportsCollectionConfig?.admin?.custom?.disableDownload === true
3847

3948
const label = t('general:save')
4049

@@ -43,7 +52,7 @@ export const ExportSaveButton: React.FC = () => {
4352
let toastID: null | number | string = null
4453

4554
try {
46-
setModified(false) // Reset modified state
55+
setModified(false)
4756
const data = getData()
4857

4958
// Set a timeout to show toast if the request takes longer than 200ms
@@ -68,12 +77,10 @@ export const ExportSaveButton: React.FC = () => {
6877
},
6978
)
7079

71-
// Clear the timeout if fetch completes quickly
7280
if (timeoutID) {
7381
clearTimeout(timeoutID)
7482
}
7583

76-
// Dismiss the toast if it was shown
7784
if (toastID) {
7885
toast.dismiss(toastID)
7986
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
'use client'
2+
3+
import type { ReactSelectOption } from '@payloadcms/ui'
4+
import type { SelectFieldClientComponent } from 'payload'
5+
6+
import { FieldLabel, ReactSelect, useConfig, useField } from '@payloadcms/ui'
7+
import React, { useCallback, useEffect, useMemo } from 'react'
8+
9+
const baseClass = 'field-type select'
10+
11+
type Format = 'csv' | 'json'
12+
13+
const allOptions: ReactSelectOption[] = [
14+
{ label: 'CSV', value: 'csv' },
15+
{ label: 'JSON', value: 'json' },
16+
]
17+
18+
export const FormatField: SelectFieldClientComponent = (props) => {
19+
const { getEntityConfig } = useConfig()
20+
const width = props.field.admin?.width
21+
22+
const { setValue, value: formatValue } = useField<Format>()
23+
const { value: targetCollectionSlug } = useField<string>({ path: 'collectionSlug' })
24+
25+
const targetCollectionConfig = getEntityConfig({ collectionSlug: targetCollectionSlug })
26+
const forcedFormat = targetCollectionConfig?.admin?.custom?.['plugin-import-export']
27+
?.exportFormat as Format | undefined
28+
29+
const options = useMemo<ReactSelectOption[]>(() => {
30+
if (forcedFormat) {
31+
return [{ label: forcedFormat.toUpperCase(), value: forcedFormat }]
32+
}
33+
return allOptions
34+
}, [forcedFormat])
35+
36+
const selectedOption = useMemo<ReactSelectOption | undefined>(() => {
37+
const effectiveValue = forcedFormat || formatValue || 'csv'
38+
return options.find((o) => o.value === effectiveValue) ?? options[0]
39+
}, [forcedFormat, formatValue, options])
40+
41+
useEffect(() => {
42+
if (forcedFormat && formatValue !== forcedFormat) {
43+
setValue(forcedFormat)
44+
}
45+
}, [forcedFormat, formatValue, setValue])
46+
47+
const handleChange = useCallback(
48+
(selected: ReactSelectOption | ReactSelectOption[]) => {
49+
if (forcedFormat) {
50+
return
51+
}
52+
if (Array.isArray(selected)) {
53+
setValue((selected[0]?.value as Format) ?? 'csv')
54+
} else {
55+
setValue((selected?.value as Format) ?? 'csv')
56+
}
57+
},
58+
[forcedFormat, setValue],
59+
)
60+
61+
const isReadOnly = Boolean(forcedFormat) || props.readOnly
62+
63+
return (
64+
<div className={baseClass} style={{ width }}>
65+
<FieldLabel label={props.field.label} path={props.path} />
66+
<ReactSelect
67+
className={'format-field'}
68+
disabled={isReadOnly}
69+
inputId={`field-${props.path.replace(/\./g, '__')}`}
70+
isClearable={false}
71+
isSearchable={false}
72+
onChange={handleChange}
73+
options={options}
74+
value={selectedOption}
75+
/>
76+
</div>
77+
)
78+
}

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

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,11 @@ export const createExport = async (args: CreateExportArgs) => {
8080

8181
if (debug) {
8282
req.payload.logger.debug({
83-
message: 'Starting export process with args:',
83+
msg: '[createExport] Starting export process',
84+
exportDocId: id,
85+
exportName: nameArg,
8486
collectionSlug,
87+
exportCollection,
8588
draft: draftsFromInput,
8689
fields,
8790
format,
@@ -513,22 +516,50 @@ export const createExport = async (args: CreateExportArgs) => {
513516
}
514517
} else {
515518
if (debug) {
516-
req.payload.logger.debug(`Updating existing export with id: ${id}`)
519+
req.payload.logger.debug({
520+
msg: '[createExport] Updating export document with file',
521+
exportDocId: id,
522+
exportCollection,
523+
fileName: name,
524+
fileSize: buffer.length,
525+
mimeType: isCSV ? 'text/csv' : 'application/json',
526+
})
527+
}
528+
try {
529+
await req.payload.update({
530+
id,
531+
collection: exportCollection,
532+
data: {},
533+
file: {
534+
name,
535+
data: buffer,
536+
mimetype: isCSV ? 'text/csv' : 'application/json',
537+
size: buffer.length,
538+
},
539+
// Override access only here so that we can be sure the export collection itself is updated as expected
540+
overrideAccess: true,
541+
req,
542+
})
543+
} catch (error) {
544+
const errorDetails =
545+
error instanceof Error
546+
? {
547+
message: error.message,
548+
name: error.name,
549+
stack: error.stack,
550+
// @ts-expect-error - data might exist on Payload errors
551+
data: error.data,
552+
}
553+
: error
554+
req.payload.logger.error({
555+
msg: '[createExport] Failed to update export document with file',
556+
err: errorDetails,
557+
exportDocId: id,
558+
exportCollection,
559+
fileName: name,
560+
})
561+
throw error
517562
}
518-
await req.payload.update({
519-
id,
520-
collection: exportCollection,
521-
data: {},
522-
file: {
523-
name,
524-
data: buffer,
525-
mimetype: isCSV ? 'text/csv' : 'application/json',
526-
size: buffer.length,
527-
},
528-
// Override access only here so that we can be sure the export collection itself is updated as expected
529-
overrideAccess: true,
530-
req,
531-
})
532563
}
533564
if (debug) {
534565
req.payload.logger.debug('Export process completed successfully')

0 commit comments

Comments
 (0)