feat: plugin-import-export initial work (#10795)
Adds new plugin-import-export initial version.
Allows for direct download and creation of downloadable collection data
stored to a json or csv uses the access control of the user creating the
request to make the file.
config options:
```ts
/**
* Collections to include the Import/Export controls in
* Defaults to all collections
*/
collections?: string[]
/**
* Enable to force the export to run synchronously
*/
disableJobsQueue?: boolean
/**
* This function takes the default export collection configured in the plugin and allows you to override it by modifying and returning it
* @param collection
* @returns collection
*/
overrideExportCollection?: (collection: CollectionOverride) => CollectionOverride
// payload.config.ts:
plugins: [
importExportPlugin({
collections: ['pages', 'users'],
overrideExportCollection: (collection) => {
collection.admin.group = 'System'
collection.upload.staticDir = path.resolve(dirname, 'uploads')
return collection
},
disableJobsQueue: true,
}),
],
```
---------
Co-authored-by: Jessica Chowdhury <jessica@trbl.design>
Co-authored-by: Kendell Joseph <kendelljoseph@gmail.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
7
packages/plugin-import-export/.gitignore
vendored
Normal file
7
packages/plugin-import-export/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
demo/uploads
|
||||
build
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
12
packages/plugin-import-export/.prettierignore
Normal file
12
packages/plugin-import-export/.prettierignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
**/docs/**
|
||||
tsconfig.json
|
||||
24
packages/plugin-import-export/.swcrc
Normal file
24
packages/plugin-import-export/.swcrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
7
packages/plugin-import-export/README.md
Normal file
7
packages/plugin-import-export/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Payload Import/Export Plugin
|
||||
|
||||
A plugin for [Payload](https://github.com/payloadcms/payload) to easily import and export data.
|
||||
|
||||
- [Source code](https://github.com/payloadcms/payload/tree/main/packages/plugin-import-export)
|
||||
- [Documentation](https://payloadcms.com/docs/plugins/import-export)
|
||||
- [Documentation source](https://github.com/payloadcms/payload/tree/main/docs/plugins/import-export.mdx)
|
||||
18
packages/plugin-import-export/eslint.config.js
Normal file
18
packages/plugin-import-export/eslint.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
|
||||
|
||||
/** @typedef {import('eslint').Linter.Config} Config */
|
||||
|
||||
/** @type {Config[]} */
|
||||
export const index = [
|
||||
...rootEslintConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
...rootParserOptions,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default index
|
||||
22
packages/plugin-import-export/license.md
Normal file
22
packages/plugin-import-export/license.md
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
'Software'), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
101
packages/plugin-import-export/package.json
Normal file
101
packages/plugin-import-export/package.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-import-export",
|
||||
"version": "3.26.0",
|
||||
"description": "Import-Export plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
"cms",
|
||||
"plugin",
|
||||
"typescript",
|
||||
"react",
|
||||
"nextjs",
|
||||
"import",
|
||||
"export"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/payloadcms/payload.git",
|
||||
"directory": "packages/plugin-import-export"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "Payload",
|
||||
"email": "info@payloadcms.com",
|
||||
"url": "https://payloadcms.com"
|
||||
}
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./src/exports/types.ts",
|
||||
"types": "./src/exports/types.ts",
|
||||
"default": "./src/exports/types.ts"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./src/exports/rsc.ts",
|
||||
"types": "./src/exports/rsc.ts",
|
||||
"default": "./src/exports/rsc.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"prepublishOnly": "pnpm clean && pnpm turbo build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@faceless-ui/modal": "3.0.0-beta.2",
|
||||
"@payloadcms/translations": "workspace:*",
|
||||
"@payloadcms/ui": "workspace:*",
|
||||
"csv-parse": "^5.6.0",
|
||||
"csv-stringify": "^6.5.2",
|
||||
"qs-esm": "7.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@payloadcms/ui": "workspace:*",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@payloadcms/ui": "workspace:*",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./dist/exports/types.js",
|
||||
"types": "./dist/exports/types.d.ts",
|
||||
"default": "./dist/exports/types.js"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./dist/exports/rsc.js",
|
||||
"types": "./dist/exports/rsc.d.ts",
|
||||
"default": "./dist/exports/rsc.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"homepage:": "https://payloadcms.com"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { PopupList, useConfig, useDocumentDrawer, useTranslation } from '@payloadcms/ui'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { useImportExport } from '../ImportExportProvider/index.js'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'export-list-menu-item'
|
||||
|
||||
export const ExportListMenuItem: React.FC<{
|
||||
collectionSlug: string
|
||||
exportCollectionSlug: string
|
||||
}> = ({ collectionSlug, exportCollectionSlug }) => {
|
||||
const { getEntityConfig } = useConfig()
|
||||
const { i18n } = useTranslation()
|
||||
const currentCollectionConfig = getEntityConfig({ collectionSlug })
|
||||
|
||||
const [DocumentDrawer, DocumentDrawerToggler] = useDocumentDrawer({
|
||||
collectionSlug: exportCollectionSlug,
|
||||
})
|
||||
const { setCollection } = useImportExport()
|
||||
|
||||
// Set collection and selected items on mount or when selection changes
|
||||
useEffect(() => {
|
||||
setCollection(currentCollectionConfig.slug ?? '')
|
||||
}, [currentCollectionConfig, setCollection])
|
||||
|
||||
return (
|
||||
<PopupList.Button className={baseClass}>
|
||||
<DocumentDrawerToggler>
|
||||
Export {getTranslation(currentCollectionConfig.labels.plural, i18n)}
|
||||
</DocumentDrawerToggler>
|
||||
<DocumentDrawer />
|
||||
</PopupList.Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { Button, SaveButton, useConfig, useForm, useTranslation } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
export const ExportSaveButton: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
config: {
|
||||
routes: { api },
|
||||
serverURL,
|
||||
},
|
||||
} = useConfig()
|
||||
|
||||
const { getData } = useForm()
|
||||
|
||||
const label = t('general:save')
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const data = getData()
|
||||
const response = await fetch(`${serverURL}${api}/exports/download`, {
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
}),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download file')
|
||||
}
|
||||
|
||||
const fileStream = response.body
|
||||
const reader = fileStream?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let result = ''
|
||||
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
result += decoder.decode(value, { stream: true })
|
||||
}
|
||||
|
||||
const blob = new Blob([result], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${data.name}.${data.format}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<SaveButton label={label}></SaveButton>
|
||||
<Button onClick={handleDownload} size="medium" type="button">
|
||||
Download
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import type { ListPreferences, SelectFieldClientComponent } from 'payload'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
FieldLabel,
|
||||
ReactSelect,
|
||||
useConfig,
|
||||
useDocumentInfo,
|
||||
useField,
|
||||
usePreferences,
|
||||
} from '@payloadcms/ui'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import { useImportExport } from '../ImportExportProvider/index.js'
|
||||
import { reduceFields } from './reduceFields.js'
|
||||
|
||||
const baseClass = 'fields-to-export'
|
||||
|
||||
export const FieldsToExport: SelectFieldClientComponent = (props) => {
|
||||
const { id } = useDocumentInfo()
|
||||
const { path } = props
|
||||
const { setValue, value } = useField<string[]>({ path })
|
||||
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
|
||||
const { getEntityConfig } = useConfig()
|
||||
const { collection } = useImportExport()
|
||||
const { getPreference } = usePreferences()
|
||||
const [displayedValue, setDisplayedValue] = useState<
|
||||
{ id: string; label: ReactNode; value: string }[]
|
||||
>([])
|
||||
|
||||
const collectionConfig = getEntityConfig({ collectionSlug: collectionSlug ?? collection })
|
||||
const fieldOptions = reduceFields({ fields: collectionConfig?.fields })
|
||||
|
||||
useEffect(() => {
|
||||
if (value && value.length > 0) {
|
||||
setDisplayedValue((prevDisplayedValue) => {
|
||||
if (prevDisplayedValue.length > 0) {
|
||||
return prevDisplayedValue
|
||||
} // Prevent unnecessary updates
|
||||
|
||||
return value.map((field) => {
|
||||
const match = fieldOptions.find((option) => option.value === field)
|
||||
return match ? { ...match, id: field } : { id: field, label: field, value: field }
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [value, fieldOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (id || !collectionSlug) {
|
||||
return
|
||||
}
|
||||
const doAsync = async () => {
|
||||
const currentPreferences = await getPreference<{
|
||||
columns: ListPreferences['columns']
|
||||
}>(`${collectionSlug}-list`)
|
||||
|
||||
const columns = currentPreferences?.columns?.filter((a) => a.active).map((b) => b.accessor)
|
||||
setValue(columns ?? collectionConfig?.admin?.defaultColumns ?? [])
|
||||
}
|
||||
|
||||
void doAsync()
|
||||
}, [
|
||||
getPreference,
|
||||
collection,
|
||||
setValue,
|
||||
collectionSlug,
|
||||
id,
|
||||
collectionConfig?.admin?.defaultColumns,
|
||||
])
|
||||
const onChange = (options: { id: string; label: ReactNode; value: string }[]) => {
|
||||
if (!options) {
|
||||
setValue([])
|
||||
return
|
||||
}
|
||||
const updatedValue = options?.map((option) =>
|
||||
typeof option === 'object' ? option.value : option,
|
||||
)
|
||||
setValue(updatedValue)
|
||||
setDisplayedValue(options)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<FieldLabel label="Columns to Export" />
|
||||
<ReactSelect
|
||||
className={baseClass}
|
||||
disabled={props.readOnly}
|
||||
getOptionValue={(option) => String(option.value)}
|
||||
isClearable={true}
|
||||
isMulti={true}
|
||||
isSortable={true}
|
||||
// @ts-expect-error react select option
|
||||
onChange={onChange}
|
||||
options={fieldOptions}
|
||||
value={displayedValue}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ClientField } from 'payload'
|
||||
|
||||
import { fieldAffectsData, fieldHasSubFields } from 'payload/shared'
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
const createNestedClientFieldPath = (parentPath: string, field: ClientField): string => {
|
||||
if (parentPath) {
|
||||
if (fieldAffectsData(field)) {
|
||||
return `${parentPath}.${field.name}`
|
||||
}
|
||||
return parentPath
|
||||
}
|
||||
|
||||
if (fieldAffectsData(field)) {
|
||||
return field.name
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const combineLabel = ({
|
||||
field,
|
||||
prefix,
|
||||
}: {
|
||||
field: ClientField
|
||||
prefix?: React.ReactNode
|
||||
}): React.ReactNode => {
|
||||
return (
|
||||
<Fragment>
|
||||
{prefix ? (
|
||||
<Fragment>
|
||||
<span style={{ display: 'inline-block' }}>{prefix}</span>
|
||||
{' > '}
|
||||
</Fragment>
|
||||
) : null}
|
||||
<span style={{ display: 'inline-block' }}>
|
||||
{'label' in field && typeof field.label === 'string'
|
||||
? field.label
|
||||
: (('name' in field && field.name) ?? 'unnamed field')}
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export const reduceFields = ({
|
||||
fields,
|
||||
labelPrefix = null,
|
||||
path = '',
|
||||
}: {
|
||||
fields: ClientField[]
|
||||
labelPrefix?: React.ReactNode
|
||||
path?: string
|
||||
}): { id: string; label: React.ReactNode; value: string }[] => {
|
||||
if (!fields) {
|
||||
return []
|
||||
}
|
||||
|
||||
return fields.reduce<{ id: string; label: React.ReactNode; value: string }[]>(
|
||||
(fieldsToUse, field) => {
|
||||
// escape for a variety of reasons, include ui fields as they have `name`.
|
||||
if (field.type === 'ui') {
|
||||
return fieldsToUse
|
||||
}
|
||||
|
||||
if (!(field.type === 'array' || field.type === 'blocks') && fieldHasSubFields(field)) {
|
||||
return [
|
||||
...fieldsToUse,
|
||||
...reduceFields({
|
||||
fields: field.fields,
|
||||
labelPrefix: combineLabel({ field, prefix: labelPrefix }),
|
||||
path: createNestedClientFieldPath(path, field),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
if (field.type === 'tabs' && 'tabs' in field) {
|
||||
return [
|
||||
...fieldsToUse,
|
||||
...field.tabs.reduce<{ id: string; label: React.ReactNode; value: string }[]>(
|
||||
(tabFields, tab) => {
|
||||
if ('fields' in tab) {
|
||||
const isNamedTab = 'name' in tab && tab.name
|
||||
return [
|
||||
...tabFields,
|
||||
...reduceFields({
|
||||
fields: tab.fields,
|
||||
labelPrefix,
|
||||
path: isNamedTab ? createNestedClientFieldPath(path, field) : path,
|
||||
}),
|
||||
]
|
||||
}
|
||||
return tabFields
|
||||
},
|
||||
[],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
const val = createNestedClientFieldPath(path, field)
|
||||
|
||||
const formattedField = {
|
||||
id: val,
|
||||
label: combineLabel({ field, prefix: labelPrefix }),
|
||||
value: val,
|
||||
}
|
||||
|
||||
return [...fieldsToUse, formattedField]
|
||||
},
|
||||
[],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import React, { createContext, useCallback, useContext, useState } from 'react'
|
||||
|
||||
type ImportExportContext = {
|
||||
collection: string
|
||||
setCollection: (collection: string) => void
|
||||
}
|
||||
|
||||
export const ImportExportContext = createContext({} as ImportExportContext)
|
||||
|
||||
export const ImportExportProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [collection, setCollectionState] = useState<string>('')
|
||||
|
||||
const setCollection = useCallback((collection: string) => {
|
||||
setCollectionState(collection)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ImportExportContext.Provider
|
||||
value={{
|
||||
collection,
|
||||
setCollection,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ImportExportContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useImportExport = (): ImportExportContext => useContext(ImportExportContext)
|
||||
@@ -0,0 +1,8 @@
|
||||
.preview {
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
109
packages/plugin-import-export/src/components/Preview/index.tsx
Normal file
109
packages/plugin-import-export/src/components/Preview/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
import type { Column } from '@payloadcms/ui'
|
||||
import type { ClientField, FieldAffectingDataClient } from 'payload'
|
||||
|
||||
import { Table, useConfig, useField } from '@payloadcms/ui'
|
||||
import { fieldAffectsData } from 'payload/shared'
|
||||
import * as qs from 'qs-esm'
|
||||
import React from 'react'
|
||||
|
||||
import { useImportExport } from '../ImportExportProvider/index.js'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'preview'
|
||||
|
||||
export const Preview = () => {
|
||||
const { collection } = useImportExport()
|
||||
const { config } = useConfig()
|
||||
const { value: where } = useField({ path: 'where' })
|
||||
const { value: limit } = useField<number>({ path: 'limit' })
|
||||
const { value: fields } = useField<string[]>({ path: 'fields' })
|
||||
const { value: sort } = useField({ path: 'sort' })
|
||||
const { value: draft } = useField({ path: 'draft' })
|
||||
const [dataToRender, setDataToRender] = React.useState<any[]>([])
|
||||
const [resultCount, setResultCount] = React.useState<any>('')
|
||||
const [columns, setColumns] = React.useState<Column[]>([])
|
||||
|
||||
const collectionSlug = typeof collection === 'string' && collection
|
||||
const collectionConfig = config.collections.find(
|
||||
(collection) => collection.slug === collectionSlug,
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!collectionSlug) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const whereQuery = qs.stringify(
|
||||
{
|
||||
depth: 0,
|
||||
draft,
|
||||
limit: limit > 10 ? 10 : limit,
|
||||
sort,
|
||||
where,
|
||||
},
|
||||
{
|
||||
addQueryPrefix: true,
|
||||
},
|
||||
)
|
||||
const response = await fetch(`/api/${collectionSlug}${whereQuery}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setResultCount(limit && limit < data.totalDocs ? limit : data.totalDocs)
|
||||
// TODO: check if this data is in the correct format for the table
|
||||
|
||||
const filteredFields = (collectionConfig?.fields?.filter((field) => {
|
||||
if (!fieldAffectsData(field)) {
|
||||
return false
|
||||
}
|
||||
if (fields?.length > 0) {
|
||||
return fields.includes(field.name)
|
||||
}
|
||||
return true
|
||||
}) ?? []) as FieldAffectingDataClient[]
|
||||
|
||||
setColumns(
|
||||
filteredFields.map((field) => ({
|
||||
accessor: field.name || '',
|
||||
active: true,
|
||||
field: field as ClientField,
|
||||
Heading: field?.label || field.name,
|
||||
renderedCells: data.docs.map((doc: Record<string, unknown>) => {
|
||||
if (!field.name || !doc[field.name]) {
|
||||
return null
|
||||
}
|
||||
if (typeof doc[field.name] === 'object') {
|
||||
return JSON.stringify(doc[field.name])
|
||||
}
|
||||
return String(doc[field.name])
|
||||
}),
|
||||
})) as Column[],
|
||||
)
|
||||
setDataToRender(data.docs)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void fetchData()
|
||||
}, [collectionConfig?.fields, collectionSlug, draft, fields, limit, sort, where])
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<div className={`${baseClass}__header`}>
|
||||
<h3>Preview</h3>
|
||||
{resultCount && <span>{resultCount} total documents</span>}
|
||||
</div>
|
||||
{dataToRender && <Table columns={columns} data={dataToRender} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.sort-by-fields {
|
||||
display: block;
|
||||
width: 33%;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import type { SelectFieldClientComponent } from 'payload'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
FieldLabel,
|
||||
ReactSelect,
|
||||
useConfig,
|
||||
useDocumentInfo,
|
||||
useField,
|
||||
useListQuery,
|
||||
} from '@payloadcms/ui'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import { reduceFields } from '../FieldsToExport/reduceFields.js'
|
||||
import { useImportExport } from '../ImportExportProvider/index.js'
|
||||
|
||||
const baseClass = 'sort-by-fields'
|
||||
|
||||
export const SortBy: SelectFieldClientComponent = (props) => {
|
||||
const { id } = useDocumentInfo()
|
||||
const { path } = props
|
||||
const { setValue, value } = useField<string>({ path })
|
||||
const { value: collectionSlug } = useField<string>({ path: 'collectionSlug' })
|
||||
const { query } = useListQuery()
|
||||
const { getEntityConfig } = useConfig()
|
||||
const { collection } = useImportExport()
|
||||
const [displayedValue, setDisplayedValue] = useState<{
|
||||
id: string
|
||||
label: ReactNode
|
||||
value: string
|
||||
} | null>(null)
|
||||
|
||||
const collectionConfig = getEntityConfig({ collectionSlug: collectionSlug ?? collection })
|
||||
const fieldOptions = reduceFields({ fields: collectionConfig?.fields })
|
||||
|
||||
// Sync displayedValue with value from useField
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setDisplayedValue(null)
|
||||
return
|
||||
}
|
||||
|
||||
const option = fieldOptions.find((field) => field.value === value)
|
||||
if (option && (!displayedValue || displayedValue.value !== value)) {
|
||||
setDisplayedValue(option)
|
||||
}
|
||||
}, [value, fieldOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (id || !query?.sort || value) {
|
||||
return
|
||||
}
|
||||
|
||||
const option = fieldOptions.find((field) => field.value === query.sort)
|
||||
if (option) {
|
||||
setValue(option.value)
|
||||
setDisplayedValue(option)
|
||||
}
|
||||
}, [fieldOptions, id, query?.sort, value, setValue])
|
||||
|
||||
const onChange = (option: { id: string; label: ReactNode; value: string } | null) => {
|
||||
if (!option) {
|
||||
setValue('')
|
||||
setDisplayedValue(null)
|
||||
} else {
|
||||
setValue(option.value)
|
||||
setDisplayedValue(option)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass} style={{ '--field-width': '33%' } as React.CSSProperties}>
|
||||
<FieldLabel label="Sort By" />
|
||||
<ReactSelect
|
||||
className={baseClass}
|
||||
disabled={props.readOnly}
|
||||
getOptionValue={(option) => String(option.value)}
|
||||
isClearable={true}
|
||||
isSortable={true}
|
||||
// @ts-expect-error react select option
|
||||
onChange={onChange}
|
||||
options={fieldOptions}
|
||||
// @ts-expect-error react select
|
||||
value={displayedValue}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
151
packages/plugin-import-export/src/export/createExport.ts
Normal file
151
packages/plugin-import-export/src/export/createExport.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { PaginatedDocs, PayloadRequest, Sort, User, Where } from 'payload'
|
||||
|
||||
import { stringify } from 'csv-stringify/sync'
|
||||
import { APIError } from 'payload'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
import { flattenObject } from './flattenObject.js'
|
||||
import { getFilename } from './getFilename.js'
|
||||
import { getSelect } from './getSelect.js'
|
||||
|
||||
type Export = {
|
||||
collectionSlug: string
|
||||
exportsCollection: string
|
||||
fields?: string[]
|
||||
format: 'csv' | 'json'
|
||||
globals?: string[]
|
||||
id: number | string
|
||||
locale?: string
|
||||
name: string
|
||||
slug: string
|
||||
sort: Sort
|
||||
user: string
|
||||
userCollection: string
|
||||
where?: Where
|
||||
}
|
||||
|
||||
export type CreateExportArgs = {
|
||||
/**
|
||||
* If true, stream the file instead of saving it
|
||||
*/
|
||||
download?: boolean
|
||||
input: Export
|
||||
req: PayloadRequest
|
||||
user?: User
|
||||
}
|
||||
|
||||
export const createExport = async (args: CreateExportArgs) => {
|
||||
const {
|
||||
download,
|
||||
input: {
|
||||
id,
|
||||
name: nameArg,
|
||||
collectionSlug,
|
||||
exportsCollection,
|
||||
fields,
|
||||
format,
|
||||
locale: localeInput,
|
||||
sort,
|
||||
user,
|
||||
where,
|
||||
},
|
||||
req: { locale: localeArg, payload },
|
||||
req,
|
||||
} = args
|
||||
const locale = localeInput ?? localeArg
|
||||
const collectionConfig = payload.config.collections.find(({ slug }) => slug === collectionSlug)
|
||||
if (!collectionConfig) {
|
||||
throw new APIError(`Collection with slug ${collectionSlug} not found`)
|
||||
}
|
||||
|
||||
const name = `${nameArg ?? `${getFilename()}-${collectionSlug}`}.${format}`
|
||||
const isCSV = format === 'csv'
|
||||
|
||||
const findArgs = {
|
||||
collection: collectionSlug,
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
locale,
|
||||
overrideAccess: false,
|
||||
page: 0,
|
||||
select: fields ? getSelect(fields) : undefined,
|
||||
sort,
|
||||
user,
|
||||
where,
|
||||
}
|
||||
|
||||
let result: PaginatedDocs = { hasNextPage: true } as PaginatedDocs
|
||||
|
||||
if (download) {
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new Readable({
|
||||
async read() {
|
||||
let result = await payload.find(findArgs)
|
||||
let isFirstBatch = true
|
||||
|
||||
while (result.docs.length > 0) {
|
||||
const csvInput = result.docs.map((doc) => flattenObject(doc))
|
||||
const csvString = stringify(csvInput, { header: isFirstBatch })
|
||||
this.push(encoder.encode(csvString))
|
||||
isFirstBatch = false
|
||||
|
||||
if (!result.hasNextPage) {
|
||||
this.push(null) // End the stream
|
||||
break
|
||||
}
|
||||
|
||||
findArgs.page += 1
|
||||
result = await payload.find(findArgs)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream as any, {
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${name}"`,
|
||||
'Content-Type': isCSV ? 'text/csv' : 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const outputData: string[] = []
|
||||
let isFirstBatch = true
|
||||
|
||||
while (result.hasNextPage) {
|
||||
findArgs.page += 1
|
||||
result = await payload.find(findArgs)
|
||||
|
||||
if (isCSV) {
|
||||
const csvInput = result.docs.map((doc) => flattenObject(doc))
|
||||
outputData.push(stringify(csvInput, { header: isFirstBatch }))
|
||||
isFirstBatch = false
|
||||
} else {
|
||||
const jsonInput = result.docs.map((doc) => JSON.stringify(doc))
|
||||
outputData.push(jsonInput.join(',\n'))
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(format === 'json' ? `[${outputData.join(',')}]` : outputData.join(''))
|
||||
|
||||
if (!id) {
|
||||
req.file = {
|
||||
name,
|
||||
data: buffer,
|
||||
mimetype: isCSV ? 'text/csv' : 'application/json',
|
||||
size: buffer.length,
|
||||
}
|
||||
} else {
|
||||
await req.payload.update({
|
||||
id,
|
||||
collection: exportsCollection,
|
||||
data: {},
|
||||
file: {
|
||||
name,
|
||||
data: buffer,
|
||||
mimetype: isCSV ? 'text/csv' : 'application/json',
|
||||
size: buffer.length,
|
||||
},
|
||||
user,
|
||||
})
|
||||
}
|
||||
}
|
||||
26
packages/plugin-import-export/src/export/download.ts
Normal file
26
packages/plugin-import-export/src/export/download.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { PayloadHandler } from 'payload'
|
||||
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import { createExport } from './createExport.js'
|
||||
|
||||
export const download: PayloadHandler = async (req) => {
|
||||
let body
|
||||
if (typeof req?.json === 'function') {
|
||||
body = await req.json()
|
||||
}
|
||||
|
||||
if (!body || !body.data) {
|
||||
throw new APIError('Request data is required.')
|
||||
}
|
||||
|
||||
req.payload.logger.info(`Download request received ${body.data.collectionSlug}`)
|
||||
|
||||
body.data.user = req.user
|
||||
|
||||
return createExport({
|
||||
download: true,
|
||||
input: body.data,
|
||||
req,
|
||||
}) as Promise<Response>
|
||||
}
|
||||
23
packages/plugin-import-export/src/export/flattenObject.ts
Normal file
23
packages/plugin-import-export/src/export/flattenObject.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const flattenObject = (obj: any, prefix: string = ''): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
const newKey = prefix ? `${prefix}_${key}` : key
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
Object.assign(result, flattenObject(item, `${newKey}_${index}`))
|
||||
} else {
|
||||
result[`${newKey}_${index}`] = item
|
||||
}
|
||||
})
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
Object.assign(result, flattenObject(value, newKey))
|
||||
} else {
|
||||
result[newKey] = value
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Config, TaskHandler, User } from 'payload'
|
||||
|
||||
import type { CreateExportArgs } from './createExport.js'
|
||||
|
||||
import { createExport } from './createExport.js'
|
||||
import { getFields } from './getFields.js'
|
||||
|
||||
export const getCreateCollectionExportTask = (config: Config): TaskHandler<any, string> => {
|
||||
const inputSchema = getFields(config).concat(
|
||||
{
|
||||
name: 'user',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'userCollection',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'exportsCollection',
|
||||
type: 'text',
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
// @ts-expect-error plugin tasks cannot have predefined type slug
|
||||
slug: 'createCollectionExport',
|
||||
handler: async ({ input, req }: CreateExportArgs) => {
|
||||
let user: undefined | User
|
||||
|
||||
if (input.userCollection && input.user) {
|
||||
user = (await req.payload.findByID({
|
||||
id: input.user,
|
||||
collection: input.userCollection,
|
||||
})) as User
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found')
|
||||
}
|
||||
|
||||
await createExport({ input, req, user })
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
},
|
||||
inputSchema,
|
||||
outputSchema: [
|
||||
{
|
||||
name: 'success',
|
||||
type: 'checkbox',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
189
packages/plugin-import-export/src/export/getFields.ts
Normal file
189
packages/plugin-import-export/src/export/getFields.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { Config, Field, SelectField } from 'payload'
|
||||
|
||||
import { getFilename } from './getFilename.js'
|
||||
|
||||
export const getFields = (config: Config): Field[] => {
|
||||
let localeField: SelectField | undefined
|
||||
if (config.localization) {
|
||||
localeField = {
|
||||
name: 'locale',
|
||||
type: 'select',
|
||||
admin: {
|
||||
width: '33%',
|
||||
},
|
||||
defaultValue: 'all',
|
||||
label: 'Locale',
|
||||
options: [
|
||||
{
|
||||
label: 'All Locales',
|
||||
value: 'all',
|
||||
},
|
||||
...config.localization.locales.map((locale) => ({
|
||||
label: typeof locale === 'string' ? locale : locale.label,
|
||||
value: typeof locale === 'string' ? locale : locale.code,
|
||||
})),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'collapsible',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
defaultValue: () => getFilename(),
|
||||
label: 'File Name',
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'format',
|
||||
type: 'select',
|
||||
admin: {
|
||||
width: '33%',
|
||||
},
|
||||
defaultValue: 'csv',
|
||||
label: 'Export Format',
|
||||
options: [
|
||||
{
|
||||
label: 'CSV',
|
||||
value: 'csv',
|
||||
},
|
||||
{
|
||||
label: 'JSON',
|
||||
value: 'json',
|
||||
},
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
admin: {
|
||||
placeholder: 'No limit',
|
||||
width: '33%',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sort',
|
||||
type: 'text',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@payloadcms/plugin-import-export/rsc#SortBy',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
...(localeField ? [localeField] : []),
|
||||
{
|
||||
name: 'drafts',
|
||||
type: 'select',
|
||||
admin: {
|
||||
condition: (data) => {
|
||||
const collectionConfig = (config.collections ?? []).find(
|
||||
(collection) => collection.slug === data.collectionSlug,
|
||||
)
|
||||
return Boolean(
|
||||
typeof collectionConfig?.versions === 'object' &&
|
||||
collectionConfig?.versions?.drafts,
|
||||
)
|
||||
},
|
||||
width: '33%',
|
||||
},
|
||||
defaultValue: 'true',
|
||||
label: 'Drafts',
|
||||
options: [
|
||||
{
|
||||
label: 'True',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
label: 'False',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// name: 'depth',
|
||||
// type: 'number',
|
||||
// admin: {
|
||||
// width: '33%',
|
||||
// },
|
||||
// defaultValue: 1,
|
||||
// required: true,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
// virtual field for the UI component to modify the hidden `where` field
|
||||
name: 'selectionToUse',
|
||||
type: 'radio',
|
||||
defaultValue: 'all',
|
||||
options: [
|
||||
{
|
||||
label: 'Use current selection',
|
||||
value: 'currentSelection',
|
||||
},
|
||||
{
|
||||
label: 'Use current filters',
|
||||
value: 'currentFilters',
|
||||
},
|
||||
{
|
||||
label: 'Use all documents',
|
||||
value: 'all',
|
||||
},
|
||||
],
|
||||
virtual: true,
|
||||
},
|
||||
{
|
||||
name: 'fields',
|
||||
type: 'text',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@payloadcms/plugin-import-export/rsc#FieldsToExport',
|
||||
},
|
||||
},
|
||||
hasMany: true,
|
||||
},
|
||||
{
|
||||
name: 'collectionSlug',
|
||||
type: 'text',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@payloadcms/plugin-import-export/rsc#CollectionField',
|
||||
},
|
||||
hidden: true,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'where',
|
||||
type: 'json',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@payloadcms/plugin-import-export/rsc#WhereField',
|
||||
},
|
||||
},
|
||||
defaultValue: {},
|
||||
},
|
||||
],
|
||||
label: 'Export Options',
|
||||
},
|
||||
{
|
||||
name: 'preview',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: '@payloadcms/plugin-import-export/rsc#Preview',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
7
packages/plugin-import-export/src/export/getFilename.ts
Normal file
7
packages/plugin-import-export/src/export/getFilename.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const getFilename = () => {
|
||||
const now = new Date()
|
||||
const yyymmdd = now.toISOString().split('T')[0] // "YYYY-MM-DD"
|
||||
const hhmmss = now.toTimeString().split(' ')[0] // "HH:MM:SS"
|
||||
|
||||
return `${yyymmdd} ${hhmmss}`
|
||||
}
|
||||
31
packages/plugin-import-export/src/export/getSelect.ts
Normal file
31
packages/plugin-import-export/src/export/getSelect.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { SelectType } from 'payload'
|
||||
|
||||
/**
|
||||
* Takes an input of array of string paths in dot notation and returns a select object
|
||||
* example args: ['id', 'title', 'group.value', 'createdAt', 'updatedAt']
|
||||
*/
|
||||
export const getSelect = (fields: string[]): SelectType => {
|
||||
const select: SelectType = {}
|
||||
|
||||
fields.forEach((field) => {
|
||||
// TODO: this can likely be removed, the form was not saving, leaving in for now
|
||||
if (!field) {
|
||||
return
|
||||
}
|
||||
const segments = field.split('.')
|
||||
let selectRef = select
|
||||
|
||||
segments.forEach((segment, i) => {
|
||||
if (i === segments.length - 1) {
|
||||
selectRef[segment] = true
|
||||
} else {
|
||||
if (!selectRef[segment]) {
|
||||
selectRef[segment] = {}
|
||||
}
|
||||
selectRef = selectRef[segment] as SelectType
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return select
|
||||
}
|
||||
8
packages/plugin-import-export/src/exports/rsc.ts
Normal file
8
packages/plugin-import-export/src/exports/rsc.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { CollectionField } from '../components/CollectionField/index.js'
|
||||
export { ExportListMenuItem } from '../components/ExportListMenuItem/index.js'
|
||||
export { ExportSaveButton } from '../components/ExportSaveButton/index.js'
|
||||
export { FieldsToExport } from '../components/FieldsToExport/index.js'
|
||||
export { ImportExportProvider } from '../components/ImportExportProvider/index.js'
|
||||
export { Preview } from '../components/Preview/index.js'
|
||||
export { SortBy } from '../components/SortBy/index.js'
|
||||
export { WhereField } from '../components/WhereField/index.js'
|
||||
1
packages/plugin-import-export/src/exports/types.ts
Normal file
1
packages/plugin-import-export/src/exports/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { ImportExportPluginConfig } from '../types.js'
|
||||
88
packages/plugin-import-export/src/getExportCollection.ts
Normal file
88
packages/plugin-import-export/src/getExportCollection.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
CollectionAfterChangeHook,
|
||||
CollectionBeforeChangeHook,
|
||||
CollectionBeforeOperationHook,
|
||||
CollectionConfig,
|
||||
Config,
|
||||
} from 'payload'
|
||||
|
||||
import type { CollectionOverride, ImportExportPluginConfig } from './types.js'
|
||||
|
||||
import { createExport } from './export/createExport.js'
|
||||
import { download } from './export/download.js'
|
||||
import { getFields } from './export/getFields.js'
|
||||
|
||||
export const getExportCollection = ({
|
||||
config,
|
||||
pluginConfig,
|
||||
}: {
|
||||
config: Config
|
||||
pluginConfig: ImportExportPluginConfig
|
||||
}): CollectionConfig => {
|
||||
const { overrideExportCollection } = pluginConfig
|
||||
|
||||
const beforeOperation: CollectionBeforeOperationHook[] = []
|
||||
const afterChange: CollectionAfterChangeHook[] = []
|
||||
|
||||
let collection: CollectionOverride = {
|
||||
slug: 'exports',
|
||||
access: {
|
||||
update: () => false,
|
||||
},
|
||||
admin: {
|
||||
group: false,
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
disableDuplicate: true,
|
||||
endpoints: [
|
||||
{
|
||||
handler: download,
|
||||
method: 'post',
|
||||
path: '/download',
|
||||
},
|
||||
],
|
||||
fields: getFields(config),
|
||||
hooks: {
|
||||
afterChange,
|
||||
beforeOperation,
|
||||
},
|
||||
upload: {
|
||||
filesRequiredOnCreate: false,
|
||||
hideFileInputOnCreate: true,
|
||||
hideRemoveFile: true,
|
||||
},
|
||||
}
|
||||
|
||||
if (typeof overrideExportCollection === 'function') {
|
||||
collection = overrideExportCollection(collection)
|
||||
}
|
||||
|
||||
if (pluginConfig.disableJobsQueue) {
|
||||
beforeOperation.push(async ({ args, operation, req }) => {
|
||||
if (operation !== 'create') {
|
||||
return
|
||||
}
|
||||
const { user } = req
|
||||
await createExport({ input: { ...args.data, user }, req })
|
||||
})
|
||||
} else {
|
||||
afterChange.push(async ({ doc, operation, req }) => {
|
||||
if (operation !== 'create') {
|
||||
return
|
||||
}
|
||||
|
||||
const input = {
|
||||
...doc,
|
||||
exportsCollection: collection.slug,
|
||||
user: req?.user?.id || req?.user?.user?.id,
|
||||
userCollection: 'users',
|
||||
}
|
||||
await req.payload.jobs.queue({
|
||||
input,
|
||||
task: 'createCollectionExport',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return collection
|
||||
}
|
||||
72
packages/plugin-import-export/src/index.ts
Normal file
72
packages/plugin-import-export/src/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { Config, JobsConfig } from 'payload'
|
||||
|
||||
import { deepMergeSimple } from 'payload'
|
||||
|
||||
import type { ImportExportPluginConfig } from './types.js'
|
||||
|
||||
import { getCreateCollectionExportTask } from './export/getCreateExportCollectionTask.js'
|
||||
import { getExportCollection } from './getExportCollection.js'
|
||||
import { translations } from './translations/index.js'
|
||||
|
||||
export const importExportPlugin =
|
||||
(pluginConfig: ImportExportPluginConfig) =>
|
||||
(config: Config): Config => {
|
||||
const exportCollection = getExportCollection({ config, pluginConfig })
|
||||
if (config.collections) {
|
||||
config.collections.push(exportCollection)
|
||||
} else {
|
||||
config.collections = [exportCollection]
|
||||
}
|
||||
|
||||
// inject custom import export provider
|
||||
config.admin = config.admin || {}
|
||||
config.admin.components = config.admin.components || {}
|
||||
config.admin.components.providers = config.admin.components.providers || []
|
||||
config.admin.components.providers.push(
|
||||
'@payloadcms/plugin-import-export/rsc#ImportExportProvider',
|
||||
)
|
||||
|
||||
// inject the createExport job into the config
|
||||
config.jobs =
|
||||
config.jobs ||
|
||||
({
|
||||
tasks: [getCreateCollectionExportTask(config)],
|
||||
} as unknown as JobsConfig) // cannot type jobs config inside of plugins
|
||||
|
||||
let collectionsToUpdate = config.collections
|
||||
|
||||
const usePluginCollections = pluginConfig.collections && pluginConfig.collections?.length > 0
|
||||
|
||||
if (usePluginCollections) {
|
||||
collectionsToUpdate = config.collections?.filter((collection) => {
|
||||
return pluginConfig.collections?.includes(collection.slug)
|
||||
})
|
||||
}
|
||||
|
||||
collectionsToUpdate.forEach((collection) => {
|
||||
if (!collection.admin) {
|
||||
collection.admin = { components: { listMenuItems: [] } }
|
||||
}
|
||||
const components = collection.admin.components || {}
|
||||
if (!components.listMenuItems) {
|
||||
components.listMenuItems = []
|
||||
}
|
||||
if (!components.edit) {
|
||||
components.edit = {}
|
||||
}
|
||||
if (!components.edit.SaveButton) {
|
||||
components.edit.SaveButton = '@payloadcms/plugin-import-export/rsc#ExportSaveButton'
|
||||
}
|
||||
components.listMenuItems.push({
|
||||
clientProps: {
|
||||
exportCollectionSlug: exportCollection.slug,
|
||||
},
|
||||
path: '@payloadcms/plugin-import-export/rsc#ExportListMenuItem',
|
||||
})
|
||||
collection.admin.components = components
|
||||
})
|
||||
|
||||
config.i18n = deepMergeSimple(translations, config.i18n?.translations ?? {})
|
||||
|
||||
return config
|
||||
}
|
||||
9
packages/plugin-import-export/src/translations/en.ts
Normal file
9
packages/plugin-import-export/src/translations/en.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { GenericTranslationsObject } from '@payloadcms/translations'
|
||||
|
||||
export const en: GenericTranslationsObject = {
|
||||
$schema: './translation-schema.json',
|
||||
'plugin-seo': {
|
||||
export: 'Export',
|
||||
import: 'Import',
|
||||
},
|
||||
}
|
||||
11
packages/plugin-import-export/src/translations/index.ts
Normal file
11
packages/plugin-import-export/src/translations/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { GenericTranslationsObject, NestedKeysStripped } from '@payloadcms/translations'
|
||||
|
||||
import { en } from './en.js'
|
||||
|
||||
export const translations = {
|
||||
en,
|
||||
}
|
||||
|
||||
export type PluginImportExportTranslations = GenericTranslationsObject
|
||||
|
||||
export type PluginImportExportTranslationKeys = NestedKeysStripped<PluginImportExportTranslations>
|
||||
@@ -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"]
|
||||
}
|
||||
24
packages/plugin-import-export/src/types.ts
Normal file
24
packages/plugin-import-export/src/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { CollectionAdminOptions, CollectionConfig, UploadConfig } from 'payload'
|
||||
|
||||
export type CollectionOverride = {
|
||||
admin: CollectionAdminOptions
|
||||
upload: UploadConfig
|
||||
} & CollectionConfig
|
||||
|
||||
export type ImportExportPluginConfig = {
|
||||
/**
|
||||
* Collections to include the Import/Export controls in
|
||||
* Defaults to all collections
|
||||
*/
|
||||
collections?: string[]
|
||||
/**
|
||||
* Enable to force the export to run synchronously
|
||||
*/
|
||||
disableJobsQueue?: boolean
|
||||
/**
|
||||
* This function takes the default export collection configured in the plugin and allows you to override it by modifying and returning it
|
||||
* @param collection
|
||||
* @returns collection
|
||||
*/
|
||||
overrideExportCollection?: (collection: CollectionOverride) => CollectionOverride
|
||||
}
|
||||
25
packages/plugin-import-export/tsconfig.json
Normal file
25
packages/plugin-import-export/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true, // Make sure typescript knows that this module depends on their references
|
||||
"noEmit": false /* Do not emit outputs. */,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
||||
"rootDir": "./src" /* Specify the root folder within your source files. */
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"build",
|
||||
"tests",
|
||||
"test",
|
||||
"node_modules",
|
||||
"eslint.config.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.spec.tsx"
|
||||
],
|
||||
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/**/*.ts", "src/**/**/*.tsx", "src/**/*.d.ts", "src/**/*.json", ],
|
||||
"references": [{ "path": "../payload" }, { "path": "../ui"}]
|
||||
}
|
||||
Reference in New Issue
Block a user