Compare commits

...

41 Commits

Author SHA1 Message Date
Kendell Joseph
ebfc4b5936 chore: WIP changes 2025-02-06 16:00:12 -05:00
Kendell Joseph
fb90315307 chore: adds exportsCollection to input to support export collection slug overrides 2025-02-06 13:57:36 -05:00
Kendell Joseph
2418d56824 chore: corrects type warning 2025-02-06 13:57:05 -05:00
Kendell Joseph
5ea3c276c1 chore: implements export to JSON format 2025-02-06 13:55:38 -05:00
Kendell Joseph
3f12fdfa8c chore: sends the exports collection slug to the task 2025-02-06 13:52:44 -05:00
Kendell Joseph
2866a2d006 chore: adds test for JSON export format 2025-02-06 13:51:24 -05:00
Jessica Chowdhury
be6afb5e8f chore: make selectionToUse into state 2025-02-06 13:56:10 +00:00
Jessica Chowdhury
83eb2cebdb chore: updates fields in export drawer 2025-02-06 12:24:22 +00:00
Kendell Joseph
3fb057a2dc chore: adds where clause test 2025-02-05 13:13:43 -05:00
Kendell Joseph
93062f6e37 chore: adds where clause option 2025-02-05 13:11:46 -05:00
Kendell Joseph
b924ca9f5f chore: updates Export type 2025-02-05 13:07:36 -05:00
Jessica Chowdhury
3a420a6e4e chore: fix pnpm lock file 2025-02-05 15:12:13 +00:00
Jessica Chowdhury
66f8029873 Merge branch 'main' into feat/export-ui 2025-02-05 15:11:35 +00:00
Jessica Chowdhury
4368560b0d chore: use export collection fields in export drawer 2025-02-05 14:57:04 +00:00
Jessica Chowdhury
88ee2aab04 chore: merge with main 2025-02-05 13:06:10 +00:00
Dan Ribbens
094157aaf0 feat: jobs queue flag 2025-02-04 17:12:15 -05:00
Jessica Chowdhury
868daeb03b chore(ui): fix conditional check in listControls 2025-02-04 17:41:38 +00:00
Jessica Chowdhury
66d69cbb8f chore(ui): renders kebab menu containing admin.components.afterListControls 2025-02-04 17:06:43 +00:00
Jessica Chowdhury
b67bea0011 feat(ui): adds admin.components.afterListControls option 2025-02-04 16:38:26 +00:00
Jessica Chowdhury
a8ef7869a4 chore: add export drawer and update tsconfig 2025-02-04 16:04:44 +00:00
Jessica Chowdhury
4bbf6ab193 chore: move export button into plugin and misc updates 2025-02-04 14:19:26 +00:00
Dan Ribbens
1b145ff28f feat: exports file names 2025-02-03 16:15:43 -05:00
Dan Ribbens
7fe74f0c67 test: blocks 2025-02-03 16:14:56 -05:00
Dan Ribbens
f0dc521718 fix: paginate find chunks 2025-02-03 15:50:21 -05:00
Dan Ribbens
1ce8eb5af8 test: recursion of data 2025-01-29 15:37:37 -05:00
Dan Ribbens
033cd8c7d9 feat: recursion of data 2025-01-29 15:08:57 -05:00
Dan Ribbens
ebc8ea62af feat: flattened array subfield data 2025-01-28 17:01:09 -05:00
Dan Ribbens
25be18a023 feat: flatten csv array data 2025-01-28 16:44:14 -05:00
Dan Ribbens
bf9b9c7b3b test(plugin-import-export): e2e setup 2025-01-24 16:26:13 -05:00
Dan Ribbens
8436b0ba68 feat(plugin-import-export): collection to csv 2025-01-24 15:52:13 -05:00
Dan Ribbens
11b2c3ebb6 feat(plugin-import-export): collection to csv 2025-01-24 15:18:55 -05:00
Dan Ribbens
57800b26da chore(plugin-import-export): test .gitignore 2025-01-24 15:16:16 -05:00
Dan Ribbens
fa63ead3ab merge main 2025-01-23 15:22:58 -05:00
Dan Ribbens
c5b4333fbd Merge branch 'main' into feat/export-ui 2025-01-15 14:54:08 -05:00
Dan Ribbens
bfbc5f4d7b Merge branch 'main' into feat/export-ui 2025-01-04 14:41:13 -05:00
Dan Ribbens
95bac9b5dd feat: WIP csv exports 2024-12-27 16:51:02 -05:00
Dan Ribbens
9db0603229 merge main 2024-12-27 12:13:20 -05:00
Dan Ribbens
3e76465f77 chore(plugin-import-export): e2e ci 2024-12-13 11:42:01 -05:00
Dan Ribbens
b75ac5692f test(plugin-import-export): scaffolding plugin test 2024-12-13 11:31:55 -05:00
Dan Ribbens
7f36c138cd chore(plugin-import-export): scaffolding plugin 2024-12-13 11:28:36 -05:00
Jessica Chowdhury
299f1459e0 feat: adds export button and drawer to list view 2024-12-12 17:02:33 +00:00
65 changed files with 3224 additions and 25 deletions

View File

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

View File

@@ -33,6 +33,7 @@
"build:payload-cloud": "turbo build --filter \"@payloadcms/payload-cloud\"",
"build:plugin-cloud-storage": "turbo build --filter \"@payloadcms/plugin-cloud-storage\"",
"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-nested-docs": "turbo build --filter \"@payloadcms/plugin-nested-docs\"",
"build:plugin-redirects": "turbo build --filter \"@payloadcms/plugin-redirects\"",

View File

@@ -33,6 +33,15 @@ export const renderListViewSlots = ({
})
}
if (collectionConfig.admin.components?.afterListControls) {
result.AfterListControls = RenderServerComponent({
clientProps,
Component: collectionConfig.admin.components.afterListControls,
importMap: payload.importMap,
serverProps,
})
}
if (collectionConfig.admin.components?.afterListTable) {
result.AfterListTable = RenderServerComponent({
clientProps,

View File

@@ -30,6 +30,7 @@ export function iterateCollections({
})
addToImportMap(collection.admin?.components?.afterList)
addToImportMap(collection.admin?.components?.afterListControls)
addToImportMap(collection.admin?.components?.afterListTable)
addToImportMap(collection.admin?.components?.beforeList)
addToImportMap(collection.admin?.components?.beforeListTable)

View File

@@ -276,6 +276,7 @@ export type CollectionAdminOptions = {
*/
components?: {
afterList?: CustomComponent[]
afterListControls?: CustomComponent[]
afterListTable?: CustomComponent[]
beforeList?: CustomComponent[]
beforeListTable?: CustomComponent[]

View File

@@ -9,7 +9,7 @@ const traverseArrayOrBlocksField = ({
fillEmpty,
parentRef,
}: {
callback: TraverseFieldsCallback
callback: TraverseFieldsCallback<unknown>
data: Record<string, unknown>[]
field: ArrayField | BlocksField
fillEmpty: boolean
@@ -41,7 +41,7 @@ const traverseArrayOrBlocksField = ({
}
}
export type TraverseFieldsCallback = (args: {
export type TraverseFieldsCallback<T = unknown> = (args: {
/**
* The current field
*/
@@ -53,19 +53,19 @@ export type TraverseFieldsCallback = (args: {
/**
* The parent reference object
*/
parentRef?: Record<string, unknown> | unknown
parentRef: Record<string, T> | T
/**
* The current reference object
*/
ref?: Record<string, unknown> | unknown
ref: Record<string, T> | T
}) => boolean | void
type TraverseFieldsArgs = {
callback: TraverseFieldsCallback
type TraverseFieldsArgs<T = unknown> = {
callback: TraverseFieldsCallback<T>
fields: (Field | TabAsField)[]
fillEmpty?: boolean
parentRef?: Record<string, unknown> | unknown
ref?: Record<string, unknown> | unknown
parentRef?: Record<string, T> | T
ref?: Record<string, T> | T
}
/**
@@ -77,13 +77,13 @@ type TraverseFieldsArgs = {
* @param ref the data or any artifacts assigned in the callback during field recursion
* @param parentRef the data or any artifacts assigned in the callback during field recursion one level up
*/
export const traverseFields = ({
export const traverseFields = <T = unknown>({
callback,
fields,
fillEmpty = true,
parentRef = {},
ref = {},
}: TraverseFieldsArgs): void => {
}: TraverseFieldsArgs<T>): void => {
fields.some((field) => {
let skip = false
const next = () => {

View File

@@ -13,6 +13,7 @@ export const PAYLOAD_PACKAGE_LIST = [
'@payloadcms/plugin-cloud-storage',
'@payloadcms/payload-cloud',
'@payloadcms/plugin-form-builder',
'@payloadcms/plugin-import-export',
// '@payloadcms/plugin-multi-tenant',
'@payloadcms/plugin-nested-docs',
'@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,17 @@
# 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)
[//]: # 'TODO: Remove requirements'
## Requirements
### Exports
- [ ] The export button should be visible on the collection list.
Create writable streams for each collection and write the data to the streams. The streams should be piped to a zip stream and sent to the client.

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,100 @@
{
"name": "@payloadcms/plugin-import-export",
"version": "3.6.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"
},
"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,27 @@
@import '../../scss/styles';
@layer payload-default {
.dots {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 2px;
background-color: var(--theme-elevation-150);
border-radius: $style-radius-m;
height: calc(var(--base) * 1.2);
width: calc(var(--base) * 1.2);
&:hover {
background-color: var(--theme-elevation-100);
}
> div {
width: 2.5px;
height: 2.5px;
border-radius: 100%;
background-color: currentColor;
}
}
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import './index.scss'
export const Dots: React.FC<{ className?: string }> = ({ className }) => (
<div className={[className && className, 'dots'].filter(Boolean).join(' ')}>
<div />
<div />
<div />
</div>
)

View File

@@ -0,0 +1,5 @@
.export-button {
cursor: pointer;
width: 100%;
text-align: left;
}

View File

@@ -0,0 +1,28 @@
'use client'
import { useModal } from '@payloadcms/ui'
import React from 'react'
import { ExportDrawer } from '../ExportDrawer/index.js'
import './index.scss'
const baseClass = 'export-button'
export const ExportButton: React.FC<{ collectionSlug: string; exportCollectionSlug: string }> = ({
collectionSlug,
exportCollectionSlug,
}) => {
const { toggleModal } = useModal()
const exportDrawerSlug = `export-${collectionSlug}`
return (
<React.Fragment>
{/* <button className={baseClass} onClick={() => toggleModal(exportDrawerSlug)} type="button">
Export
</button> */}
<ExportDrawer
collectionSlug={collectionSlug}
drawerSlug={exportDrawerSlug}
exportCollectionSlug={exportCollectionSlug}
/>
</React.Fragment>
)
}

View File

@@ -0,0 +1,78 @@
import type { CollectionConfig, FormState, SanitizedLocalizationConfig } from 'payload'
import { getFilename } from '../../export/getFilename.js'
export const useInitialState = ({
collectionConfig,
localization,
}: {
collectionConfig: CollectionConfig
localization?: any
}): FormState => {
const filename = getFilename()
const locales: string[] =
localization?.localeCodes ||
(localization?.locales
? localization.locales.map((locale: { label: Record<string, string> | string } | string) =>
typeof locale === 'string'
? locale
: typeof locale.label === 'string'
? locale.label
: '',
)
: []) ||
[]
const columns = collectionConfig.fields
.map((field) => ('name' in field ? field.name : null))
.filter(Boolean)
return {
name: {
initialValue: filename,
valid: true,
value: filename,
},
columnsToExport: {
initialValue: columns,
valid: true,
value: columns,
},
depth: {
initialValue: 1,
valid: true,
value: 1,
},
drafts: {
initialValue: 'false',
valid: true,
value: 'false',
},
format: {
initialValue: 'csv',
valid: true,
value: 'csv',
},
limit: {
initialValue: 100,
valid: true,
value: 100,
},
locales: {
initialValue: locales,
valid: true,
value: locales,
},
selectionToUse: {
initialValue: 'currentSelection',
valid: true,
value: 'currentSelection',
},
sort: {
initialValue: ['ID'],
valid: true,
value: ['ID'],
},
}
}

View File

@@ -0,0 +1,44 @@
@import '~@payloadcms/ui/scss';
@layer payload-default {
.export-drawer {
&__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,153 @@
'use client'
import type { FormProps } from '@payloadcms/ui'
import type { ClientField, CollectionConfig } from 'payload'
import { useModal } from '@faceless-ui/modal'
import {
Collapsible,
Drawer,
Form,
FormSubmit,
PlusIcon,
RenderFields,
useConfig,
useDocumentDrawer,
useSelection,
useServerFunctions,
XIcon,
} from '@payloadcms/ui'
import './index.scss'
import React, { Fragment } from 'react'
import { modifyFields } from '../../export/modifyFields.js'
import { fields } from '../../exportFields.js'
import { useInitialState } from './exportFields.js'
const baseClass = 'export-drawer'
export const ExportDrawer: React.FC<{
collectionSlug: string
drawerSlug: string
exportCollectionSlug: string
}> = ({ collectionSlug, drawerSlug, exportCollectionSlug }) => {
const [DocumentDrawer, DocumentDrawerToggler, { isDrawerOpen, toggleDrawer }] = useDocumentDrawer(
{
collectionSlug: exportCollectionSlug,
},
)
const { toggleModal } = useModal()
const { getFormState } = useServerFunctions()
const [selectionToUse, setSelectionToUse] = React.useState('')
const {
config: { collections, localization },
} = useConfig()
const collectionConfig =
(collections.find((collection) => collection.slug === collectionSlug) as CollectionConfig) || {}
const collectionLabel = collectionConfig.labels
? collectionConfig.labels.singular
: collectionSlug || 'Collection'
const onSuccess = React.useCallback(() => {
console.log('Exported')
toggleModal(drawerSlug)
}, [toggleModal, drawerSlug])
const exportFields = modifyFields(fields, collectionConfig) as ClientField[]
const initialState = useInitialState({ collectionConfig, localization })
const onChange: FormProps['onChange'][0] = React.useCallback(
(formData: any) => {
const currentSelection = formData.formState.selectionToUse.value
if (currentSelection !== selectionToUse) {
setSelectionToUse(currentSelection)
}
console.log('formData', formData)
},
[setSelectionToUse],
)
const onSubmit = React.useCallback(() => {
console.log('Submit')
}, [])
const selectedDocs = []
const selection = useSelection()
selection.selected.forEach((value, key) => {
if (value === true) {
selectedDocs.push(key)
}
})
return (
<Fragment>
<DocumentDrawerToggler
// className={[`${baseClass}__add-button`].filter(Boolean).join(' ')}
// onClick={() => setShowTooltip(false)}
// onMouseEnter={() => setShowTooltip(true)}
// onMouseLeave={() => setShowTooltip(false)}
>
Export
</DocumentDrawerToggler>
<DocumentDrawer
// onSave={onSave}
/>
</Fragment>
// <Drawer
// className={baseClass}
// gutter={false}
// Header={
// <div className={`${baseClass}__header`}>
// <h2>{`Export ${collectionLabel}`}</h2>
// <button
// className={`${baseClass}__close`}
// onClick={() => toggleModal(drawerSlug)}
// type="button"
// >
// <XIcon className={`${baseClass}__icon`} />
// </button>
// </div>
// }
// slug={drawerSlug}
// >
// <div className={`${baseClass}__subheader`}>
// <div>Creating export{collectionLabel ? ` from ${collectionLabel}` : ''}</div>
// <FormSubmit onClick={onSubmit}>Export</FormSubmit>
// </div>
// <div className={`${baseClass}__options`}>
// <Collapsible header="Export options">
// <Form
// action={'/admin'}
// initialState={initialState}
// method="POST"
// onChange={[onChange]}
// onSuccess={onSuccess}
// >
// <RenderFields
// fields={exportFields}
// parentIndexPath=""
// parentPath=""
// parentSchemaPath=""
// permissions={true}
// />
// </Form>
// </Collapsible>
// </div>
// <div className={`${baseClass}__preview`}>
// <div className={`${baseClass}__preview-title`}>
// <h3>Preview</h3>
// <span>(result count) total documents</span>
// </div>
// (data preview here)
// {/* if selectionToUse is current selection, return only the selected docs */}
// {/* if selectionToUse is all, return all docs and apply export settings */}
// {/* if selectionToUse is current filters, add filters to all docs and export settings */}
// </div>
// </Drawer>
)
}

View File

@@ -0,0 +1,117 @@
import type { PaginatedDocs, PayloadRequest, Sort, User, Where } from 'payload'
import { Buffer } from 'buffer'
import { stringify } from 'csv-stringify/sync'
import { APIError } from 'payload'
import { flattenObject } from './flattenObject.js'
import { getFilename } from './getFilename.js'
import { getSelect } from './getSelect.js'
type Export = {
collections: {
fields?: string[]
slug: string
sort: Sort
where?: Where
}[]
exportsCollection: string
format: 'csv' | 'json'
globals?: string[]
id: number | string
name: string
user: string
userCollection: string
}
export type CreateExportArgs = {
input: Export
req: PayloadRequest
user?: User
}
export const createExport = async (args: CreateExportArgs) => {
const {
input: { id, name: nameArg, collections = [], exportsCollection, format, user },
req: { locale, payload },
req,
} = args
if (collections.length === 1) {
const { slug, fields, sort, where } = collections[0] as Export['collections'][0]
const collection = payload.config.collections.find((collection) => collection.slug === slug)
if (!collection) {
throw new Error(`Collection with slug ${slug} not found`)
}
const name = nameArg ?? `${getFilename()}-${collection.slug}`
if (!fields) {
throw new APIError('fields must be defined when exporting')
}
const findArgs = {
collection: slug,
depth: 0,
limit: 100,
locale,
overrideAccess: false,
page: 0,
select: getSelect(fields),
sort,
user,
where,
}
let result: PaginatedDocs = { hasNextPage: true } as PaginatedDocs
const outputData: string[] = []
let isFirstBatch = true
while (result.hasNextPage) {
findArgs.page = findArgs.page + 1
result = await payload.find(findArgs)
if (format === 'csv') {
const csvInput = result.docs.map((doc) => flattenObject(doc))
const csvString = stringify(csvInput, {
header: isFirstBatch, // Only include header in the first batch
})
outputData.push(csvString)
isFirstBatch = false
}
if (format === 'json') {
const jsonInput = result.docs.map((doc) => JSON.stringify(doc))
outputData.push(jsonInput.join(',\n'))
}
}
const buffer = Buffer.from(
format === 'json' ? `[${outputData.join(',')}]` : outputData.join(''),
)
// when `disableJobsQueue` is true, the export is created synchronously in a beforeOperation hook
if (!id) {
req.file = {
name: `${name}-${collection.slug}.${format}`,
data: buffer,
mimetype: format === 'json' ? 'application/json' : `text/${format}`,
size: buffer.length,
}
} else {
await req.payload.update({
id,
collection: exportsCollection,
data: {},
file: {
name: `${name}.${format}`,
data: buffer,
mimetype: format === 'json' ? 'application/json' : `text/${format}`,
size: buffer.length,
},
})
}
}
}

View File

@@ -0,0 +1,53 @@
import type { TaskHandler, User } from 'payload'
import type { CreateExportArgs } from './createExport.js'
import { fields } from '../exportFields.js'
import { createExport } from './createExport.js'
const inputSchema = fields.concat(
{
name: 'user',
type: 'text',
},
{
name: 'userCollection',
type: 'text',
},
{
name: 'exportsCollection',
type: 'text',
},
)
export const createCollectionExportTask: TaskHandler<any, string> = {
// @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,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,7 @@
export const getFilename = () => {
const [yyymmdd = '', hhmmss = ''] = new Date().toISOString().split('T')
const formattedDate = yyymmdd.replace(/\D/g, '')
const formattedTime = (hhmmss.split('.')[0] ?? '').replace(/\D/g, '')
return `${formattedDate}_${formattedTime}`
}

View File

@@ -0,0 +1,27 @@
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) => {
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,114 @@
import type { CollectionConfig, Field } from 'payload'
const fieldsToHide = ['slug', 'fields', 'where']
export function modifyFields(fields: Field[], collectionConfig: CollectionConfig): Field[] {
const columns = collectionConfig.fields
.map((field: Field) =>
'name' in field
? {
label: field.name,
value: field.name,
}
: null,
)
.filter(Boolean)
const fieldsToAdd = [
{
name: 'drafts',
type: 'select',
label: 'Drafts',
options: [
{
label: 'True',
value: 'true',
},
{
label: 'False',
value: 'false',
},
],
},
{
name: 'depth',
type: 'number',
defaultValue: 1,
label: 'Depth',
required: true,
},
{
name: 'selectionToUse',
type: 'radio',
options: [
{
label: 'Use current selection',
value: 'currentSelection',
},
{
label: 'Use current filters',
value: 'currentFilters',
},
{
label: 'Use all documents',
value: 'all',
},
],
},
{
name: 'columnsToExport',
type: 'select',
hasMany: true,
label: 'Columns to Export',
options: columns,
},
]
const flattenFields = (fields: Field[]): Field[] => {
let result: Field[] = []
fields.forEach((field) => {
if ('fields' in field && Array.isArray(field.fields)) {
result = result.concat(flattenFields(field.fields))
} else {
if ('name' in field && !fieldsToHide.includes(field.name)) {
result.push(field)
}
}
})
return result
}
const modifiedFields = flattenFields(fields)
const allFields = [...modifiedFields, ...fieldsToAdd] as Field[]
const rows = [
allFields[0],
{
type: 'row',
fields: allFields.slice(1, 4).map((field) => ({
...field,
admin: {
width: '33%',
},
})),
},
{
type: 'row',
fields: allFields.slice(4, 7).map((field) => ({
...field,
admin: {
width: '33%',
},
})),
},
]
if (allFields.length > 7) {
rows.push(...allFields.slice(7))
}
return [...rows] as Field[]
}

View File

@@ -0,0 +1,107 @@
export const recursiveAccessor = ({
docRef,
prefix = '',
segment,
}: {
docRef: Record<string, unknown> | unknown
prefix?: string
segment: string
}): { path: string; value: unknown } => {
if (Array.isArray(docRef)) {
docRef.forEach((item: Record<string, unknown>, i) => {
Object.entries(item).forEach(([itemKey, itemValue]) => {
recursiveAccessor({
docRef: itemValue as Record<string, unknown>,
prefix: `${prefix}_${i}_${itemKey}`,
segment: itemKey,
})
})
})
} else if (typeof docRef === 'object') {
Object.entries(docRef as Record<string, unknown>).forEach(([key, value]) => {
if (value && typeof value === 'object') {
recursiveAccessor({
docRef: value as Record<string, unknown>,
prefix: `${prefix}_${key}`,
segment: key,
})
} else {
return { path: `${prefix ? `${prefix}_` : ''}${key}`, value }
// data[`${key}`] = value
}
})
}
return { path: `${prefix ? `${prefix}_` : ''}${segment}`, value: docRef }
}
// export const recursiveAccessor = ({
// docRef,
// prefix = '',
// segment,
// }: {
// docRef: Record<string, unknown> | unknown
// prefix?: string
// segment: string
// }): { path: string; value: unknown } => {
// if (Array.isArray(docRef)) {
// docRef.forEach((item: Record<string, unknown>, i) => {
// Object.entries(item).forEach(([itemKey, itemValue]) => {
// recursiveAccessor({
// docRef: itemValue as Record<string, unknown>,
// prefix: `${prefix}_${i}_${itemKey}`,
// segment: itemKey,
// })
// })
// })
// } else if (typeof docRef === 'object') {
// Object.entries(docRef as Record<string, unknown>).forEach(([key, value]) => {
// if (value && typeof value === 'object') {
// recursiveAccessor({
// docRef: value as Record<string, unknown>,
// prefix: `${prefix}_${key}`,
// segment: key,
// })
// } else {
// return { path: `${prefix ? `${prefix}_` : ''}${key}`, value }
// // data[`${key}`] = value
// }
// })
// }
// return { path: `${prefix ? `${prefix}_` : ''}${segment}`, value: docRef }
// }
// const traverseFieldsCallback: TraverseFieldsCallback<Record<string, unknown>> = ({
// field,
// parentRef,
// ref,
// }) => {
// // always false because we are using flattenedFields but useful for type narrowing
// if (!('name' in field)) {
// return
// }
//
// if (field.type === 'group') {
// selectRef = select[field.name] = {}
// ref._name = field.name
// }
// const prefix =
// typeof parentRef === 'object' && parentRef?._name && typeof parentRef._name === 'string'
// ? `${parentRef._name}.`
// : ''
//
// // when fields aren't defined we assume all fields are included
// if (
// fields &&
// 'name' in field &&
// !fields.some((f) => {
// const segment = f.indexOf('.') > -1 ? f.substring(0, f.indexOf('.')) : f
// return segment === `${prefix}${field.name}`
// })
// ) {
// return
// }
//
// if (field.name) {
// selectRef[field.name] = true
// }
// }

View File

@@ -0,0 +1,74 @@
import type { Field } from 'payload'
import { getFilename } from './export/getFilename.js'
const exportCollectionFields: Field[] = [
{
name: 'slug',
type: 'text',
required: true,
},
{
name: 'fields',
type: 'text',
hasMany: true,
},
{
name: 'limit',
type: 'number',
label: 'Limit',
},
{
name: 'sort',
type: 'text',
hasMany: true,
label: 'Sort By',
},
{
name: 'where',
type: 'json',
},
]
export const fields: Field[] = [
{
name: 'name',
type: 'text',
defaultValue: () => getFilename(),
label: 'File Name',
virtual: true,
},
{
name: 'format',
type: 'select',
label: 'Export Format',
options: [
{
label: 'JSON',
value: 'json',
},
{
label: 'CSV',
value: 'csv',
},
],
required: true,
},
{
name: 'collections',
type: 'array',
fields: exportCollectionFields,
},
{
name: 'locales',
type: 'text',
hasMany: true,
label: 'Locales',
},
// {
// name: 'globals',
// type: 'text',
// hasMany: true,
// },
]

View File

@@ -0,0 +1 @@
export { ExportButton } from '../components/ExportButton/index.js'

View File

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

View File

@@ -0,0 +1,81 @@
import type {
CollectionAfterChangeHook,
CollectionBeforeOperationHook,
CollectionConfig,
} from 'payload'
import type { CollectionOverride, ImportExportPluginConfig } from './types.js'
import { createExport } from './export/createExport.js'
import { fields } from './exportFields.js'
export const getExportCollection = ({
pluginConfig,
}: {
pluginConfig: ImportExportPluginConfig
}): CollectionConfig => {
const { overrideExportCollection } = pluginConfig
const beforeOperation: CollectionBeforeOperationHook[] = []
const afterChange: CollectionAfterChangeHook[] = []
let collection: CollectionOverride = {
slug: 'exports',
access: {
update: () => false,
},
admin: {
group: false,
useAsTitle: 'filename',
},
disableDuplicate: true,
fields,
hooks: {
afterChange,
beforeOperation,
},
upload: {
filesRequiredOnCreate: false,
// must be csv, json or zip
mimeTypes: ['application/json', 'text/csv', 'application/zip'],
},
}
if (typeof overrideExportCollection === 'function') {
collection = overrideExportCollection(collection)
}
if (pluginConfig.disableJobsQueue) {
beforeOperation.push(async ({ args, operation, req }) => {
if (operation !== 'create') {
return
}
const { user } = req
if (args.data.collections.length === 1) {
await createExport({ input: { ...args.data, user }, req })
}
})
} else {
afterChange.push(async ({ doc, operation, req }) => {
if (operation !== 'create') {
return
}
if (doc.collections.length === 1) {
const input = {
...doc,
exportsCollection: collection.slug,
user: req?.user?.id || req?.user?.user?.id,
userCollection: 'users',
}
const { id } = await req.payload.jobs.queue({
input,
task: 'createCollectionExport',
})
void req.payload.jobs.runByID({ id })
}
})
}
return collection
}

View File

@@ -0,0 +1,59 @@
import type { Config, JobsConfig } from 'payload'
import { deepMergeSimple } from 'payload'
import type { ImportExportPluginConfig } from './types.js'
import { createCollectionExportTask } from './export/createExportCollectionTask.js'
import { getExportCollection } from './getExportCollection.js'
import { translations } from './translations/index.js'
export const importExportPlugin =
(pluginConfig: ImportExportPluginConfig) =>
(config: Config): Config => {
const exportCollection = getExportCollection({ pluginConfig })
if (config.collections) {
config.collections.push(exportCollection)
} else {
config.collections = [exportCollection]
}
// inject the createExport job into the config
config.jobs =
config.jobs ||
({
tasks: [createCollectionExportTask],
} 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: { afterListControls: [] } }
}
if (!collection.admin.components) {
collection.admin.components = { afterListControls: [] }
}
if (!collection.admin.components.afterListControls) {
collection.admin.components.afterListControls = []
}
collection.admin.components.afterListControls.push({
clientProps: {
exportCollectionSlug: exportCollection.slug,
},
path: '@payloadcms/plugin-import-export/rsc#ExportButton',
})
})
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,28 @@
import type { CollectionConfig, UploadConfig } from 'payload'
export type CollectionOverride = {
admin: CollectionConfig['admin']
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
/**
* Globals to include the Import/Export controls in
*/
globals?: string[]
/**
* 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"}]
}

View File

@@ -0,0 +1,49 @@
import type { FormState } from 'payload'
export const initialState: FormState = {
depth: {
initialValue: 1,
valid: true,
value: 1,
},
drafts: {
initialValue: 'include',
valid: true,
value: 'include',
},
filename: {
initialValue: 'untitled-collection-export-01-01-2025.csv',
valid: true,
value: 'untitled-collection-export-01-01-2025.csv',
},
format: {
initialValue: 'csv',
valid: true,
value: 'csv',
},
limit: {
initialValue: 100,
valid: true,
value: 100,
},
locales: {
initialValue: ['all'],
valid: true,
value: ['all'],
},
sortby: {
initialValue: ['ID'],
valid: true,
value: ['ID'],
},
useCurrentFilters: {
initialValue: false,
valid: true,
value: false,
},
useCurrentSelection: {
initialValue: false,
valid: true,
value: false,
},
}

View File

@@ -0,0 +1,25 @@
@import '../../../scss/styles';
@layer payload-default {
.export-drawer {
&__subheader {
padding: 0 var(--gutter-h);
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--theme-border-color);
}
&__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);
}
}
}

View File

@@ -0,0 +1,203 @@
'use client'
import type { ClientCollectionConfig, ClientField } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { fieldAffectsData } from 'payload/shared'
import React from 'react'
import { Collapsible } from '../../../elements/Collapsible/index.js'
import { Form } from '../../../forms/Form/index.js'
import { RenderFields } from '../../../forms/RenderFields/index.js'
import { FormSubmit } from '../../../forms/Submit/index.js'
import { useConfig } from '../../../providers/Config/index.js'
import { DrawerHeader } from '../../BulkUpload/Header/index.js'
import { Drawer } from '../../Drawer/index.js'
import { initialState } from './exportFields.js'
import './index.scss'
const baseClass = 'export-drawer'
export const ExportDrawer: React.FC<{
collectionConfig: ClientCollectionConfig
collectionLabel: string | undefined
drawerSlug: string
}> = ({ collectionConfig, collectionLabel, drawerSlug }) => {
const { toggleModal } = useModal()
const {
config: { localization },
} = useConfig()
const localizationEnabled = localization && localization.locales.length > 1
const localeOptions =
localizationEnabled &&
localization.locales.map((locale) =>
typeof locale === 'string'
? { label: locale, value: locale }
: { label: locale.label, value: locale.code },
)
const sortByFields = collectionConfig.fields.filter((field) => fieldAffectsData(field))
const sortByOptions = sortByFields.map((field) => ({
label: field.label || field.name,
value: field.name,
}))
const exportFields: ClientField[] = [
{
name: 'filename',
type: 'text',
label: 'Filename',
},
{
type: 'row',
fields: [
{
name: 'format',
type: 'select',
admin: {
width: '33.3%',
},
label: 'Export Format',
options: [
{
label: 'CSV',
value: 'csv',
},
],
},
{
name: 'limit',
type: 'number',
admin: {
width: '33.3%',
},
label: 'Limit',
},
{
name: 'sortby',
type: 'select',
admin: {
isClearable: false,
width: '33.3%',
},
label: 'Sort By',
options: sortByOptions,
},
],
},
{
type: 'row',
fields: [
localizationEnabled && {
name: 'locales',
type: 'select',
admin: {
width: '33.3%',
},
hasMany: true,
label: 'Locales',
options: [
{
label: 'All',
value: 'all',
},
...localeOptions,
],
},
{
name: 'drafts',
type: 'select',
admin: {
width: `${localizationEnabled ? '33.3%' : '50%'}`,
},
label: 'Drafts',
options: [
{
label: 'Include',
value: 'include',
},
{
label: 'Exclude',
value: 'exclude',
},
],
},
{
name: 'depth',
type: 'number',
admin: {
width: `${localizationEnabled ? '33.3%' : '50%'}`,
},
label: 'Depth',
},
],
},
{
type: 'row',
fields: [
{
name: 'useCurrentFilters',
type: 'checkbox',
admin: {
width: 'min-content',
},
label: 'Use current selection',
},
{
name: 'useCurrentSelection',
type: 'checkbox',
admin: {
width: 'min-content',
},
label: 'Use selected documents',
},
],
},
]
const onSuccess = React.useCallback(() => {
console.log('Exported')
toggleModal(drawerSlug)
}, [toggleModal, drawerSlug])
return (
<Drawer
className={baseClass}
gutter={false}
Header={
<DrawerHeader
onClose={() => {
toggleModal(drawerSlug)
}}
title={`Export ${collectionLabel}`}
/>
}
slug={drawerSlug}
>
<div className={`${baseClass}__subheader`}>
<div>Creating export{collectionLabel ? ` from ${collectionLabel}` : ''}</div>
<FormSubmit>Export</FormSubmit>
</div>
<div className={`${baseClass}__options`}>
<Collapsible header="Export options">
<Form action={'/admin'} initialState={initialState} method="POST" onSuccess={onSuccess}>
<RenderFields
fields={exportFields}
parentIndexPath=""
parentPath=""
parentSchemaPath=""
permissions={true}
/>
</Form>
</Collapsible>
</div>
<div className={`${baseClass}__preview`}>
<div className={`${baseClass}__preview-title`}>
<h3>Preview</h3>
<span>(result count) total documents</span>
</div>
(data preview here)
</div>
</Drawer>
)
}

View File

@@ -1,12 +1,15 @@
'use client'
import type { ClientCollectionConfig, Where } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { useWindowInfo } from '@faceless-ui/window-info'
import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useEffect, useRef, useState } from 'react'
import { Popup, PopupList } from '../../elements/Popup/index.js'
import { useUseTitleField } from '../../hooks/useUseAsTitle.js'
import { ChevronIcon } from '../../icons/Chevron/index.js'
import { Dots } from '../../icons/Dots/index.js'
import { SearchIcon } from '../../icons/Search/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
@@ -19,13 +22,14 @@ import { PublishMany } from '../PublishMany/index.js'
import { SearchFilter } from '../SearchFilter/index.js'
import { UnpublishMany } from '../UnpublishMany/index.js'
import { WhereBuilder } from '../WhereBuilder/index.js'
import validateWhereQuery from '../WhereBuilder/validateWhereQuery.js'
import './index.scss'
import validateWhereQuery from '../WhereBuilder/validateWhereQuery.js'
import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched.js'
const baseClass = 'list-controls'
export type ListControlsProps = {
readonly afterListControls?: React.ReactNode | React.ReactNode[]
readonly beforeActions?: React.ReactNode[]
readonly collectionConfig: ClientCollectionConfig
readonly collectionSlug: string
@@ -46,6 +50,7 @@ export type ListControlsProps = {
*/
export const ListControls: React.FC<ListControlsProps> = (props) => {
const {
afterListControls,
beforeActions,
collectionConfig,
collectionSlug,
@@ -56,13 +61,17 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
renderedFilters,
} = props
const collectionLabel = (collectionConfig.labels?.plural as string) || collectionSlug || ''
const { handleSearchChange, query } = useListQuery()
const titleField = useUseTitleField(collectionConfig)
const { i18n, t } = useTranslation()
const { toggleModal } = useModal()
const {
breakpoints: { s: smallBreak },
} = useWindowInfo()
const exportDrawerSlug = `export-drawer-${collectionSlug}`
const searchLabel =
(titleField &&
getTranslation(
@@ -193,6 +202,25 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
{t('general:sort')}
</Pill>
)}
{afterListControls != null && afterListControls !== false && (
<Popup
button={<Dots />}
className={`${baseClass}__popup`}
horizontalAlign="right"
size="large"
verticalAlign="bottom"
>
<PopupList.ButtonGroup>
{Array.isArray(afterListControls) ? (
afterListControls.map((control, index) => (
<PopupList.Button key={index}>{control}</PopupList.Button>
))
) : (
<PopupList.Button>{afterListControls}</PopupList.Button>
)}
</PopupList.ButtonGroup>
</Popup>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
@import '../../scss/styles';
@layer payload-default {
.dots {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 2px;
background-color: var(--theme-elevation-150);
border-radius: $style-radius-m;
height: calc(var(--base) * 1.2);
width: calc(var(--base) * 1.2);
&:hover {
background-color: var(--theme-elevation-100);
}
> div {
width: 2.5px;
height: 2.5px;
border-radius: 100%;
background-color: currentColor;
}
}
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import './index.scss'
export const Dots: React.FC<{ className?: string }> = ({ className }) => (
<div className={[className && className, 'dots'].filter(Boolean).join(' ')}>
<div />
<div />
<div />
</div>
)

View File

@@ -44,6 +44,7 @@ const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.
export type ListViewSlots = {
AfterList?: React.ReactNode
AfterListControls?: React.ReactNode | React.ReactNode[]
AfterListTable?: React.ReactNode
BeforeList?: React.ReactNode
BeforeListTable?: React.ReactNode
@@ -68,6 +69,7 @@ export type ListViewClientProps = {
export const DefaultListView: React.FC<ListViewClientProps> = (props) => {
const {
AfterList,
AfterListControls,
AfterListTable,
beforeActions,
BeforeList,
@@ -207,6 +209,7 @@ export const DefaultListView: React.FC<ListViewClientProps> = (props) => {
t={t}
/>
<ListControls
afterListControls={AfterListControls}
beforeActions={
enableRowSelections && typeof onBulkSelect === 'function'
? beforeActions

67
pnpm-lock.yaml generated
View File

@@ -45,7 +45,7 @@ importers:
version: 1.50.0
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
'@sentry/node':
specifier: ^8.33.1
version: 8.37.1
@@ -135,7 +135,7 @@ importers:
version: 10.1.3(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
next:
specifier: 15.1.5
version: 15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
version: 15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
open:
specifier: ^10.1.0
version: 10.1.0
@@ -1004,11 +1004,36 @@ importers:
specifier: workspace:*
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
devDependencies:
'@payloadcms/eslint-config':
specifier: workspace:*
version: link:../eslint-config
payload:
specifier: workspace:*
version: link:../payload
packages/plugin-multi-tenant:
dependencies:
next:
specifier: ^15.0.3
version: 15.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
version: 15.1.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
devDependencies:
'@payloadcms/eslint-config':
specifier: workspace:*
@@ -1070,7 +1095,7 @@ importers:
dependencies:
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
'@sentry/types':
specifier: ^8.33.1
version: 8.37.1
@@ -1420,7 +1445,7 @@ importers:
version: link:../plugin-cloud-storage
uploadthing:
specifier: 7.3.0
version: 7.3.0(next@15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))
version: 7.3.0(next@15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))
devDependencies:
payload:
specifier: workspace:*
@@ -1644,6 +1669,9 @@ importers:
'@payloadcms/plugin-form-builder':
specifier: workspace:*
version: link:../packages/plugin-form-builder
'@payloadcms/plugin-import-export':
specifier: workspace:*
version: link:../packages/plugin-import-export
'@payloadcms/plugin-multi-tenant':
specifier: workspace:*
version: link:../packages/plugin-multi-tenant
@@ -1694,7 +1722,7 @@ importers:
version: link:../packages/ui
'@sentry/nextjs':
specifier: ^8.33.1
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
version: 8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
'@sentry/react':
specifier: ^7.77.0
version: 7.119.2(react@19.0.0)
@@ -1716,6 +1744,9 @@ importers:
create-payload-app:
specifier: workspace:*
version: link:../packages/create-payload-app
csv-parse:
specifier: ^5.6.0
version: 5.6.0
dotenv:
specifier: 16.4.7
version: 16.4.7
@@ -1745,7 +1776,7 @@ importers:
version: 8.9.5(@aws-sdk/credential-providers@3.687.0(@aws-sdk/client-sso-oidc@3.687.0(@aws-sdk/client-sts@3.687.0)))(socks@2.8.3)
next:
specifier: 15.1.5
version: 15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
version: 15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
nodemailer:
specifier: 6.9.16
version: 6.9.16
@@ -6106,6 +6137,12 @@ packages:
csstype@3.1.3:
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:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -13595,7 +13632,7 @@ snapshots:
'@sentry/utils': 7.119.2
localforage: 1.10.0
'@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))':
'@sentry/nextjs@8.37.1(@opentelemetry/core@1.27.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.54.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.27.0(@opentelemetry/api@1.9.0))(next@15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(react@19.0.0)(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation-http': 0.53.0(@opentelemetry/api@1.9.0)
@@ -13611,7 +13648,7 @@ snapshots:
'@sentry/vercel-edge': 8.37.1
'@sentry/webpack-plugin': 2.22.6(webpack@5.96.1(@swc/core@1.10.12(@swc/helpers@0.5.15)))
chalk: 3.0.0
next: 15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
next: 15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
resolve: 1.22.8
rollup: 3.29.5
stacktrace-parser: 0.1.10
@@ -15469,6 +15506,10 @@ snapshots:
csstype@3.1.3: {}
csv-parse@5.6.0: {}
csv-stringify@6.5.2: {}
damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@4.0.1: {}
@@ -18054,7 +18095,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@15.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
next@15.1.3(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
dependencies:
'@next/env': 15.1.3
'@swc/counter': 0.1.3
@@ -18082,7 +18123,7 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
next@15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
next@15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
dependencies:
'@next/env': 15.1.5
'@swc/counter': 0.1.3
@@ -19749,14 +19790,14 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
uploadthing@7.3.0(next@15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)):
uploadthing@7.3.0(next@15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)):
dependencies:
'@effect/platform': 0.69.8(effect@3.10.3)
'@uploadthing/mime-types': 0.3.2
'@uploadthing/shared': 7.1.1
effect: 3.10.3
optionalDependencies:
next: 15.1.5(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
next: 15.1.5(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.50.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-714736e-20250131)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
uri-js@4.4.1:
dependencies:

View File

@@ -33,6 +33,26 @@ export const Posts: CollectionConfig = {
},
},
],
afterListControls: [
{
path: '/components/Banner/index.js#Banner',
clientProps: {
message: 'AfterListControls',
},
},
{
path: '/components/Banner/index.js#Banner',
clientProps: {
message: 'Many of them',
},
},
{
path: '/components/Banner/index.js#Banner',
clientProps: {
message: 'Ok last one',
},
},
],
afterList: [
{
path: '/components/Banner/index.js#Banner',

View File

@@ -196,6 +196,19 @@ describe('List View', () => {
).toBeVisible()
})
test('should render custom afterListControls component', async () => {
await page.goto(postsUrl.list)
const kebabMenu = page.locator('.list-controls__popup')
await expect(kebabMenu).toBeVisible()
await kebabMenu.click()
await expect(
page.locator('.popup-button-list__button').locator('div', {
hasText: 'AfterListControls',
}),
).toBeVisible()
})
test('should render custom afterListTable component', async () => {
await page.goto(postsUrl.list)
await expect(

View File

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

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

@@ -0,0 +1 @@
uploads

View File

@@ -0,0 +1,112 @@
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: '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,60 @@
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 { devUser } from '../credentials.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 payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
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,502 @@
import type { CollectionSlug, Payload, User } from 'payload'
import fs from 'fs'
import { parse } from 'csv-parse'
import path from 'path'
import { fileURLToPath } from 'url'
import type { Page } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { richTextData } from './seed/richTextData.js'
import { devUser } from '../credentials.js'
let payload: Payload
let page: Page
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))
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 () => {
// large data set
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Page ${i}`,
group: {
array: [{ field1: 'test' }],
},
},
})
}
let doc = await payload.create({
collection: 'exports',
user,
data: {
collections: [
{
slug: 'pages',
sort: 'createdAt',
fields: [
'id',
'title',
'group.value',
'group.array.field1',
'createdAt',
'updatedAt',
],
},
],
format: 'csv',
},
})
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('Page 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 array', async () => {
// large data set
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',
},
],
},
})
}
let doc = await payload.create({
collection: 'exports',
user,
data: {
collections: [
{
slug: 'pages',
fields: ['id', 'array'],
},
],
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].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 () => {
// large data set
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',
},
],
},
})
}
let doc = await payload.create({
collection: 'exports',
user,
data: {
collections: [
{
slug: 'pages',
fields: ['id', 'array.field1'],
},
],
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].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 () => {
// large data set
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Array ${i}`,
hasManyNumber: [0, 1, 1, 2, 3, 5, 8, 13, 21],
},
})
}
let doc = await payload.create({
collection: 'exports',
user,
data: {
collections: [
{
slug: 'pages',
fields: ['id', 'hasManyNumber'],
},
],
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].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 () => {
// large data set
const allPromises = []
let promises = []
for (let i = 0; i < 5; i++) {
promises.push(
payload.create({
collection: 'pages',
data: {
title: `Array ${i}`,
blocks: [
{
blockType: 'hero',
title: 'test',
},
{
blockType: 'content',
richText: richTextData,
},
],
},
}),
)
}
await Promise.all(promises)
let doc = await payload.create({
collection: 'exports',
user,
data: {
collections: [
{
slug: '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')
})
it('should create a file for collection csv using a where filter', async () => {
// data set
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Array ${i}`,
},
})
}
let doc = await payload.create({
collection: 'exports',
user,
data: {
collections: [
{
slug: 'pages',
fields: ['id', 'title'],
where: {
title: { equals: 'Array 2' },
},
},
],
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].title).toStrictEqual('Array 2')
})
it('should create a JSON file for collection', async () => {
// data set
for (let i = 0; i < 5; i++) {
await payload.create({
collection: 'pages',
data: {
title: `Array ${i}`,
},
})
}
let doc = await payload.create({
collection: 'exports-tasks' as CollectionSlug,
user,
data: {
collections: [
{
slug: 'pages',
fields: ['id', 'title'],
},
],
format: 'json',
},
})
// Wait for job to complete...
await new Promise((resolve) => setTimeout(resolve, 1000))
doc = await payload.findByID({
collection: 'exports-tasks' as CollectionSlug,
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('Array 0')
})
it('should create jobs task for exports', async () => {
let doc = await payload.create({
collection: 'exports-tasks' as CollectionSlug,
user,
data: {
collections: [
{
slug: 'pages',
fields: ['id', 'title'],
},
],
format: 'csv',
},
})
const jobs = await payload.find({
collection: 'payload-jobs' as CollectionSlug,
})
expect(jobs.docs).toHaveLength(1)
})
// 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({
collection: '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: {
collections: [
{
slug: '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')
})
})
})
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 = JSON.parse(str)
return json
} catch (error) {
console.error('Error reading JSON file:', error)
throw error
}
}

View File

@@ -0,0 +1,551 @@
/* 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.
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
users: User;
pages: Page;
'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-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;
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-tasks".
*/
export interface ExportsTask {
id: string;
name?: string | null;
format: 'json' | 'csv';
collections?:
| {
slug: string;
fields?: string[] | null;
limit?: number | null;
sort?: string[] | null;
where?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
id?: string | null;
}[]
| null;
locales?: string[] | 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-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;
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-tasks_select".
*/
export interface ExportsTasksSelect<T extends boolean = true> {
name?: T;
format?: T;
collections?:
| T
| {
slug?: T;
fields?: T;
limit?: T;
sort?: T;
where?: T;
id?: T;
};
locales?: 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: 'json' | 'csv';
collections?:
| {
slug: string;
fields?: string[] | null;
limit?: number | null;
sort?: string[] | null;
where?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}[]
| null;
locales?: string[] | null;
user?: string | null;
userCollection?: 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,22 @@
import type { Payload, PayloadRequest } from 'payload'
export const seed = async (payload: Payload): Promise<boolean> => {
payload.logger.info('Seeding data...')
const req = {} as PayloadRequest
try {
await payload.create({
collection: 'users',
data: {
email: 'demo@payloadcms.com',
password: 'demo',
},
req,
})
return true
} catch (err) {
console.error(err)
return false
}
}

View File

@@ -0,0 +1,99 @@
export const richTextData = [
{
type: 'ul',
children: [
{
type: 'li',
children: [
{
children: [
{
text: 'I am semantically connected to my sub-bullets',
},
],
},
{
type: 'ul',
children: [
{
type: 'li',
children: [
{
text: 'I am sub-bullets that are semantically connected to the parent bullet',
},
],
},
],
},
],
},
{
children: [
{
text: 'Normal bullet',
},
],
type: 'li',
},
{
type: 'li',
children: [
{
type: 'ul',
children: [
{
type: 'li',
children: [
{
text: 'I am the old style of sub-bullet',
},
],
},
],
},
],
},
{
type: 'li',
children: [
{
text: 'Another normal bullet',
},
],
},
{
type: 'li',
children: [
{
children: [
{
text: 'This text precedes a nested list',
},
],
},
{
type: 'ul',
children: [
{
type: 'li',
children: [
{
text: 'I am a sub-bullet',
},
],
},
{
type: 'li',
children: [
{
text: 'And I am another sub-bullet',
},
],
},
],
},
],
},
],
},
]

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/plugin-cloud-storage': 'payloadcms-plugin-cloud-storage-*',
'@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-nested-docs': 'payloadcms-plugin-nested-docs-*',
'@payloadcms/plugin-redirects': 'payloadcms-plugin-redirects-*',

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/plugin-search/config.ts"],
"@payload-config": ["./test/plugin-import-export/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],
@@ -55,6 +55,9 @@
"@payloadcms/plugin-form-builder/client": [
"./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/utilities": [
"./packages/plugin-multi-tenant/src/exports/utilities.ts"

View File

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