Compare commits
76 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb90e9f622 | ||
|
|
b05a5e1fb6 | ||
|
|
92a5da1006 | ||
|
|
75a95469b2 | ||
|
|
c0ae287d46 | ||
|
|
a2b92aa3ff | ||
|
|
544a2285d3 | ||
|
|
8c39950ea3 | ||
|
|
6d642fe9b9 | ||
|
|
f175a741bc | ||
|
|
3290376f80 | ||
|
|
2b5c1ba99a | ||
|
|
bcb3f08386 | ||
|
|
b729b9bebd | ||
|
|
26ee91eb48 | ||
|
|
43a17f67a0 | ||
|
|
c71d2db949 | ||
|
|
04f1df8af1 | ||
|
|
1c490aee42 | ||
|
|
c6132df866 | ||
|
|
d8f91cc94c | ||
|
|
568b074809 | ||
|
|
401c16e485 | ||
|
|
17bee6a145 | ||
|
|
8829fba6cf | ||
|
|
e8983abe65 | ||
|
|
01f38c4e33 | ||
|
|
10b99ceb6f | ||
|
|
1140426b73 | ||
|
|
5a82f34801 | ||
|
|
5420d889fe | ||
|
|
d9bb51fdc7 | ||
|
|
9a636a3cfb | ||
|
|
181f82f33e | ||
|
|
6a9cde24b0 | ||
|
|
dc31d9c715 | ||
|
|
45b3f06e1b | ||
|
|
d5f7944ac4 | ||
|
|
3d50caf985 | ||
|
|
4d7ef58e7e | ||
|
|
3e117f4e99 | ||
|
|
888d6f8856 | ||
|
|
9ebf8693d4 | ||
|
|
d8c3127b09 | ||
|
|
b6631f4778 | ||
|
|
2e77bdf11e | ||
|
|
cce75f11ca | ||
|
|
d8b6b39dbb | ||
|
|
fa89057aac | ||
|
|
15db0a8018 | ||
|
|
b7a4d9cea4 | ||
|
|
5b676c36e5 | ||
|
|
32231762ff | ||
|
|
a7096c1599 | ||
|
|
bed428c27e | ||
|
|
873e698352 | ||
|
|
ad13577399 | ||
|
|
31a9c77055 | ||
|
|
bae0c2df5f | ||
|
|
0ed31def68 | ||
|
|
0e7a6ad5ab | ||
|
|
180797540c | ||
|
|
c00babf9b3 | ||
|
|
943681ae3c | ||
|
|
f14ce367d2 | ||
|
|
3eb5766323 | ||
|
|
cd5e8d7b52 | ||
|
|
361d12e97c | ||
|
|
fb4a5a3715 | ||
|
|
9c2585ba86 | ||
|
|
feb6296bb4 | ||
|
|
74eb71c304 | ||
|
|
fa2083f764 | ||
|
|
7111834a99 | ||
|
|
a943c7eddb | ||
|
|
2d089a7bae |
@@ -8,6 +8,7 @@ module.exports = {
|
||||
plugins: ['payload'],
|
||||
rules: {
|
||||
'payload/no-jsx-import-statements': 'warn',
|
||||
'payload/no-relative-monorepo-imports': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
43
.github/workflows/main.yml
vendored
43
.github/workflows/main.yml
vendored
@@ -13,6 +13,8 @@ concurrency:
|
||||
env:
|
||||
NODE_VERSION: 18.20.2
|
||||
PNPM_VERSION: 8.15.7
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
@@ -89,6 +91,8 @@ jobs:
|
||||
|
||||
- run: pnpm install
|
||||
- run: pnpm run build:all
|
||||
env:
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
|
||||
- name: Cache build
|
||||
uses: actions/cache@v4
|
||||
@@ -243,6 +247,7 @@ jobs:
|
||||
- fields__collections__Blocks
|
||||
- fields__collections__Array
|
||||
- fields__collections__Relationship
|
||||
- fields__collections__RichText
|
||||
- fields__collections__Lexical
|
||||
- live-preview
|
||||
- localization
|
||||
@@ -252,7 +257,8 @@ jobs:
|
||||
- plugin-seo
|
||||
- versions
|
||||
- uploads
|
||||
|
||||
env:
|
||||
SUITE_NAME: ${{ matrix.suite }}
|
||||
steps:
|
||||
# https://github.com/actions/virtual-environments/issues/1187
|
||||
- name: tune linux network
|
||||
@@ -280,11 +286,33 @@ jobs:
|
||||
run: pnpm docker:start
|
||||
if: ${{ matrix.suite == 'plugin-cloud-storage' }}
|
||||
|
||||
- name: Install Playwright
|
||||
run: pnpm exec playwright install --with-deps
|
||||
- name: Store Playwright's Version
|
||||
run: |
|
||||
# Extract the version number using a more targeted regex pattern with awk
|
||||
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --depth=0 | awk '/@playwright\/test/ {print $2}')
|
||||
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
|
||||
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Playwright Browsers for Playwright's Version
|
||||
id: cache-playwright-browsers
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}
|
||||
|
||||
- name: Setup Playwright - Browsers and Dependencies
|
||||
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Setup Playwright - Dependencies-only
|
||||
if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: E2E Tests
|
||||
run: pnpm test:e2e ${{ matrix.suite }}
|
||||
run: PLAYWRIGHT_JSON_OUTPUT_NAME=results_${{ matrix.suite }}.json pnpm test:e2e ${{ matrix.suite }}
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: results_${{ matrix.suite }}.json
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -294,6 +322,13 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
# Disabled until this is fixed: https://github.com/daun/playwright-report-summary/issues/156
|
||||
# - uses: daun/playwright-report-summary@v3
|
||||
# with:
|
||||
# report-file: results_${{ matrix.suite }}.json
|
||||
# report-tag: ${{ matrix.suite }}
|
||||
# job-summary: true
|
||||
|
||||
app-build-with-packed:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
|
||||
4
.github/workflows/pr-title.yml
vendored
4
.github/workflows/pr-title.yml
vendored
@@ -71,6 +71,10 @@ jobs:
|
||||
|
||||
# Disallow uppercase letters at the beginning of the subject
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
subjectPatternError: |
|
||||
The subject "{subject}" found in the pull request title "{title}"
|
||||
didn't match the configured pattern. Please ensure that the subject
|
||||
doesn't start with an uppercase character.
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@v2
|
||||
# When the previous steps fails, the workflow would stop. By adding this
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -40,5 +40,6 @@
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files.insertFinalNewline": true
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
module.exports = {
|
||||
// gitRawCommitsOpts: {
|
||||
// from: 'v2.0.9',
|
||||
// path: 'packages/payload',
|
||||
// },
|
||||
// infile: 'CHANGELOG.md',
|
||||
options: {
|
||||
preset: {
|
||||
name: 'conventionalcommits',
|
||||
types: [
|
||||
{ section: 'Features', type: 'feat' },
|
||||
{ section: 'Features', type: 'feature' },
|
||||
{ section: 'Bug Fixes', type: 'fix' },
|
||||
{ section: 'Documentation', type: 'docs' },
|
||||
],
|
||||
},
|
||||
},
|
||||
// outfile: 'NEW.md',
|
||||
writerOpts: {
|
||||
commitGroupsSort: (a, b) => {
|
||||
const groupOrder = ['Features', 'Bug Fixes', 'Documentation']
|
||||
return groupOrder.indexOf(a.title) - groupOrder.indexOf(b.title)
|
||||
},
|
||||
|
||||
// Scoped commits at the end, alphabetical sort
|
||||
commitsSort: (a, b) => {
|
||||
if (a.scope || b.scope) {
|
||||
if (!a.scope) return -1
|
||||
if (!b.scope) return 1
|
||||
return a.scope === b.scope
|
||||
? a.subject.localeCompare(b.subject)
|
||||
: a.scope.localeCompare(b.scope)
|
||||
}
|
||||
|
||||
// Alphabetical sort
|
||||
return a.subject.localeCompare(b.subject)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -49,7 +49,8 @@ export default buildConfig({
|
||||
{
|
||||
label: 'Arabic',
|
||||
code: 'ar',
|
||||
// opt-in to setting default text-alignment on Input fields to rtl (right-to-left) when current locale is rtl
|
||||
// opt-in to setting default text-alignment on Input fields to rtl (right-to-left)
|
||||
// when current locale is rtl
|
||||
rtl: true,
|
||||
},
|
||||
],
|
||||
@@ -134,13 +135,9 @@ to support localization, you need to specify each field that you would like to l
|
||||
```js
|
||||
{
|
||||
name: 'title',
|
||||
type
|
||||
:
|
||||
'text',
|
||||
// highlight-start
|
||||
localized
|
||||
:
|
||||
true,
|
||||
type: 'text',
|
||||
// highlight-start
|
||||
localized: true,
|
||||
// highlight-end
|
||||
}
|
||||
```
|
||||
|
||||
@@ -20,7 +20,8 @@ The initial request made to Payload will begin a new transaction and attach it t
|
||||
|
||||
```ts
|
||||
const afterChange: CollectionAfterChangeHook = async ({ req }) => {
|
||||
// because req.transactionID is assigned from Payload and passed through, my-slug will only persist if the entire request is successful
|
||||
// because req.transactionID is assigned from Payload and passed through,
|
||||
// my-slug will only persist if the entire request is successful
|
||||
await req.payload.create({
|
||||
req,
|
||||
collection: 'my-slug',
|
||||
|
||||
@@ -196,7 +196,8 @@ const Pages: CollectionConfig = {
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
// The HTMLConverter Feature is the feature which manages the HTML serializers. If you do not pass any arguments to it, it will use the default serializers.
|
||||
// The HTMLConverter Feature is the feature which manages the HTML serializers.
|
||||
// If you do not pass any arguments to it, it will use the default serializers.
|
||||
HTMLConverterFeature({}),
|
||||
],
|
||||
}),
|
||||
|
||||
2
examples/tailwind-shadcn-ui/.env.example
Normal file
2
examples/tailwind-shadcn-ui/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URI=mongodb://127.0.0.1/payload-template-blank-3-0
|
||||
PAYLOAD_SECRET=YOUR_SECRET_HERE
|
||||
7
examples/tailwind-shadcn-ui/.eslintrc.cjs
Normal file
7
examples/tailwind-shadcn-ui/.eslintrc.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
43
examples/tailwind-shadcn-ui/.gitignore
vendored
Normal file
43
examples/tailwind-shadcn-ui/.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
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.env
|
||||
|
||||
/media
|
||||
6
examples/tailwind-shadcn-ui/.prettierrc.json
Normal file
6
examples/tailwind-shadcn-ui/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": false
|
||||
}
|
||||
42
examples/tailwind-shadcn-ui/README.md
Normal file
42
examples/tailwind-shadcn-ui/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Payload Blank Template
|
||||
|
||||
A blank template for [Payload](https://github.com/payloadcms/payload) to help you get up and running quickly. This repo may have been created by running `npx create-payload-app@latest` and selecting the "blank" template or by cloning this template on [Payload Cloud](https://payloadcms.com/new/clone/blank).
|
||||
|
||||
See the official [Examples Directory](https://github.com/payloadcms/payload/tree/main/examples) for details on how to use Payload in a variety of different ways.
|
||||
|
||||
## Development
|
||||
|
||||
To spin up the project locally, follow these steps:
|
||||
|
||||
1. First clone the repo
|
||||
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
|
||||
1. Next `yarn && yarn dev` (or `docker-compose up`, see [Docker](#docker))
|
||||
1. Now `open http://localhost:3000/admin` to access the admin panel
|
||||
1. Create your first admin user using the form on the page
|
||||
|
||||
That's it! Changes made in `./src` will be reflected in your app.
|
||||
|
||||
### Docker
|
||||
|
||||
Alternatively, you can use [Docker](https://www.docker.com) to spin up this project locally. To do so, follow these steps:
|
||||
|
||||
1. Follow [steps 1 and 2 from above](#development), the docker-compose file will automatically use the `.env` file in your project root
|
||||
1. Next run `docker-compose up`
|
||||
1. Follow [steps 4 and 5 from above](#development) to login and create your first admin user
|
||||
|
||||
That's it! The Docker instance will help you get up and running quickly while also standardizing the development environment across your teams.
|
||||
|
||||
## Production
|
||||
|
||||
To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:
|
||||
|
||||
1. First invoke the `payload build` script by running `yarn build` or `npm run build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
|
||||
1. Then run `yarn serve` or `npm run serve` to run Node in production and serve Payload from the `./build` directory.
|
||||
|
||||
### Deployment
|
||||
|
||||
The easiest way to deploy your project is to use [Payload Cloud](https://payloadcms.com/new/import), a one-click hosting solution to deploy production-ready instances of your Payload apps directly from your GitHub repo. You can also deploy your app manually, check out the [deployment documentation](https://payloadcms.com/docs/production/deployment) for full details.
|
||||
|
||||
## Questions
|
||||
|
||||
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).
|
||||
17
examples/tailwind-shadcn-ui/components.json
Normal file
17
examples/tailwind-shadcn-ui/components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/(app)/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
8
examples/tailwind-shadcn-ui/next.config.mjs
Normal file
8
examples/tailwind-shadcn-ui/next.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withPayload } from '@payloadcms/next/withPayload'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Your Next.js config here
|
||||
}
|
||||
|
||||
export default withPayload(nextConfig)
|
||||
46
examples/tailwind-shadcn-ui/package.json
Normal file
46
examples/tailwind-shadcn-ui/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "tailwind-shadcn",
|
||||
"description": "A blank template to get started with Payload 3.0",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||
"generate:types": "payload generate:types"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-mongodb": "beta",
|
||||
"@payloadcms/next": "beta",
|
||||
"@payloadcms/richtext-lexical": "beta",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "14.3.0-canary.7",
|
||||
"payload": "beta",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sharp": "0.32.6",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.25",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dotenv": "^16.4.5",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.4.2"
|
||||
}
|
||||
}
|
||||
5617
examples/tailwind-shadcn-ui/pnpm-lock.yaml
generated
Normal file
5617
examples/tailwind-shadcn-ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
examples/tailwind-shadcn-ui/postcss.config.js
Normal file
6
examples/tailwind-shadcn-ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
76
examples/tailwind-shadcn-ui/src/app/(app)/globals.css
Normal file
76
examples/tailwind-shadcn-ui/src/app/(app)/globals.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
26
examples/tailwind-shadcn-ui/src/app/(app)/layout.tsx
Normal file
26
examples/tailwind-shadcn-ui/src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Inter as FontSans } from 'next/font/google'
|
||||
|
||||
type LayoutProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
import './globals.css'
|
||||
|
||||
const fontSans = FontSans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
})
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
return (
|
||||
<html>
|
||||
<body className={cn('min-h-screen bg-background font-sans antialiased', fontSans.variable)}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
11
examples/tailwind-shadcn-ui/src/app/(app)/page.tsx
Normal file
11
examples/tailwind-shadcn-ui/src/app/(app)/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { NextPage } from 'next'
|
||||
|
||||
const Page: NextPage = () => {
|
||||
return (
|
||||
<div className="container">
|
||||
<h1 className="text-4xl font-bold">Hello, Next.js 14!</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,17 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import config from '@payload-config'
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import { RootPage } from '@payloadcms/next/views'
|
||||
|
||||
type Args = {
|
||||
params: {
|
||||
segments: string[]
|
||||
}
|
||||
searchParams: {
|
||||
[key: string]: string | string[]
|
||||
}
|
||||
}
|
||||
|
||||
const Page = ({ params, searchParams }: Args) => RootPage({ config, params, searchParams })
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,9 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY it because it could be re-written at any time. */
|
||||
import config from '@payload-config'
|
||||
import { REST_DELETE, REST_GET, REST_PATCH, REST_POST } 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)
|
||||
@@ -0,0 +1,6 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY it because it could be re-written at any time. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
@@ -0,0 +1,6 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY it because it could be re-written at any time. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
64
examples/tailwind-shadcn-ui/src/app/(payload)/custom.scss
Normal file
64
examples/tailwind-shadcn-ui/src/app/(payload)/custom.scss
Normal file
@@ -0,0 +1,64 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
16
examples/tailwind-shadcn-ui/src/app/(payload)/layout.tsx
Normal file
16
examples/tailwind-shadcn-ui/src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import configPromise from '@payload-config'
|
||||
import '@payloadcms/next/css'
|
||||
import { RootLayout } from '@payloadcms/next/layouts'
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import React from 'react'
|
||||
|
||||
import './custom.scss'
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Layout = ({ children }: Args) => <RootLayout config={configPromise}>{children}</RootLayout>
|
||||
|
||||
export default Layout
|
||||
24
examples/tailwind-shadcn-ui/src/collections/Posts.ts
Normal file
24
examples/tailwind-shadcn-ui/src/collections/Posts.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import AlertBox from '@/components/AlertBox'
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'alertBox',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: AlertBox,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
13
examples/tailwind-shadcn-ui/src/collections/Users.ts
Normal file
13
examples/tailwind-shadcn-ui/src/collections/Users.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
// Email added by default
|
||||
// Add more fields as needed
|
||||
],
|
||||
}
|
||||
13
examples/tailwind-shadcn-ui/src/components/AlertBox.tsx
Normal file
13
examples/tailwind-shadcn-ui/src/components/AlertBox.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
||||
const AlertBox: React.FC = () => {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>Please add an appropriate title.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertBox
|
||||
59
examples/tailwind-shadcn-ui/src/components/ui/alert.tsx
Normal file
59
examples/tailwind-shadcn-ui/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
6
examples/tailwind-shadcn-ui/src/lib/utils.ts
Normal file
6
examples/tailwind-shadcn-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
26
examples/tailwind-shadcn-ui/src/payload.config.ts
Normal file
26
examples/tailwind-shadcn-ui/src/payload.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload/config'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { Posts } from './collections/Posts'
|
||||
import { Users } from './collections/Users'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
},
|
||||
collections: [Users, Posts],
|
||||
editor: lexicalEditor({}),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URI || '',
|
||||
}),
|
||||
})
|
||||
77
examples/tailwind-shadcn-ui/tailwind.config.js
Normal file
77
examples/tailwind-shadcn-ui/tailwind.config.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['selector', '[data-mode="dark"]', '.dark'],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: '',
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
}
|
||||
44
examples/tailwind-shadcn-ui/tsconfig.json
Normal file
44
examples/tailwind-shadcn-ui/tsconfig.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"./src/payload.config.ts"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,20 @@
|
||||
/**
|
||||
* Ignores all ESM packages that make Jest mad.
|
||||
*
|
||||
* "Jest encountered an unexpected token"
|
||||
*
|
||||
* Direct packages:
|
||||
* - file-type
|
||||
*/
|
||||
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',
|
||||
].join('|')
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
const baseJestConfig = {
|
||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||
@@ -14,6 +31,10 @@ const baseJestConfig = {
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': ['@swc/jest'],
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
`/node_modules/(?!.pnpm)(?!(${esModules})/)`,
|
||||
`/node_modules/.pnpm/(?!(${esModules.replace(/\//g, '\\+')})@)`,
|
||||
],
|
||||
verbose: true,
|
||||
}
|
||||
|
||||
|
||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.0.0-beta.19",
|
||||
"version": "3.0.0-beta.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -13,6 +13,7 @@
|
||||
"build:db-mongodb": "turbo build --filter db-mongodb",
|
||||
"build:db-postgres": "turbo build --filter db-postgres",
|
||||
"build:email-nodemailer": "turbo build --filter email-nodemailer",
|
||||
"build:email-resend": "turbo build --filter email-resend",
|
||||
"build:eslint-config-payload": "turbo build --filter eslint-config-payload",
|
||||
"build:graphql": "turbo build --filter graphql",
|
||||
"build:live-preview": "turbo build --filter live-preview",
|
||||
@@ -73,7 +74,8 @@
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --cache --fix"
|
||||
]
|
||||
],
|
||||
"tsconfig.json": "node scripts/reset-tsconfig.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.525.0",
|
||||
@@ -88,10 +90,6 @@
|
||||
"@testing-library/jest-dom": "6.4.2",
|
||||
"@testing-library/react": "14.2.1",
|
||||
"@types/concat-stream": "^2.0.1",
|
||||
"@types/conventional-changelog": "^3.1.4",
|
||||
"@types/conventional-changelog-core": "^4.2.5",
|
||||
"@types/conventional-changelog-preset-loader": "^2.3.4",
|
||||
"@types/conventional-changelog-writer": "^4.0.10",
|
||||
"@types/fs-extra": "^11.0.2",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/minimist": "1.2.5",
|
||||
@@ -103,13 +101,9 @@
|
||||
"@types/shelljs": "0.8.15",
|
||||
"add-stream": "^1.0.0",
|
||||
"chalk": "^4.1.2",
|
||||
"changelogen": "^0.5.5",
|
||||
"comment-json": "^4.2.3",
|
||||
"concat-stream": "^2.0.0",
|
||||
"conventional-changelog": "^5.1.0",
|
||||
"conventional-changelog-conventionalcommits": "^7.0.2",
|
||||
"conventional-changelog-core": "^7.0.0",
|
||||
"conventional-changelog-preset-loader": "^4.1.0",
|
||||
"conventional-changelog-writer": "^7.0.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "8.6.0",
|
||||
@@ -127,9 +121,8 @@
|
||||
"husky": "^8.0.3",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"json5": "^2.2.3",
|
||||
"jwt-decode": "4.0.0",
|
||||
"lexical": "0.13.1",
|
||||
"lexical": "0.14.5",
|
||||
"lint-staged": "^14.0.1",
|
||||
"minimist": "1.2.8",
|
||||
"mongodb-memory-server": "^9.0",
|
||||
@@ -161,8 +154,8 @@
|
||||
"tempy": "^1.0.1",
|
||||
"ts-node": "10.9.1",
|
||||
"tsx": "^4.7.1",
|
||||
"turbo": "^1.13.2",
|
||||
"typescript": "5.4.4",
|
||||
"turbo": "^1.13.3",
|
||||
"typescript": "5.4.5",
|
||||
"uuid": "^9.0.1",
|
||||
"yocto-queue": "^1.0.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-payload-app",
|
||||
"version": "3.0.0-beta.19",
|
||||
"version": "3.0.0-beta.23",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-mongodb",
|
||||
"version": "3.0.0-beta.19",
|
||||
"version": "3.0.0-beta.23",
|
||||
"description": "The officially supported MongoDB database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -2,16 +2,19 @@ import { SanitizedConfig, sanitizeConfig } from 'payload/config'
|
||||
import { Config } from 'payload/config'
|
||||
import { getLocalizedSortProperty } from './getLocalizedSortProperty.js'
|
||||
|
||||
const config = sanitizeConfig({
|
||||
localization: {
|
||||
locales: ['en', 'es'],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
},
|
||||
} as Config) as SanitizedConfig
|
||||
let config: SanitizedConfig
|
||||
|
||||
describe('get localized sort property', () => {
|
||||
it('passes through a non-localized sort property', () => {
|
||||
beforeAll(async () => {
|
||||
config = (await sanitizeConfig({
|
||||
localization: {
|
||||
locales: ['en', 'es'],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
},
|
||||
} as Config)) as SanitizedConfig
|
||||
})
|
||||
it('passes through a non-localized sort property', async () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
segments: ['title'],
|
||||
config,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-postgres",
|
||||
"version": "3.0.0-beta.19",
|
||||
"version": "3.0.0-beta.23",
|
||||
"description": "The officially supported Postgres database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-nodemailer",
|
||||
"version": "3.0.0-beta.19",
|
||||
"version": "3.0.0-beta.23",
|
||||
"description": "Payload Nodemailer Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -27,6 +27,7 @@ export const nodemailerAdapter = async (
|
||||
const { defaultFromAddress, defaultFromName, transport } = await buildEmail(args)
|
||||
|
||||
const adapter: NodemailerAdapter = () => ({
|
||||
name: 'nodemailer',
|
||||
defaultFromAddress,
|
||||
defaultFromName,
|
||||
sendEmail: async (message) => {
|
||||
|
||||
10
packages/email-resend/.eslintignore
Normal file
10
packages/email-resend/.eslintignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
7
packages/email-resend/.eslintrc.cjs
Normal file
7
packages/email-resend/.eslintrc.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
10
packages/email-resend/.prettierignore
Normal file
10
packages/email-resend/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
15
packages/email-resend/.swcrc
Normal file
15
packages/email-resend/.swcrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
19
packages/email-resend/.swcrc-build
Normal file
19
packages/email-resend/.swcrc-build
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"exclude": [
|
||||
"/**/mocks",
|
||||
"/**/*.spec.ts"
|
||||
],
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
22
packages/email-resend/LICENSE.md
Normal file
22
packages/email-resend/LICENSE.md
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2022 Payload CMS, LLC <info@payloadcms.com>
|
||||
Portions Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
30
packages/email-resend/README.md
Normal file
30
packages/email-resend/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Resend REST Email Adapter
|
||||
|
||||
This adapter allows you to send emails using the [Resend](https://resend.com) REST API.
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @payloadcms/email-resend`
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
- Sign up for a [Resend](https://resend.com) account
|
||||
- Set up a domain
|
||||
- Create an API key
|
||||
- Set API key as RESEND_API_KEY environment variable
|
||||
- Configure your Payload config
|
||||
|
||||
```ts
|
||||
// payload.config.js
|
||||
import { resendAdapter } from '@payloadcms/email-resend'
|
||||
|
||||
export default buildConfig({
|
||||
email: resendAdapter({
|
||||
defaultFromAddress: 'dev@payloadcms.com',
|
||||
defaultFromName: 'Payload CMS',
|
||||
apiKey: process.env.RESEND_API_KEY || '',
|
||||
}),
|
||||
})
|
||||
```
|
||||
17
packages/email-resend/jest.config.js
Normal file
17
packages/email-resend/jest.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('jest').Config} */
|
||||
const customJestConfig = {
|
||||
rootDir: '.',
|
||||
extensionsToTreatAsEsm: ['.ts', '.tsx'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/**/*spec.ts'],
|
||||
testTimeout: 10000,
|
||||
transform: {
|
||||
'^.+\\.(t|j)sx?$': ['@swc/jest'],
|
||||
},
|
||||
verbose: true,
|
||||
}
|
||||
|
||||
export default customJestConfig
|
||||
57
packages/email-resend/package.json
Normal file
57
packages/email-resend/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@payloadcms/email-resend",
|
||||
"version": "3.0.0-beta.23",
|
||||
"description": "Payload Resend Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/payloadcms/payload.git",
|
||||
"directory": "packages/email-resend"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Payload CMS, Inc.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"require": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm build:swc && pnpm build:types",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc-build",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"prepublishOnly": "pnpm clean && pnpm turbo build",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.12",
|
||||
"jest": "^29.7.0",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
}
|
||||
87
packages/email-resend/src/email-resend.spec.ts
Normal file
87
packages/email-resend/src/email-resend.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { resendAdapter } from './index.js'
|
||||
import { Payload } from 'payload/types'
|
||||
|
||||
describe('email-resend', () => {
|
||||
const defaultFromAddress = 'dev@payloadcms.com'
|
||||
const defaultFromName = 'Payload CMS'
|
||||
const apiKey = 'test-api-key'
|
||||
const from = 'dev@payloadcms.com'
|
||||
const to = from
|
||||
const subject = 'This was sent on init'
|
||||
const text = 'This is my message body'
|
||||
|
||||
const mockPayload = {} as unknown as Payload
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should handle sending an email', async () => {
|
||||
global.fetch = jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => {
|
||||
return { id: 'test-id' }
|
||||
},
|
||||
}),
|
||||
) as jest.Mock,
|
||||
) as jest.Mock
|
||||
|
||||
const adapter = resendAdapter({
|
||||
defaultFromAddress,
|
||||
defaultFromName,
|
||||
apiKey,
|
||||
})
|
||||
|
||||
await adapter({ payload: mockPayload }).sendEmail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
})
|
||||
|
||||
// @ts-expect-error
|
||||
expect(global.fetch.mock.calls[0][0]).toStrictEqual('https://api.resend.com/emails')
|
||||
// @ts-expect-error
|
||||
const request = global.fetch.mock.calls[0][1]
|
||||
expect(request.headers.Authorization).toStrictEqual(`Bearer ${apiKey}`)
|
||||
expect(JSON.parse(request.body)).toMatchObject({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
})
|
||||
})
|
||||
|
||||
it('should throw an error if the email fails to send', async () => {
|
||||
const errorResponse = {
|
||||
message: 'error information',
|
||||
name: 'validation_error',
|
||||
statusCode: 403,
|
||||
}
|
||||
global.fetch = jest.spyOn(global, 'fetch').mockImplementation(
|
||||
jest.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () => errorResponse,
|
||||
}),
|
||||
) as jest.Mock,
|
||||
) as jest.Mock
|
||||
|
||||
const adapter = resendAdapter({
|
||||
defaultFromAddress,
|
||||
defaultFromName,
|
||||
apiKey,
|
||||
})
|
||||
|
||||
await expect(() =>
|
||||
adapter({ payload: mockPayload }).sendEmail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Error sending email: ${errorResponse.statusCode} ${errorResponse.name} - ${errorResponse.message}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
240
packages/email-resend/src/index.ts
Normal file
240
packages/email-resend/src/index.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { EmailAdapter } from 'payload/config'
|
||||
import type { SendEmailOptions } from 'payload/types'
|
||||
|
||||
import { APIError } from 'payload/errors'
|
||||
|
||||
export type ResendAdapterArgs = {
|
||||
apiKey: string
|
||||
defaultFromAddress: string
|
||||
defaultFromName: string
|
||||
}
|
||||
|
||||
type ResendAdapter = EmailAdapter<ResendResponse>
|
||||
|
||||
type ResendError = {
|
||||
message: string
|
||||
name: string
|
||||
statusCode: number
|
||||
}
|
||||
|
||||
type ResendResponse = { id: string } | ResendError
|
||||
|
||||
/**
|
||||
* Email adapter for [Resend](https://resend.com) REST API
|
||||
*/
|
||||
export const resendAdapter = (args: ResendAdapterArgs): ResendAdapter => {
|
||||
const { apiKey, defaultFromAddress, defaultFromName } = args
|
||||
|
||||
const adapter: ResendAdapter = () => ({
|
||||
name: 'resend-rest',
|
||||
defaultFromAddress,
|
||||
defaultFromName,
|
||||
sendEmail: async (message) => {
|
||||
// Map the Payload email options to Resend email options
|
||||
const sendEmailOptions = mapPayloadEmailToResendEmail(
|
||||
message,
|
||||
defaultFromAddress,
|
||||
defaultFromName,
|
||||
)
|
||||
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
body: JSON.stringify(sendEmailOptions),
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const data = (await res.json()) as ResendResponse
|
||||
|
||||
if ('id' in data) {
|
||||
return data
|
||||
} else {
|
||||
const statusCode = data.statusCode || res.status
|
||||
let formattedError = `Error sending email: ${statusCode}`
|
||||
if (data.name && data.message) {
|
||||
formattedError += ` ${data.name} - ${data.message}`
|
||||
}
|
||||
|
||||
throw new APIError(formattedError, statusCode)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return adapter
|
||||
}
|
||||
|
||||
function mapPayloadEmailToResendEmail(
|
||||
message: SendEmailOptions,
|
||||
defaultFromAddress: string,
|
||||
defaultFromName: string,
|
||||
): ResendSendEmailOptions {
|
||||
return {
|
||||
// Required
|
||||
from: mapFromAddress(message.from, defaultFromName, defaultFromAddress),
|
||||
subject: message.subject ?? '',
|
||||
to: mapAddresses(message.to),
|
||||
|
||||
// Other To fields
|
||||
bcc: mapAddresses(message.bcc),
|
||||
cc: mapAddresses(message.cc),
|
||||
|
||||
// Optional
|
||||
attachments: mapAttachments(message.attachments),
|
||||
html: message.html?.toString() || '',
|
||||
text: message.text?.toString() || '',
|
||||
} as ResendSendEmailOptions
|
||||
}
|
||||
|
||||
function mapFromAddress(
|
||||
address: SendEmailOptions['from'],
|
||||
defaultFromName: string,
|
||||
defaultFromAddress: string,
|
||||
): ResendSendEmailOptions['from'] {
|
||||
if (!address) {
|
||||
return `${defaultFromName} <${defaultFromAddress}>`
|
||||
}
|
||||
|
||||
if (typeof address === 'string') {
|
||||
return address
|
||||
}
|
||||
|
||||
return `${address.name} <${address.address}>`
|
||||
}
|
||||
|
||||
function mapAddresses(addresses: SendEmailOptions['to']): ResendSendEmailOptions['to'] {
|
||||
if (!addresses) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof addresses === 'string') {
|
||||
return addresses
|
||||
}
|
||||
|
||||
if (Array.isArray(addresses)) {
|
||||
return addresses.map((address) => (typeof address === 'string' ? address : address.address))
|
||||
}
|
||||
|
||||
return [addresses.address]
|
||||
}
|
||||
|
||||
function mapAttachments(
|
||||
attachments: SendEmailOptions['attachments'],
|
||||
): ResendSendEmailOptions['attachments'] {
|
||||
if (!attachments) {
|
||||
return []
|
||||
}
|
||||
|
||||
return attachments.map((attachment) => {
|
||||
if (!attachment.filename || !attachment.content) {
|
||||
throw new APIError('Attachment is missing filename or content', 400)
|
||||
}
|
||||
|
||||
if (typeof attachment.content === 'string') {
|
||||
return {
|
||||
content: Buffer.from(attachment.content),
|
||||
filename: attachment.filename,
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.content instanceof Buffer) {
|
||||
return {
|
||||
content: attachment.content,
|
||||
filename: attachment.filename,
|
||||
}
|
||||
}
|
||||
|
||||
throw new APIError('Attachment content must be a string or a buffer', 400)
|
||||
})
|
||||
}
|
||||
|
||||
type ResendSendEmailOptions = {
|
||||
/**
|
||||
* Filename and content of attachments (max 40mb per email)
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
attachments?: Attachment[]
|
||||
/**
|
||||
* Blind carbon copy recipient email address. For multiple addresses, send as an array of strings.
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
bcc?: string | string[]
|
||||
|
||||
/**
|
||||
* Carbon copy recipient email address. For multiple addresses, send as an array of strings.
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
cc?: string | string[]
|
||||
/**
|
||||
* Sender email address. To include a friendly name, use the format `"Your Name <sender@domain.com>"`
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
from: string
|
||||
/**
|
||||
* Custom headers to add to the email.
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
headers?: Record<string, string>
|
||||
/**
|
||||
* The HTML version of the message.
|
||||
*
|
||||
* @link https://resend.com/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
html?: string
|
||||
/**
|
||||
* Reply-to email address. For multiple addresses, send as an array of strings.
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
reply_to?: string | string[]
|
||||
/**
|
||||
* Email subject.
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
subject: string
|
||||
/**
|
||||
* Email tags
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
tags?: Tag[]
|
||||
/**
|
||||
* The plain text version of the message.
|
||||
*
|
||||
* @link https://resend.com/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
text?: string
|
||||
/**
|
||||
* Recipient email address. For multiple addresses, send as an array of strings. Max 50.
|
||||
*
|
||||
* @link https://resend.com/docs/api-reference/emails/send-email#body-parameters
|
||||
*/
|
||||
to: string | string[]
|
||||
}
|
||||
|
||||
type Attachment = {
|
||||
/** Content of an attached file. */
|
||||
content?: Buffer | string
|
||||
/** Name of attached file. */
|
||||
filename?: false | string | undefined
|
||||
/** Path where the attachment file is hosted */
|
||||
path?: string
|
||||
}
|
||||
|
||||
export type Tag = {
|
||||
/**
|
||||
* The name of the email tag. It can only contain ASCII letters (a–z, A–Z), numbers (0–9), underscores (_), or dashes (-). It can contain no more than 256 characters.
|
||||
*/
|
||||
name: string
|
||||
/**
|
||||
* The value of the email tag. It can only contain ASCII letters (a–z, A–Z), numbers (0–9), underscores (_), or dashes (-). It can contain no more than 256 characters.
|
||||
*/
|
||||
value: string
|
||||
}
|
||||
20
packages/email-resend/tsconfig.json
Normal file
20
packages/email-resend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true, // Make sure typescript knows that this module depends on their references
|
||||
"noEmit": false /* Do not emit outputs. */,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
|
||||
"rootDir": "./src" /* Specify the root folder within your source files. */,
|
||||
"strict": true,
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"src/**/*.spec.ts",
|
||||
],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
|
||||
"references": [
|
||||
{ "path": "../payload" },
|
||||
]
|
||||
}
|
||||
@@ -53,8 +53,9 @@ const typescriptRules = {
|
||||
'@typescript-eslint/consistent-type-imports': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
// Type-aware any rules end
|
||||
// ts-expect should always be preferred over ts-ignore
|
||||
'@typescript-eslint/prefer-ts-expect-error': 'warn',
|
||||
|
||||
// ts-expect preferred over ts-ignore. It will error if the expected error is no longer present.
|
||||
'@typescript-eslint/prefer-ts-expect-error': 'error',
|
||||
// By default, it errors for unused variables. This is annoying, warnings are enough.
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Disallows imports from .jsx extensions. Auto-fixes to .js.
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Disallows imports from relative monorepo package paths.
|
||||
*
|
||||
* ie. `import { mongooseAdapter } from '../../../packages/mongoose-adapter/src'`
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/graphql",
|
||||
"version": "3.0.0-beta.19",
|
||||
"version": "3.0.0-beta.23",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -462,6 +462,10 @@ function buildObjectType({
|
||||
async resolve(parent, args, context: Context) {
|
||||
let depth = config.defaultDepth
|
||||
if (typeof args.depth !== 'undefined') depth = args.depth
|
||||
if (typeof field?.editor === 'function') {
|
||||
throw new Error('Attempted to access unsanitized rich text editor.')
|
||||
}
|
||||
|
||||
const editor: RichTextAdapter = field?.editor
|
||||
|
||||
// RichText fields have their own depth argument in GraphQL.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/next",
|
||||
"version": "3.0.0-beta.19",
|
||||
"version": "3.0.0-beta.23",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -49,8 +49,10 @@
|
||||
"@types/busboy": "^1.5.3",
|
||||
"busboy": "^1.6.0",
|
||||
"deep-equal": "2.2.2",
|
||||
"file-type": "19.0.0",
|
||||
"graphql-http": "^1.22.0",
|
||||
"graphql-playground-html": "1.6.30",
|
||||
"http-status": "1.6.2",
|
||||
"path-to-regexp": "^6.2.1",
|
||||
"qs": "6.11.2",
|
||||
"react-diff-viewer-continued": "3.2.6",
|
||||
@@ -66,7 +68,6 @@
|
||||
"@types/ws": "^8.5.10",
|
||||
"css-loader": "^6.10.0",
|
||||
"css-minimizer-webpack-plugin": "^6.0.0",
|
||||
"file-type": "16.5.4",
|
||||
"mini-css-extract-plugin": "1.6.2",
|
||||
"payload": "workspace:*",
|
||||
"postcss-loader": "^8.1.1",
|
||||
@@ -79,9 +80,7 @@
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"file-type": "16.5.4",
|
||||
"graphql": "^16.8.1",
|
||||
"http-status": "1.6.2",
|
||||
"next": "^14.3.0-canary.7",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -5,3 +5,4 @@ export { createPayloadRequest } from '../utilities/createPayloadRequest.js'
|
||||
export { getNextRequestI18n } from '../utilities/getNextRequestI18n.js'
|
||||
export { getPayloadHMR, reload } from '../utilities/getPayloadHMR.js'
|
||||
export { headersWithCors } from '../utilities/headersWithCors.js'
|
||||
export { initPage } from '../utilities/initPage.js'
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { FieldSchemaMap } from '../../utilities/buildFieldSchemaMap/types.j
|
||||
|
||||
import { buildFieldSchemaMap } from '../../utilities/buildFieldSchemaMap/index.js'
|
||||
import { headersWithCors } from '../../utilities/headersWithCors.js'
|
||||
import { routeError } from './routeError.js'
|
||||
|
||||
let cached = global._payload_fieldSchemaMap
|
||||
|
||||
@@ -226,14 +227,12 @@ export const buildFormState = async ({ req }: { req: PayloadRequestWithData }) =
|
||||
status: httpStatus.OK,
|
||||
})
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
message: 'There was an error building form state',
|
||||
},
|
||||
{
|
||||
headers,
|
||||
status: httpStatus.BAD_REQUEST,
|
||||
},
|
||||
)
|
||||
req.payload.logger.error({ err, msg: `There was an error building form state` })
|
||||
|
||||
return routeError({
|
||||
config: req.payload.config,
|
||||
err,
|
||||
req,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Collection, PayloadRequestWithData } from 'payload/types'
|
||||
|
||||
import getFileType from 'file-type'
|
||||
import { fileTypeFromFile } from 'file-type'
|
||||
import fsPromises from 'fs/promises'
|
||||
import httpStatus from 'http-status'
|
||||
import path from 'path'
|
||||
@@ -58,7 +58,7 @@ export const getFile = async ({ collection, filename, req }: Args): Promise<Resp
|
||||
'Content-Length': stats.size + '',
|
||||
})
|
||||
|
||||
const fileTypeResult = (await getFileType.fromFile(filePath)) || getFileTypeFallback(filePath)
|
||||
const fileTypeResult = (await fileTypeFromFile(filePath)) || getFileTypeFallback(filePath)
|
||||
headers.set('Content-Type', fileTypeResult.mime)
|
||||
|
||||
return new Response(data, {
|
||||
|
||||
@@ -68,6 +68,10 @@ export const traverseFields = ({
|
||||
break
|
||||
|
||||
case 'richText':
|
||||
if (typeof field.editor === 'function') {
|
||||
throw new Error('Attempted to access unsanitized rich text editor.')
|
||||
}
|
||||
|
||||
if (typeof field.editor.generateSchemaMap === 'function') {
|
||||
field.editor.generateSchemaMap({
|
||||
config,
|
||||
|
||||
@@ -25,7 +25,15 @@ type Args = {
|
||||
searchParams: { [key: string]: string | string[] | undefined }
|
||||
}
|
||||
|
||||
const authRoutes = ['/login', '/logout', '/create-first-user', '/forgot', '/reset', '/verify']
|
||||
const authRoutes = [
|
||||
'/login',
|
||||
'/logout',
|
||||
'/create-first-user',
|
||||
'/forgot',
|
||||
'/reset',
|
||||
'/verify',
|
||||
'/logout-inactivity',
|
||||
]
|
||||
|
||||
export const initPage = async ({
|
||||
config: configPromise,
|
||||
@@ -91,7 +99,8 @@ export const initPage = async ({
|
||||
const globalSlug = entityType === 'globals' ? entitySlug : undefined
|
||||
const docID = collectionSlug && createOrID !== 'create' ? createOrID : undefined
|
||||
|
||||
const isAuthRoute = authRoutes.some((r) => r === route.replace(adminRoute, ''))
|
||||
const isAdminRoute = route.startsWith(adminRoute)
|
||||
const isAuthRoute = authRoutes.some((r) => route.replace(adminRoute, '').startsWith(r))
|
||||
|
||||
if (redirectUnauthenticatedUser && !user && !isAuthRoute) {
|
||||
if (searchParams && 'redirect' in searchParams) delete searchParams.redirect
|
||||
@@ -103,7 +112,7 @@ export const initPage = async ({
|
||||
redirect(`${routes.admin}/login?redirect=${route + stringifiedSearchParams}`)
|
||||
}
|
||||
|
||||
if (!permissions.canAccessAdmin && !isAuthRoute) {
|
||||
if (!permissions.canAccessAdmin && isAdminRoute && !isAuthRoute) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export const Account: React.FC<AdminViewProps> = ({ initPageResult, params, sear
|
||||
config={payload.config}
|
||||
hideTabs
|
||||
i18n={i18n}
|
||||
permissions={permissions}
|
||||
/>
|
||||
<HydrateClientUser permissions={permissions} user={user} />
|
||||
<FormQueryParamsProvider
|
||||
|
||||
@@ -200,6 +200,7 @@ export const Document: React.FC<AdminViewProps> = async ({
|
||||
config={payload.config}
|
||||
globalConfig={globalConfig}
|
||||
i18n={i18n}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
<HydrateClientUser permissions={permissions} user={user} />
|
||||
|
||||
@@ -88,7 +88,7 @@ export const DefaultEditView: React.FC = () => {
|
||||
globalSlug: globalConfig?.slug,
|
||||
})
|
||||
|
||||
const operation = id ? 'update' : 'create'
|
||||
const operation = collectionSlug && !id ? 'create' : 'update'
|
||||
|
||||
const auth = collectionConfig ? collectionConfig.auth : undefined
|
||||
const upload = collectionConfig ? collectionConfig.upload : undefined
|
||||
|
||||
@@ -46,10 +46,13 @@ export const NotFoundPage = async ({
|
||||
[key: string]: string | string[]
|
||||
}
|
||||
}) => {
|
||||
const config = await configPromise
|
||||
const { routes: { admin: adminRoute } = {} } = config
|
||||
|
||||
const initPageResult = await initPage({
|
||||
config: configPromise,
|
||||
config,
|
||||
redirectUnauthenticatedUser: true,
|
||||
route: '/not-found',
|
||||
route: `${adminRoute}/not-found`,
|
||||
searchParams,
|
||||
})
|
||||
|
||||
|
||||
103
packages/next/src/views/ResetPassword/index.client.tsx
Normal file
103
packages/next/src/views/ResetPassword/index.client.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
import type { FormState } from 'payload/types'
|
||||
|
||||
import { ConfirmPassword } from '@payloadcms/ui/fields/ConfirmPassword'
|
||||
import { HiddenInput } from '@payloadcms/ui/fields/HiddenInput'
|
||||
import { Password } from '@payloadcms/ui/fields/Password'
|
||||
import { Form, useFormFields } from '@payloadcms/ui/forms/Form'
|
||||
import { FormSubmit } from '@payloadcms/ui/forms/Submit'
|
||||
import { useAuth } from '@payloadcms/ui/providers/Auth'
|
||||
import { useConfig } from '@payloadcms/ui/providers/Config'
|
||||
import { useTranslation } from '@payloadcms/ui/providers/Translation'
|
||||
import { useRouter } from 'next/navigation.js'
|
||||
import React from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
type Args = {
|
||||
token: string
|
||||
}
|
||||
|
||||
const initialState: FormState = {
|
||||
'confirm-password': {
|
||||
initialValue: '',
|
||||
valid: false,
|
||||
value: '',
|
||||
},
|
||||
password: {
|
||||
initialValue: '',
|
||||
valid: false,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
|
||||
export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
|
||||
const i18n = useTranslation()
|
||||
const {
|
||||
admin: { user: userSlug },
|
||||
routes: { admin, api },
|
||||
serverURL,
|
||||
} = useConfig()
|
||||
|
||||
const history = useRouter()
|
||||
|
||||
const { fetchFullUser } = useAuth()
|
||||
|
||||
const onSuccess = React.useCallback(
|
||||
async (data) => {
|
||||
if (data.token) {
|
||||
await fetchFullUser()
|
||||
history.push(`${admin}`)
|
||||
} else {
|
||||
history.push(`${admin}/login`)
|
||||
toast.success(i18n.t('general:updatedSuccessfully'), { autoClose: 3000 })
|
||||
}
|
||||
},
|
||||
[fetchFullUser, history, admin, i18n],
|
||||
)
|
||||
|
||||
return (
|
||||
<Form
|
||||
action={`${serverURL}${api}/${userSlug}/reset-password`}
|
||||
initialState={initialState}
|
||||
method="POST"
|
||||
onSuccess={onSuccess}
|
||||
>
|
||||
<PasswordToConfirm />
|
||||
<ConfirmPassword />
|
||||
<HiddenInput forceUsePathFromProps name="token" value={token} />
|
||||
<FormSubmit>{i18n.t('authentication:resetPassword')}</FormSubmit>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
const PasswordToConfirm = () => {
|
||||
const { t } = useTranslation()
|
||||
const { value: confirmValue } = useFormFields(([fields]) => {
|
||||
return fields['confirm-password']
|
||||
})
|
||||
|
||||
const validate = React.useCallback(
|
||||
(value: string) => {
|
||||
if (!value) {
|
||||
return t('validation:required')
|
||||
}
|
||||
|
||||
if (value === confirmValue) {
|
||||
return true
|
||||
}
|
||||
|
||||
return t('fields:passwordsDoNotMatch')
|
||||
},
|
||||
[confirmValue, t],
|
||||
)
|
||||
|
||||
return (
|
||||
<Password
|
||||
autoComplete="off"
|
||||
label={t('authentication:newPassword')}
|
||||
name="password"
|
||||
required
|
||||
validate={validate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,5 @@
|
||||
.reset-password {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-height: 100vh;
|
||||
|
||||
&__wrap {
|
||||
margin: 0 auto var(--base);
|
||||
width: 100%;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
}
|
||||
form > .field-type {
|
||||
margin-bottom: var(--base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,11 @@ import type { AdminViewProps } from 'payload/types'
|
||||
|
||||
import { Button } from '@payloadcms/ui/elements/Button'
|
||||
import { Translation } from '@payloadcms/ui/elements/Translation'
|
||||
import { ConfirmPassword } from '@payloadcms/ui/fields/ConfirmPassword'
|
||||
import { HiddenInput } from '@payloadcms/ui/fields/HiddenInput'
|
||||
import { Password } from '@payloadcms/ui/fields/Password'
|
||||
import { Form } from '@payloadcms/ui/forms/Form'
|
||||
import { FormSubmit } from '@payloadcms/ui/forms/Submit'
|
||||
import { MinimalTemplate } from '@payloadcms/ui/templates/Minimal'
|
||||
import LinkImport from 'next/link.js'
|
||||
import React from 'react'
|
||||
|
||||
import { ResetPasswordClient } from './index.client.js'
|
||||
import './index.scss'
|
||||
|
||||
export const resetPasswordBaseClass = 'reset-password'
|
||||
@@ -22,7 +18,9 @@ export { generateResetPasswordMetadata } from './meta.js'
|
||||
export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params }) => {
|
||||
const { req } = initPageResult
|
||||
|
||||
const { token } = params
|
||||
const {
|
||||
segments: [_, token],
|
||||
} = params
|
||||
|
||||
const {
|
||||
i18n,
|
||||
@@ -31,21 +29,9 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
|
||||
} = req
|
||||
|
||||
const {
|
||||
admin: { user: userSlug },
|
||||
routes: { admin, api },
|
||||
serverURL,
|
||||
routes: { admin },
|
||||
} = config
|
||||
|
||||
// const onSuccess = async (data) => {
|
||||
// if (data.token) {
|
||||
// await fetchFullUser()
|
||||
// history.push(`${admin}`)
|
||||
// } else {
|
||||
// history.push(`${admin}/login`)
|
||||
// toast.success(i18n.t('general:updatedSuccessfully'), { autoClose: 3000 })
|
||||
// }
|
||||
// }
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<MinimalTemplate className={resetPasswordBaseClass}>
|
||||
@@ -73,22 +59,7 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
|
||||
<MinimalTemplate className={resetPasswordBaseClass}>
|
||||
<div className={`${resetPasswordBaseClass}__wrap`}>
|
||||
<h1>{i18n.t('authentication:resetPassword')}</h1>
|
||||
<Form
|
||||
action={`${serverURL}${api}/${userSlug}/reset-password`}
|
||||
method="POST"
|
||||
// onSuccess={onSuccess}
|
||||
redirect={admin}
|
||||
>
|
||||
<Password
|
||||
autoComplete="off"
|
||||
label={i18n.t('authentication:newPassword')}
|
||||
name="password"
|
||||
required
|
||||
/>
|
||||
<ConfirmPassword />
|
||||
<HiddenInput forceUsePathFromProps name="token" value={token} />
|
||||
<FormSubmit>{i18n.t('authentication:resetPassword')}</FormSubmit>
|
||||
</Form>
|
||||
<ResetPasswordClient token={token} />
|
||||
</div>
|
||||
</MinimalTemplate>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload",
|
||||
"version": "3.0.0-beta.19",
|
||||
"version": "3.0.0-beta.23",
|
||||
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
|
||||
"keywords": [
|
||||
"admin panel",
|
||||
@@ -171,7 +171,7 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.2"
|
||||
"node": "^18.20.2 || >=20.6.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { I18n } from '@payloadcms/translations'
|
||||
import type { JSONSchema4 } from 'json-schema'
|
||||
|
||||
import type { SanitizedConfig } from '../config/types.js'
|
||||
import type { Config, SanitizedConfig } from '../config/types.js'
|
||||
import type { Field, FieldBase, RichTextField, Validate } from '../fields/config/types.js'
|
||||
import type { PayloadRequestWithData, RequestContext } from '../types/index.js'
|
||||
import type { WithServerSideProps } from './elements/WithServerSideProps.js'
|
||||
@@ -84,3 +84,15 @@ export type RichTextAdapter<
|
||||
CellComponent: React.FC<any>
|
||||
FieldComponent: React.FC<RichTextFieldProps<Value, AdapterProps, ExtraFieldProperties>>
|
||||
}
|
||||
|
||||
export type RichTextAdapterProvider<
|
||||
Value extends object = object,
|
||||
AdapterProps = any,
|
||||
ExtraFieldProperties = {},
|
||||
> = ({
|
||||
config,
|
||||
}: {
|
||||
config: SanitizedConfig
|
||||
}) =>
|
||||
| Promise<RichTextAdapter<Value, AdapterProps, ExtraFieldProperties>>
|
||||
| RichTextAdapter<Value, AdapterProps, ExtraFieldProperties>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { I18n } from '@payloadcms/translations'
|
||||
|
||||
import type { Permissions } from '../../auth/types.js'
|
||||
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
|
||||
import type { SanitizedConfig } from '../../config/types.js'
|
||||
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
|
||||
@@ -10,12 +11,14 @@ export type DocumentTabProps = {
|
||||
config: SanitizedConfig
|
||||
globalConfig?: SanitizedGlobalConfig
|
||||
i18n: I18n
|
||||
permissions: Permissions
|
||||
}
|
||||
|
||||
export type DocumentTabCondition = (args: {
|
||||
collectionConfig: SanitizedCollectionConfig
|
||||
config: SanitizedConfig
|
||||
globalConfig: SanitizedGlobalConfig
|
||||
permissions: Permissions
|
||||
}) => boolean
|
||||
|
||||
// Everything is optional because we merge in the defaults
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { RichTextAdapter, RichTextFieldProps } from './RichText.js'
|
||||
export type { RichTextAdapter, RichTextAdapterProvider, RichTextFieldProps } from './RichText.js'
|
||||
export type { CellComponentProps, DefaultCellComponentProps } from './elements/Cell.js'
|
||||
export type { ConditionalDateProps } from './elements/DatePicker.js'
|
||||
export type { DayPickerProps, SharedProps, TimePickerProps } from './elements/DatePicker.js'
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function getAccessResults({ req }: GetAccessResultsArgs): Promise<P
|
||||
? payload.config.collections.find((collection) => collection.slug === user.collection)
|
||||
: null
|
||||
|
||||
if (userCollectionConfig) {
|
||||
if (userCollectionConfig && payload.config.admin.user === user.collection) {
|
||||
results.canAccessAdmin = userCollectionConfig.access.admin
|
||||
? await userCollectionConfig.access.admin({ req })
|
||||
: isLoggedIn
|
||||
|
||||
@@ -101,11 +101,11 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
|
||||
})
|
||||
|
||||
if (!disableEmail) {
|
||||
const protocol = new URL(req.url).protocol
|
||||
const protocol = new URL(req.url).protocol // includes the final :
|
||||
const serverURL =
|
||||
config.serverURL !== null && config.serverURL !== ''
|
||||
? config.serverURL
|
||||
: `${protocol}://${req.headers.get('host')}`
|
||||
: `${protocol}//${req.headers.get('host')}`
|
||||
|
||||
let html = `${req.t('authentication:youAreReceivingResetPassword')}
|
||||
<a href="${serverURL}${config.routes.admin}/reset/${token}">
|
||||
|
||||
@@ -141,6 +141,10 @@ export const loginOperation = async <TSlug extends keyof GeneratedTypes['collect
|
||||
user,
|
||||
})
|
||||
|
||||
// /////////////////////////////////////
|
||||
// beforeLogin - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
await collectionConfig.hooks.beforeLogin.reduce(async (priorHook, hook) => {
|
||||
await priorHook
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ export async function sendVerificationEmail(args: Args): Promise<void> {
|
||||
} = args
|
||||
|
||||
if (!disableEmail) {
|
||||
const protocol = new URL(req.url).protocol
|
||||
const protocol = new URL(req.url).protocol // includes the final :
|
||||
const serverURL =
|
||||
config.serverURL !== null && config.serverURL !== ''
|
||||
? config.serverURL
|
||||
: `${protocol}://${req.headers.get('host')}`
|
||||
: `${protocol}//${req.headers.get('host')}`
|
||||
|
||||
const verificationURL = `${serverURL}${config.routes.admin}/${collectionConfig.slug}/verify/${token}`
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from 'url'
|
||||
|
||||
import { CLIENT_EXTENSIONS } from './clientExtensions.js'
|
||||
import { compile } from './compile.js'
|
||||
import { resolveOriginalPath } from './resolveOriginalPath.js'
|
||||
|
||||
interface ResolveContext {
|
||||
conditions: string[]
|
||||
@@ -115,7 +116,7 @@ export const resolve: ResolveFn = async (specifier, context, nextResolve) => {
|
||||
return {
|
||||
format: resolvedIsTS ? 'ts' : undefined,
|
||||
shortCircuit: true,
|
||||
url: pathToFileURL(resolvedModule.resolvedFileName).href,
|
||||
url: pathToFileURL(await resolveOriginalPath(resolvedModule.resolvedFileName)).href, // The typescript module resolver does not resolve to the original path, but to the symlinked path, if present. This can cause issues
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
packages/payload/src/bin/loader/resolveOriginalPath.ts
Normal file
35
packages/payload/src/bin/loader/resolveOriginalPath.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* In case any directory in the path contains symlinks, this function attempts to detect that resolves the original path.
|
||||
*
|
||||
* Example Input: /Users/alessio/Documents/GitHub/payload-3.0-alpha-demo/node_modules/tailwindcss/resolveConfig.js
|
||||
* The "tailwindcss" in this example is a symlinked directory.
|
||||
*
|
||||
* Example Output: /Users/alessio/Documents/GitHub/payload-3.0-alpha-demo/node_modules/.pnpm/tailwindcss@3.4.3/node_modules/tailwindcss/resolveConfig.js
|
||||
*/
|
||||
export async function resolveOriginalPath(filePath: string) {
|
||||
try {
|
||||
// Normalize and split the path
|
||||
const parts = path.resolve(filePath).split(path.sep)
|
||||
|
||||
let currentPath = '/'
|
||||
// skip the first slash
|
||||
for (const part of parts.slice(1)) {
|
||||
currentPath = path.join(currentPath, part)
|
||||
|
||||
// Check if the current path component is a symlink
|
||||
const stats = await fs.lstat(currentPath)
|
||||
if (stats.isSymbolicLink()) {
|
||||
// Resolve the symlink
|
||||
const resolvedLink = await fs.readlink(currentPath)
|
||||
currentPath = path.join(path.dirname(currentPath), resolvedLink)
|
||||
}
|
||||
}
|
||||
|
||||
return currentPath
|
||||
} catch (error) {
|
||||
console.error('Error resolving path:', error)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import merge from 'deepmerge'
|
||||
|
||||
import type { Config } from '../../config/types.js'
|
||||
import type { Config, SanitizedConfig } from '../../config/types.js'
|
||||
import type { CollectionConfig, SanitizedCollectionConfig } from './types.js'
|
||||
|
||||
import baseAccountLockFields from '../../auth/baseFields/accountLock.js'
|
||||
@@ -17,10 +17,15 @@ import { isPlainObject } from '../../utilities/isPlainObject.js'
|
||||
import baseVersionFields from '../../versions/baseFields.js'
|
||||
import { authDefaults, defaults } from './defaults.js'
|
||||
|
||||
const sanitizeCollection = (
|
||||
export const sanitizeCollection = async (
|
||||
config: Config,
|
||||
collection: CollectionConfig,
|
||||
): SanitizedCollectionConfig => {
|
||||
/**
|
||||
* If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises
|
||||
* so that you can sanitize them together, after the config has been sanitized.
|
||||
*/
|
||||
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>,
|
||||
): Promise<SanitizedCollectionConfig> => {
|
||||
// /////////////////////////////////
|
||||
// Make copy of collection config
|
||||
// /////////////////////////////////
|
||||
@@ -151,13 +156,12 @@ const sanitizeCollection = (
|
||||
// /////////////////////////////////
|
||||
|
||||
const validRelationships = config.collections.map((c) => c.slug) || []
|
||||
sanitized.fields = sanitizeFields({
|
||||
sanitized.fields = await sanitizeFields({
|
||||
config,
|
||||
fields: sanitized.fields,
|
||||
richTextSanitizationPromises,
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
return sanitized as SanitizedCollectionConfig
|
||||
}
|
||||
|
||||
export default sanitizeCollection
|
||||
|
||||
@@ -152,6 +152,7 @@ const collectionSchema = joi.object().keys({
|
||||
}),
|
||||
upload: joi.alternatives().try(
|
||||
joi.object({
|
||||
adapter: joi.string(),
|
||||
adminThumbnail: joi.alternatives().try(joi.string(), componentSchema),
|
||||
crop: joi.bool(),
|
||||
disableLocalStorage: joi.bool(),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import type { Config, SanitizedConfig } from './types.js'
|
||||
|
||||
import { sanitizeConfig } from './sanitize.js'
|
||||
@@ -16,10 +14,8 @@ export async function buildConfig(config: Config): Promise<SanitizedConfig> {
|
||||
return plugin(configAfterPlugin)
|
||||
}, Promise.resolve(config))
|
||||
|
||||
const sanitizedConfig = sanitizeConfig(configAfterPlugins)
|
||||
|
||||
return sanitizedConfig
|
||||
return await sanitizeConfig(configAfterPlugins)
|
||||
}
|
||||
|
||||
return sanitizeConfig(config)
|
||||
return await sanitizeConfig(config)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import type {
|
||||
} from './types.js'
|
||||
|
||||
import { defaultUserCollection } from '../auth/defaultUser.js'
|
||||
import sanitizeCollection from '../collections/config/sanitize.js'
|
||||
import { sanitizeCollection } from '../collections/config/sanitize.js'
|
||||
import { migrationsCollection } from '../database/migrations/migrationsCollection.js'
|
||||
import { InvalidConfiguration } from '../errors/index.js'
|
||||
import sanitizeGlobals from '../globals/config/sanitize.js'
|
||||
import { sanitizeGlobals } from '../globals/config/sanitize.js'
|
||||
import getPreferencesCollection from '../preferences/preferencesCollection.js'
|
||||
import checkDuplicateCollections from '../utilities/checkDuplicateCollections.js'
|
||||
import { isPlainObject } from '../utilities/isPlainObject.js'
|
||||
@@ -32,16 +32,19 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
|
||||
}
|
||||
}
|
||||
|
||||
if (!sanitizedConfig.collections.find(({ slug }) => slug === sanitizedConfig.admin.user)) {
|
||||
const userCollection = sanitizedConfig.collections.find(
|
||||
({ slug }) => slug === sanitizedConfig.admin.user,
|
||||
)
|
||||
if (!userCollection || !userCollection.auth) {
|
||||
throw new InvalidConfiguration(
|
||||
`${sanitizedConfig.admin.user} is not a valid admin user collection`,
|
||||
)
|
||||
}
|
||||
|
||||
return sanitizedConfig as Partial<SanitizedConfig>
|
||||
return sanitizedConfig as unknown as Partial<SanitizedConfig>
|
||||
}
|
||||
|
||||
export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => {
|
||||
export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedConfig> => {
|
||||
const configWithDefaults: Config = merge(defaults, incomingConfig, {
|
||||
isMergeableObject: isPlainObject,
|
||||
}) as Config
|
||||
@@ -94,21 +97,51 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => {
|
||||
...(incomingConfig?.i18n ?? {}),
|
||||
}
|
||||
|
||||
configWithDefaults.collections.push(getPreferencesCollection(configWithDefaults))
|
||||
configWithDefaults.collections.push(getPreferencesCollection(config as unknown as Config))
|
||||
configWithDefaults.collections.push(migrationsCollection)
|
||||
|
||||
config.collections = config.collections.map((collection) =>
|
||||
sanitizeCollection(configWithDefaults, collection),
|
||||
)
|
||||
const richTextSanitizationPromises: Array<(config: SanitizedConfig) => Promise<void>> = []
|
||||
for (let i = 0; i < config.collections.length; i++) {
|
||||
config.collections[i] = await sanitizeCollection(
|
||||
config as unknown as Config,
|
||||
config.collections[i],
|
||||
richTextSanitizationPromises,
|
||||
)
|
||||
}
|
||||
|
||||
checkDuplicateCollections(config.collections)
|
||||
|
||||
if (config.globals.length > 0) {
|
||||
config.globals = sanitizeGlobals(config as SanitizedConfig)
|
||||
config.globals = await sanitizeGlobals(
|
||||
config as unknown as Config,
|
||||
richTextSanitizationPromises,
|
||||
)
|
||||
}
|
||||
|
||||
if (config.serverURL !== '') {
|
||||
config.csrf.push(config.serverURL)
|
||||
}
|
||||
|
||||
// Get deduped list of upload adapters
|
||||
if (!config.upload) config.upload = { adapters: [] }
|
||||
config.upload.adapters = Array.from(
|
||||
new Set(config.collections.map((c) => c.upload?.adapter).filter(Boolean)),
|
||||
)
|
||||
|
||||
/*
|
||||
Execute richText sanitization
|
||||
*/
|
||||
if (typeof incomingConfig.editor === 'function') {
|
||||
config.editor = await incomingConfig.editor({
|
||||
config: config as SanitizedConfig,
|
||||
})
|
||||
}
|
||||
|
||||
const promises: Promise<void>[] = []
|
||||
for (const sanitizeFunction of richTextSanitizationPromises) {
|
||||
promises.push(sanitizeFunction(config as SanitizedConfig))
|
||||
}
|
||||
await Promise.all(promises)
|
||||
|
||||
return config as SanitizedConfig
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export default joi.object({
|
||||
validate: joi.func().required(),
|
||||
})
|
||||
.unknown(),
|
||||
email: joi.object(),
|
||||
email: joi.alternatives().try(joi.object(), joi.func()),
|
||||
endpoints: endpointsSchema,
|
||||
globals: joi.array(),
|
||||
graphQL: joi.object().keys({
|
||||
|
||||
@@ -6,6 +6,7 @@ import type React from 'react'
|
||||
import type { default as sharp } from 'sharp'
|
||||
import type { DeepRequired } from 'ts-essentials'
|
||||
|
||||
import type { RichTextAdapterProvider } from '../admin/RichText.js'
|
||||
import type { DocumentTab, RichTextAdapter } from '../admin/types.js'
|
||||
import type { AdminView, ServerSideEditViewProps } from '../admin/views/types.js'
|
||||
import type { User } from '../auth/types.js'
|
||||
@@ -527,7 +528,7 @@ export type Config = {
|
||||
*/
|
||||
defaultMaxTextLength?: number
|
||||
/** Default richtext editor to use for richText fields */
|
||||
editor: RichTextAdapter<any, any, any>
|
||||
editor: RichTextAdapterProvider<any, any, any>
|
||||
/**
|
||||
* Email Adapter
|
||||
*
|
||||
@@ -640,9 +641,11 @@ export type Config = {
|
||||
|
||||
export type SanitizedConfig = Omit<
|
||||
DeepRequired<Config>,
|
||||
'collections' | 'endpoint' | 'globals' | 'i18n' | 'localization'
|
||||
'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
|
||||
> & {
|
||||
collections: SanitizedCollectionConfig[]
|
||||
/** Default richtext editor to use for richText fields */
|
||||
editor: RichTextAdapter<any, any, any>
|
||||
endpoints: Endpoint[]
|
||||
globals: SanitizedGlobalConfig[]
|
||||
i18n: Required<I18nOptions>
|
||||
@@ -652,6 +655,12 @@ export type SanitizedConfig = Omit<
|
||||
configDir: string
|
||||
rawConfig: string
|
||||
}
|
||||
upload: ExpressFileUploadOptions & {
|
||||
/**
|
||||
* Deduped list of adapters used in the project
|
||||
*/
|
||||
adapters: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type EditConfig =
|
||||
|
||||
@@ -4,6 +4,7 @@ import { emailDefaults } from './defaults.js'
|
||||
import { getStringifiedToAddress } from './getStringifiedToAddress.js'
|
||||
|
||||
export const consoleEmailAdapter: EmailAdapter<void> = ({ payload }) => ({
|
||||
name: 'console',
|
||||
defaultFromAddress: emailDefaults.defaultFromAddress,
|
||||
defaultFromName: emailDefaults.defaultFromName,
|
||||
sendEmail: async (message) => {
|
||||
|
||||
@@ -27,5 +27,6 @@ export type InitializedEmailAdapter<TSendEmailResponse = unknown> = ReturnType<
|
||||
export type EmailAdapter<TSendEmailResponse = unknown> = ({ payload }: { payload: Payload }) => {
|
||||
defaultFromAddress: string
|
||||
defaultFromName: string
|
||||
name: string
|
||||
sendEmail: (message: SendEmailOptions) => Promise<TSendEmailResponse>
|
||||
}
|
||||
|
||||
1
packages/payload/src/exports/i18n.ts
Normal file
1
packages/payload/src/exports/i18n.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getLocalI18n } from '../translations/getLocalI18n.js'
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Config } from '../../config/types.js'
|
||||
import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors/index.js'
|
||||
import { sanitizeFields } from './sanitize.js'
|
||||
import type {
|
||||
ArrayField,
|
||||
Block,
|
||||
@@ -6,41 +9,27 @@ import type {
|
||||
Field,
|
||||
NumberField,
|
||||
TextField,
|
||||
} from './types'
|
||||
import { Config } from '../../config/types'
|
||||
import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors'
|
||||
import { sanitizeFields } from './sanitize'
|
||||
import type { BaseDatabaseAdapter } from '../../database/types.js'
|
||||
|
||||
const dummyConfig: Config = {
|
||||
collections: [],
|
||||
db: {
|
||||
defaultIDType: 'text',
|
||||
init: () => ({}) as BaseDatabaseAdapter['init'],
|
||||
} as BaseDatabaseAdapter,
|
||||
}
|
||||
} from './types.js'
|
||||
|
||||
describe('sanitizeFields', () => {
|
||||
it('should throw on missing type field', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const config = {} as Config
|
||||
it('should throw on missing type field', async () => {
|
||||
const fields: Field[] = [
|
||||
// @ts-expect-error
|
||||
{
|
||||
label: 'some-collection',
|
||||
name: 'Some Collection',
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
sanitizeFields({
|
||||
config: dummyConfig,
|
||||
await expect(async () => {
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
}).toThrow(MissingFieldType)
|
||||
}).rejects.toThrow(MissingFieldType)
|
||||
})
|
||||
it('should throw on invalid field name', () => {
|
||||
it('should throw on invalid field name', async () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
label: 'some.collection',
|
||||
@@ -48,33 +37,35 @@ describe('sanitizeFields', () => {
|
||||
type: 'text',
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields({
|
||||
config: dummyConfig,
|
||||
await expect(async () => {
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
}).toThrow(InvalidFieldName)
|
||||
}).rejects.toThrow(InvalidFieldName)
|
||||
})
|
||||
|
||||
describe('auto-labeling', () => {
|
||||
it('should populate label if missing', () => {
|
||||
it('should populate label if missing', async () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'someField',
|
||||
type: 'text',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as TextField
|
||||
const sanitizedField = (
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
)[0] as TextField
|
||||
expect(sanitizedField.name).toStrictEqual('someField')
|
||||
expect(sanitizedField.label).toStrictEqual('Some Field')
|
||||
expect(sanitizedField.type).toStrictEqual('text')
|
||||
})
|
||||
it('should allow auto-label override', () => {
|
||||
it('should allow auto-label override', async () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
label: 'Do not label',
|
||||
@@ -82,18 +73,20 @@ describe('sanitizeFields', () => {
|
||||
type: 'text',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as TextField
|
||||
const sanitizedField = (
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
)[0] as TextField
|
||||
expect(sanitizedField.name).toStrictEqual('someField')
|
||||
expect(sanitizedField.label).toStrictEqual('Do not label')
|
||||
expect(sanitizedField.type).toStrictEqual('text')
|
||||
})
|
||||
|
||||
describe('opt-out', () => {
|
||||
it('should allow label opt-out', () => {
|
||||
it('should allow label opt-out', async () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
label: false,
|
||||
@@ -101,17 +94,19 @@ describe('sanitizeFields', () => {
|
||||
type: 'text',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as TextField
|
||||
const sanitizedField = (
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
)[0] as TextField
|
||||
expect(sanitizedField.name).toStrictEqual('someField')
|
||||
expect(sanitizedField.label).toStrictEqual(false)
|
||||
expect(sanitizedField.type).toStrictEqual('text')
|
||||
})
|
||||
|
||||
it('should allow label opt-out for arrays', () => {
|
||||
it('should allow label opt-out for arrays', async () => {
|
||||
const arrayField: ArrayField = {
|
||||
fields: [
|
||||
{
|
||||
@@ -123,17 +118,19 @@ describe('sanitizeFields', () => {
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
}
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields: [arrayField],
|
||||
validRelationships: [],
|
||||
})[0] as ArrayField
|
||||
const sanitizedField = (
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields: [arrayField],
|
||||
validRelationships: [],
|
||||
})
|
||||
)[0] as ArrayField
|
||||
expect(sanitizedField.name).toStrictEqual('items')
|
||||
expect(sanitizedField.label).toStrictEqual(false)
|
||||
expect(sanitizedField.type).toStrictEqual('array')
|
||||
expect(sanitizedField.labels).toBeUndefined()
|
||||
})
|
||||
it('should allow label opt-out for blocks', () => {
|
||||
it('should allow label opt-out for blocks', async () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
blocks: [
|
||||
@@ -152,11 +149,13 @@ describe('sanitizeFields', () => {
|
||||
type: 'blocks',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as BlockField
|
||||
const sanitizedField = (
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
)[0] as BlockField
|
||||
expect(sanitizedField.name).toStrictEqual('noLabelBlock')
|
||||
expect(sanitizedField.label).toStrictEqual(false)
|
||||
expect(sanitizedField.type).toStrictEqual('blocks')
|
||||
@@ -164,7 +163,7 @@ describe('sanitizeFields', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should label arrays with plural and singular', () => {
|
||||
it('should label arrays with plural and singular', async () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
fields: [
|
||||
@@ -177,18 +176,20 @@ describe('sanitizeFields', () => {
|
||||
type: 'array',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as ArrayField
|
||||
const sanitizedField = (
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
)[0] as ArrayField
|
||||
expect(sanitizedField.name).toStrictEqual('items')
|
||||
expect(sanitizedField.label).toStrictEqual('Items')
|
||||
expect(sanitizedField.type).toStrictEqual('array')
|
||||
expect(sanitizedField.labels).toMatchObject({ plural: 'Items', singular: 'Item' })
|
||||
})
|
||||
|
||||
it('should label blocks with plural and singular', () => {
|
||||
it('should label blocks with plural and singular', async () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
blocks: [
|
||||
@@ -201,11 +202,13 @@ describe('sanitizeFields', () => {
|
||||
type: 'blocks',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as BlockField
|
||||
const sanitizedField = (
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
)[0] as BlockField
|
||||
expect(sanitizedField.name).toStrictEqual('specialBlock')
|
||||
expect(sanitizedField.label).toStrictEqual('Special Block')
|
||||
expect(sanitizedField.type).toStrictEqual('blocks')
|
||||
@@ -218,7 +221,7 @@ describe('sanitizeFields', () => {
|
||||
})
|
||||
|
||||
describe('relationships', () => {
|
||||
it('should not throw on valid relationship', () => {
|
||||
it('should not throw on valid relationship', async () => {
|
||||
const validRelationships = ['some-collection']
|
||||
const fields: Field[] = [
|
||||
{
|
||||
@@ -228,12 +231,12 @@ describe('sanitizeFields', () => {
|
||||
type: 'relationship',
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
await expect(async () => {
|
||||
await sanitizeFields({ config, fields, validRelationships })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw on valid relationship - multiple', () => {
|
||||
it('should not throw on valid relationship - multiple', async () => {
|
||||
const validRelationships = ['some-collection', 'another-collection']
|
||||
const fields: Field[] = [
|
||||
{
|
||||
@@ -243,12 +246,12 @@ describe('sanitizeFields', () => {
|
||||
type: 'relationship',
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
await expect(async () => {
|
||||
await sanitizeFields({ config, fields, validRelationships })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw on valid relationship inside blocks', () => {
|
||||
it('should not throw on valid relationship inside blocks', async () => {
|
||||
const validRelationships = ['some-collection']
|
||||
const relationshipBlock: Block = {
|
||||
fields: [
|
||||
@@ -269,12 +272,12 @@ describe('sanitizeFields', () => {
|
||||
type: 'blocks',
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
await expect(async () => {
|
||||
await sanitizeFields({ config, fields, validRelationships })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should throw on invalid relationship', () => {
|
||||
it('should throw on invalid relationship', async () => {
|
||||
const validRelationships = ['some-collection']
|
||||
const fields: Field[] = [
|
||||
{
|
||||
@@ -284,12 +287,12 @@ describe('sanitizeFields', () => {
|
||||
type: 'relationship',
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).toThrow(InvalidFieldRelationship)
|
||||
await expect(async () => {
|
||||
await sanitizeFields({ config, fields, validRelationships })
|
||||
}).rejects.toThrow(InvalidFieldRelationship)
|
||||
})
|
||||
|
||||
it('should throw on invalid relationship - multiple', () => {
|
||||
it('should throw on invalid relationship - multiple', async () => {
|
||||
const validRelationships = ['some-collection', 'another-collection']
|
||||
const fields: Field[] = [
|
||||
{
|
||||
@@ -299,12 +302,12 @@ describe('sanitizeFields', () => {
|
||||
type: 'relationship',
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).toThrow(InvalidFieldRelationship)
|
||||
await expect(async () => {
|
||||
await sanitizeFields({ config, fields, validRelationships })
|
||||
}).rejects.toThrow(InvalidFieldRelationship)
|
||||
})
|
||||
|
||||
it('should throw on invalid relationship inside blocks', () => {
|
||||
it('should throw on invalid relationship inside blocks', async () => {
|
||||
const validRelationships = ['some-collection']
|
||||
const relationshipBlock: Block = {
|
||||
fields: [
|
||||
@@ -325,12 +328,12 @@ describe('sanitizeFields', () => {
|
||||
type: 'blocks',
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).toThrow(InvalidFieldRelationship)
|
||||
await expect(async () => {
|
||||
await sanitizeFields({ config, fields, validRelationships })
|
||||
}).rejects.toThrow(InvalidFieldRelationship)
|
||||
})
|
||||
|
||||
it('should defaultValue of checkbox to false if required and undefined', () => {
|
||||
it('should defaultValue of checkbox to false if required and undefined', async () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'My Checkbox',
|
||||
@@ -339,17 +342,19 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as CheckboxField
|
||||
const sanitizedField = (
|
||||
await sanitizeFields({
|
||||
config,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
)[0] as CheckboxField
|
||||
expect(sanitizedField.defaultValue).toStrictEqual(false)
|
||||
})
|
||||
|
||||
it('should return empty field array if no fields', () => {
|
||||
const sanitizedFields = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
it('should return empty field array if no fields', async () => {
|
||||
const sanitizedFields = await sanitizeFields({
|
||||
config,
|
||||
fields: [],
|
||||
validRelationships: [],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Config } from '../../config/types.js'
|
||||
import type { Config, SanitizedConfig } from '../../config/types.js'
|
||||
import type { Field } from './types.js'
|
||||
|
||||
import { MissingEditorProp } from '../../errors/MissingEditorProp.js'
|
||||
@@ -25,6 +25,12 @@ type Args = {
|
||||
* @default false
|
||||
*/
|
||||
requireFieldLevelRichTextEditor?: boolean
|
||||
/**
|
||||
* If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises
|
||||
* so that you can sanitize them together, after the config has been sanitized.
|
||||
*/
|
||||
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>
|
||||
|
||||
/**
|
||||
* If not null, will validate that upload and relationship fields do not relate to a collection that is not in this array.
|
||||
* This validation will be skipped if validRelationships is null.
|
||||
@@ -32,17 +38,18 @@ type Args = {
|
||||
validRelationships: null | string[]
|
||||
}
|
||||
|
||||
export const sanitizeFields = ({
|
||||
export const sanitizeFields = async ({
|
||||
config,
|
||||
existingFieldNames = new Set(),
|
||||
fields,
|
||||
requireFieldLevelRichTextEditor = false,
|
||||
richTextSanitizationPromises,
|
||||
validRelationships,
|
||||
}: Args): Field[] => {
|
||||
}: Args): Promise<Field[]> => {
|
||||
if (!fields) return []
|
||||
|
||||
return fields.map((unsanitizedField) => {
|
||||
const field: Field = { ...unsanitizedField }
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i]
|
||||
|
||||
if (!field.type) throw new MissingFieldType(field)
|
||||
|
||||
@@ -143,88 +150,95 @@ export const sanitizeFields = ({
|
||||
|
||||
// Make sure that the richText field has an editor
|
||||
if (field.type === 'richText') {
|
||||
if (!field.editor) {
|
||||
if (config.editor && !requireFieldLevelRichTextEditor) {
|
||||
field.editor = config.editor
|
||||
} else {
|
||||
throw new MissingEditorProp(field)
|
||||
const sanitizeRichText = async (_config: SanitizedConfig) => {
|
||||
if (!field.editor) {
|
||||
if (_config.editor && !requireFieldLevelRichTextEditor) {
|
||||
// config.editor should be sanitized at this point
|
||||
field.editor = _config.editor
|
||||
} else {
|
||||
throw new MissingEditorProp(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add editor adapter hooks to field hooks
|
||||
if (!field.hooks) field.hooks = {}
|
||||
if (typeof field.editor === 'function') {
|
||||
field.editor = await field.editor({ config: _config })
|
||||
}
|
||||
|
||||
if (field?.editor?.hooks?.afterRead?.length) {
|
||||
field.hooks.afterRead = field.hooks.afterRead
|
||||
? field.hooks.afterRead.concat(field.editor.hooks.afterRead)
|
||||
: field.editor.hooks.afterRead
|
||||
// Add editor adapter hooks to field hooks
|
||||
if (!field.hooks) field.hooks = {}
|
||||
|
||||
const mergeHooks = (hookName: keyof typeof field.editor.hooks) => {
|
||||
if (typeof field.editor === 'function') return
|
||||
|
||||
if (field.editor?.hooks?.[hookName]?.length) {
|
||||
field.hooks[hookName] = field.hooks[hookName]
|
||||
? field.hooks[hookName].concat(field.editor.hooks[hookName])
|
||||
: [...field.editor.hooks[hookName]]
|
||||
}
|
||||
}
|
||||
|
||||
mergeHooks('afterRead')
|
||||
mergeHooks('afterChange')
|
||||
mergeHooks('beforeChange')
|
||||
mergeHooks('beforeValidate')
|
||||
mergeHooks('beforeDuplicate')
|
||||
}
|
||||
if (field?.editor?.hooks?.beforeChange?.length) {
|
||||
field.hooks.beforeChange = field.hooks.beforeChange
|
||||
? field.hooks.beforeChange.concat(field.editor.hooks.beforeChange)
|
||||
: field.editor.hooks.beforeChange
|
||||
}
|
||||
if (field?.editor?.hooks?.beforeValidate?.length) {
|
||||
field.hooks.beforeValidate = field.hooks.beforeValidate
|
||||
? field.hooks.beforeValidate.concat(field.editor.hooks.beforeValidate)
|
||||
: field.editor.hooks.beforeValidate
|
||||
}
|
||||
if (field?.editor?.hooks?.beforeChange?.length) {
|
||||
field.hooks.beforeChange = field.hooks.beforeChange
|
||||
? field.hooks.beforeChange.concat(field.editor.hooks.beforeChange)
|
||||
: field.editor.hooks.beforeChange
|
||||
if (richTextSanitizationPromises) {
|
||||
richTextSanitizationPromises.push(sanitizeRichText)
|
||||
} else {
|
||||
await sanitizeRichText(config as unknown as SanitizedConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Handle sanitization for any lexical sub-fields here as well
|
||||
if ('fields' in field && field.fields) {
|
||||
field.fields = sanitizeFields({
|
||||
field.fields = await sanitizeFields({
|
||||
config,
|
||||
existingFieldNames: fieldAffectsData(field) ? new Set() : existingFieldNames,
|
||||
fields: field.fields,
|
||||
requireFieldLevelRichTextEditor,
|
||||
richTextSanitizationPromises,
|
||||
validRelationships,
|
||||
})
|
||||
}
|
||||
|
||||
if (field.type === 'tabs') {
|
||||
field.tabs = field.tabs.map((tab) => {
|
||||
const unsanitizedTab = { ...tab }
|
||||
for (let j = 0; j < field.tabs.length; j++) {
|
||||
const tab = field.tabs[j]
|
||||
if (tabHasName(tab) && typeof tab.label === 'undefined') {
|
||||
unsanitizedTab.label = toWords(tab.name)
|
||||
tab.label = toWords(tab.name)
|
||||
}
|
||||
|
||||
unsanitizedTab.fields = sanitizeFields({
|
||||
tab.fields = await sanitizeFields({
|
||||
config,
|
||||
existingFieldNames: tabHasName(tab) ? new Set() : existingFieldNames,
|
||||
fields: tab.fields,
|
||||
requireFieldLevelRichTextEditor,
|
||||
richTextSanitizationPromises,
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
return unsanitizedTab
|
||||
})
|
||||
field.tabs[j] = tab
|
||||
}
|
||||
}
|
||||
|
||||
if ('blocks' in field && field.blocks) {
|
||||
field.blocks = field.blocks.map((block) => {
|
||||
const unsanitizedBlock = { ...block }
|
||||
unsanitizedBlock.labels = !unsanitizedBlock.labels
|
||||
? formatLabels(unsanitizedBlock.slug)
|
||||
: unsanitizedBlock.labels
|
||||
for (let j = 0; j < field.blocks.length; j++) {
|
||||
const block = field.blocks[j]
|
||||
block.labels = !block.labels ? formatLabels(block.slug) : block.labels
|
||||
|
||||
unsanitizedBlock.fields = sanitizeFields({
|
||||
block.fields = await sanitizeFields({
|
||||
config,
|
||||
existingFieldNames: new Set(),
|
||||
fields: block.fields,
|
||||
requireFieldLevelRichTextEditor,
|
||||
richTextSanitizationPromises,
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
return unsanitizedBlock
|
||||
})
|
||||
field.blocks[j] = block
|
||||
}
|
||||
}
|
||||
|
||||
return field
|
||||
})
|
||||
fields[i] = field
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import type { CSSProperties } from 'react'
|
||||
import monacoeditor from 'monaco-editor' // IMPORTANT - DO NOT REMOVE: This is required for pnpm's default isolated mode to work - even though the import is not used. This is due to a typescript bug: https://github.com/microsoft/TypeScript/issues/47663#issuecomment-1519138189. (tsbugisolatedmode)
|
||||
import type React from 'react'
|
||||
|
||||
import type { RichTextAdapter, RichTextAdapterProvider } from '../../admin/RichText.js'
|
||||
import type {
|
||||
ConditionalDateProps,
|
||||
Description,
|
||||
ErrorProps,
|
||||
LabelProps,
|
||||
RichTextAdapter,
|
||||
RowLabel,
|
||||
} from '../../admin/types.js'
|
||||
import type { SanitizedCollectionConfig, TypeWithID } from '../../collections/config/types.js'
|
||||
@@ -585,7 +585,9 @@ export type RichTextField<
|
||||
Label?: CustomComponent<LabelProps>
|
||||
}
|
||||
}
|
||||
editor?: RichTextAdapter<Value, AdapterProps, AdapterProps>
|
||||
editor?:
|
||||
| RichTextAdapter<Value, AdapterProps, AdapterProps>
|
||||
| RichTextAdapterProvider<Value, AdapterProps, AdapterProps>
|
||||
type: 'richText'
|
||||
} & ExtraProperties
|
||||
|
||||
@@ -594,7 +596,9 @@ export type RichTextFieldRequiredEditor<
|
||||
AdapterProps = any,
|
||||
ExtraProperties = object,
|
||||
> = Omit<RichTextField<Value, AdapterProps, ExtraProperties>, 'editor'> & {
|
||||
editor: RichTextAdapter<Value, AdapterProps, ExtraProperties>
|
||||
editor:
|
||||
| RichTextAdapter<Value, AdapterProps, AdapterProps>
|
||||
| RichTextAdapterProvider<Value, AdapterProps, AdapterProps>
|
||||
}
|
||||
|
||||
export type ArrayField = FieldBase & {
|
||||
|
||||
@@ -141,6 +141,10 @@ export const promise = async ({
|
||||
}
|
||||
|
||||
case 'richText': {
|
||||
if (typeof field?.editor === 'function') {
|
||||
throw new Error('Attempted to access unsanitized rich text editor.')
|
||||
}
|
||||
|
||||
const editor: RichTextAdapter = field?.editor
|
||||
// This is run here AND in the GraphQL Resolver
|
||||
if (editor?.populationPromises) {
|
||||
|
||||
@@ -264,6 +264,10 @@ export const richText: Validate<object, unknown, unknown, RichTextField> = async
|
||||
value,
|
||||
options,
|
||||
) => {
|
||||
if (typeof options?.editor === 'function') {
|
||||
throw new Error('Attempted to access unsanitized rich text editor.')
|
||||
}
|
||||
|
||||
const editor: RichTextAdapter = options?.editor
|
||||
|
||||
return editor.validate(value, options)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Config } from '../../config/types.js'
|
||||
import type { Config, SanitizedConfig } from '../../config/types.js'
|
||||
import type { SanitizedGlobalConfig } from './types.js'
|
||||
|
||||
import defaultAccess from '../../auth/defaultAccess.js'
|
||||
@@ -8,60 +8,66 @@ import mergeBaseFields from '../../fields/mergeBaseFields.js'
|
||||
import { toWords } from '../../utilities/formatLabels.js'
|
||||
import baseVersionFields from '../../versions/baseFields.js'
|
||||
|
||||
const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
|
||||
export const sanitizeGlobals = async (
|
||||
config: Config,
|
||||
/**
|
||||
* If this property is set, RichText fields won't be sanitized immediately. Instead, they will be added to this array as promises
|
||||
* so that you can sanitize them together, after the config has been sanitized.
|
||||
*/
|
||||
richTextSanitizationPromises?: Array<(config: SanitizedConfig) => Promise<void>>,
|
||||
): Promise<SanitizedGlobalConfig[]> => {
|
||||
const { collections, globals } = config
|
||||
|
||||
const sanitizedGlobals = globals.map((global) => {
|
||||
const sanitizedGlobal = { ...global }
|
||||
|
||||
sanitizedGlobal.label = sanitizedGlobal.label || toWords(sanitizedGlobal.slug)
|
||||
for (let i = 0; i < globals.length; i++) {
|
||||
const global = globals[i]
|
||||
global.label = global.label || toWords(global.slug)
|
||||
|
||||
// /////////////////////////////////
|
||||
// Ensure that collection has required object structure
|
||||
// /////////////////////////////////
|
||||
|
||||
sanitizedGlobal.endpoints = sanitizedGlobal.endpoints ?? []
|
||||
if (!sanitizedGlobal.hooks) sanitizedGlobal.hooks = {}
|
||||
if (!sanitizedGlobal.access) sanitizedGlobal.access = {}
|
||||
if (!sanitizedGlobal.admin) sanitizedGlobal.admin = {}
|
||||
global.endpoints = global.endpoints ?? []
|
||||
if (!global.hooks) global.hooks = {}
|
||||
if (!global.access) global.access = {}
|
||||
if (!global.admin) global.admin = {}
|
||||
|
||||
if (!sanitizedGlobal.access.read) sanitizedGlobal.access.read = defaultAccess
|
||||
if (!sanitizedGlobal.access.update) sanitizedGlobal.access.update = defaultAccess
|
||||
if (!global.access.read) global.access.read = defaultAccess
|
||||
if (!global.access.update) global.access.update = defaultAccess
|
||||
|
||||
if (!sanitizedGlobal.hooks.beforeValidate) sanitizedGlobal.hooks.beforeValidate = []
|
||||
if (!sanitizedGlobal.hooks.beforeChange) sanitizedGlobal.hooks.beforeChange = []
|
||||
if (!sanitizedGlobal.hooks.afterChange) sanitizedGlobal.hooks.afterChange = []
|
||||
if (!sanitizedGlobal.hooks.beforeRead) sanitizedGlobal.hooks.beforeRead = []
|
||||
if (!sanitizedGlobal.hooks.afterRead) sanitizedGlobal.hooks.afterRead = []
|
||||
if (!global.hooks.beforeValidate) global.hooks.beforeValidate = []
|
||||
if (!global.hooks.beforeChange) global.hooks.beforeChange = []
|
||||
if (!global.hooks.afterChange) global.hooks.afterChange = []
|
||||
if (!global.hooks.beforeRead) global.hooks.beforeRead = []
|
||||
if (!global.hooks.afterRead) global.hooks.afterRead = []
|
||||
|
||||
if (sanitizedGlobal.versions) {
|
||||
if (sanitizedGlobal.versions === true) sanitizedGlobal.versions = { drafts: false }
|
||||
if (global.versions) {
|
||||
if (global.versions === true) global.versions = { drafts: false }
|
||||
|
||||
if (sanitizedGlobal.versions.drafts) {
|
||||
if (sanitizedGlobal.versions.drafts === true) {
|
||||
sanitizedGlobal.versions.drafts = {
|
||||
if (global.versions.drafts) {
|
||||
if (global.versions.drafts === true) {
|
||||
global.versions.drafts = {
|
||||
autosave: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedGlobal.versions.drafts.autosave === true) {
|
||||
sanitizedGlobal.versions.drafts.autosave = {
|
||||
if (global.versions.drafts.autosave === true) {
|
||||
global.versions.drafts.autosave = {
|
||||
interval: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
sanitizedGlobal.fields = mergeBaseFields(sanitizedGlobal.fields, baseVersionFields)
|
||||
global.fields = mergeBaseFields(global.fields, baseVersionFields)
|
||||
}
|
||||
}
|
||||
|
||||
if (!sanitizedGlobal.custom) sanitizedGlobal.custom = {}
|
||||
if (!global.custom) global.custom = {}
|
||||
|
||||
// /////////////////////////////////
|
||||
// Sanitize fields
|
||||
// /////////////////////////////////
|
||||
let hasUpdatedAt = null
|
||||
let hasCreatedAt = null
|
||||
sanitizedGlobal.fields.some((field) => {
|
||||
global.fields.some((field) => {
|
||||
if (fieldAffectsData(field)) {
|
||||
if (field.name === 'updatedAt') hasUpdatedAt = true
|
||||
if (field.name === 'createdAt') hasCreatedAt = true
|
||||
@@ -69,7 +75,7 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
|
||||
return hasCreatedAt && hasUpdatedAt
|
||||
})
|
||||
if (!hasUpdatedAt) {
|
||||
sanitizedGlobal.fields.push({
|
||||
global.fields.push({
|
||||
name: 'updatedAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
@@ -80,7 +86,7 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
|
||||
})
|
||||
}
|
||||
if (!hasCreatedAt) {
|
||||
sanitizedGlobal.fields.push({
|
||||
global.fields.push({
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
admin: {
|
||||
@@ -92,16 +98,15 @@ const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
|
||||
}
|
||||
|
||||
const validRelationships = collections.map((c) => c.slug) || []
|
||||
sanitizedGlobal.fields = sanitizeFields({
|
||||
global.fields = await sanitizeFields({
|
||||
config,
|
||||
fields: sanitizedGlobal.fields,
|
||||
fields: global.fields,
|
||||
richTextSanitizationPromises,
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
return sanitizedGlobal as SanitizedGlobalConfig
|
||||
})
|
||||
globals[i] = global
|
||||
}
|
||||
|
||||
return sanitizedGlobals
|
||||
return globals as SanitizedGlobalConfig[]
|
||||
}
|
||||
|
||||
export default sanitizeGlobals
|
||||
|
||||
@@ -70,6 +70,10 @@ export type ImageSize = Omit<ResizeOptions, 'withoutEnlargement'> & {
|
||||
export type GetAdminThumbnail = (args: { doc: Record<string, unknown> }) => false | null | string
|
||||
|
||||
export type UploadConfig = {
|
||||
/**
|
||||
* The adapter to use for uploads.
|
||||
*/
|
||||
adapter?: string
|
||||
/**
|
||||
* Represents an admin thumbnail, which can be either a React component or a string.
|
||||
* - If a string, it should be one of the image size names.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sanitizeConfig } from '../config/sanitize.js'
|
||||
import { configToJSONSchema } from './configToJSONSchema.js'
|
||||
|
||||
describe('configToJSONSchema', () => {
|
||||
it('should handle optional arrays with required fields', () => {
|
||||
it('should handle optional arrays with required fields', async () => {
|
||||
const config: Config = {
|
||||
collections: [
|
||||
{
|
||||
@@ -27,7 +27,7 @@ describe('configToJSONSchema', () => {
|
||||
],
|
||||
}
|
||||
|
||||
const sanitizedConfig = sanitizeConfig(config)
|
||||
const sanitizedConfig = await sanitizeConfig(config)
|
||||
const schema = configToJSONSchema(sanitizedConfig, 'text')
|
||||
|
||||
expect(schema?.definitions?.test).toStrictEqual({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user