From 4f822a439ba2b51a6139adf41ab2ffccbc497a3d Mon Sep 17 00:00:00 2001 From: Dan Ribbens Date: Tue, 4 Mar 2025 20:06:43 -0500 Subject: [PATCH] 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 Co-authored-by: Kendell Joseph --- .github/workflows/main.yml | 1 + .idea/payload.iml | 3 +- package.json | 1 + .../src/versions/payloadPackageList.ts | 1 + packages/plugin-import-export/.gitignore | 7 + packages/plugin-import-export/.prettierignore | 12 + packages/plugin-import-export/.swcrc | 24 + packages/plugin-import-export/README.md | 7 + .../plugin-import-export/eslint.config.js | 18 + packages/plugin-import-export/license.md | 22 + packages/plugin-import-export/package.json | 101 +++ .../src/components/CollectionField/index.tsx | 22 + .../components/ExportListMenuItem/index.scss | 52 ++ .../components/ExportListMenuItem/index.tsx | 38 + .../src/components/ExportSaveButton/index.tsx | 72 ++ .../src/components/FieldsToExport/index.scss | 0 .../src/components/FieldsToExport/index.tsx | 102 +++ .../FieldsToExport/reduceFields.tsx | 111 +++ .../components/ImportExportProvider/index.tsx | 30 + .../src/components/Preview/index.scss | 8 + .../src/components/Preview/index.tsx | 109 +++ .../src/components/SortBy/index.scss | 4 + .../src/components/SortBy/index.tsx | 90 +++ .../src/components/WhereField/index.scss | 0 .../src/components/WhereField/index.tsx | 71 ++ .../src/export/createExport.ts | 151 ++++ .../src/export/download.ts | 26 + .../src/export/flattenObject.ts | 23 + .../export/getCreateExportCollectionTask.ts | 55 ++ .../src/export/getFields.ts | 189 +++++ .../src/export/getFilename.ts | 7 + .../src/export/getSelect.ts | 31 + .../plugin-import-export/src/exports/rsc.ts | 8 + .../plugin-import-export/src/exports/types.ts | 1 + .../src/getExportCollection.ts | 88 +++ packages/plugin-import-export/src/index.ts | 72 ++ .../src/translations/en.ts | 9 + .../src/translations/index.ts | 11 + .../src/translations/translation-schema.json | 24 + packages/plugin-import-export/src/types.ts | 24 + packages/plugin-import-export/tsconfig.json | 25 + pnpm-lock.yaml | 44 ++ test/joins/payload-types.ts | 5 +- test/package.json | 2 + test/plugin-import-export/.gitignore | 1 + .../plugin-import-export/collections/Pages.ts | 117 +++ .../plugin-import-export/collections/Users.ts | 16 + test/plugin-import-export/config.ts | 51 ++ test/plugin-import-export/e2e.spec.ts | 50 ++ test/plugin-import-export/eslint.config.js | 19 + test/plugin-import-export/helpers.ts | 51 ++ test/plugin-import-export/int.spec.ts | 400 +++++++++++ test/plugin-import-export/payload-types.ts | 670 ++++++++++++++++++ test/plugin-import-export/seed/index.ts | 135 ++++ .../plugin-import-export/seed/richTextData.ts | 110 +++ test/plugin-import-export/shared.ts | 1 + .../plugin-import-export/tsconfig.eslint.json | 13 + test/plugin-import-export/tsconfig.json | 3 + test/setupProd.ts | 1 + tsconfig.base.json | 11 +- tsconfig.json | 3 + 61 files changed, 3345 insertions(+), 8 deletions(-) create mode 100644 packages/plugin-import-export/.gitignore create mode 100644 packages/plugin-import-export/.prettierignore create mode 100644 packages/plugin-import-export/.swcrc create mode 100644 packages/plugin-import-export/README.md create mode 100644 packages/plugin-import-export/eslint.config.js create mode 100644 packages/plugin-import-export/license.md create mode 100644 packages/plugin-import-export/package.json create mode 100644 packages/plugin-import-export/src/components/CollectionField/index.tsx create mode 100644 packages/plugin-import-export/src/components/ExportListMenuItem/index.scss create mode 100644 packages/plugin-import-export/src/components/ExportListMenuItem/index.tsx create mode 100644 packages/plugin-import-export/src/components/ExportSaveButton/index.tsx create mode 100644 packages/plugin-import-export/src/components/FieldsToExport/index.scss create mode 100644 packages/plugin-import-export/src/components/FieldsToExport/index.tsx create mode 100644 packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx create mode 100644 packages/plugin-import-export/src/components/ImportExportProvider/index.tsx create mode 100644 packages/plugin-import-export/src/components/Preview/index.scss create mode 100644 packages/plugin-import-export/src/components/Preview/index.tsx create mode 100644 packages/plugin-import-export/src/components/SortBy/index.scss create mode 100644 packages/plugin-import-export/src/components/SortBy/index.tsx create mode 100644 packages/plugin-import-export/src/components/WhereField/index.scss create mode 100644 packages/plugin-import-export/src/components/WhereField/index.tsx create mode 100644 packages/plugin-import-export/src/export/createExport.ts create mode 100644 packages/plugin-import-export/src/export/download.ts create mode 100644 packages/plugin-import-export/src/export/flattenObject.ts create mode 100644 packages/plugin-import-export/src/export/getCreateExportCollectionTask.ts create mode 100644 packages/plugin-import-export/src/export/getFields.ts create mode 100644 packages/plugin-import-export/src/export/getFilename.ts create mode 100644 packages/plugin-import-export/src/export/getSelect.ts create mode 100644 packages/plugin-import-export/src/exports/rsc.ts create mode 100644 packages/plugin-import-export/src/exports/types.ts create mode 100644 packages/plugin-import-export/src/getExportCollection.ts create mode 100644 packages/plugin-import-export/src/index.ts create mode 100644 packages/plugin-import-export/src/translations/en.ts create mode 100644 packages/plugin-import-export/src/translations/index.ts create mode 100644 packages/plugin-import-export/src/translations/translation-schema.json create mode 100644 packages/plugin-import-export/src/types.ts create mode 100644 packages/plugin-import-export/tsconfig.json create mode 100644 test/plugin-import-export/.gitignore create mode 100644 test/plugin-import-export/collections/Pages.ts create mode 100644 test/plugin-import-export/collections/Users.ts create mode 100644 test/plugin-import-export/config.ts create mode 100644 test/plugin-import-export/e2e.spec.ts create mode 100644 test/plugin-import-export/eslint.config.js create mode 100644 test/plugin-import-export/helpers.ts create mode 100644 test/plugin-import-export/int.spec.ts create mode 100644 test/plugin-import-export/payload-types.ts create mode 100644 test/plugin-import-export/seed/index.ts create mode 100644 test/plugin-import-export/seed/richTextData.ts create mode 100644 test/plugin-import-export/shared.ts create mode 100644 test/plugin-import-export/tsconfig.eslint.json create mode 100644 test/plugin-import-export/tsconfig.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d961c552f..f8aa258f6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -313,6 +313,7 @@ jobs: - i18n - plugin-cloud-storage - plugin-form-builder + - plugin-import-export - plugin-nested-docs - plugin-seo - versions diff --git a/.idea/payload.iml b/.idea/payload.iml index 89c279912..ea3e14c84 100644 --- a/.idea/payload.iml +++ b/.idea/payload.iml @@ -80,8 +80,9 @@ + - + \ No newline at end of file diff --git a/package.json b/package.json index a593536f1..1a25ae1ad 100644 --- a/package.json +++ b/package.json @@ -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\"", diff --git a/packages/payload/src/versions/payloadPackageList.ts b/packages/payload/src/versions/payloadPackageList.ts index f223f9e4e..7f824c5a8 100644 --- a/packages/payload/src/versions/payloadPackageList.ts +++ b/packages/payload/src/versions/payloadPackageList.ts @@ -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', diff --git a/packages/plugin-import-export/.gitignore b/packages/plugin-import-export/.gitignore new file mode 100644 index 000000000..4baaac85f --- /dev/null +++ b/packages/plugin-import-export/.gitignore @@ -0,0 +1,7 @@ +node_modules +.env +dist +demo/uploads +build +.DS_Store +package-lock.json diff --git a/packages/plugin-import-export/.prettierignore b/packages/plugin-import-export/.prettierignore new file mode 100644 index 000000000..17883dc0e --- /dev/null +++ b/packages/plugin-import-export/.prettierignore @@ -0,0 +1,12 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp +**/docs/** +tsconfig.json diff --git a/packages/plugin-import-export/.swcrc b/packages/plugin-import-export/.swcrc new file mode 100644 index 000000000..b4fb882ca --- /dev/null +++ b/packages/plugin-import-export/.swcrc @@ -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" + } +} diff --git a/packages/plugin-import-export/README.md b/packages/plugin-import-export/README.md new file mode 100644 index 000000000..a25c4e55f --- /dev/null +++ b/packages/plugin-import-export/README.md @@ -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) diff --git a/packages/plugin-import-export/eslint.config.js b/packages/plugin-import-export/eslint.config.js new file mode 100644 index 000000000..f9d341be5 --- /dev/null +++ b/packages/plugin-import-export/eslint.config.js @@ -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 diff --git a/packages/plugin-import-export/license.md b/packages/plugin-import-export/license.md new file mode 100644 index 000000000..b31a68cbd --- /dev/null +++ b/packages/plugin-import-export/license.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018-2024 Payload CMS, Inc. + +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. diff --git a/packages/plugin-import-export/package.json b/packages/plugin-import-export/package.json new file mode 100644 index 000000000..4ec2a14d1 --- /dev/null +++ b/packages/plugin-import-export/package.json @@ -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 (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" +} diff --git a/packages/plugin-import-export/src/components/CollectionField/index.tsx b/packages/plugin-import-export/src/components/CollectionField/index.tsx new file mode 100644 index 000000000..efda7073a --- /dev/null +++ b/packages/plugin-import-export/src/components/CollectionField/index.tsx @@ -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 +} diff --git a/packages/plugin-import-export/src/components/ExportListMenuItem/index.scss b/packages/plugin-import-export/src/components/ExportListMenuItem/index.scss new file mode 100644 index 000000000..eb05087f5 --- /dev/null +++ b/packages/plugin-import-export/src/components/ExportListMenuItem/index.scss @@ -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; + } + } + } +} diff --git a/packages/plugin-import-export/src/components/ExportListMenuItem/index.tsx b/packages/plugin-import-export/src/components/ExportListMenuItem/index.tsx new file mode 100644 index 000000000..e01891788 --- /dev/null +++ b/packages/plugin-import-export/src/components/ExportListMenuItem/index.tsx @@ -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 ( + + + Export {getTranslation(currentCollectionConfig.labels.plural, i18n)} + + + + ) +} diff --git a/packages/plugin-import-export/src/components/ExportSaveButton/index.tsx b/packages/plugin-import-export/src/components/ExportSaveButton/index.tsx new file mode 100644 index 000000000..2ebd46f47 --- /dev/null +++ b/packages/plugin-import-export/src/components/ExportSaveButton/index.tsx @@ -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 ( + + + + + ) +} diff --git a/packages/plugin-import-export/src/components/FieldsToExport/index.scss b/packages/plugin-import-export/src/components/FieldsToExport/index.scss new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-import-export/src/components/FieldsToExport/index.tsx b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx new file mode 100644 index 000000000..1a33f1fe1 --- /dev/null +++ b/packages/plugin-import-export/src/components/FieldsToExport/index.tsx @@ -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({ path }) + const { value: collectionSlug } = useField({ 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 ( +
+ + String(option.value)} + isClearable={true} + isMulti={true} + isSortable={true} + // @ts-expect-error react select option + onChange={onChange} + options={fieldOptions} + value={displayedValue} + /> +
+ ) +} diff --git a/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx b/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx new file mode 100644 index 000000000..9d7153051 --- /dev/null +++ b/packages/plugin-import-export/src/components/FieldsToExport/reduceFields.tsx @@ -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 ( + + {prefix ? ( + + {prefix} + {' > '} + + ) : null} + + {'label' in field && typeof field.label === 'string' + ? field.label + : (('name' in field && field.name) ?? 'unnamed field')} + + + ) +} + +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] + }, + [], + ) +} diff --git a/packages/plugin-import-export/src/components/ImportExportProvider/index.tsx b/packages/plugin-import-export/src/components/ImportExportProvider/index.tsx new file mode 100644 index 000000000..fc4c4d03f --- /dev/null +++ b/packages/plugin-import-export/src/components/ImportExportProvider/index.tsx @@ -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('') + + const setCollection = useCallback((collection: string) => { + setCollectionState(collection) + }, []) + + return ( + + {children} + + ) +} + +export const useImportExport = (): ImportExportContext => useContext(ImportExportContext) diff --git a/packages/plugin-import-export/src/components/Preview/index.scss b/packages/plugin-import-export/src/components/Preview/index.scss new file mode 100644 index 000000000..4571ee623 --- /dev/null +++ b/packages/plugin-import-export/src/components/Preview/index.scss @@ -0,0 +1,8 @@ +.preview { + &__header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 10px; + } +} diff --git a/packages/plugin-import-export/src/components/Preview/index.tsx b/packages/plugin-import-export/src/components/Preview/index.tsx new file mode 100644 index 000000000..0862c9430 --- /dev/null +++ b/packages/plugin-import-export/src/components/Preview/index.tsx @@ -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({ path: 'limit' }) + const { value: fields } = useField({ path: 'fields' }) + const { value: sort } = useField({ path: 'sort' }) + const { value: draft } = useField({ path: 'draft' }) + const [dataToRender, setDataToRender] = React.useState([]) + const [resultCount, setResultCount] = React.useState('') + const [columns, setColumns] = React.useState([]) + + 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) => { + 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 ( +
+
+

Preview

+ {resultCount && {resultCount} total documents} +
+ {dataToRender && } + + ) +} diff --git a/packages/plugin-import-export/src/components/SortBy/index.scss b/packages/plugin-import-export/src/components/SortBy/index.scss new file mode 100644 index 000000000..b142d9f3c --- /dev/null +++ b/packages/plugin-import-export/src/components/SortBy/index.scss @@ -0,0 +1,4 @@ +.sort-by-fields { + display: block; + width: 33%; +} diff --git a/packages/plugin-import-export/src/components/SortBy/index.tsx b/packages/plugin-import-export/src/components/SortBy/index.tsx new file mode 100644 index 000000000..75d7cbb29 --- /dev/null +++ b/packages/plugin-import-export/src/components/SortBy/index.tsx @@ -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({ path }) + const { value: collectionSlug } = useField({ 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 ( +
+ + String(option.value)} + isClearable={true} + isSortable={true} + // @ts-expect-error react select option + onChange={onChange} + options={fieldOptions} + // @ts-expect-error react select + value={displayedValue} + /> +
+ ) +} diff --git a/packages/plugin-import-export/src/components/WhereField/index.scss b/packages/plugin-import-export/src/components/WhereField/index.scss new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-import-export/src/components/WhereField/index.tsx b/packages/plugin-import-export/src/components/WhereField/index.tsx new file mode 100644 index 000000000..02c1d3db3 --- /dev/null +++ b/packages/plugin-import-export/src/components/WhereField/index.tsx @@ -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 +} diff --git a/packages/plugin-import-export/src/export/createExport.ts b/packages/plugin-import-export/src/export/createExport.ts new file mode 100644 index 000000000..ff408eacb --- /dev/null +++ b/packages/plugin-import-export/src/export/createExport.ts @@ -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, + }) + } +} diff --git a/packages/plugin-import-export/src/export/download.ts b/packages/plugin-import-export/src/export/download.ts new file mode 100644 index 000000000..86d02b097 --- /dev/null +++ b/packages/plugin-import-export/src/export/download.ts @@ -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 +} diff --git a/packages/plugin-import-export/src/export/flattenObject.ts b/packages/plugin-import-export/src/export/flattenObject.ts new file mode 100644 index 000000000..8fe2c83f2 --- /dev/null +++ b/packages/plugin-import-export/src/export/flattenObject.ts @@ -0,0 +1,23 @@ +export const flattenObject = (obj: any, prefix: string = ''): Record => { + const result: Record = {} + + 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 +} diff --git a/packages/plugin-import-export/src/export/getCreateExportCollectionTask.ts b/packages/plugin-import-export/src/export/getCreateExportCollectionTask.ts new file mode 100644 index 000000000..6302f1c7f --- /dev/null +++ b/packages/plugin-import-export/src/export/getCreateExportCollectionTask.ts @@ -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 => { + 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', + }, + ], + } +} diff --git a/packages/plugin-import-export/src/export/getFields.ts b/packages/plugin-import-export/src/export/getFields.ts new file mode 100644 index 000000000..e088957d2 --- /dev/null +++ b/packages/plugin-import-export/src/export/getFields.ts @@ -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', + }, + }, + }, + ] +} diff --git a/packages/plugin-import-export/src/export/getFilename.ts b/packages/plugin-import-export/src/export/getFilename.ts new file mode 100644 index 000000000..d931c23d2 --- /dev/null +++ b/packages/plugin-import-export/src/export/getFilename.ts @@ -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}` +} diff --git a/packages/plugin-import-export/src/export/getSelect.ts b/packages/plugin-import-export/src/export/getSelect.ts new file mode 100644 index 000000000..4e156816a --- /dev/null +++ b/packages/plugin-import-export/src/export/getSelect.ts @@ -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 +} diff --git a/packages/plugin-import-export/src/exports/rsc.ts b/packages/plugin-import-export/src/exports/rsc.ts new file mode 100644 index 000000000..507228892 --- /dev/null +++ b/packages/plugin-import-export/src/exports/rsc.ts @@ -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' diff --git a/packages/plugin-import-export/src/exports/types.ts b/packages/plugin-import-export/src/exports/types.ts new file mode 100644 index 000000000..af681756f --- /dev/null +++ b/packages/plugin-import-export/src/exports/types.ts @@ -0,0 +1 @@ +export type { ImportExportPluginConfig } from '../types.js' diff --git a/packages/plugin-import-export/src/getExportCollection.ts b/packages/plugin-import-export/src/getExportCollection.ts new file mode 100644 index 000000000..9c863059e --- /dev/null +++ b/packages/plugin-import-export/src/getExportCollection.ts @@ -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 +} diff --git a/packages/plugin-import-export/src/index.ts b/packages/plugin-import-export/src/index.ts new file mode 100644 index 000000000..74326a2c6 --- /dev/null +++ b/packages/plugin-import-export/src/index.ts @@ -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 + } diff --git a/packages/plugin-import-export/src/translations/en.ts b/packages/plugin-import-export/src/translations/en.ts new file mode 100644 index 000000000..3ae206461 --- /dev/null +++ b/packages/plugin-import-export/src/translations/en.ts @@ -0,0 +1,9 @@ +import type { GenericTranslationsObject } from '@payloadcms/translations' + +export const en: GenericTranslationsObject = { + $schema: './translation-schema.json', + 'plugin-seo': { + export: 'Export', + import: 'Import', + }, +} diff --git a/packages/plugin-import-export/src/translations/index.ts b/packages/plugin-import-export/src/translations/index.ts new file mode 100644 index 000000000..82fb59f80 --- /dev/null +++ b/packages/plugin-import-export/src/translations/index.ts @@ -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 diff --git a/packages/plugin-import-export/src/translations/translation-schema.json b/packages/plugin-import-export/src/translations/translation-schema.json new file mode 100644 index 000000000..a42e09dc5 --- /dev/null +++ b/packages/plugin-import-export/src/translations/translation-schema.json @@ -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"] +} diff --git a/packages/plugin-import-export/src/types.ts b/packages/plugin-import-export/src/types.ts new file mode 100644 index 000000000..a53cade0e --- /dev/null +++ b/packages/plugin-import-export/src/types.ts @@ -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 +} diff --git a/packages/plugin-import-export/tsconfig.json b/packages/plugin-import-export/tsconfig.json new file mode 100644 index 000000000..261a42c3b --- /dev/null +++ b/packages/plugin-import-export/tsconfig.json @@ -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"}] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4e8469a7..a54df297e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1025,6 +1025,34 @@ 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 + 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: dependencies: next: @@ -1677,6 +1705,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 @@ -1749,6 +1780,9 @@ importers: create-payload-app: specifier: workspace:* version: link:../packages/create-payload-app + csv-parse: + specifier: ^5.6.0 + version: 5.6.0 dequal: specifier: 2.0.3 version: 2.0.3 @@ -6302,6 +6336,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==} @@ -15963,6 +16003,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: {} diff --git a/test/joins/payload-types.ts b/test/joins/payload-types.ts index 50c6cac39..ad414e394 100644 --- a/test/joins/payload-types.ts +++ b/test/joins/payload-types.ts @@ -1232,6 +1232,7 @@ export interface Auth { declare module 'payload' { - // @ts-ignore + // @ts-ignore export interface GeneratedTypes extends Config {} -} \ No newline at end of file +} + diff --git a/test/package.json b/test/package.json index 01442ac8f..456357d77 100644 --- a/test/package.json +++ b/test/package.json @@ -41,6 +41,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:*", @@ -65,6 +66,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", "dequal": "2.0.3", "dotenv": "16.4.7", "drizzle-kit": "0.28.0", diff --git a/test/plugin-import-export/.gitignore b/test/plugin-import-export/.gitignore new file mode 100644 index 000000000..3f549faf9 --- /dev/null +++ b/test/plugin-import-export/.gitignore @@ -0,0 +1 @@ +uploads diff --git a/test/plugin-import-export/collections/Pages.ts b/test/plugin-import-export/collections/Pages.ts new file mode 100644 index 000000000..04a777cb2 --- /dev/null +++ b/test/plugin-import-export/collections/Pages.ts @@ -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', + }, + ], +} diff --git a/test/plugin-import-export/collections/Users.ts b/test/plugin-import-export/collections/Users.ts new file mode 100644 index 000000000..b29c9debf --- /dev/null +++ b/test/plugin-import-export/collections/Users.ts @@ -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 + ], +} diff --git a/test/plugin-import-export/config.ts b/test/plugin-import-export/config.ts new file mode 100644 index 000000000..ab44964e1 --- /dev/null +++ b/test/plugin-import-export/config.ts @@ -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'), + }, +}) diff --git a/test/plugin-import-export/e2e.spec.ts b/test/plugin-import-export/e2e.spec.ts new file mode 100644 index 000000000..cbf771f96 --- /dev/null +++ b/test/plugin-import-export/e2e.spec.ts @@ -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 + + test.beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + const { payload: payloadFromInit, serverURL } = await initPayloadE2ENoConfig({ + 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 + }) + }) +}) diff --git a/test/plugin-import-export/eslint.config.js b/test/plugin-import-export/eslint.config.js new file mode 100644 index 000000000..d6b5fea55 --- /dev/null +++ b/test/plugin-import-export/eslint.config.js @@ -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 diff --git a/test/plugin-import-export/helpers.ts b/test/plugin-import-export/helpers.ts new file mode 100644 index 000000000..5ef507c3f --- /dev/null +++ b/test/plugin-import-export/helpers.ts @@ -0,0 +1,51 @@ +import { parse } from 'csv-parse' +import fs from 'fs' + +export const readCSV = async (path: string): Promise => { + const buffer = fs.readFileSync(path) + const data: any[] = [] + const promise = new Promise((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 => { + 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 + } +} diff --git a/test/plugin-import-export/int.spec.ts b/test/plugin-import-export/int.spec.ts new file mode 100644 index 000000000..96de3363e --- /dev/null +++ b/test/plugin-import-export/int.spec.ts @@ -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') + }) + }) +}) diff --git a/test/plugin-import-export/payload-types.ts b/test/plugin-import-export/payload-types.ts new file mode 100644 index 000000000..64164ec97 --- /dev/null +++ b/test/plugin-import-export/payload-types.ts @@ -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 | UsersSelect; + pages: PagesSelect | PagesSelect; + exports: ExportsSelect | ExportsSelect; + 'exports-tasks': ExportsTasksSelect | ExportsTasksSelect; + 'payload-jobs': PayloadJobsSelect | PayloadJobsSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 {} +} \ No newline at end of file diff --git a/test/plugin-import-export/seed/index.ts b/test/plugin-import-export/seed/index.ts new file mode 100644 index 000000000..bbc8dd322 --- /dev/null +++ b/test/plugin-import-export/seed/index.ts @@ -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 => { + 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 + } +} diff --git a/test/plugin-import-export/seed/richTextData.ts b/test/plugin-import-export/seed/richTextData.ts new file mode 100644 index 000000000..1dbacf25e --- /dev/null +++ b/test/plugin-import-export/seed/richTextData.ts @@ -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, + }, +} diff --git a/test/plugin-import-export/shared.ts b/test/plugin-import-export/shared.ts new file mode 100644 index 000000000..748a8a017 --- /dev/null +++ b/test/plugin-import-export/shared.ts @@ -0,0 +1 @@ +export const pagesSlug = 'pages' diff --git a/test/plugin-import-export/tsconfig.eslint.json b/test/plugin-import-export/tsconfig.eslint.json new file mode 100644 index 000000000..b34cc7afb --- /dev/null +++ b/test/plugin-import-export/tsconfig.eslint.json @@ -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" + ] +} diff --git a/test/plugin-import-export/tsconfig.json b/test/plugin-import-export/tsconfig.json new file mode 100644 index 000000000..3c43903cf --- /dev/null +++ b/test/plugin-import-export/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} diff --git a/test/setupProd.ts b/test/setupProd.ts index 1f2a524f3..b90671567 100644 --- a/test/setupProd.ts +++ b/test/setupProd.ts @@ -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-*', diff --git a/tsconfig.base.json b/tsconfig.base.json index 26c3c9821..4b37de391 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "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-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" @@ -68,13 +71,11 @@ "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], "@payloadcms/next": ["./packages/next/src/exports/*"], "@payloadcms/storage-s3/client": ["./packages/storage-s3/src/exports/client.ts"], - "@payloadcms/storage-vercel-blob/client": [ - "./packages/storage-vercel-blob/src/exports/client.ts" + "@payloadcms/storage-vercel-blob/client": ["./packages/storage-vercel-blob/src/exports/client.ts" ], "@payloadcms/storage-gcs/client": ["./packages/storage-gcs/src/exports/client.ts"], "@payloadcms/storage-uploadthing/client": [ - "./packages/storage-uploadthing/src/exports/client.ts" - ] + "./packages/storage-uploadthing/src/exports/client.ts"] } }, "include": ["${configDir}/src"], diff --git a/tsconfig.json b/tsconfig.json index 33c6a6c04..bcb2746e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -61,6 +61,9 @@ { "path": "./packages/plugin-seo" }, + { + "path": "./packages/plugin-import-export" + }, { "path": "./packages/plugin-stripe" },