Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
7800706
initial commit
paulpopus Nov 13, 2025
284978f
Merge branch 'main' into feat/add-import-to-import-export-plugin
paulpopus Nov 24, 2025
802778b
commit some changes
paulpopus Nov 25, 2025
7dac54b
update plugin
paulpopus Dec 1, 2025
e225eb0
update int tests
paulpopus Dec 1, 2025
3e4dcbc
more fixes
paulpopus Dec 1, 2025
1665905
e2e
paulpopus Dec 1, 2025
1eb3f54
add translations
paulpopus Dec 1, 2025
9f26bcd
update test
paulpopus Dec 1, 2025
0aa8b5a
Merge branch 'main' into feat/add-import-to-import-export-plugin
paulpopus Dec 1, 2025
ab0a1d1
update translations
paulpopus Dec 2, 2025
f965b9f
update some logic
paulpopus Dec 2, 2025
3423c7a
update docs
paulpopus Dec 2, 2025
1f1d809
fix some errors
paulpopus Dec 2, 2025
e6490b5
change imports
paulpopus Dec 2, 2025
88a7b5e
fix translations
paulpopus Dec 2, 2025
c026dca
fix build
paulpopus Dec 2, 2025
6bbbd36
fix util
paulpopus Dec 2, 2025
35fb6d9
update config
paulpopus Dec 2, 2025
2e780b6
commit progress
paulpopus Dec 2, 2025
fbb48d1
commit progress
paulpopus Dec 3, 2025
92932cd
update seed
paulpopus Dec 3, 2025
8f6963e
commit progress
paulpopus Dec 3, 2025
6bf2f94
commit progress
paulpopus Dec 3, 2025
bf28762
update test config
paulpopus Dec 3, 2025
f0813ea
progress
paulpopus Dec 3, 2025
4fd0322
update config
paulpopus Dec 3, 2025
5eb4227
progress
paulpopus Dec 4, 2025
8339a0a
update tests
paulpopus Dec 4, 2025
27e4ba1
type changes
paulpopus Dec 4, 2025
d60ed96
use batch processing util
paulpopus Dec 5, 2025
3362af6
fixes
paulpopus Dec 5, 2025
867ff79
use flattened fields
paulpopus Dec 5, 2025
5974239
add tests
paulpopus Dec 5, 2025
1a60d51
extend test coverage
paulpopus Dec 5, 2025
e00cbf6
update docs
paulpopus Dec 5, 2025
99fd19d
progress
paulpopus Dec 5, 2025
48958c5
add docs
paulpopus Dec 5, 2025
3f6bd25
pin packages
paulpopus Dec 5, 2025
f23c757
Merge branch 'main' into feat/add-import-to-import-export-plugin
paulpopus Dec 5, 2025
332599a
update lockfile
paulpopus Dec 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
461 changes: 441 additions & 20 deletions docs/plugins/import-export.mdx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export const beforeChangeCart: (args: Props) => CollectionBeforeChangeHook =
data.secret = secret

// Store in context so afterRead hook can include it in the creation response
if (!req.context) { req.context = {} }
if (!req.context) {
req.context = {}
}
req.context.newCartSecret = secret
}

Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-import-export/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@
"@faceless-ui/modal": "3.0.0",
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"csv-parse": "^5.6.0",
"csv-stringify": "^6.5.2",
"csv-parse": "5.6.0",
"csv-stringify": "6.5.2",
"qs-esm": "7.0.2"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@ import { useEffect } from 'react'
import { useImportExport } from '../ImportExportProvider/index.js'

export const CollectionField: React.FC = () => {
const { id } = useDocumentInfo()
const { id, collectionSlug } = useDocumentInfo()
const { setValue } = useField({ path: 'collectionSlug' })
const { collection } = useImportExport()

useEffect(() => {
if (id) {
return
}
setValue(collection)
}, [id, collection, setValue])
if (collection) {
setValue(collection)
} else if (collectionSlug) {
setValue(collectionSlug)
}
}, [id, collection, setValue, collectionSlug])

return null
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Translation,
useConfig,
useDocumentDrawer,
useDocumentInfo,
useTranslation,
} from '@payloadcms/ui'
import React, { useEffect } from 'react'
Expand All @@ -16,7 +17,6 @@ import type {
} from '../../translations/index.js'

import { useImportExport } from '../ImportExportProvider/index.js'
import './index.scss'

const baseClass = 'export-list-menu-item'

Expand All @@ -25,10 +25,12 @@ export const ExportListMenuItem: React.FC<{
exportCollectionSlug: string
}> = ({ collectionSlug, exportCollectionSlug }) => {
const { getEntityConfig } = useConfig()

const { i18n, t } = useTranslation<
PluginImportExportTranslations,
PluginImportExportTranslationKeys
>()

const currentCollectionConfig = getEntityConfig({ collectionSlug })

const [DocumentDrawer, DocumentDrawerToggler] = useDocumentDrawer({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@layer payload-default {
.export-preview {
&__header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 10px;
}
}
}
235 changes: 235 additions & 0 deletions packages/plugin-import-export/src/components/ExportPreview/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
'use client'
import type { Column } from '@payloadcms/ui'
import type { ClientField, Where } from 'payload'

import { getTranslation } from '@payloadcms/translations'
import {
CodeEditorLazy,
Table,
Translation,
useConfig,
useDebouncedEffect,
useDocumentInfo,
useFormFields,
useTranslation,
} from '@payloadcms/ui'
import React, { useEffect, useMemo, useState, useTransition } from 'react'

import type {
PluginImportExportTranslationKeys,
PluginImportExportTranslations,
} from '../../translations/index.js'

import { buildDisabledFieldRegex } from '../../utilities/buildDisabledFieldRegex.js'
import './index.scss'
import { useImportExport } from '../ImportExportProvider/index.js'

const baseClass = 'export-preview'

export const ExportPreview: React.FC = () => {
const [isPending, startTransition] = useTransition()
const { collection } = useImportExport()
const {
config,
config: { routes },
} = useConfig()
const { collectionSlug } = useDocumentInfo()
const { draft, fields, format, limit, locale, page, sort, where } = useFormFields(([fields]) => {
return {
draft: fields['drafts']?.value,
fields: fields['fields']?.value,
format: fields['format']?.value,
limit: fields['limit']?.value as number,
locale: fields['locale']?.value as string,
page: fields['page']?.value as number,
sort: fields['sort']?.value as string,
where: fields['where']?.value as Where,
}
})
const [dataToRender, setDataToRender] = useState<any[]>([])
const [resultCount, setResultCount] = useState<any>('')
const [columns, setColumns] = useState<Column[]>([])
const { i18n, t } = useTranslation<
PluginImportExportTranslations,
PluginImportExportTranslationKeys
>()

const targetCollectionSlug = typeof collection === 'string' && collection

const targetCollectionConfig = useMemo(
() => config.collections.find((collection) => collection.slug === targetCollectionSlug),
[config.collections, targetCollectionSlug],
)

const disabledFieldRegexes: RegExp[] = useMemo(() => {
const disabledFieldPaths =
targetCollectionConfig?.admin?.custom?.['plugin-import-export']?.disabledFields ?? []

return disabledFieldPaths.map(buildDisabledFieldRegex)
}, [targetCollectionConfig])

const isCSV = format === 'csv'

useDebouncedEffect(
() => {
if (!collectionSlug || !targetCollectionSlug) {
return
}

const abortController = new AbortController()

const fetchData = async () => {
try {
const res = await fetch(`${routes.api}/${collectionSlug}/export-preview`, {
body: JSON.stringify({
collectionSlug: targetCollectionSlug,
draft,
fields,
format,
limit,
locale,
page,
sort,
where,
}),
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: abortController.signal,
})

if (!res.ok) {
return
}

const { docs, totalDocs }: { docs: Record<string, unknown>[]; totalDocs: number } =
await res.json()

const allKeys = Array.from(new Set(docs.flatMap((doc) => Object.keys(doc))))
const defaultMetaFields = ['createdAt', 'updatedAt', '_status', 'id']

// Match CSV column ordering by building keys based on fields and regex
const fieldToRegex = (field: string): RegExp => {
const parts = field.split('.').map((part) => `${part}(?:_\\d+)?`)
return new RegExp(`^${parts.join('_')}`)
}

// Construct final list of field keys to match field order + meta order
const selectedKeys =
Array.isArray(fields) && fields.length > 0
? fields.flatMap((field) => {
const regex = fieldToRegex(field)
return allKeys.filter(
(key) =>
regex.test(key) &&
!disabledFieldRegexes.some((disabledRegex) => disabledRegex.test(key)),
)
})
: allKeys.filter(
(key) =>
!defaultMetaFields.includes(key) &&
!disabledFieldRegexes.some((regex) => regex.test(key)),
)

const fieldKeys =
Array.isArray(fields) && fields.length > 0
? selectedKeys // strictly use selected fields only
: [
...selectedKeys,
...defaultMetaFields.filter(
(key) => allKeys.includes(key) && !selectedKeys.includes(key),
),
]

// Build columns based on flattened keys
const newColumns: Column[] = fieldKeys.map((key) => ({
accessor: key,
active: true,
field: { name: key } as ClientField,
Heading: getTranslation(key, i18n),
renderedCells: docs.map((doc: Record<string, unknown>) => {
const val = doc[key]

if (val === undefined || val === null) {
return null
}

// Avoid ESLint warning by type-checking before calling String()
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
return String(val)
}

if (Array.isArray(val)) {
return val.map(String).join(', ')
}

return JSON.stringify(val)
}),
}))

setResultCount(totalDocs)
setColumns(newColumns)
setDataToRender(docs)
} catch (error) {
console.error('Error fetching preview data:', error)
}
}

startTransition(async () => await fetchData())

return () => {
if (!abortController.signal.aborted) {
abortController.abort('Component unmounted')
}
}
},
[
collectionSlug,
disabledFieldRegexes,
draft,
fields,
format,
i18n,
limit,
locale,
page,
sort,
where,
routes.api,
targetCollectionSlug,
],
500,
)

return (
<div className={baseClass}>
<div className={`${baseClass}__header`}>
<h3>
<Translation i18nKey="version:preview" t={t} />
</h3>
{resultCount && !isPending && (
<Translation
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
i18nKey="plugin-import-export:totalDocumentsCount"
t={t}
variables={{
count: resultCount,
}}
/>
)}
</div>
{isPending && !dataToRender && (
<div className={`${baseClass}__loading`}>
<Translation i18nKey="general:loading" t={t} />
</div>
)}
{dataToRender &&
(isCSV ? (
<Table columns={columns} data={dataToRender} />
) : (
<CodeEditorLazy language="json" readOnly value={JSON.stringify(dataToRender, null, 2)} />
))}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client'
import type { SelectFieldClientComponent } from 'payload'

import { SelectField, useDocumentInfo } from '@payloadcms/ui'

export const ImportCollectionField: SelectFieldClientComponent = (props) => {
const { id, initialData } = useDocumentInfo()

// If creating (no id) and have initialData with collectionSlug (e.g., from drawer),
// hide the field to prevent user selection.
const shouldHide = !id && initialData?.collectionSlug

if (shouldHide) {
return (
<div style={{ display: 'none' }}>
<SelectField {...props} />
</div>
)
}

// Otherwise render the normal select field
return <SelectField {...props} />
}
Loading
Loading