diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4ac06a82def..5b326fd9207 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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: | diff --git a/docs/plugins/build-your-own.mdx b/docs/plugins/build-your-own.mdx index 158c725aba3..724c6a85e05 100644 --- a/docs/plugins/build-your-own.mdx +++ b/docs/plugins/build-your-own.mdx @@ -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. - 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. Our plugin template includes everything you need to build a full life-cycle plugin: diff --git a/packages/create-payload-app/src/lib/configure-plugin-project.ts b/packages/create-payload-app/src/lib/configure-plugin-project.ts new file mode 100644 index 00000000000..9cc0c508585 --- /dev/null +++ b/packages/create-payload-app/src/lib/configure-plugin-project.ts @@ -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) +} diff --git a/packages/create-payload-app/src/lib/create-project.spec.ts b/packages/create-payload-app/src/lib/create-project.spec.ts index 14bae42d136..dabe49d7e55 100644 --- a/packages/create-payload-app/src/lib/create-project.spec.ts +++ b/packages/create-payload-app/src/lib/create-project.spec.ts @@ -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, diff --git a/packages/create-payload-app/src/lib/create-project.ts b/packages/create-payload-app/src/lib/create-project.ts index 96dedf7758a..3de9c5d3f4d 100644 --- a/packages/create-payload-app/src/lib/create-project.ts +++ b/packages/create-payload-app/src/lib/create-project.ts @@ -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') diff --git a/packages/create-payload-app/src/lib/templates.ts b/packages/create-payload-app/src/lib/templates.ts index f8009545508..b16e1c1347a 100644 --- a/packages/create-payload-app/src/lib/templates.ts +++ b/packages/create-payload-app/src/lib/templates.ts @@ -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', + }, ] } diff --git a/packages/create-payload-app/src/utils/casing.ts b/packages/create-payload-app/src/utils/casing.ts new file mode 100644 index 00000000000..3636c8af8bb --- /dev/null +++ b/packages/create-payload-app/src/utils/casing.ts @@ -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 +} diff --git a/templates/plugin/.gitignore b/templates/plugin/.gitignore new file mode 100644 index 00000000000..38ba1e39582 --- /dev/null +++ b/templates/plugin/.gitignore @@ -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 diff --git a/templates/plugin/.prettierrc.json b/templates/plugin/.prettierrc.json new file mode 100644 index 00000000000..cb8ee2671df --- /dev/null +++ b/templates/plugin/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": false +} diff --git a/templates/plugin/.swcrc b/templates/plugin/.swcrc new file mode 100644 index 00000000000..b4fb882caaa --- /dev/null +++ b/templates/plugin/.swcrc @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "sourceMaps": true, + "jsc": { + "target": "esnext", + "parser": { + "syntax": "typescript", + "tsx": true, + "dts": true + }, + "transform": { + "react": { + "runtime": "automatic", + "pragmaFrag": "React.Fragment", + "throwIfNamespace": true, + "development": false, + "useBuiltins": true + } + } + }, + "module": { + "type": "es6" + } +} diff --git a/templates/plugin/.vscode/extensions.json b/templates/plugin/.vscode/extensions.json new file mode 100644 index 00000000000..1d7ac851ea8 --- /dev/null +++ b/templates/plugin/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/templates/plugin/.vscode/launch.json b/templates/plugin/.vscode/launch.json new file mode 100644 index 00000000000..572ee15f7fd --- /dev/null +++ b/templates/plugin/.vscode/launch.json @@ -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": ["/**"], + "serverReadyAction": { + "action": "debugWithChrome", + "killOnServerStop": true, + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "webRoot": "${workspaceFolder}" + }, + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/templates/plugin/.vscode/settings.json b/templates/plugin/.vscode/settings.json new file mode 100644 index 00000000000..5918b307925 --- /dev/null +++ b/templates/plugin/.vscode/settings.json @@ -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" + } + } +} diff --git a/templates/plugin/README.md b/templates/plugin/README.md new file mode 100644 index 00000000000..fde03b6a9e2 --- /dev/null +++ b/templates/plugin/README.md @@ -0,0 +1 @@ +# Plugin diff --git a/templates/plugin/dev/.env.example b/templates/plugin/dev/.env.example new file mode 100644 index 00000000000..36c6f26c591 --- /dev/null +++ b/templates/plugin/dev/.env.example @@ -0,0 +1,2 @@ +DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template +PAYLOAD_SECRET=YOUR_SECRET_HERE diff --git a/templates/plugin/dev/app/(payload)/admin/[[...segments]]/not-found.tsx b/templates/plugin/dev/app/(payload)/admin/[[...segments]]/not-found.tsx new file mode 100644 index 00000000000..180e6f81cdf --- /dev/null +++ b/templates/plugin/dev/app/(payload)/admin/[[...segments]]/not-found.tsx @@ -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 => + generatePageMetadata({ config, params, searchParams }) + +const NotFound = ({ params, searchParams }: Args) => + NotFoundPage({ config, importMap, params, searchParams }) + +export default NotFound diff --git a/templates/plugin/dev/app/(payload)/admin/[[...segments]]/page.tsx b/templates/plugin/dev/app/(payload)/admin/[[...segments]]/page.tsx new file mode 100644 index 00000000000..e59b2d3a84b --- /dev/null +++ b/templates/plugin/dev/app/(payload)/admin/[[...segments]]/page.tsx @@ -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 => + generatePageMetadata({ config, params, searchParams }) + +const Page = ({ params, searchParams }: Args) => + RootPage({ config, importMap, params, searchParams }) + +export default Page diff --git a/templates/plugin/dev/app/(payload)/admin/importMap.js b/templates/plugin/dev/app/(payload)/admin/importMap.js new file mode 100644 index 00000000000..fbe8b2a4087 --- /dev/null +++ b/templates/plugin/dev/app/(payload)/admin/importMap.js @@ -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, +} diff --git a/templates/plugin/dev/app/(payload)/api/[...slug]/route.ts b/templates/plugin/dev/app/(payload)/api/[...slug]/route.ts new file mode 100644 index 00000000000..e58c50f50ca --- /dev/null +++ b/templates/plugin/dev/app/(payload)/api/[...slug]/route.ts @@ -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) diff --git a/templates/plugin/dev/app/(payload)/api/graphql-playground/route.ts b/templates/plugin/dev/app/(payload)/api/graphql-playground/route.ts new file mode 100644 index 00000000000..17d2954ca2d --- /dev/null +++ b/templates/plugin/dev/app/(payload)/api/graphql-playground/route.ts @@ -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) diff --git a/templates/plugin/dev/app/(payload)/api/graphql/route.ts b/templates/plugin/dev/app/(payload)/api/graphql/route.ts new file mode 100644 index 00000000000..2069ff86b0a --- /dev/null +++ b/templates/plugin/dev/app/(payload)/api/graphql/route.ts @@ -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) diff --git a/templates/plugin/dev/app/(payload)/custom.scss b/templates/plugin/dev/app/(payload)/custom.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/templates/plugin/dev/app/(payload)/layout.tsx b/templates/plugin/dev/app/(payload)/layout.tsx new file mode 100644 index 00000000000..7f8698e1380 --- /dev/null +++ b/templates/plugin/dev/app/(payload)/layout.tsx @@ -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) => ( + + {children} + +) + +export default Layout diff --git a/templates/plugin/dev/app/my-route/route.ts b/templates/plugin/dev/app/my-route/route.ts new file mode 100644 index 00000000000..a6422f37334 --- /dev/null +++ b/templates/plugin/dev/app/my-route/route.ts @@ -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) +} diff --git a/templates/plugin/dev/helpers/NextRESTClient.ts b/templates/plugin/dev/helpers/NextRESTClient.ts new file mode 100644 index 00000000000..d96ac71df8d --- /dev/null +++ b/templates/plugin/dev/helpers/NextRESTClient.ts @@ -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 +} + +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 + + private _GET: ( + request: Request, + args: { params: Promise<{ slug: string[] }> }, + ) => Promise + + private _GRAPHQL_POST: (request: Request) => Promise + + private _PATCH: ( + request: Request, + args: { params: Promise<{ slug: string[] }> }, + ) => Promise + + private _POST: ( + request: Request, + args: { params: Promise<{ slug: string[] }> }, + ) => Promise + + private _PUT: ( + request: Request, + args: { params: Promise<{ slug: string[] }> }, + ) => Promise + + 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 { + 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 & RequestOptions = {}, + ): Promise { + 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 { + 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=(?.+?);/) + this.token = tokenMatchResult?.groups?.token + } + + return result + } + + async PATCH(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise { + 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 { + 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 { + 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 }) }) + } +} diff --git a/templates/plugin/dev/helpers/credentials.ts b/templates/plugin/dev/helpers/credentials.ts new file mode 100644 index 00000000000..7ccbcae327a --- /dev/null +++ b/templates/plugin/dev/helpers/credentials.ts @@ -0,0 +1,4 @@ +export const devUser = { + email: 'dev@payloadcms.com', + password: 'test', +} diff --git a/templates/plugin/dev/helpers/testEmailAdapter.ts b/templates/plugin/dev/helpers/testEmailAdapter.ts new file mode 100644 index 00000000000..693cf979949 --- /dev/null +++ b/templates/plugin/dev/helpers/testEmailAdapter.ts @@ -0,0 +1,38 @@ +import type { EmailAdapter, SendEmailOptions } from 'payload' + +/** + * Logs all emails to stdout + */ +export const testEmailAdapter: EmailAdapter = ({ 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 +} diff --git a/templates/plugin/dev/int.spec.ts b/templates/plugin/dev/int.spec.ts new file mode 100644 index 00000000000..b3662203ee7 --- /dev/null +++ b/templates/plugin/dev/int.spec.ts @@ -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) + }) +}) diff --git a/templates/plugin/dev/next-env.d.ts b/templates/plugin/dev/next-env.d.ts new file mode 100644 index 00000000000..1b3be0840f3 --- /dev/null +++ b/templates/plugin/dev/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/templates/plugin/dev/next.config.mjs b/templates/plugin/dev/next.config.mjs new file mode 100644 index 00000000000..eb65f30932d --- /dev/null +++ b/templates/plugin/dev/next.config.mjs @@ -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) diff --git a/templates/plugin/dev/payload-types.ts b/templates/plugin/dev/payload-types.ts new file mode 100644 index 00000000000..620ba8e22c7 --- /dev/null +++ b/templates/plugin/dev/payload-types.ts @@ -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 | PostsSelect; + media: MediaSelect | MediaSelect; + 'plugin-collection': PluginCollectionSelect | PluginCollectionSelect; + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + 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 { + addedByPlugin?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media_select". + */ +export interface MediaSelect { + 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 { + id?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/templates/plugin/dev/payload.config.ts b/templates/plugin/dev/payload.config.ts new file mode 100644 index 00000000000..ca9976eb1ea --- /dev/null +++ b/templates/plugin/dev/payload.config.ts @@ -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'), + }, +}) diff --git a/templates/plugin/dev/seed.ts b/templates/plugin/dev/seed.ts new file mode 100644 index 00000000000..8e731f17e1e --- /dev/null +++ b/templates/plugin/dev/seed.ts @@ -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, + }) + } +} diff --git a/templates/plugin/dev/server.ts b/templates/plugin/dev/server.ts new file mode 100644 index 00000000000..7b0bae69a22 --- /dev/null +++ b/templates/plugin/dev/server.ts @@ -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) diff --git a/templates/plugin/dev/tsconfig.json b/templates/plugin/dev/tsconfig.json new file mode 100644 index 00000000000..4ecd3ce5c3a --- /dev/null +++ b/templates/plugin/dev/tsconfig.json @@ -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 + } +} diff --git a/templates/plugin/eslint.config.js b/templates/plugin/eslint.config.js new file mode 100644 index 00000000000..aa472509b27 --- /dev/null +++ b/templates/plugin/eslint.config.js @@ -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, + }, + }, + }, +] diff --git a/templates/plugin/jest.config.js b/templates/plugin/jest.config.js new file mode 100644 index 00000000000..9288ceae7e4 --- /dev/null +++ b/templates/plugin/jest.config.js @@ -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)$': '/test/helpers/mocks/emptyModule.js', + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': + '/test/helpers/mocks/fileMock.js', + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + testEnvironment: 'node', + testTimeout: 90000, + transform: { + '^.+\\.(t|j)sx?$': ['@swc/jest'], + }, + verbose: true, + testMatch: ['/**/*int.spec.ts'], + moduleNameMapper: { + '\\.(css|scss)$': '/helpers/mocks/emptyModule.js', + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': + '/helpers/mocks/fileMock.js', + '^(\\.{1,2}/.*)\\.js$': '$1', + }, +} + +export default customJestConfig diff --git a/templates/plugin/package.json b/templates/plugin/package.json new file mode 100644 index 00000000000..27a376822f0 --- /dev/null +++ b/templates/plugin/package.json @@ -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/" +} diff --git a/templates/plugin/src/components/BeforeDashboardClient.tsx b/templates/plugin/src/components/BeforeDashboardClient.tsx new file mode 100644 index 00000000000..a099e3431c5 --- /dev/null +++ b/templates/plugin/src/components/BeforeDashboardClient.tsx @@ -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 ( + + Added by the plugin: Before Dashboard Client + + Message from the endpoint: + {message || 'Loading...'} + + + ) +} diff --git a/templates/plugin/src/components/BeforeDashboardServer.module.css b/templates/plugin/src/components/BeforeDashboardServer.module.css new file mode 100644 index 00000000000..162c9276a95 --- /dev/null +++ b/templates/plugin/src/components/BeforeDashboardServer.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: flex; + gap: 5px; + flex-direction: column; +} diff --git a/templates/plugin/src/components/BeforeDashboardServer.tsx b/templates/plugin/src/components/BeforeDashboardServer.tsx new file mode 100644 index 00000000000..cc590d9a106 --- /dev/null +++ b/templates/plugin/src/components/BeforeDashboardServer.tsx @@ -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 ( + + Added by the plugin: Before Dashboard Server + Docs from Local API: + {docs.map((doc) => ( + {doc.id} + ))} + + ) +} diff --git a/templates/plugin/src/exports/client.ts b/templates/plugin/src/exports/client.ts new file mode 100644 index 00000000000..4db01b7ff2d --- /dev/null +++ b/templates/plugin/src/exports/client.ts @@ -0,0 +1 @@ +export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js' diff --git a/templates/plugin/src/exports/rsc.ts b/templates/plugin/src/exports/rsc.ts new file mode 100644 index 00000000000..4a9b5f4de0f --- /dev/null +++ b/templates/plugin/src/exports/rsc.ts @@ -0,0 +1 @@ +export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js' diff --git a/templates/plugin/src/index.ts b/templates/plugin/src/index.ts new file mode 100644 index 00000000000..8dca108315f --- /dev/null +++ b/templates/plugin/src/index.ts @@ -0,0 +1,113 @@ +import type { CollectionSlug, Config } from 'payload' + +export type MyPluginConfig = { + /** + * List of collections to add a custom field + */ + collections?: Partial> + 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 + } diff --git a/templates/plugin/tsconfig.json b/templates/plugin/tsconfig.json new file mode 100644 index 00000000000..52dc90f19b8 --- /dev/null +++ b/templates/plugin/tsconfig.json @@ -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" + ], +}