feat: plugin-import-export initial work (#10795)

Adds new plugin-import-export initial version.

Allows for direct download and creation of downloadable collection data
stored to a json or csv uses the access control of the user creating the
request to make the file.

config options:
```ts
  /**
   * Collections to include the Import/Export controls in
   * Defaults to all collections
   */
  collections?: string[]
  /**
   * Enable to force the export to run synchronously
   */
  disableJobsQueue?: boolean
  /**
   * This function takes the default export collection configured in the plugin and allows you to override it by modifying and returning it
   * @param collection
   * @returns collection
   */
  overrideExportCollection?: (collection: CollectionOverride) => CollectionOverride

// payload.config.ts:
plugins: [
    importExportPlugin({
      collections: ['pages', 'users'],
      overrideExportCollection: (collection) => {
        collection.admin.group = 'System'
        collection.upload.staticDir = path.resolve(dirname, 'uploads')
        return collection
      },
      disableJobsQueue: true,
    }),
 ],
```

---------

Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
Co-authored-by: Kendell Joseph <kendelljoseph@gmail.com>
This commit is contained in:
Dan Ribbens
2025-03-04 20:06:43 -05:00
committed by GitHub
parent cc05937633
commit 4f822a439b
61 changed files with 3345 additions and 8 deletions

View File

@@ -313,6 +313,7 @@ jobs:
- i18n - i18n
- plugin-cloud-storage - plugin-cloud-storage
- plugin-form-builder - plugin-form-builder
- plugin-import-export
- plugin-nested-docs - plugin-nested-docs
- plugin-seo - plugin-seo
- versions - versions

3
.idea/payload.iml generated
View File

@@ -80,8 +80,9 @@
<excludeFolder url="file://$MODULE_DIR$/packages/drizzle/dist" /> <excludeFolder url="file://$MODULE_DIR$/packages/drizzle/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/db-sqlite/.turbo" /> <excludeFolder url="file://$MODULE_DIR$/packages/db-sqlite/.turbo" />
<excludeFolder url="file://$MODULE_DIR$/packages/db-sqlite/dist" /> <excludeFolder url="file://$MODULE_DIR$/packages/db-sqlite/dist" />
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-import-export/dist" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

View File

@@ -33,6 +33,7 @@
"build:payload-cloud": "turbo build --filter \"@payloadcms/payload-cloud\"", "build:payload-cloud": "turbo build --filter \"@payloadcms/payload-cloud\"",
"build:plugin-cloud-storage": "turbo build --filter \"@payloadcms/plugin-cloud-storage\"", "build:plugin-cloud-storage": "turbo build --filter \"@payloadcms/plugin-cloud-storage\"",
"build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"", "build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"",
"build:plugin-import-export": "turbo build --filter \"@payloadcms/plugin-import-export\"",
"build:plugin-multi-tenant": "turbo build --filter \"@payloadcms/plugin-multi-tenant\"", "build:plugin-multi-tenant": "turbo build --filter \"@payloadcms/plugin-multi-tenant\"",
"build:plugin-nested-docs": "turbo build --filter \"@payloadcms/plugin-nested-docs\"", "build:plugin-nested-docs": "turbo build --filter \"@payloadcms/plugin-nested-docs\"",
"build:plugin-redirects": "turbo build --filter \"@payloadcms/plugin-redirects\"", "build:plugin-redirects": "turbo build --filter \"@payloadcms/plugin-redirects\"",

View File

@@ -13,6 +13,7 @@ export const PAYLOAD_PACKAGE_LIST = [
'@payloadcms/plugin-cloud-storage', '@payloadcms/plugin-cloud-storage',
'@payloadcms/payload-cloud', '@payloadcms/payload-cloud',
'@payloadcms/plugin-form-builder', '@payloadcms/plugin-form-builder',
'@payloadcms/plugin-import-export',
// '@payloadcms/plugin-multi-tenant', // '@payloadcms/plugin-multi-tenant',
'@payloadcms/plugin-nested-docs', '@payloadcms/plugin-nested-docs',
'@payloadcms/plugin-redirects', '@payloadcms/plugin-redirects',

View File

@@ -0,0 +1,7 @@
node_modules
.env
dist
demo/uploads
build
.DS_Store
package-lock.json

View File

@@ -0,0 +1,12 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
},
"transform": {
"react": {
"runtime": "automatic",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": true
}
}
},
"module": {
"type": "es6"
}
}

View File

@@ -0,0 +1,7 @@
# Payload Import/Export Plugin
A plugin for [Payload](https://github.com/payloadcms/payload) to easily import and export data.
- [Source code](https://github.com/payloadcms/payload/tree/main/packages/plugin-import-export)
- [Documentation](https://payloadcms.com/docs/plugins/import-export)
- [Documentation source](https://github.com/payloadcms/payload/tree/main/docs/plugins/import-export.mdx)

View File

@@ -0,0 +1,18 @@
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...rootEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,101 @@
{
"name": "@payloadcms/plugin-import-export",
"version": "3.26.0",
"description": "Import-Export plugin for Payload",
"keywords": [
"payload",
"cms",
"plugin",
"typescript",
"react",
"nextjs",
"import",
"export"
],
"repository": {
"type": "git",
"url": "https://github.com/payloadcms/payload.git",
"directory": "packages/plugin-import-export"
},
"license": "MIT",
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
"maintainers": [
{
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
],
"type": "module",
"exports": {
".": {
"import": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./types": {
"import": "./src/exports/types.ts",
"types": "./src/exports/types.ts",
"default": "./src/exports/types.ts"
},
"./rsc": {
"import": "./src/exports/rsc.ts",
"types": "./src/exports/rsc.ts",
"default": "./src/exports/rsc.ts"
}
},
"main": "./src/index.ts",
"types": "./src/index.ts",
"files": [
"dist"
],
"scripts": {
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
"build:types": "tsc --emitDeclarationOnly --outDir dist",
"clean": "rimraf {dist,*.tsbuildinfo}",
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"prepublishOnly": "pnpm clean && pnpm turbo build"
},
"dependencies": {
"@faceless-ui/modal": "3.0.0-beta.2",
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"csv-parse": "^5.6.0",
"csv-stringify": "^6.5.2",
"qs-esm": "7.0.2"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/ui": "workspace:*",
"payload": "workspace:*"
},
"peerDependencies": {
"@payloadcms/ui": "workspace:*",
"payload": "workspace:*"
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./types": {
"import": "./dist/exports/types.js",
"types": "./dist/exports/types.d.ts",
"default": "./dist/exports/types.js"
},
"./rsc": {
"import": "./dist/exports/rsc.js",
"types": "./dist/exports/rsc.d.ts",
"default": "./dist/exports/rsc.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"homepage:": "https://payloadcms.com"
}

View File

@@ -0,0 +1,22 @@
'use client'
import type React from 'react'
import { useDocumentInfo, useField } from '@payloadcms/ui'
import { useEffect } from 'react'
import { useImportExport } from '../ImportExportProvider/index.js'
export const CollectionField: React.FC = () => {
const { id } = useDocumentInfo()
const { setValue } = useField({ path: 'collectionSlug' })
const { collection } = useImportExport()
useEffect(() => {
if (id) {
return
}
setValue(collection)
}, [id, collection, setValue])
return null
}

View File

@@ -0,0 +1,52 @@
@import '~@payloadcms/ui/scss';
@layer payload-default {
.export-list-menu-item {
.doc-drawer__toggler {
height: 100%;
width: 100%;
text-align: left;
}
// TODO: is any of this css needed?
&__subheader,
&__header {
padding: 0 var(--gutter-h);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--theme-border-color);
& h2 {
margin: calc(var(--gutter-h) * 0.5) 0;
}
}
&__options,
&__preview {
padding: calc(var(--gutter-h) * 0.5) var(--gutter-h);
}
&__preview-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: calc(var(--gutter-h) * 0.5);
}
&__close {
@include btn-reset;
}
&__icon {
width: 3rem;
height: 3rem;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
}

View File

@@ -0,0 +1,38 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import { PopupList, useConfig, useDocumentDrawer, useTranslation } from '@payloadcms/ui'
import React, { useEffect } from 'react'
import { useImportExport } from '../ImportExportProvider/index.js'
import './index.scss'
const baseClass = 'export-list-menu-item'
export const ExportListMenuItem: React.FC<{
collectionSlug: string
exportCollectionSlug: string
}> = ({ collectionSlug, exportCollectionSlug }) => {
const { getEntityConfig } = useConfig()
const { i18n } = useTranslation()
const currentCollectionConfig = getEntityConfig({ collectionSlug })
const [DocumentDrawer, DocumentDrawerToggler] = useDocumentDrawer({
collectionSlug: exportCollectionSlug,
})
const { setCollection } = useImportExport()
// Set collection and selected items on mount or when selection changes
useEffect(() => {
setCollection(currentCollectionConfig.slug ?? '')
}, [currentCollectionConfig, setCollection])
return (
<PopupList.Button className={baseClass}>
<DocumentDrawerToggler>
Export {getTranslation(currentCollectionConfig.labels.plural, i18n)}
</DocumentDrawerToggler>
<DocumentDrawer />
</PopupList.Button>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { Button, SaveButton, useConfig, useForm, useTranslation } from '@payloadcms/ui'
import React from 'react'
export const ExportSaveButton: React.FC = () => {
const { t } = useTranslation()
const {
config: {
routes: { api },
serverURL,
},
} = useConfig()
const { getData } = useForm()
const label = t('general:save')
const handleDownload = async () => {
try {
const data = getData()
const response = await fetch(`${serverURL}${api}/exports/download`, {
body: JSON.stringify({
data,
}),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to download file')
}
const fileStream = response.body
const reader = fileStream?.getReader()
const decoder = new TextDecoder()
let result = ''
while (reader) {
const { done, value } = await reader.read()
if (done) {
break
}
result += decoder.decode(value, { stream: true })
}
const blob = new Blob([result], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${data.name}.${data.format}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
console.error('Error downloading file:', error)
}
}
return (
<React.Fragment>
<SaveButton label={label}></SaveButton>
<Button onClick={handleDownload} size="medium" type="button">
Download
</Button>
</React.Fragment>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import type { ListPreferences, SelectFieldClientComponent } from 'payload'
import type { ReactNode } from 'react'
import {
FieldLabel,
ReactSelect,
useConfig,
useDocumentInfo,
useField,
usePreferences,
} from '@payloadcms/ui'
import React, { useEffect, useState } from 'react'
import { useImportExport } from '../ImportExportProvider/index.js'
import { reduceFields } from './reduceFields.js'
const baseClass = 'fields-to-export'
export const FieldsToExport: SelectFieldClientComponent = (props) => {
const { id } = useDocumentInfo()
const { path } = props
const { setValue, value } = useField<string[]>({ path })
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
const { getEntityConfig } = useConfig()
const { collection } = useImportExport()
const { getPreference } = usePreferences()
const [displayedValue, setDisplayedValue] = useState<
{ id: string; label: ReactNode; value: string }[]
>([])
const collectionConfig = getEntityConfig({ collectionSlug: collectionSlug ?? collection })
const fieldOptions = reduceFields({ fields: collectionConfig?.fields })
useEffect(() => {
if (value && value.length > 0) {
setDisplayedValue((prevDisplayedValue) => {
if (prevDisplayedValue.length > 0) {
return prevDisplayedValue
} // Prevent unnecessary updates
return value.map((field) => {
const match = fieldOptions.find((option) => option.value === field)
return match ? { ...match, id: field } : { id: field, label: field, value: field }
})
})
}
}, [value, fieldOptions])
useEffect(() => {
if (id || !collectionSlug) {
return
}
const doAsync = async () => {
const currentPreferences = await getPreference<{
columns: ListPreferences['columns']
}>(`${collectionSlug}-list`)
const columns = currentPreferences?.columns?.filter((a) => a.active).map((b) => b.accessor)
setValue(columns ?? collectionConfig?.admin?.defaultColumns ?? [])
}
void doAsync()
}, [
getPreference,
collection,
setValue,
collectionSlug,
id,
collectionConfig?.admin?.defaultColumns,
])
const onChange = (options: { id: string; label: ReactNode; value: string }[]) => {
if (!options) {
setValue([])
return
}
const updatedValue = options?.map((option) =>
typeof option === 'object' ? option.value : option,
)
setValue(updatedValue)
setDisplayedValue(options)
}
return (
<div className={baseClass}>
<FieldLabel label="Columns to Export" />
<ReactSelect
className={baseClass}
disabled={props.readOnly}
getOptionValue={(option) => String(option.value)}
isClearable={true}
isMulti={true}
isSortable={true}
// @ts-expect-error react select option
onChange={onChange}
options={fieldOptions}
value={displayedValue}
/>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import type { ClientField } from 'payload'
import { fieldAffectsData, fieldHasSubFields } from 'payload/shared'
import React, { Fragment } from 'react'
const createNestedClientFieldPath = (parentPath: string, field: ClientField): string => {
if (parentPath) {
if (fieldAffectsData(field)) {
return `${parentPath}.${field.name}`
}
return parentPath
}
if (fieldAffectsData(field)) {
return field.name
}
return ''
}
const combineLabel = ({
field,
prefix,
}: {
field: ClientField
prefix?: React.ReactNode
}): React.ReactNode => {
return (
<Fragment>
{prefix ? (
<Fragment>
<span style={{ display: 'inline-block' }}>{prefix}</span>
{' > '}
</Fragment>
) : null}
<span style={{ display: 'inline-block' }}>
{'label' in field && typeof field.label === 'string'
? field.label
: (('name' in field && field.name) ?? 'unnamed field')}
</span>
</Fragment>
)
}
export const reduceFields = ({
fields,
labelPrefix = null,
path = '',
}: {
fields: ClientField[]
labelPrefix?: React.ReactNode
path?: string
}): { id: string; label: React.ReactNode; value: string }[] => {
if (!fields) {
return []
}
return fields.reduce<{ id: string; label: React.ReactNode; value: string }[]>(
(fieldsToUse, field) => {
// escape for a variety of reasons, include ui fields as they have `name`.
if (field.type === 'ui') {
return fieldsToUse
}
if (!(field.type === 'array' || field.type === 'blocks') && fieldHasSubFields(field)) {
return [
...fieldsToUse,
...reduceFields({
fields: field.fields,
labelPrefix: combineLabel({ field, prefix: labelPrefix }),
path: createNestedClientFieldPath(path, field),
}),
]
}
if (field.type === 'tabs' && 'tabs' in field) {
return [
...fieldsToUse,
...field.tabs.reduce<{ id: string; label: React.ReactNode; value: string }[]>(
(tabFields, tab) => {
if ('fields' in tab) {
const isNamedTab = 'name' in tab && tab.name
return [
...tabFields,
...reduceFields({
fields: tab.fields,
labelPrefix,
path: isNamedTab ? createNestedClientFieldPath(path, field) : path,
}),
]
}
return tabFields
},
[],
),
]
}
const val = createNestedClientFieldPath(path, field)
const formattedField = {
id: val,
label: combineLabel({ field, prefix: labelPrefix }),
value: val,
}
return [...fieldsToUse, formattedField]
},
[],
)
}

View File

@@ -0,0 +1,30 @@
'use client'
import React, { createContext, useCallback, useContext, useState } from 'react'
type ImportExportContext = {
collection: string
setCollection: (collection: string) => void
}
export const ImportExportContext = createContext({} as ImportExportContext)
export const ImportExportProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [collection, setCollectionState] = useState<string>('')
const setCollection = useCallback((collection: string) => {
setCollectionState(collection)
}, [])
return (
<ImportExportContext.Provider
value={{
collection,
setCollection,
}}
>
{children}
</ImportExportContext.Provider>
)
}
export const useImportExport = (): ImportExportContext => useContext(ImportExportContext)

View File

@@ -0,0 +1,8 @@
.preview {
&__header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 10px;
}
}

View File

@@ -0,0 +1,109 @@
'use client'
import type { Column } from '@payloadcms/ui'
import type { ClientField, FieldAffectingDataClient } from 'payload'
import { Table, useConfig, useField } from '@payloadcms/ui'
import { fieldAffectsData } from 'payload/shared'
import * as qs from 'qs-esm'
import React from 'react'
import { useImportExport } from '../ImportExportProvider/index.js'
import './index.scss'
const baseClass = 'preview'
export const Preview = () => {
const { collection } = useImportExport()
const { config } = useConfig()
const { value: where } = useField({ path: 'where' })
const { value: limit } = useField<number>({ path: 'limit' })
const { value: fields } = useField<string[]>({ path: 'fields' })
const { value: sort } = useField({ path: 'sort' })
const { value: draft } = useField({ path: 'draft' })
const [dataToRender, setDataToRender] = React.useState<any[]>([])
const [resultCount, setResultCount] = React.useState<any>('')
const [columns, setColumns] = React.useState<Column[]>([])
const collectionSlug = typeof collection === 'string' && collection
const collectionConfig = config.collections.find(
(collection) => collection.slug === collectionSlug,
)
React.useEffect(() => {
const fetchData = async () => {
if (!collectionSlug) {
return
}
try {
const whereQuery = qs.stringify(
{
depth: 0,
draft,
limit: limit > 10 ? 10 : limit,
sort,
where,
},
{
addQueryPrefix: true,
},
)
const response = await fetch(`/api/${collectionSlug}${whereQuery}`, {
headers: {
'Content-Type': 'application/json',
},
method: 'GET',
})
if (response.ok) {
const data = await response.json()
setResultCount(limit && limit < data.totalDocs ? limit : data.totalDocs)
// TODO: check if this data is in the correct format for the table
const filteredFields = (collectionConfig?.fields?.filter((field) => {
if (!fieldAffectsData(field)) {
return false
}
if (fields?.length > 0) {
return fields.includes(field.name)
}
return true
}) ?? []) as FieldAffectingDataClient[]
setColumns(
filteredFields.map((field) => ({
accessor: field.name || '',
active: true,
field: field as ClientField,
Heading: field?.label || field.name,
renderedCells: data.docs.map((doc: Record<string, unknown>) => {
if (!field.name || !doc[field.name]) {
return null
}
if (typeof doc[field.name] === 'object') {
return JSON.stringify(doc[field.name])
}
return String(doc[field.name])
}),
})) as Column[],
)
setDataToRender(data.docs)
}
} catch (error) {
console.error('Error fetching data:', error)
}
}
void fetchData()
}, [collectionConfig?.fields, collectionSlug, draft, fields, limit, sort, where])
return (
<div className={baseClass}>
<div className={`${baseClass}__header`}>
<h3>Preview</h3>
{resultCount && <span>{resultCount} total documents</span>}
</div>
{dataToRender && <Table columns={columns} data={dataToRender} />}
</div>
)
}

View File

@@ -0,0 +1,4 @@
.sort-by-fields {
display: block;
width: 33%;
}

View File

@@ -0,0 +1,90 @@
'use client'
import type { SelectFieldClientComponent } from 'payload'
import type { ReactNode } from 'react'
import {
FieldLabel,
ReactSelect,
useConfig,
useDocumentInfo,
useField,
useListQuery,
} from '@payloadcms/ui'
import React, { useEffect, useState } from 'react'
import { reduceFields } from '../FieldsToExport/reduceFields.js'
import { useImportExport } from '../ImportExportProvider/index.js'
const baseClass = 'sort-by-fields'
export const SortBy: SelectFieldClientComponent = (props) => {
const { id } = useDocumentInfo()
const { path } = props
const { setValue, value } = useField<string>({ path })
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
const { query } = useListQuery()
const { getEntityConfig } = useConfig()
const { collection } = useImportExport()
const [displayedValue, setDisplayedValue] = useState<{
id: string
label: ReactNode
value: string
} | null>(null)
const collectionConfig = getEntityConfig({ collectionSlug: collectionSlug ?? collection })
const fieldOptions = reduceFields({ fields: collectionConfig?.fields })
// Sync displayedValue with value from useField
useEffect(() => {
if (!value) {
setDisplayedValue(null)
return
}
const option = fieldOptions.find((field) => field.value === value)
if (option && (!displayedValue || displayedValue.value !== value)) {
setDisplayedValue(option)
}
}, [value, fieldOptions])
useEffect(() => {
if (id || !query?.sort || value) {
return
}
const option = fieldOptions.find((field) => field.value === query.sort)
if (option) {
setValue(option.value)
setDisplayedValue(option)
}
}, [fieldOptions, id, query?.sort, value, setValue])
const onChange = (option: { id: string; label: ReactNode; value: string } | null) => {
if (!option) {
setValue('')
setDisplayedValue(null)
} else {
setValue(option.value)
setDisplayedValue(option)
}
}
return (
<div className={baseClass} style={{ '--field-width': '33%' } as React.CSSProperties}>
<FieldLabel label="Sort By" />
<ReactSelect
className={baseClass}
disabled={props.readOnly}
getOptionValue={(option) => String(option.value)}
isClearable={true}
isSortable={true}
// @ts-expect-error react select option
onChange={onChange}
options={fieldOptions}
// @ts-expect-error react select
value={displayedValue}
/>
</div>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import type React from 'react'
import { useDocumentInfo, useField, useListQuery, useSelection } from '@payloadcms/ui'
import { useEffect } from 'react'
import './index.scss'
export const WhereField: React.FC = () => {
const { setValue: setSelectionToUseValue, value: selectionToUseValue } = useField({
path: 'selectionToUse',
})
const { setValue } = useField({ path: 'where' })
const { selectAll, selected } = useSelection()
const { query } = useListQuery()
const { id } = useDocumentInfo()
// setValue based on selectionToUseValue
useEffect(() => {
if (id) {
return
}
if (selectionToUseValue === 'currentFilters' && query && query?.where) {
setValue(query.where)
}
if (selectionToUseValue === 'currentSelection' && selected) {
const ids = []
for (const [key, value] of selected) {
if (value) {
ids.push(key)
}
}
setValue({
id: {
in: ids,
},
})
}
if (selectionToUseValue === 'all' && selected) {
setValue({})
}
// Selected set a where query with IDs
}, [id, selectionToUseValue, query, selected, setValue])
// handles default value of selectionToUse
useEffect(() => {
if (id) {
return
}
let defaultSelection: 'all' | 'currentFilters' | 'currentSelection' = 'all'
if (['allInPage', 'some'].includes(selectAll)) {
defaultSelection = 'currentSelection'
}
if (defaultSelection === 'all' && query?.where) {
defaultSelection = 'currentFilters'
}
setSelectionToUseValue(defaultSelection)
}, [id, query, selectAll, setSelectionToUseValue])
return null
}

View File

@@ -0,0 +1,151 @@
import type { PaginatedDocs, PayloadRequest, Sort, User, Where } from 'payload'
import { stringify } from 'csv-stringify/sync'
import { APIError } from 'payload'
import { Readable } from 'stream'
import { flattenObject } from './flattenObject.js'
import { getFilename } from './getFilename.js'
import { getSelect } from './getSelect.js'
type Export = {
collectionSlug: string
exportsCollection: string
fields?: string[]
format: 'csv' | 'json'
globals?: string[]
id: number | string
locale?: string
name: string
slug: string
sort: Sort
user: string
userCollection: string
where?: Where
}
export type CreateExportArgs = {
/**
* If true, stream the file instead of saving it
*/
download?: boolean
input: Export
req: PayloadRequest
user?: User
}
export const createExport = async (args: CreateExportArgs) => {
const {
download,
input: {
id,
name: nameArg,
collectionSlug,
exportsCollection,
fields,
format,
locale: localeInput,
sort,
user,
where,
},
req: { locale: localeArg, payload },
req,
} = args
const locale = localeInput ?? localeArg
const collectionConfig = payload.config.collections.find(({ slug }) => slug === collectionSlug)
if (!collectionConfig) {
throw new APIError(`Collection with slug ${collectionSlug} not found`)
}
const name = `${nameArg ?? `${getFilename()}-${collectionSlug}`}.${format}`
const isCSV = format === 'csv'
const findArgs = {
collection: collectionSlug,
depth: 0,
limit: 100,
locale,
overrideAccess: false,
page: 0,
select: fields ? getSelect(fields) : undefined,
sort,
user,
where,
}
let result: PaginatedDocs = { hasNextPage: true } as PaginatedDocs
if (download) {
const encoder = new TextEncoder()
const stream = new Readable({
async read() {
let result = await payload.find(findArgs)
let isFirstBatch = true
while (result.docs.length > 0) {
const csvInput = result.docs.map((doc) => flattenObject(doc))
const csvString = stringify(csvInput, { header: isFirstBatch })
this.push(encoder.encode(csvString))
isFirstBatch = false
if (!result.hasNextPage) {
this.push(null) // End the stream
break
}
findArgs.page += 1
result = await payload.find(findArgs)
}
},
})
return new Response(stream as any, {
headers: {
'Content-Disposition': `attachment; filename="${name}"`,
'Content-Type': isCSV ? 'text/csv' : 'application/json',
},
})
}
const outputData: string[] = []
let isFirstBatch = true
while (result.hasNextPage) {
findArgs.page += 1
result = await payload.find(findArgs)
if (isCSV) {
const csvInput = result.docs.map((doc) => flattenObject(doc))
outputData.push(stringify(csvInput, { header: isFirstBatch }))
isFirstBatch = false
} else {
const jsonInput = result.docs.map((doc) => JSON.stringify(doc))
outputData.push(jsonInput.join(',\n'))
}
}
const buffer = Buffer.from(format === 'json' ? `[${outputData.join(',')}]` : outputData.join(''))
if (!id) {
req.file = {
name,
data: buffer,
mimetype: isCSV ? 'text/csv' : 'application/json',
size: buffer.length,
}
} else {
await req.payload.update({
id,
collection: exportsCollection,
data: {},
file: {
name,
data: buffer,
mimetype: isCSV ? 'text/csv' : 'application/json',
size: buffer.length,
},
user,
})
}
}

View File

@@ -0,0 +1,26 @@
import type { PayloadHandler } from 'payload'
import { APIError } from 'payload'
import { createExport } from './createExport.js'
export const download: PayloadHandler = async (req) => {
let body
if (typeof req?.json === 'function') {
body = await req.json()
}
if (!body || !body.data) {
throw new APIError('Request data is required.')
}
req.payload.logger.info(`Download request received ${body.data.collectionSlug}`)
body.data.user = req.user
return createExport({
download: true,
input: body.data,
req,
}) as Promise<Response>
}

View File

@@ -0,0 +1,23 @@
export const flattenObject = (obj: any, prefix: string = ''): Record<string, unknown> => {
const result: Record<string, unknown> = {}
Object.entries(obj).forEach(([key, value]) => {
const newKey = prefix ? `${prefix}_${key}` : key
if (Array.isArray(value)) {
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
Object.assign(result, flattenObject(item, `${newKey}_${index}`))
} else {
result[`${newKey}_${index}`] = item
}
})
} else if (typeof value === 'object' && value !== null) {
Object.assign(result, flattenObject(value, newKey))
} else {
result[newKey] = value
}
})
return result
}

View File

@@ -0,0 +1,55 @@
import type { Config, TaskHandler, User } from 'payload'
import type { CreateExportArgs } from './createExport.js'
import { createExport } from './createExport.js'
import { getFields } from './getFields.js'
export const getCreateCollectionExportTask = (config: Config): TaskHandler<any, string> => {
const inputSchema = getFields(config).concat(
{
name: 'user',
type: 'text',
},
{
name: 'userCollection',
type: 'text',
},
{
name: 'exportsCollection',
type: 'text',
},
)
return {
// @ts-expect-error plugin tasks cannot have predefined type slug
slug: 'createCollectionExport',
handler: async ({ input, req }: CreateExportArgs) => {
let user: undefined | User
if (input.userCollection && input.user) {
user = (await req.payload.findByID({
id: input.user,
collection: input.userCollection,
})) as User
}
if (!user) {
throw new Error('User not found')
}
await createExport({ input, req, user })
return {
success: true,
}
},
inputSchema,
outputSchema: [
{
name: 'success',
type: 'checkbox',
},
],
}
}

View File

@@ -0,0 +1,189 @@
import type { Config, Field, SelectField } from 'payload'
import { getFilename } from './getFilename.js'
export const getFields = (config: Config): Field[] => {
let localeField: SelectField | undefined
if (config.localization) {
localeField = {
name: 'locale',
type: 'select',
admin: {
width: '33%',
},
defaultValue: 'all',
label: 'Locale',
options: [
{
label: 'All Locales',
value: 'all',
},
...config.localization.locales.map((locale) => ({
label: typeof locale === 'string' ? locale : locale.label,
value: typeof locale === 'string' ? locale : locale.code,
})),
],
}
}
return [
{
type: 'collapsible',
fields: [
{
name: 'name',
type: 'text',
defaultValue: () => getFilename(),
label: 'File Name',
},
{
type: 'row',
fields: [
{
name: 'format',
type: 'select',
admin: {
width: '33%',
},
defaultValue: 'csv',
label: 'Export Format',
options: [
{
label: 'CSV',
value: 'csv',
},
{
label: 'JSON',
value: 'json',
},
],
required: true,
},
{
name: 'limit',
type: 'number',
admin: {
placeholder: 'No limit',
width: '33%',
},
},
{
name: 'sort',
type: 'text',
admin: {
components: {
Field: '@payloadcms/plugin-import-export/rsc#SortBy',
},
},
},
],
},
{
type: 'row',
fields: [
...(localeField ? [localeField] : []),
{
name: 'drafts',
type: 'select',
admin: {
condition: (data) => {
const collectionConfig = (config.collections ?? []).find(
(collection) => collection.slug === data.collectionSlug,
)
return Boolean(
typeof collectionConfig?.versions === 'object' &&
collectionConfig?.versions?.drafts,
)
},
width: '33%',
},
defaultValue: 'true',
label: 'Drafts',
options: [
{
label: 'True',
value: 'true',
},
{
label: 'False',
value: 'false',
},
],
},
// {
// name: 'depth',
// type: 'number',
// admin: {
// width: '33%',
// },
// defaultValue: 1,
// required: true,
// },
],
},
{
// virtual field for the UI component to modify the hidden `where` field
name: 'selectionToUse',
type: 'radio',
defaultValue: 'all',
options: [
{
label: 'Use current selection',
value: 'currentSelection',
},
{
label: 'Use current filters',
value: 'currentFilters',
},
{
label: 'Use all documents',
value: 'all',
},
],
virtual: true,
},
{
name: 'fields',
type: 'text',
admin: {
components: {
Field: '@payloadcms/plugin-import-export/rsc#FieldsToExport',
},
},
hasMany: true,
},
{
name: 'collectionSlug',
type: 'text',
admin: {
components: {
Field: '@payloadcms/plugin-import-export/rsc#CollectionField',
},
hidden: true,
},
required: true,
},
{
name: 'where',
type: 'json',
admin: {
components: {
Field: '@payloadcms/plugin-import-export/rsc#WhereField',
},
},
defaultValue: {},
},
],
label: 'Export Options',
},
{
name: 'preview',
type: 'ui',
admin: {
components: {
Field: '@payloadcms/plugin-import-export/rsc#Preview',
},
},
},
]
}

View File

@@ -0,0 +1,7 @@
export const getFilename = () => {
const now = new Date()
const yyymmdd = now.toISOString().split('T')[0] // "YYYY-MM-DD"
const hhmmss = now.toTimeString().split(' ')[0] // "HH:MM:SS"
return `${yyymmdd} ${hhmmss}`
}

View File

@@ -0,0 +1,31 @@
import type { SelectType } from 'payload'
/**
* Takes an input of array of string paths in dot notation and returns a select object
* example args: ['id', 'title', 'group.value', 'createdAt', 'updatedAt']
*/
export const getSelect = (fields: string[]): SelectType => {
const select: SelectType = {}
fields.forEach((field) => {
// TODO: this can likely be removed, the form was not saving, leaving in for now
if (!field) {
return
}
const segments = field.split('.')
let selectRef = select
segments.forEach((segment, i) => {
if (i === segments.length - 1) {
selectRef[segment] = true
} else {
if (!selectRef[segment]) {
selectRef[segment] = {}
}
selectRef = selectRef[segment] as SelectType
}
})
})
return select
}

View File

@@ -0,0 +1,8 @@
export { CollectionField } from '../components/CollectionField/index.js'
export { ExportListMenuItem } from '../components/ExportListMenuItem/index.js'
export { ExportSaveButton } from '../components/ExportSaveButton/index.js'
export { FieldsToExport } from '../components/FieldsToExport/index.js'
export { ImportExportProvider } from '../components/ImportExportProvider/index.js'
export { Preview } from '../components/Preview/index.js'
export { SortBy } from '../components/SortBy/index.js'
export { WhereField } from '../components/WhereField/index.js'

View File

@@ -0,0 +1 @@
export type { ImportExportPluginConfig } from '../types.js'

View File

@@ -0,0 +1,88 @@
import type {
CollectionAfterChangeHook,
CollectionBeforeChangeHook,
CollectionBeforeOperationHook,
CollectionConfig,
Config,
} from 'payload'
import type { CollectionOverride, ImportExportPluginConfig } from './types.js'
import { createExport } from './export/createExport.js'
import { download } from './export/download.js'
import { getFields } from './export/getFields.js'
export const getExportCollection = ({
config,
pluginConfig,
}: {
config: Config
pluginConfig: ImportExportPluginConfig
}): CollectionConfig => {
const { overrideExportCollection } = pluginConfig
const beforeOperation: CollectionBeforeOperationHook[] = []
const afterChange: CollectionAfterChangeHook[] = []
let collection: CollectionOverride = {
slug: 'exports',
access: {
update: () => false,
},
admin: {
group: false,
useAsTitle: 'name',
},
disableDuplicate: true,
endpoints: [
{
handler: download,
method: 'post',
path: '/download',
},
],
fields: getFields(config),
hooks: {
afterChange,
beforeOperation,
},
upload: {
filesRequiredOnCreate: false,
hideFileInputOnCreate: true,
hideRemoveFile: true,
},
}
if (typeof overrideExportCollection === 'function') {
collection = overrideExportCollection(collection)
}
if (pluginConfig.disableJobsQueue) {
beforeOperation.push(async ({ args, operation, req }) => {
if (operation !== 'create') {
return
}
const { user } = req
await createExport({ input: { ...args.data, user }, req })
})
} else {
afterChange.push(async ({ doc, operation, req }) => {
if (operation !== 'create') {
return
}
const input = {
...doc,
exportsCollection: collection.slug,
user: req?.user?.id || req?.user?.user?.id,
userCollection: 'users',
}
await req.payload.jobs.queue({
input,
task: 'createCollectionExport',
})
})
}
return collection
}

View File

@@ -0,0 +1,72 @@
import type { Config, JobsConfig } from 'payload'
import { deepMergeSimple } from 'payload'
import type { ImportExportPluginConfig } from './types.js'
import { getCreateCollectionExportTask } from './export/getCreateExportCollectionTask.js'
import { getExportCollection } from './getExportCollection.js'
import { translations } from './translations/index.js'
export const importExportPlugin =
(pluginConfig: ImportExportPluginConfig) =>
(config: Config): Config => {
const exportCollection = getExportCollection({ config, pluginConfig })
if (config.collections) {
config.collections.push(exportCollection)
} else {
config.collections = [exportCollection]
}
// inject custom import export provider
config.admin = config.admin || {}
config.admin.components = config.admin.components || {}
config.admin.components.providers = config.admin.components.providers || []
config.admin.components.providers.push(
'@payloadcms/plugin-import-export/rsc#ImportExportProvider',
)
// inject the createExport job into the config
config.jobs =
config.jobs ||
({
tasks: [getCreateCollectionExportTask(config)],
} as unknown as JobsConfig) // cannot type jobs config inside of plugins
let collectionsToUpdate = config.collections
const usePluginCollections = pluginConfig.collections && pluginConfig.collections?.length > 0
if (usePluginCollections) {
collectionsToUpdate = config.collections?.filter((collection) => {
return pluginConfig.collections?.includes(collection.slug)
})
}
collectionsToUpdate.forEach((collection) => {
if (!collection.admin) {
collection.admin = { components: { listMenuItems: [] } }
}
const components = collection.admin.components || {}
if (!components.listMenuItems) {
components.listMenuItems = []
}
if (!components.edit) {
components.edit = {}
}
if (!components.edit.SaveButton) {
components.edit.SaveButton = '@payloadcms/plugin-import-export/rsc#ExportSaveButton'
}
components.listMenuItems.push({
clientProps: {
exportCollectionSlug: exportCollection.slug,
},
path: '@payloadcms/plugin-import-export/rsc#ExportListMenuItem',
})
collection.admin.components = components
})
config.i18n = deepMergeSimple(translations, config.i18n?.translations ?? {})
return config
}

View File

@@ -0,0 +1,9 @@
import type { GenericTranslationsObject } from '@payloadcms/translations'
export const en: GenericTranslationsObject = {
$schema: './translation-schema.json',
'plugin-seo': {
export: 'Export',
import: 'Import',
},
}

View File

@@ -0,0 +1,11 @@
import type { GenericTranslationsObject, NestedKeysStripped } from '@payloadcms/translations'
import { en } from './en.js'
export const translations = {
en,
}
export type PluginImportExportTranslations = GenericTranslationsObject
export type PluginImportExportTranslationKeys = NestedKeysStripped<PluginImportExportTranslations>

View File

@@ -0,0 +1,24 @@
{
"type": "object",
"$schema": "http://json-schema.org/draft-04/schema#",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string"
},
"plugin-import-export": {
"type": "object",
"additionalProperties": false,
"properties": {
"export": {
"type": "string"
},
"import": {
"type": "string"
}
},
"required": ["export", "import"]
}
},
"required": ["plugin-import-export"]
}

View File

@@ -0,0 +1,24 @@
import type { CollectionAdminOptions, CollectionConfig, UploadConfig } from 'payload'
export type CollectionOverride = {
admin: CollectionAdminOptions
upload: UploadConfig
} & CollectionConfig
export type ImportExportPluginConfig = {
/**
* Collections to include the Import/Export controls in
* Defaults to all collections
*/
collections?: string[]
/**
* Enable to force the export to run synchronously
*/
disableJobsQueue?: boolean
/**
* This function takes the default export collection configured in the plugin and allows you to override it by modifying and returning it
* @param collection
* @returns collection
*/
overrideExportCollection?: (collection: CollectionOverride) => CollectionOverride
}

View File

@@ -0,0 +1,25 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true, // Make sure typescript knows that this module depends on their references
"noEmit": false /* Do not emit outputs. */,
"emitDeclarationOnly": true,
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
"rootDir": "./src" /* Specify the root folder within your source files. */
},
"exclude": [
"dist",
"build",
"tests",
"test",
"node_modules",
"eslint.config.js",
"src/**/*.spec.js",
"src/**/*.spec.jsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/**/*.ts", "src/**/**/*.tsx", "src/**/*.d.ts", "src/**/*.json", ],
"references": [{ "path": "../payload" }, { "path": "../ui"}]
}

44
pnpm-lock.yaml generated
View File

@@ -1025,6 +1025,34 @@ importers:
specifier: workspace:* specifier: workspace:*
version: link:../payload version: link:../payload
packages/plugin-import-export:
dependencies:
'@faceless-ui/modal':
specifier: 3.0.0-beta.2
version: 3.0.0-beta.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@payloadcms/translations':
specifier: workspace:*
version: link:../translations
'@payloadcms/ui':
specifier: workspace:*
version: link:../ui
csv-parse:
specifier: ^5.6.0
version: 5.6.0
csv-stringify:
specifier: ^6.5.2
version: 6.5.2
qs-esm:
specifier: 7.0.2
version: 7.0.2
devDependencies:
'@payloadcms/eslint-config':
specifier: workspace:*
version: link:../eslint-config
payload:
specifier: workspace:*
version: link:../payload
packages/plugin-multi-tenant: packages/plugin-multi-tenant:
dependencies: dependencies:
next: next:
@@ -1677,6 +1705,9 @@ importers:
'@payloadcms/plugin-form-builder': '@payloadcms/plugin-form-builder':
specifier: workspace:* specifier: workspace:*
version: link:../packages/plugin-form-builder version: link:../packages/plugin-form-builder
'@payloadcms/plugin-import-export':
specifier: workspace:*
version: link:../packages/plugin-import-export
'@payloadcms/plugin-multi-tenant': '@payloadcms/plugin-multi-tenant':
specifier: workspace:* specifier: workspace:*
version: link:../packages/plugin-multi-tenant version: link:../packages/plugin-multi-tenant
@@ -1749,6 +1780,9 @@ importers:
create-payload-app: create-payload-app:
specifier: workspace:* specifier: workspace:*
version: link:../packages/create-payload-app version: link:../packages/create-payload-app
csv-parse:
specifier: ^5.6.0
version: 5.6.0
dequal: dequal:
specifier: 2.0.3 specifier: 2.0.3
version: 2.0.3 version: 2.0.3
@@ -6302,6 +6336,12 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csv-parse@5.6.0:
resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==}
csv-stringify@6.5.2:
resolution: {integrity: sha512-RFPahj0sXcmUyjrObAK+DOWtMvMIFV328n4qZJhgX3x2RqkQgOTU2mCUmiFR0CzM6AzChlRSUErjiJeEt8BaQA==}
damerau-levenshtein@1.0.8: damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -15963,6 +16003,10 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
csv-parse@5.6.0: {}
csv-stringify@6.5.2: {}
damerau-levenshtein@1.0.8: {} damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@4.0.1: {}

View File

@@ -1232,6 +1232,7 @@ export interface Auth {
declare module 'payload' { declare module 'payload' {
// @ts-ignore // @ts-ignore
export interface GeneratedTypes extends Config {} export interface GeneratedTypes extends Config {}
} }

View File

@@ -41,6 +41,7 @@
"@payloadcms/payload-cloud": "workspace:*", "@payloadcms/payload-cloud": "workspace:*",
"@payloadcms/plugin-cloud-storage": "workspace:*", "@payloadcms/plugin-cloud-storage": "workspace:*",
"@payloadcms/plugin-form-builder": "workspace:*", "@payloadcms/plugin-form-builder": "workspace:*",
"@payloadcms/plugin-import-export": "workspace:*",
"@payloadcms/plugin-multi-tenant": "workspace:*", "@payloadcms/plugin-multi-tenant": "workspace:*",
"@payloadcms/plugin-nested-docs": "workspace:*", "@payloadcms/plugin-nested-docs": "workspace:*",
"@payloadcms/plugin-redirects": "workspace:*", "@payloadcms/plugin-redirects": "workspace:*",
@@ -65,6 +66,7 @@
"babel-plugin-react-compiler": "19.0.0-beta-714736e-20250131", "babel-plugin-react-compiler": "19.0.0-beta-714736e-20250131",
"comment-json": "^4.2.3", "comment-json": "^4.2.3",
"create-payload-app": "workspace:*", "create-payload-app": "workspace:*",
"csv-parse": "^5.6.0",
"dequal": "2.0.3", "dequal": "2.0.3",
"dotenv": "16.4.7", "dotenv": "16.4.7",
"drizzle-kit": "0.28.0", "drizzle-kit": "0.28.0",

1
test/plugin-import-export/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
uploads

View File

@@ -0,0 +1,117 @@
import type { CollectionConfig } from 'payload'
import { pagesSlug } from '../shared.js'
export const Pages: CollectionConfig = {
slug: pagesSlug,
labels: {
singular: 'Page',
plural: 'Pages',
},
admin: {
useAsTitle: 'title',
},
versions: {
drafts: true,
},
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
},
{
name: 'localized',
type: 'text',
localized: true,
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'value',
type: 'text',
defaultValue: 'group value',
},
{
name: 'ignore',
type: 'text',
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'field1',
type: 'text',
},
{
name: 'field2',
type: 'text',
},
],
},
],
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'field1',
type: 'text',
},
{
name: 'field2',
type: 'text',
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'hero',
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'content',
fields: [
{
name: 'richText',
type: 'richText',
},
],
},
],
},
{
name: 'author',
type: 'relationship',
relationTo: 'users',
},
{
name: 'hasManyNumber',
type: 'number',
hasMany: true,
},
{
name: 'relationship',
type: 'relationship',
relationTo: 'users',
},
{
name: 'excerpt',
label: 'Excerpt',
type: 'text',
},
],
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
access: {
read: () => true,
},
fields: [
// Email added by default
// Add more fields as needed
],
}

View File

@@ -0,0 +1,51 @@
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import { importExportPlugin } from '@payloadcms/plugin-import-export'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Pages } from './collections/Pages.js'
import { Users } from './collections/Users.js'
import { seed } from './seed/index.js'
export default buildConfigWithDefaults({
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Pages],
localization: {
defaultLocale: 'en',
fallback: true,
locales: ['en', 'es', 'de'],
},
onInit: async (payload) => {
await seed(payload)
},
plugins: [
importExportPlugin({
overrideExportCollection: (collection) => {
collection.admin.group = 'System'
collection.upload.staticDir = path.resolve(dirname, 'uploads')
return collection
},
disableJobsQueue: true,
}),
importExportPlugin({
collections: ['pages'],
overrideExportCollection: (collection) => {
collection.slug = 'exports-tasks'
if (collection.admin) {
collection.admin.group = 'System'
}
collection.upload.staticDir = path.resolve(dirname, 'uploads')
return collection
},
}),
],
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,50 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import * as path from 'path'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import type { Config } from './payload-types.js'
import { ensureCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
test.describe('Import Export', () => {
let page: Page
let pagesURL: AdminUrlUtil
let payload: PayloadTestSDK<Config>
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
const { payload: payloadFromInit, serverURL } = await initPayloadE2ENoConfig<Config>({
dirname,
})
pagesURL = new AdminUrlUtil(serverURL, 'pages')
payload = payloadFromInit
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL })
})
test.describe('Import', () => {
test('works', async () => {
// TODO: write e2e tests
})
})
test.describe('Export', () => {
test('works', async () => {
// TODO: write e2e tests
})
})
})

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import testEslintConfig from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
...rootParserOptions,
},
},
},
]
export default index

View File

@@ -0,0 +1,51 @@
import { parse } from 'csv-parse'
import fs from 'fs'
export const readCSV = async (path: string): Promise<any[]> => {
const buffer = fs.readFileSync(path)
const data: any[] = []
const promise = new Promise<void>((resolve) => {
const parser = parse({ bom: true, columns: true })
// Collect data from the CSV
parser.on('readable', () => {
let record
while ((record = parser.read())) {
data.push(record)
}
})
// Resolve the promise on 'end'
parser.on('end', () => {
resolve()
})
// Handle errors (optional, but good practice)
parser.on('error', (error) => {
console.error('Error parsing CSV:', error)
resolve() // Ensures promise doesn't hang on error
})
// Pipe the buffer into the parser
parser.write(buffer)
parser.end()
})
// Await the promise
await promise
return data
}
export const readJSON = async (path: string): Promise<any[]> => {
const buffer = fs.readFileSync(path)
const str = buffer.toString()
try {
const json = await JSON.parse(str)
return json
} catch (error) {
console.error('Error reading JSON file:', error)
throw error
}
}

View File

@@ -0,0 +1,400 @@
import type { CollectionSlug, Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { readCSV, readJSON } from './helpers.js'
import { richTextData } from './seed/richTextData.js'
let payload: Payload
let user: any
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('@payloadcms/plugin-import-export', () => {
beforeAll(async () => {
;({ payload } = (await initPayloadInt(dirname)) as { payload: Payload })
user = await payload.login({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
describe('exports', () => {
it('should create a file for collection csv from defined fields', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
sort: 'createdAt',
fields: ['id', 'title', 'group.value', 'group.array.field1', 'createdAt', 'updatedAt'],
format: 'csv',
where: {
title: { contains: 'Title ' },
},
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toContain('pages.csv')
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].id).toBeDefined()
expect(data[0].title).toStrictEqual('Title 0')
expect(data[0].group_value).toStrictEqual('group value')
expect(data[0].group_ignore).toBeUndefined()
expect(data[0].group_array_0_field1).toStrictEqual('test')
expect(data[0].createdAt).toBeDefined()
expect(data[0].updatedAt).toBeDefined()
})
it('should create a file for collection csv from one locale', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'localized'],
locale: 'en',
format: 'csv',
where: {
title: { contains: 'Localized ' },
},
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].id).toBeDefined()
expect(data[0].localized).toStrictEqual('en test')
})
it('should create a file for collection csv from multiple locales', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'localized'],
locale: 'all',
format: 'csv',
where: {
title: { contains: 'Localized ' },
},
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].id).toBeDefined()
expect(data[0].localized_en).toStrictEqual('en test')
expect(data[0].localized_es).toStrictEqual('es test')
})
it('should create a file for collection csv from array', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'array'],
format: 'csv',
where: {
title: { contains: 'Array ' },
},
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].array_0_field1).toStrictEqual('foo')
expect(data[0].array_0_field2).toStrictEqual('bar')
expect(data[0].array_1_field1).toStrictEqual('foo')
expect(data[0].array_1_field2).toStrictEqual('baz')
})
it('should create a file for collection csv from array.subfield', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'array.field1'],
format: 'csv',
where: {
title: { contains: 'Array Subfield ' },
},
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].array_0_field1).toStrictEqual('foo')
expect(data[0].array_0_field2).toBeUndefined()
expect(data[0].array_1_field1).toStrictEqual('foo')
expect(data[0].array_1_field2).toBeUndefined()
})
it('should create a file for collection csv from hasMany field', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'hasManyNumber'],
format: 'csv',
where: {
title: { contains: 'hasMany Number ' },
},
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].hasManyNumber_0).toStrictEqual('0')
expect(data[0].hasManyNumber_1).toStrictEqual('1')
expect(data[0].hasManyNumber_2).toStrictEqual('1')
expect(data[0].hasManyNumber_3).toStrictEqual('2')
expect(data[0].hasManyNumber_4).toStrictEqual('3')
})
it('should create a file for collection csv from blocks field', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'blocks'],
format: 'csv',
where: {
title: { contains: 'Blocks ' },
},
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].blocks_0_blockType).toStrictEqual('hero')
expect(data[0].blocks_1_blockType).toStrictEqual('content')
})
it('should create a JSON file for collection', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'title'],
format: 'json',
sort: 'title',
where: {
title: { contains: 'JSON ' },
},
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readJSON(expectedPath)
expect(data[0].title).toStrictEqual('JSON 0')
})
it('should create an export with every field when no fields are defined', async () => {
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
format: 'json',
sort: 'title',
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readJSON(expectedPath)
expect(data[0].id).toBeDefined()
expect(data[0].title).toBeDefined()
expect(data[0].createdAt).toBeDefined()
expect(data[0].updatedAt).toBeDefined()
})
it('should create jobs task for exports', async () => {
const doc = await payload.create({
collection: 'exports-tasks' as CollectionSlug,
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'title'],
format: 'csv',
sort: 'title',
where: {
title: { contains: 'Jobs ' },
},
},
})
const { docs } = await payload.find({
collection: 'payload-jobs' as CollectionSlug,
})
const job = docs[0]
expect(job).toBeDefined()
const { input } = job
expect(input.id).toBeDefined()
expect(input.name).toBeDefined()
expect(input.format).toStrictEqual('csv')
expect(input.locale).toStrictEqual('all')
expect(input.fields).toStrictEqual(['id', 'title'])
expect(input.collectionSlug).toStrictEqual('pages')
expect(input.exportsCollection).toStrictEqual('exports-tasks')
expect(input.user).toBeDefined()
expect(input.userCollection).toBeDefined()
await payload.jobs.run()
const exportDoc = await payload.findByID({
collection: 'exports-tasks' as CollectionSlug,
id: doc.id,
})
expect(exportDoc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', exportDoc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].title).toStrictEqual('Jobs 0')
})
// disabled so we don't always run a massive test
it.skip('should create a file from a large set of collection documents', async () => {
const allPromises = []
let promises = []
for (let i = 0; i < 100000; i++) {
promises.push(
payload.create({
collectionSlug: 'pages',
data: {
title: `Array ${i}`,
blocks: [
{
blockType: 'hero',
title: 'test',
},
{
blockType: 'content',
richText: richTextData,
},
],
},
}),
)
if (promises.length >= 500) {
await Promise.all(promises)
promises = []
}
if (i % 1000 === 0) {
console.log('created', i)
}
}
await Promise.all(promises)
console.log('seeded')
let doc = await payload.create({
collection: 'exports',
user,
data: {
collectionSlug: 'pages',
fields: ['id', 'blocks'],
format: 'csv',
},
})
doc = await payload.findByID({
collection: 'exports',
id: doc.id,
})
expect(doc.filename).toBeDefined()
const expectedPath = path.join(dirname, './uploads', doc.filename as string)
const data = await readCSV(expectedPath)
expect(data[0].blocks_0_blockType).toStrictEqual('hero')
expect(data[0].blocks_1_blockType).toStrictEqual('content')
})
})
})

View File

@@ -0,0 +1,670 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
pages: Page;
exports: Export;
'exports-tasks': ExportsTask;
'payload-jobs': PayloadJob;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
exports: ExportsSelect<false> | ExportsSelect<true>;
'exports-tasks': ExportsTasksSelect<false> | ExportsTasksSelect<true>;
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: 'en' | 'es' | 'de';
user: User & {
collection: 'users';
};
jobs: {
tasks: {
createCollectionExport: TaskCreateCollectionExport;
inline: {
input: unknown;
output: unknown;
};
};
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages".
*/
export interface Page {
id: string;
title: string;
localized?: string | null;
group?: {
value?: string | null;
ignore?: string | null;
array?:
| {
field1?: string | null;
field2?: string | null;
id?: string | null;
}[]
| null;
};
array?:
| {
field1?: string | null;
field2?: string | null;
id?: string | null;
}[]
| null;
blocks?:
| (
| {
title?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'hero';
}
| {
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'content';
}
)[]
| null;
author?: (string | null) | User;
hasManyNumber?: number[] | null;
relationship?: (string | null) | User;
excerpt?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "exports".
*/
export interface Export {
id: string;
name?: string | null;
format: 'csv' | 'json';
limit?: number | null;
sort?: string | null;
locale?: ('all' | 'en' | 'es' | 'de') | null;
drafts?: ('true' | 'false') | null;
selectionToUse?: ('currentSelection' | 'currentFilters' | 'all') | null;
fields?: string[] | null;
collectionSlug: string;
where?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "exports-tasks".
*/
export interface ExportsTask {
id: string;
name?: string | null;
format: 'csv' | 'json';
limit?: number | null;
sort?: string | null;
locale?: ('all' | 'en' | 'es' | 'de') | null;
drafts?: ('true' | 'false') | null;
selectionToUse?: ('currentSelection' | 'currentFilters' | 'all') | null;
fields?: string[] | null;
collectionSlug: string;
where?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-jobs".
*/
export interface PayloadJob {
id: string;
/**
* Input data provided to the job
*/
input?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
taskStatus?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
completedAt?: string | null;
totalTried?: number | null;
/**
* If hasError is true this job will not be retried
*/
hasError?: boolean | null;
/**
* If hasError is true, this is the error that caused it
*/
error?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Task execution log
*/
log?:
| {
executedAt: string;
completedAt: string;
taskSlug: 'inline' | 'createCollectionExport';
taskID: string;
input?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
output?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
state: 'failed' | 'succeeded';
error?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
id?: string | null;
}[]
| null;
taskSlug?: ('inline' | 'createCollectionExport') | null;
queue?: string | null;
waitUntil?: string | null;
processing?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'users';
value: string | User;
} | null)
| ({
relationTo: 'pages';
value: string | Page;
} | null)
| ({
relationTo: 'exports';
value: string | Export;
} | null)
| ({
relationTo: 'exports-tasks';
value: string | ExportsTask;
} | null)
| ({
relationTo: 'payload-jobs';
value: string | PayloadJob;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
title?: T;
localized?: T;
group?:
| T
| {
value?: T;
ignore?: T;
array?:
| T
| {
field1?: T;
field2?: T;
id?: T;
};
};
array?:
| T
| {
field1?: T;
field2?: T;
id?: T;
};
blocks?:
| T
| {
hero?:
| T
| {
title?: T;
id?: T;
blockName?: T;
};
content?:
| T
| {
richText?: T;
id?: T;
blockName?: T;
};
};
author?: T;
hasManyNumber?: T;
relationship?: T;
excerpt?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "exports_select".
*/
export interface ExportsSelect<T extends boolean = true> {
name?: T;
format?: T;
limit?: T;
sort?: T;
locale?: T;
drafts?: T;
selectionToUse?: T;
fields?: T;
collectionSlug?: T;
where?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "exports-tasks_select".
*/
export interface ExportsTasksSelect<T extends boolean = true> {
name?: T;
format?: T;
limit?: T;
sort?: T;
locale?: T;
drafts?: T;
selectionToUse?: T;
fields?: T;
collectionSlug?: T;
where?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-jobs_select".
*/
export interface PayloadJobsSelect<T extends boolean = true> {
input?: T;
taskStatus?: T;
completedAt?: T;
totalTried?: T;
hasError?: T;
error?: T;
log?:
| T
| {
executedAt?: T;
completedAt?: T;
taskSlug?: T;
taskID?: T;
input?: T;
output?: T;
state?: T;
error?: T;
id?: T;
};
taskSlug?: T;
queue?: T;
waitUntil?: T;
processing?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskCreateCollectionExport".
*/
export interface TaskCreateCollectionExport {
input: {
name?: string | null;
format: 'csv' | 'json';
limit?: number | null;
sort?: string | null;
locale?: ('all' | 'en' | 'es' | 'de') | null;
drafts?: ('true' | 'false') | null;
selectionToUse?: ('currentSelection' | 'currentFilters' | 'all') | null;
fields?: string[] | null;
collectionSlug: string;
where?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
user?: string | null;
userCollection?: string | null;
exportsCollection?: string | null;
};
output: {
success?: boolean | null;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

View File

@@ -0,0 +1,135 @@
import type { Payload } from 'payload'
import { devUser } from '../../credentials.js'
import { richTextData } from './richTextData.js'
export const seed = async (payload: Payload): Promise<boolean> => {
payload.logger.info('Seeding data...')
try {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
// create pages
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Title ${i}`,
group: {
array: [{ field1: 'test' }],
},
},
})
}
for (let i = 0; i < 5; i++) {
const doc = await payload.create({
collection: 'pages',
data: {
title: `Localized ${i}`,
localized: 'en test',
},
locale: 'en',
})
await payload.update({
collection: 'pages',
id: doc.id,
data: {
localized: 'es test',
},
locale: 'es',
})
}
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Array ${i}`,
array: [
{
field1: 'foo',
field2: 'bar',
},
{
field1: 'foo',
field2: 'baz',
},
],
},
})
}
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Array Subfield ${i}`,
array: [
{
field1: 'foo',
field2: 'bar',
},
{
field1: 'foo',
field2: 'baz',
},
],
},
})
}
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `hasMany Number ${i}`,
hasManyNumber: [0, 1, 1, 2, 3, 5, 8, 13, 21],
},
})
}
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Blocks ${i}`,
blocks: [
{
blockType: 'hero',
title: 'test',
},
{
blockType: 'content',
richText: richTextData,
},
],
},
})
}
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `JSON ${i}`,
},
})
}
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Jobs ${i}`,
},
})
}
return true
} catch (err) {
console.error(err)
return false
}
}

View File

@@ -0,0 +1,110 @@
export const richTextData = {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'This is some content in a block',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h2',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Paragraph of text',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Testing ',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Richtext',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'number',
start: 1,
tag: 'ol',
},
{
children: [],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
}

View File

@@ -0,0 +1 @@
export const pagesSlug = 'pages'

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

View File

@@ -23,6 +23,7 @@ export const tgzToPkgNameMap = {
'@payloadcms/payload-cloud': 'payloadcms-payload-cloud-*', '@payloadcms/payload-cloud': 'payloadcms-payload-cloud-*',
'@payloadcms/plugin-cloud-storage': 'payloadcms-plugin-cloud-storage-*', '@payloadcms/plugin-cloud-storage': 'payloadcms-plugin-cloud-storage-*',
'@payloadcms/plugin-form-builder': 'payloadcms-plugin-form-builder-*', '@payloadcms/plugin-form-builder': 'payloadcms-plugin-form-builder-*',
'@payloadcms/plugin-import-export': 'payloadcms-plugin-import-export-*',
'@payloadcms/plugin-multi-tenant': 'payloadcms-plugin-multi-tenant-*', '@payloadcms/plugin-multi-tenant': 'payloadcms-plugin-multi-tenant-*',
'@payloadcms/plugin-nested-docs': 'payloadcms-plugin-nested-docs-*', '@payloadcms/plugin-nested-docs': 'payloadcms-plugin-nested-docs-*',
'@payloadcms/plugin-redirects': 'payloadcms-plugin-redirects-*', '@payloadcms/plugin-redirects': 'payloadcms-plugin-redirects-*',

View File

@@ -31,7 +31,7 @@
} }
], ],
"paths": { "paths": {
"@payload-config": ["./test/_community/config.ts"], "@payload-config": ["./test/plugin-import-export/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
@@ -55,6 +55,9 @@
"@payloadcms/plugin-form-builder/client": [ "@payloadcms/plugin-form-builder/client": [
"./packages/plugin-form-builder/src/exports/client.ts" "./packages/plugin-form-builder/src/exports/client.ts"
], ],
"@payloadcms/plugin-import-export/rsc": [
"./packages/plugin-import-export/src/exports/rsc.ts"
],
"@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"],
"@payloadcms/plugin-multi-tenant/utilities": [ "@payloadcms/plugin-multi-tenant/utilities": [
"./packages/plugin-multi-tenant/src/exports/utilities.ts" "./packages/plugin-multi-tenant/src/exports/utilities.ts"
@@ -68,13 +71,11 @@
"@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"],
"@payloadcms/next": ["./packages/next/src/exports/*"], "@payloadcms/next": ["./packages/next/src/exports/*"],
"@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], "@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"],
"@payloadcms/storage-vercel-blob/client": [ "@payloadcms/storage-vercel-blob/client": ["./packages/storage-vercel-blob/src/exports/client.ts"
"./packages/storage-vercel-blob/src/exports/client.ts"
], ],
"@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"], "@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"],
"@payloadcms/storage-uploadthing/client": [ "@payloadcms/storage-uploadthing/client": [
"./packages/storage-uploadthing/src/exports/client.ts" "./packages/storage-uploadthing/src/exports/client.ts"]
]
} }
}, },
"include": ["${configDir}/src"], "include": ["${configDir}/src"],

View File

@@ -61,6 +61,9 @@
{ {
"path": "./packages/plugin-seo" "path": "./packages/plugin-seo"
}, },
{
"path": "./packages/plugin-import-export"
},
{ {
"path": "./packages/plugin-stripe" "path": "./packages/plugin-stripe"
}, },