feat: plugin template (#10150)
Updates the plugin template and adds it to the monorepo
Includes:
* Integration testing setup
* Adding custom client / server components via a plugin
* The same building setup that we use for our plugins in the monorepo
* `create-payload-app` dynamically configures the project based on the
name:`dev/tsconfig.json`, `src/index.ts`, `dev/payload.config.ts`
For example, from project name: `payload-plugin-cool`
`src/index.ts`:
```ts
export type PayloadPluginCoolConfig = {
/**
* List of collections to add a custom field
*/
collections?: Partial<Record<CollectionSlug, true>>
disabled?: boolean
}
export const payloadPluginCool =
(pluginOptions: PayloadPluginCoolConfig) =>
/// ...
```
`dev/tsconfig.json`:
```json
{
"extends": "../tsconfig.json",
"exclude": [],
"include": [
"**/*.ts",
"**/*.tsx",
"../src/**/*.ts",
"../src/**/*.tsx",
"next.config.mjs",
".next/types/**/*.ts"
],
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@payload-config": [
"./payload.config.ts"
],
"payload-plugin-cool": [
"../src/index.ts"
],
"payload-plugin-cool/client": [
"../src/exports/client.ts"
],
"payload-plugin-cool/rsc": [
"../src/exports/rsc.ts"
]
},
"noEmit": true
}
}
```
`./dev/payload.config.ts`
```
import { payloadPluginCool } from 'payload-plugin-cool'
///
plugins: [
payloadPluginCool({
collections: {
posts: true,
},
}),
],
```
Example of published plugin
https://www.npmjs.com/package/payload-plugin-cool
This commit is contained in:
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
@@ -414,6 +414,8 @@ jobs:
|
||||
- template: with-vercel-postgres
|
||||
database: postgres
|
||||
|
||||
- template: plugin
|
||||
|
||||
# Re-enable once PG conncection is figured out
|
||||
# - template: with-vercel-website
|
||||
# database: postgres
|
||||
@@ -467,6 +469,7 @@ jobs:
|
||||
uses: supercharge/mongodb-github-action@1.11.0
|
||||
with:
|
||||
mongodb-version: 6.0
|
||||
if: matrix.database == 'mongodb'
|
||||
|
||||
- name: Build Template
|
||||
run: |
|
||||
|
||||
@@ -9,9 +9,8 @@ keywords: plugins, template, config, configuration, extensions, custom, document
|
||||
Building your own [Payload Plugin](./overview) is easy, and if you're already familiar with Payload then you'll have everything you need to get started. You can either start from scratch or use the [Plugin Template](#plugin-template) to get up and running quickly.
|
||||
|
||||
<Banner type="success">
|
||||
To use the template, run `npx create-payload-app@latest -t plugin -n my-new-plugin` directly in
|
||||
your terminal or [clone the template directly from
|
||||
GitHub](https://github.com/payloadcms/payload-plugin-template).
|
||||
To use the template, run `npx create-payload-app@latest --template plugin` directly in
|
||||
your terminal.
|
||||
</Banner>
|
||||
|
||||
Our plugin template includes everything you need to build a full life-cycle plugin:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import fse from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
import { toCamelCase, toPascalCase } from '../utils/casing.js'
|
||||
|
||||
/**
|
||||
* Configures a plugin project by updating all package name placeholders to projectName
|
||||
*/
|
||||
export const configurePluginProject = ({
|
||||
projectDirPath,
|
||||
projectName,
|
||||
}: {
|
||||
projectDirPath: string
|
||||
projectName: string
|
||||
}) => {
|
||||
const devPayloadConfigPath = path.resolve(projectDirPath, './dev/payload.config.ts')
|
||||
const devTsConfigPath = path.resolve(projectDirPath, './dev/tsconfig.json')
|
||||
const indexTsPath = path.resolve(projectDirPath, './src/index.ts')
|
||||
|
||||
const devPayloadConfig = fse.readFileSync(devPayloadConfigPath, 'utf8')
|
||||
const devTsConfig = fse.readFileSync(devTsConfigPath, 'utf8')
|
||||
const indexTs = fse.readFileSync(indexTsPath, 'utf-8')
|
||||
|
||||
const updatedTsConfig = devTsConfig.replaceAll('plugin-package-name-placeholder', projectName)
|
||||
let updatedIndexTs = indexTs.replaceAll('plugin-package-name-placeholder', projectName)
|
||||
|
||||
const pluginExportVariableName = toCamelCase(projectName)
|
||||
|
||||
updatedIndexTs = updatedIndexTs.replace(
|
||||
'export const myPlugin',
|
||||
`export const ${pluginExportVariableName}`,
|
||||
)
|
||||
|
||||
updatedIndexTs = updatedIndexTs.replaceAll('MyPluginConfig', `${toPascalCase(projectName)}Config`)
|
||||
|
||||
let updatedPayloadConfig = devPayloadConfig.replace(
|
||||
'plugin-package-name-placeholder',
|
||||
projectName,
|
||||
)
|
||||
|
||||
updatedPayloadConfig = updatedPayloadConfig.replaceAll('myPlugin', pluginExportVariableName)
|
||||
|
||||
fse.writeFileSync(devPayloadConfigPath, updatedPayloadConfig)
|
||||
fse.writeFileSync(devTsConfigPath, updatedTsConfig)
|
||||
fse.writeFileSync(indexTsPath, updatedIndexTs)
|
||||
}
|
||||
@@ -44,10 +44,11 @@ describe('createProject', () => {
|
||||
name: 'plugin',
|
||||
type: 'plugin',
|
||||
description: 'Template for creating a Payload plugin',
|
||||
url: 'https://github.com/payloadcms/payload-plugin-template',
|
||||
url: 'https://github.com/payloadcms/payload/templates/plugin',
|
||||
}
|
||||
|
||||
await createProject({
|
||||
cliArgs: args,
|
||||
cliArgs: { ...args, '--local-template': 'plugin' } as CliArgs,
|
||||
packageManager,
|
||||
projectDir,
|
||||
projectName,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CliArgs, DbDetails, PackageManager, ProjectTemplate } from '../typ
|
||||
import { tryInitRepoAndCommit } from '../utils/git.js'
|
||||
import { debug, error, info, warning } from '../utils/log.js'
|
||||
import { configurePayloadConfig } from './configure-payload-config.js'
|
||||
import { configurePluginProject } from './configure-plugin-project.js'
|
||||
import { downloadTemplate } from './download-template.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
@@ -93,11 +94,17 @@ export async function createProject(args: {
|
||||
spinner.start('Checking latest Payload version...')
|
||||
|
||||
await updatePackageJSON({ projectDir, projectName })
|
||||
spinner.message('Configuring Payload...')
|
||||
await configurePayloadConfig({
|
||||
dbType: dbDetails?.type,
|
||||
projectDirOrConfigPath: { projectDir },
|
||||
})
|
||||
|
||||
if (template.type === 'plugin') {
|
||||
spinner.message('Configuring Plugin...')
|
||||
configurePluginProject({ projectDirPath: projectDir, projectName })
|
||||
} else {
|
||||
spinner.message('Configuring Payload...')
|
||||
await configurePayloadConfig({
|
||||
dbType: dbDetails?.type,
|
||||
projectDirOrConfigPath: { projectDir },
|
||||
})
|
||||
}
|
||||
|
||||
// Remove yarn.lock file. This is only desired in Payload Cloud.
|
||||
const lockPath = path.resolve(projectDir, 'pnpm-lock.yaml')
|
||||
|
||||
@@ -27,12 +27,11 @@ export function getValidTemplates(): ProjectTemplate[] {
|
||||
description: 'Website Template',
|
||||
url: `https://github.com/payloadcms/payload/templates/website#main`,
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'plugin',
|
||||
// type: 'plugin',
|
||||
// description: 'Template for creating a Payload plugin',
|
||||
// url: 'https://github.com/payloadcms/plugin-template#beta',
|
||||
// },
|
||||
{
|
||||
name: 'plugin',
|
||||
type: 'plugin',
|
||||
description: 'Template for creating a Payload plugin',
|
||||
url: 'https://github.com/payloadcms/payload/templates/plugin#main',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
14
packages/create-payload-app/src/utils/casing.ts
Normal file
14
packages/create-payload-app/src/utils/casing.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const toCamelCase = (str: string) => {
|
||||
const s = str
|
||||
.match(/[A-Z]{2,}(?=[A-Z][a-z]+\d*|\b)|[A-Z]?[a-z]+\d*|[A-Z]|\d+/g)
|
||||
?.map((x) => x.slice(0, 1).toUpperCase() + x.slice(1).toLowerCase())
|
||||
.join('')
|
||||
return (s && s.slice(0, 1).toLowerCase() + s.slice(1)) ?? ''
|
||||
}
|
||||
|
||||
export function toPascalCase(input: string): string {
|
||||
return input
|
||||
.replace(/[_-]+/g, ' ') // Replace underscores or hyphens with spaces
|
||||
.replace(/(?:^|\s+)(\w)/g, (_, c) => c.toUpperCase()) // Capitalize first letter of each word
|
||||
.replace(/\s+/g, '') // Remove all spaces
|
||||
}
|
||||
43
templates/plugin/.gitignore
vendored
Normal file
43
templates/plugin/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
/.idea/*
|
||||
!/.idea/runConfigurations
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
.env
|
||||
|
||||
/dev/media
|
||||
6
templates/plugin/.prettierrc.json
Normal file
6
templates/plugin/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": false
|
||||
}
|
||||
24
templates/plugin/.swcrc
Normal file
24
templates/plugin/.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"
|
||||
}
|
||||
}
|
||||
3
templates/plugin/.vscode/extensions.json
vendored
Normal file
3
templates/plugin/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
24
templates/plugin/.vscode/launch.json
vendored
Normal file
24
templates/plugin/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
|
||||
"runtimeArgs": ["--inspect"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithChrome",
|
||||
"killOnServerStop": true,
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
templates/plugin/.vscode/settings.json
vendored
Normal file
40
templates/plugin/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"npm.packageManager": "pnpm",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"[javascript][typescript][typescriptreact]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
templates/plugin/README.md
Normal file
1
templates/plugin/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# Plugin
|
||||
2
templates/plugin/dev/.env.example
Normal file
2
templates/plugin/dev/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
@@ -0,0 +1,25 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
|
||||
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) =>
|
||||
NotFoundPage({ config, importMap, params, searchParams })
|
||||
|
||||
export default NotFound
|
||||
@@ -0,0 +1,25 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
|
||||
|
||||
import { importMap } from '../importMap.js'
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[]
|
||||
}>
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config, importMap, params, searchParams })
|
||||
|
||||
export default Page
|
||||
9
templates/plugin/dev/app/(payload)/admin/importMap.js
Normal file
9
templates/plugin/dev/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { BeforeDashboardClient as BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343 } from 'plugin-package-name-placeholder/client'
|
||||
import { BeforeDashboardServer as BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f } from 'plugin-package-name-placeholder/rsc'
|
||||
|
||||
export const importMap = {
|
||||
'plugin-package-name-placeholder/client#BeforeDashboardClient':
|
||||
BeforeDashboardClient_fc6e7dd366b9e2c8ce77d31252122343,
|
||||
'plugin-package-name-placeholder/rsc#BeforeDashboardServer':
|
||||
BeforeDashboardServer_c4406fcca100b2553312c5a3d7520a3f,
|
||||
}
|
||||
19
templates/plugin/dev/app/(payload)/api/[...slug]/route.ts
Normal file
19
templates/plugin/dev/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
export const PUT = REST_PUT(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
@@ -0,0 +1,7 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
8
templates/plugin/dev/app/(payload)/api/graphql/route.ts
Normal file
8
templates/plugin/dev/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
0
templates/plugin/dev/app/(payload)/custom.scss
Normal file
0
templates/plugin/dev/app/(payload)/custom.scss
Normal file
32
templates/plugin/dev/app/(payload)/layout.tsx
Normal file
32
templates/plugin/dev/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ServerFunctionClient } from 'payload'
|
||||
|
||||
import '@payloadcms/next/css'
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import config from '@payload-config'
|
||||
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
|
||||
import React from 'react'
|
||||
|
||||
import { importMap } from './admin/importMap.js'
|
||||
import './custom.scss'
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server'
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
})
|
||||
}
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
export default Layout
|
||||
14
templates/plugin/dev/app/my-route/route.ts
Normal file
14
templates/plugin/dev/app/my-route/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
export const GET = async () => {
|
||||
const payload = await getPayload({
|
||||
config: configPromise,
|
||||
})
|
||||
|
||||
const data = await payload.find({
|
||||
collection: 'users',
|
||||
})
|
||||
|
||||
return Response.json(data)
|
||||
}
|
||||
245
templates/plugin/dev/helpers/NextRESTClient.ts
Normal file
245
templates/plugin/dev/helpers/NextRESTClient.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { JoinQuery, PopulateType, SanitizedConfig, SelectType, Where } from 'payload'
|
||||
import type { ParsedQs } from 'qs-esm'
|
||||
|
||||
import {
|
||||
REST_DELETE as createDELETE,
|
||||
REST_GET as createGET,
|
||||
GRAPHQL_POST as createGraphqlPOST,
|
||||
REST_PATCH as createPATCH,
|
||||
REST_POST as createPOST,
|
||||
REST_PUT as createPUT,
|
||||
} from '@payloadcms/next/routes'
|
||||
import * as qs from 'qs-esm'
|
||||
|
||||
import { devUser } from './credentials.js'
|
||||
|
||||
type ValidPath = `/${string}`
|
||||
type RequestOptions = {
|
||||
auth?: boolean
|
||||
query?: {
|
||||
depth?: number
|
||||
fallbackLocale?: string
|
||||
joins?: JoinQuery
|
||||
limit?: number
|
||||
locale?: string
|
||||
page?: number
|
||||
populate?: PopulateType
|
||||
select?: SelectType
|
||||
sort?: string
|
||||
where?: Where
|
||||
}
|
||||
}
|
||||
|
||||
type FileArg = {
|
||||
file?: Omit<File, 'webkitRelativePath'>
|
||||
}
|
||||
|
||||
function generateQueryString(query: RequestOptions['query'], params?: ParsedQs): string {
|
||||
return qs.stringify(
|
||||
{
|
||||
...(params || {}),
|
||||
...(query || {}),
|
||||
},
|
||||
{
|
||||
addQueryPrefix: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export class NextRESTClient {
|
||||
private _DELETE: (
|
||||
request: Request,
|
||||
args: { params: Promise<{ slug: string[] }> },
|
||||
) => Promise<Response>
|
||||
|
||||
private _GET: (
|
||||
request: Request,
|
||||
args: { params: Promise<{ slug: string[] }> },
|
||||
) => Promise<Response>
|
||||
|
||||
private _GRAPHQL_POST: (request: Request) => Promise<Response>
|
||||
|
||||
private _PATCH: (
|
||||
request: Request,
|
||||
args: { params: Promise<{ slug: string[] }> },
|
||||
) => Promise<Response>
|
||||
|
||||
private _POST: (
|
||||
request: Request,
|
||||
args: { params: Promise<{ slug: string[] }> },
|
||||
) => Promise<Response>
|
||||
|
||||
private _PUT: (
|
||||
request: Request,
|
||||
args: { params: Promise<{ slug: string[] }> },
|
||||
) => Promise<Response>
|
||||
|
||||
private readonly config: SanitizedConfig
|
||||
|
||||
private token?: string
|
||||
|
||||
serverURL: string = 'http://localhost:3000'
|
||||
|
||||
constructor(config: SanitizedConfig) {
|
||||
this.config = config
|
||||
if (config?.serverURL) {
|
||||
this.serverURL = config.serverURL
|
||||
}
|
||||
this._GET = createGET(config)
|
||||
this._POST = createPOST(config)
|
||||
this._DELETE = createDELETE(config)
|
||||
this._PATCH = createPATCH(config)
|
||||
this._PUT = createPUT(config)
|
||||
this._GRAPHQL_POST = createGraphqlPOST(config)
|
||||
}
|
||||
|
||||
private buildHeaders(options: FileArg & RequestInit & RequestOptions): Headers {
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const headers = new Headers({
|
||||
...(options?.file
|
||||
? {
|
||||
'Content-Length': options.file.size.toString(),
|
||||
}
|
||||
: defaultHeaders),
|
||||
...(options?.headers || {}),
|
||||
})
|
||||
|
||||
if (options.auth !== false && this.token) {
|
||||
headers.set('Authorization', `JWT ${this.token}`)
|
||||
}
|
||||
if (options.auth === false) {
|
||||
headers.set('DisableAutologin', 'true')
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
private generateRequestParts(path: ValidPath): {
|
||||
params?: ParsedQs
|
||||
slug: string[]
|
||||
url: string
|
||||
} {
|
||||
const [slugs, params] = path.slice(1).split('?')
|
||||
const url = `${this.serverURL}${this.config.routes.api}/${slugs}`
|
||||
|
||||
return {
|
||||
slug: slugs.split('/'),
|
||||
params: params ? qs.parse(params) : undefined,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
async DELETE(path: ValidPath, options: RequestInit & RequestOptions = {}): Promise<Response> {
|
||||
const { slug, params, url } = this.generateRequestParts(path)
|
||||
const { query, ...rest } = options || {}
|
||||
const queryParams = generateQueryString(query, params)
|
||||
|
||||
const request = new Request(`${url}${queryParams}`, {
|
||||
...rest,
|
||||
headers: this.buildHeaders(options),
|
||||
method: 'DELETE',
|
||||
})
|
||||
return this._DELETE(request, { params: Promise.resolve({ slug }) })
|
||||
}
|
||||
|
||||
async GET(
|
||||
path: ValidPath,
|
||||
options: Omit<RequestInit, 'body'> & RequestOptions = {},
|
||||
): Promise<Response> {
|
||||
const { slug, params, url } = this.generateRequestParts(path)
|
||||
const { query, ...rest } = options || {}
|
||||
const queryParams = generateQueryString(query, params)
|
||||
|
||||
const request = new Request(`${url}${queryParams}`, {
|
||||
...rest,
|
||||
headers: this.buildHeaders(options),
|
||||
method: 'GET',
|
||||
})
|
||||
return this._GET(request, { params: Promise.resolve({ slug }) })
|
||||
}
|
||||
|
||||
async GRAPHQL_POST(options: RequestInit & RequestOptions): Promise<Response> {
|
||||
const { query, ...rest } = options
|
||||
const queryParams = generateQueryString(query, {})
|
||||
const request = new Request(
|
||||
`${this.serverURL}${this.config.routes.api}${this.config.routes.graphQL}${queryParams}`,
|
||||
{
|
||||
...rest,
|
||||
headers: this.buildHeaders(options),
|
||||
method: 'POST',
|
||||
},
|
||||
)
|
||||
return this._GRAPHQL_POST(request)
|
||||
}
|
||||
|
||||
async login({
|
||||
slug,
|
||||
credentials,
|
||||
}: {
|
||||
credentials?: {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
slug: string
|
||||
}): Promise<{ [key: string]: unknown }> {
|
||||
const response = await this.POST(`/${slug}/login`, {
|
||||
body: JSON.stringify(
|
||||
credentials ? { ...credentials } : { email: devUser.email, password: devUser.password },
|
||||
),
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
this.token = result.token
|
||||
|
||||
if (!result.token) {
|
||||
// If the token is not in the response body, then we can extract it from the cookies
|
||||
const setCookie = response.headers.get('Set-Cookie')
|
||||
const tokenMatchResult = setCookie?.match(/payload-token=(?<token>.+?);/)
|
||||
this.token = tokenMatchResult?.groups?.token
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async PATCH(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise<Response> {
|
||||
const { slug, params, url } = this.generateRequestParts(path)
|
||||
const { query, ...rest } = options
|
||||
const queryParams = generateQueryString(query, params)
|
||||
|
||||
const request = new Request(`${url}${queryParams}`, {
|
||||
...rest,
|
||||
headers: this.buildHeaders(options),
|
||||
method: 'PATCH',
|
||||
})
|
||||
return this._PATCH(request, { params: Promise.resolve({ slug }) })
|
||||
}
|
||||
|
||||
async POST(
|
||||
path: ValidPath,
|
||||
options: FileArg & RequestInit & RequestOptions = {},
|
||||
): Promise<Response> {
|
||||
const { slug, params, url } = this.generateRequestParts(path)
|
||||
const queryParams = generateQueryString({}, params)
|
||||
const request = new Request(`${url}${queryParams}`, {
|
||||
...options,
|
||||
headers: this.buildHeaders(options),
|
||||
method: 'POST',
|
||||
})
|
||||
return this._POST(request, { params: Promise.resolve({ slug }) })
|
||||
}
|
||||
|
||||
async PUT(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise<Response> {
|
||||
const { slug, params, url } = this.generateRequestParts(path)
|
||||
const { query, ...rest } = options
|
||||
const queryParams = generateQueryString(query, params)
|
||||
|
||||
const request = new Request(`${url}${queryParams}`, {
|
||||
...rest,
|
||||
headers: this.buildHeaders(options),
|
||||
method: 'PUT',
|
||||
})
|
||||
return this._PUT(request, { params: Promise.resolve({ slug }) })
|
||||
}
|
||||
}
|
||||
4
templates/plugin/dev/helpers/credentials.ts
Normal file
4
templates/plugin/dev/helpers/credentials.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const devUser = {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
}
|
||||
38
templates/plugin/dev/helpers/testEmailAdapter.ts
Normal file
38
templates/plugin/dev/helpers/testEmailAdapter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { EmailAdapter, SendEmailOptions } from 'payload'
|
||||
|
||||
/**
|
||||
* Logs all emails to stdout
|
||||
*/
|
||||
export const testEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
|
||||
name: 'test-email-adapter',
|
||||
defaultFromAddress: 'dev@payloadcms.com',
|
||||
defaultFromName: 'Payload Test',
|
||||
sendEmail: async (message) => {
|
||||
const stringifiedTo = getStringifiedToAddress(message)
|
||||
const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'`
|
||||
payload.logger.info({ content: message, msg: res })
|
||||
return Promise.resolve()
|
||||
},
|
||||
})
|
||||
|
||||
function getStringifiedToAddress(message: SendEmailOptions): string | undefined {
|
||||
let stringifiedTo: string | undefined
|
||||
|
||||
if (typeof message.to === 'string') {
|
||||
stringifiedTo = message.to
|
||||
} else if (Array.isArray(message.to)) {
|
||||
stringifiedTo = message.to
|
||||
.map((to: { address: string } | string) => {
|
||||
if (typeof to === 'string') {
|
||||
return to
|
||||
} else if (to.address) {
|
||||
return to.address
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.join(', ')
|
||||
} else if (message.to?.address) {
|
||||
stringifiedTo = message.to.address
|
||||
}
|
||||
return stringifiedTo
|
||||
}
|
||||
90
templates/plugin/dev/int.spec.ts
Normal file
90
templates/plugin/dev/int.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Here are your integration tests for the plugin.
|
||||
* They don't require running your Next.js so they are fast
|
||||
* Yet they still can test the Local API and custom endpoints using NextRESTClient helper.
|
||||
*/
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
import { MongoMemoryReplSet } from 'mongodb-memory-server'
|
||||
import path from 'path'
|
||||
import { getPayload } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { NextRESTClient } from './helpers/NextRESTClient.js'
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
let payload: Payload
|
||||
let restClient: NextRESTClient
|
||||
let memoryDB: MongoMemoryReplSet | undefined
|
||||
|
||||
describe('Plugin tests', () => {
|
||||
beforeAll(async () => {
|
||||
process.env.DISABLE_PAYLOAD_HMR = 'true'
|
||||
process.env.PAYLOAD_DROP_DATABASE = 'true'
|
||||
|
||||
dotenv.config({
|
||||
path: path.resolve(dirname, './.env'),
|
||||
})
|
||||
|
||||
if (!process.env.DATABASE_URI) {
|
||||
console.log('Starting memory database')
|
||||
memoryDB = await MongoMemoryReplSet.create({
|
||||
replSet: {
|
||||
count: 3,
|
||||
dbName: 'payloadmemory',
|
||||
},
|
||||
})
|
||||
console.log('Memory database started')
|
||||
|
||||
process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true`
|
||||
}
|
||||
|
||||
const { default: config } = await import('./payload.config.js')
|
||||
|
||||
payload = await getPayload({ config })
|
||||
restClient = new NextRESTClient(payload.config)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (payload.db.destroy) {
|
||||
await payload.db.destroy()
|
||||
}
|
||||
|
||||
if (memoryDB) {
|
||||
await memoryDB.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('should query added by plugin custom endpoint', async () => {
|
||||
const response = await restClient.GET('/my-plugin-endpoint')
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const data = await response.json()
|
||||
expect(data).toMatchObject({
|
||||
message: 'Hello from custom endpoint',
|
||||
})
|
||||
})
|
||||
|
||||
it('can create post with a custom text field added by plugin', async () => {
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
addedByPlugin: 'added by plugin',
|
||||
},
|
||||
})
|
||||
|
||||
expect(post.addedByPlugin).toBe('added by plugin')
|
||||
})
|
||||
|
||||
it('plugin creates and seeds plugin-collection', async () => {
|
||||
expect(payload.collections['plugin-collection']).toBeDefined()
|
||||
|
||||
const { docs } = await payload.find({ collection: 'plugin-collection' })
|
||||
|
||||
expect(docs).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
5
templates/plugin/dev/next-env.d.ts
vendored
Normal file
5
templates/plugin/dev/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
21
templates/plugin/dev/next.config.mjs
Normal file
21
templates/plugin/dev/next.config.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
webpack: (webpackConfig) => {
|
||||
webpackConfig.resolve.extensionAlias = {
|
||||
'.cjs': ['.cts', '.cjs'],
|
||||
'.js': ['.ts', '.tsx', '.js', '.jsx'],
|
||||
'.mjs': ['.mts', '.mjs'],
|
||||
}
|
||||
|
||||
return webpackConfig
|
||||
},
|
||||
// transpilePackages: ['../src'],
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig)
|
||||
276
templates/plugin/dev/payload-types.ts
Normal file
276
templates/plugin/dev/payload-types.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
collections: {
|
||||
posts: Post;
|
||||
media: Media;
|
||||
'plugin-collection': PluginCollection;
|
||||
users: User;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
posts: PostsSelect<false> | PostsSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
'plugin-collection': PluginCollectionSelect<false> | PluginCollectionSelect<true>;
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
jobs: {
|
||||
tasks: 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` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: string;
|
||||
addedByPlugin?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: string;
|
||||
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` "plugin-collection".
|
||||
*/
|
||||
export interface PluginCollection {
|
||||
id: string;
|
||||
updatedAt: string;
|
||||
createdAt: 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` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'posts';
|
||||
value: string | Post;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'media';
|
||||
value: string | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'plugin-collection';
|
||||
value: string | PluginCollection;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
} | 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` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
addedByPlugin?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
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` "plugin-collection_select".
|
||||
*/
|
||||
export interface PluginCollectionSelect<T extends boolean = true> {
|
||||
id?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
61
templates/plugin/dev/payload.config.ts
Normal file
61
templates/plugin/dev/payload.config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import { myPlugin } from 'plugin-package-name-placeholder'
|
||||
import sharp from 'sharp'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { devUser } from './helpers/credentials.js'
|
||||
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
|
||||
import { seed } from './seed.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
if (!process.env.ROOT_DIR) {
|
||||
process.env.ROOT_DIR = dirname
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-exports
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
autoLogin: devUser,
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
slug: 'posts',
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
slug: 'media',
|
||||
fields: [],
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, 'media'),
|
||||
},
|
||||
},
|
||||
],
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URI || '',
|
||||
}),
|
||||
editor: lexicalEditor(),
|
||||
email: testEmailAdapter,
|
||||
onInit: async (payload) => {
|
||||
await seed(payload)
|
||||
},
|
||||
plugins: [
|
||||
myPlugin({
|
||||
collections: {
|
||||
posts: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
secret: process.env.PAYLOAD_SECRET || 'test-secret_key',
|
||||
sharp,
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
21
templates/plugin/dev/seed.ts
Normal file
21
templates/plugin/dev/seed.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { devUser } from './helpers/credentials.js'
|
||||
|
||||
export const seed = async (payload: Payload) => {
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: {
|
||||
equals: devUser.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!totalDocs) {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: devUser,
|
||||
})
|
||||
}
|
||||
}
|
||||
29
templates/plugin/dev/server.ts
Normal file
29
templates/plugin/dev/server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { NextServerOptions } from 'next/dist/server/next.js'
|
||||
|
||||
import { createServer } from 'http'
|
||||
import next from 'next'
|
||||
import open from 'open'
|
||||
import path from 'path'
|
||||
import { fileURLToPath, parse } from 'url'
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const opts: NextServerOptions = {
|
||||
dev: true,
|
||||
dir: dirname,
|
||||
}
|
||||
|
||||
// @ts-expect-error next types do not import
|
||||
const app = next(opts)
|
||||
const handle = app.getRequestHandler()
|
||||
|
||||
await app.prepare()
|
||||
|
||||
await open(`http://localhost:3000/admin`)
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const parsedUrl = parse(req.url!, true)
|
||||
void handle(req, res, parsedUrl)
|
||||
})
|
||||
|
||||
server.listen(3000)
|
||||
30
templates/plugin/dev/tsconfig.json
Normal file
30
templates/plugin/dev/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"exclude": [],
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"../src/**/*.ts",
|
||||
"../src/**/*.tsx",
|
||||
"next.config.mjs",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@payload-config": [
|
||||
"./payload.config.ts"
|
||||
],
|
||||
"plugin-package-name-placeholder": [
|
||||
"../src/index.ts"
|
||||
],
|
||||
"plugin-package-name-placeholder/client": [
|
||||
"../src/exports/client.ts"
|
||||
],
|
||||
"plugin-package-name-placeholder/rsc": [
|
||||
"../src/exports/rsc.ts"
|
||||
]
|
||||
},
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
41
templates/plugin/eslint.config.js
Normal file
41
templates/plugin/eslint.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
|
||||
import payloadEsLintConfig from '@payloadcms/eslint-config'
|
||||
|
||||
export const defaultESLintIgnores = [
|
||||
'**/.temp',
|
||||
'**/.*', // ignore all dotfiles
|
||||
'**/.git',
|
||||
'**/.hg',
|
||||
'**/.pnp.*',
|
||||
'**/.svn',
|
||||
'**/playwright.config.ts',
|
||||
'**/jest.config.js',
|
||||
'**/tsconfig.tsbuildinfo',
|
||||
'**/README.md',
|
||||
'**/eslint.config.js',
|
||||
'**/payload-types.ts',
|
||||
'**/dist/',
|
||||
'**/.yarn/',
|
||||
'**/build/',
|
||||
'**/node_modules/',
|
||||
'**/temp/',
|
||||
]
|
||||
|
||||
export default [
|
||||
...payloadEsLintConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 'latest',
|
||||
projectService: {
|
||||
maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40,
|
||||
allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'],
|
||||
},
|
||||
// projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
53
templates/plugin/jest.config.js
Normal file
53
templates/plugin/jest.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const esModules = [
|
||||
// file-type and all dependencies: https://github.com/sindresorhus/file-type
|
||||
'file-type',
|
||||
'strtok3',
|
||||
'readable-web-to-node-stream',
|
||||
'token-types',
|
||||
'peek-readable',
|
||||
'locate-path',
|
||||
'p-locate',
|
||||
'p-limit',
|
||||
'yocto-queue',
|
||||
'unicorn-magic',
|
||||
'path-exists',
|
||||
'qs-esm',
|
||||
'uint8array-extras',
|
||||
'payload',
|
||||
'@payloadcms/next',
|
||||
'@payloadcms/ui',
|
||||
'@payloadcms/graphql',
|
||||
'@payloadcms/translations',
|
||||
'@payloadcms/db-mongodb',
|
||||
'@payloadcms/richtext-lexical',
|
||||
].join('|')
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
const customJestConfig = {
|
||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||
transformIgnorePatterns: [
|
||||
`/node_modules/(?!.pnpm)(?!(${esModules})/)`,
|
||||
`/node_modules/.pnpm/(?!(${esModules.replace(/\//g, '\\+')})@)`,
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'\\.(css|scss)$': '<rootDir>/test/helpers/mocks/emptyModule.js',
|
||||
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
|
||||
'<rootDir>/test/helpers/mocks/fileMock.js',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
testTimeout: 90000,
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': ['@swc/jest'],
|
||||
},
|
||||
verbose: true,
|
||||
testMatch: ['<rootDir>/**/*int.spec.ts'],
|
||||
moduleNameMapper: {
|
||||
'\\.(css|scss)$': '<rootDir>/helpers/mocks/emptyModule.js',
|
||||
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
|
||||
'<rootDir>/helpers/mocks/fileMock.js',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
}
|
||||
|
||||
export default customJestConfig
|
||||
85
templates/plugin/package.json
Normal file
85
templates/plugin/package.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "plugin-package-name-placeholder",
|
||||
"version": "1.0.0",
|
||||
"description": "A blank template to get started with Payload 3.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/exports/client.js",
|
||||
"types": "./dist/exports/client.d.ts",
|
||||
"default": "./dist/exports/client.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",
|
||||
"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 --outDir dist --rootDir ./src",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"dev": "payload run ./dev/server.ts",
|
||||
"dev:generate-importmap": "pnpm dev:payload generate:importmap",
|
||||
"dev:generate-types": "pnpm dev:payload generate:types",
|
||||
"dev:payload": "PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload",
|
||||
"lint": "eslint ./src",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"prepublishOnly": "pnpm clean && pnpm turbo build",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@payloadcms/db-mongodb": "3.11.0",
|
||||
"@payloadcms/db-postgres": "3.11.0",
|
||||
"@payloadcms/db-sqlite": "3.11.0",
|
||||
"@payloadcms/eslint-config": "3.9.0",
|
||||
"@payloadcms/next": "3.11.0",
|
||||
"@payloadcms/richtext-lexical": "3.11.0",
|
||||
"@payloadcms/ui": "3.11.0",
|
||||
"@swc-node/register": "1.10.9",
|
||||
"@swc/cli": "0.5.1",
|
||||
"@swc/jest": "^0.2.37",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "19.0.1",
|
||||
"@types/react-dom": "19.0.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "15.1.0",
|
||||
"graphql": "^16.8.1",
|
||||
"jest": "29.7.0",
|
||||
"mongodb-memory-server": "^10.1.2",
|
||||
"next": "15.1.0",
|
||||
"open": "^10.1.0",
|
||||
"payload": "3.11.0",
|
||||
"prettier": "^3.4.2",
|
||||
"qs-esm": "7.0.2",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"rimraf": "3.0.2",
|
||||
"sharp": "0.32.6",
|
||||
"sort-package-json": "^2.10.0",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0"
|
||||
},
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
29
templates/plugin/src/components/BeforeDashboardClient.tsx
Normal file
29
templates/plugin/src/components/BeforeDashboardClient.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const BeforeDashboardClient = () => {
|
||||
const { config } = useConfig()
|
||||
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessage = async () => {
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/my-plugin-endpoint`)
|
||||
const result = await response.json()
|
||||
setMessage(result.message)
|
||||
}
|
||||
|
||||
void fetchMessage()
|
||||
}, [config.serverURL, config.routes.api])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Added by the plugin: Before Dashboard Client</h1>
|
||||
<div>
|
||||
Message from the endpoint:
|
||||
<div>{message || 'Loading...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
19
templates/plugin/src/components/BeforeDashboardServer.tsx
Normal file
19
templates/plugin/src/components/BeforeDashboardServer.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ServerComponentProps } from 'payload'
|
||||
|
||||
import styles from './BeforeDashboardServer.module.css'
|
||||
|
||||
export const BeforeDashboardServer = async (props: ServerComponentProps) => {
|
||||
const { payload } = props
|
||||
|
||||
const { docs } = await payload.find({ collection: 'plugin-collection' })
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<h1>Added by the plugin: Before Dashboard Server</h1>
|
||||
Docs from Local API:
|
||||
{docs.map((doc) => (
|
||||
<div key={doc.id}>{doc.id}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
templates/plugin/src/exports/client.ts
Normal file
1
templates/plugin/src/exports/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
|
||||
1
templates/plugin/src/exports/rsc.ts
Normal file
1
templates/plugin/src/exports/rsc.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
|
||||
113
templates/plugin/src/index.ts
Normal file
113
templates/plugin/src/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { CollectionSlug, Config } from 'payload'
|
||||
|
||||
export type MyPluginConfig = {
|
||||
/**
|
||||
* List of collections to add a custom field
|
||||
*/
|
||||
collections?: Partial<Record<CollectionSlug, true>>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const myPlugin =
|
||||
(pluginOptions: MyPluginConfig) =>
|
||||
(config: Config): Config => {
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
}
|
||||
|
||||
config.collections.push({
|
||||
slug: 'plugin-collection',
|
||||
fields: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (pluginOptions.collections) {
|
||||
for (const collectionSlug in pluginOptions.collections) {
|
||||
const collection = config.collections.find(
|
||||
(collection) => collection.slug === collectionSlug,
|
||||
)
|
||||
|
||||
if (collection) {
|
||||
collection.fields.push({
|
||||
name: 'addedByPlugin',
|
||||
type: 'text',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the plugin is disabled, we still want to keep added collections/fields so the database schema is consistent which is important for migrations.
|
||||
* If your plugin heavily modifies the database schema, you may want to remove this property.
|
||||
*/
|
||||
if (pluginOptions.disabled) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (!config.endpoints) {
|
||||
config.endpoints = []
|
||||
}
|
||||
|
||||
if (!config.admin) {
|
||||
config.admin = {}
|
||||
}
|
||||
|
||||
if (!config.admin.components) {
|
||||
config.admin.components = {}
|
||||
}
|
||||
|
||||
if (!config.admin.components.beforeDashboard) {
|
||||
config.admin.components.beforeDashboard = []
|
||||
}
|
||||
|
||||
config.admin.components.beforeDashboard.push(
|
||||
`plugin-package-name-placeholder/client#BeforeDashboardClient`,
|
||||
)
|
||||
config.admin.components.beforeDashboard.push(
|
||||
`plugin-package-name-placeholder/rsc#BeforeDashboardServer`,
|
||||
)
|
||||
|
||||
config.endpoints.push({
|
||||
handler: () => {
|
||||
return Response.json({ message: 'Hello from custom endpoint' })
|
||||
},
|
||||
method: 'get',
|
||||
path: '/my-plugin-endpoint',
|
||||
})
|
||||
|
||||
const incomingOnInit = config.onInit
|
||||
|
||||
config.onInit = async (payload) => {
|
||||
// Ensure we are executing any existing onInit functions before running our own.
|
||||
if (incomingOnInit) {
|
||||
await incomingOnInit(payload)
|
||||
}
|
||||
|
||||
const { totalDocs } = await payload.count({
|
||||
collection: 'plugin-collection',
|
||||
where: {
|
||||
id: {
|
||||
equals: 'seeded-by-plugin',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (totalDocs === 0) {
|
||||
await payload.create({
|
||||
collection: 'plugin-collection',
|
||||
data: {
|
||||
id: 'seeded-by-plugin',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
34
templates/plugin/tsconfig.json
Normal file
34
templates/plugin/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"rootDir": "./",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"target": "ES2022",
|
||||
"composite": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx",
|
||||
"./dev/next-env.d.ts"
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user