Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions docs/plugins/import-export.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ keywords: plugins, plugin, import, export, csv, JSON, data, ETL, download

<Banner type="warning">
**Note**: This plugin is in **beta** as some aspects of it may change on any
minor releases. It is under development and currently only supports exporting
of collection data.
minor releases. It is under development.
</Banner>

This plugin adds features that give admin users the ability to download or create export data as an upload collection and import it back into a project.
Expand All @@ -22,7 +21,7 @@ This plugin adds features that give admin users the ability to download or creat
- Download the export directly through the browser
- Create a file upload of the export data
- Use the jobs queue for large exports
- (Coming soon) Import collection data
- Import collection data

## Installation

Expand Down Expand Up @@ -64,6 +63,7 @@ export default config
| `disableSave` | boolean | If true, disables the save button in the export preview UI. |
| `format` | string | Forces a specific export format (`csv` or `json`), hides the format dropdown, and prevents the user from choosing the export format. |
| `overrideExportCollection` | function | Function to override the default export collection; takes the default export collection and allows you to modify and return it. |
| `overrideImportCollection` | function | Function to override the default import collection; takes the default import collection and allows you to modify and return it. |

## Field Options

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
}
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 @@ -25,10 +26,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;
}
}
}
237 changes: 237 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,237 @@
'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
>()

console.log({ draft })

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} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client'

import { getTranslation } from '@payloadcms/translations'
import {
PopupList,
Translation,
useConfig,
useDocumentDrawer,
useTranslation,
} from '@payloadcms/ui'
import React from 'react'

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

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

export const ImportListMenuItem: React.FC<{
collectionSlug: string
importCollectionSlug: string
}> = ({ collectionSlug, importCollectionSlug }) => {
const { getEntityConfig } = useConfig()
const { i18n, t } = useTranslation<
PluginImportExportTranslations,
PluginImportExportTranslationKeys
>()
const currentCollectionConfig = getEntityConfig({ collectionSlug })

const [DocumentDrawer, DocumentDrawerToggler] = useDocumentDrawer({
collectionSlug: importCollectionSlug,
})

return (
<PopupList.Button className={baseClass}>
<DocumentDrawerToggler>
<Translation
// @ts-expect-error - this is not correctly typed in plugins right now
i18nKey="plugin-import-export:importDocumentLabel"
t={t}
variables={{
label: getTranslation(currentCollectionConfig.labels.plural, i18n),
}}
/>
</DocumentDrawerToggler>
<DocumentDrawer initialData={{ collectionSlug }} />
</PopupList.Button>
)
}
Loading