Compare commits
90 Commits
chore/sani
...
chore/file
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5cc843a2 | ||
|
|
34920a7ec0 | ||
|
|
2650eb7d44 | ||
|
|
50c2f8bec2 | ||
|
|
f49eeb1a63 | ||
|
|
1d9ad6f2f1 | ||
|
|
30fc7e3012 | ||
|
|
1ccd7ef074 | ||
|
|
34c3a5193b | ||
|
|
81532cb9c9 | ||
|
|
f70c6fe3e7 | ||
|
|
e6b664284f | ||
|
|
fafaa04e1a | ||
|
|
babcd599da | ||
|
|
ac19b78968 | ||
|
|
b40c581a27 | ||
|
|
335af1b8c9 | ||
|
|
583a733334 | ||
|
|
6e5ddc8873 | ||
|
|
9ba740e472 | ||
|
|
50029532aa | ||
|
|
c80b6e92c4 | ||
|
|
a9580e05ac | ||
|
|
57d00ad2e9 | ||
|
|
a9ad7c771e | ||
|
|
7a40a9fc06 | ||
|
|
b1ae749311 | ||
|
|
3f30a2e300 | ||
|
|
c07187d804 | ||
|
|
0e8ac0bad5 | ||
|
|
463c9754c7 | ||
|
|
4458f74cef | ||
|
|
cfc7adcbc5 | ||
|
|
16f5538e12 | ||
|
|
9f6030641a | ||
|
|
f2213e5c5c | ||
|
|
6f6d305f9d | ||
|
|
c902f14cb3 | ||
|
|
c66e5ca823 | ||
|
|
26d709dda6 | ||
|
|
c8b72141e4 | ||
|
|
1db06195c2 | ||
|
|
6a935d4d4d | ||
|
|
c3c1614fa6 | ||
|
|
e7695502e3 | ||
|
|
0e9865c564 | ||
|
|
e5e0ec86c5 | ||
|
|
c76d83985d | ||
|
|
a1822d21d0 | ||
|
|
4b9566f8b8 | ||
|
|
54afaf9529 | ||
|
|
3830d710a4 | ||
|
|
2da6d924de | ||
|
|
86e48ae70b | ||
|
|
7ebac630f7 | ||
|
|
7472798808 | ||
|
|
605c993bb7 | ||
|
|
a7ad573a0e | ||
|
|
d62d9b4b8e | ||
|
|
67fa5a0b3b | ||
|
|
bcb10b52b3 | ||
|
|
87c7952558 | ||
|
|
141133a27f | ||
|
|
379fc127cc | ||
|
|
5cf92878a4 | ||
|
|
8900a38678 | ||
|
|
5368440115 | ||
|
|
9f17db8a7b | ||
|
|
b1a57fa350 | ||
|
|
c1f62972da | ||
|
|
c094b0e520 | ||
|
|
1cdec861cd | ||
|
|
6d768748a0 | ||
|
|
1845669e68 | ||
|
|
0d50799b79 | ||
|
|
37c945b95b | ||
|
|
20bbbcfca2 | ||
|
|
cf87871fbd | ||
|
|
751691aeaf | ||
|
|
c03e9c1724 | ||
|
|
b74969d720 | ||
|
|
39e95195e1 | ||
|
|
886c07e918 | ||
|
|
053192c488 | ||
|
|
bc9b501e28 | ||
|
|
bb17cc3ea8 | ||
|
|
1b5e3fe8ba | ||
|
|
ca0d0360e0 | ||
|
|
fe58f03189 | ||
|
|
c7dc1b46c2 |
34
.github/CODEOWNERS
vendored
Normal file
34
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Order matters. The last matching pattern takes precedence
|
||||
|
||||
## Package Exports
|
||||
|
||||
**/exports/ @denolfe @DanRibbens
|
||||
|
||||
## Packages
|
||||
|
||||
/packages/create-payload-app/src/ @denolfe
|
||||
/packages/email-*/src/ @denolfe
|
||||
/packages/eslint-*/ @denolfe @AlessioGr
|
||||
/packages/plugin-cloud-storage/src/ @denolfe
|
||||
/packages/plugin-multi-tenant/src/ @JarrodMFlesch
|
||||
/packages/richtext-*/src/ @AlessioGr
|
||||
/packages/storage-*/src/ @denolfe
|
||||
/packages/ui/src/ @jacobsfletch @AlessioGr @JarrodMFlesch
|
||||
|
||||
## Templates
|
||||
|
||||
/templates/_data/ @denolfe
|
||||
/templates/_template/ @denolfe
|
||||
|
||||
## Build Files
|
||||
|
||||
**/jest.config.js @denolfe @AlessioGr
|
||||
**/tsconfig*.json @denolfe @AlessioGr
|
||||
|
||||
## Root
|
||||
|
||||
/.github/ @denolfe
|
||||
/.husky/ @denolfe
|
||||
/.vscode/ @denolfe @AlessioGr
|
||||
/package.json @denolfe
|
||||
/tools/ @denolfe
|
||||
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -10,7 +10,7 @@ inputs:
|
||||
pnpm-version:
|
||||
description: Pnpm version
|
||||
required: true
|
||||
default: 10.12.1
|
||||
default: 9.7.1
|
||||
pnpm-run-install:
|
||||
description: Whether to run pnpm install
|
||||
required: false
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
4
.github/reproduction-guide.md
vendored
4
.github/reproduction-guide.md
vendored
@@ -40,7 +40,7 @@ There are a couple ways run integration tests:
|
||||
|
||||
- **Granularly** - you can run individual tests in vscode by installing the Jest Runner plugin and using that to run individual tests. Clicking the `debug` button will run the test in debug mode allowing you to set break points.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/int-debug.png" />
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/.github/assets/int-debug.png" />
|
||||
|
||||
- **Manually** - you can run all int tests in the `/test/_community/int.spec.ts` file by running the following command:
|
||||
|
||||
@@ -57,7 +57,7 @@ The easiest way to run E2E tests is to install
|
||||
|
||||
Once they are installed you can open the `testing` tab in vscode sidebar and drill down to the test you want to run, i.e. `/test/_community/e2e.spec.ts`
|
||||
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/e2e-debug.png" />
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/.github/assets/e2e-debug.png" />
|
||||
|
||||
#### Notes
|
||||
|
||||
|
||||
69
.github/workflows/main.yml
vendored
69
.github/workflows/main.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: build
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 23.11.0
|
||||
PNPM_VERSION: 10.12.1
|
||||
PNPM_VERSION: 9.7.1
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
|
||||
|
||||
@@ -163,6 +163,7 @@ jobs:
|
||||
needs: [changes, build]
|
||||
if: ${{ needs.changes.outputs.needs_tests == 'true' }}
|
||||
name: int-${{ matrix.database }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -174,6 +175,7 @@ jobs:
|
||||
- supabase
|
||||
- sqlite
|
||||
- sqlite-uuid
|
||||
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -185,7 +187,8 @@ jobs:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: ${{ (startsWith(matrix.database, 'postgres') ) && 'postgis/postgis:16-3.4' || '' }}
|
||||
# Custom postgres 17 docker image that supports both pg-vector and postgis: https://github.com/payloadcms/postgis-vector
|
||||
image: ${{ (startsWith(matrix.database, 'postgres') ) && 'ghcr.io/payloadcms/postgis-vector:latest' || '' }}
|
||||
env:
|
||||
# must specify password for PG Docker container image, see: https://registry.hub.docker.com/_/postgres?tab=description&page=1&name=10
|
||||
POSTGRES_USER: ${{ env.POSTGRES_USER }}
|
||||
@@ -258,6 +261,7 @@ jobs:
|
||||
needs: [changes, build]
|
||||
if: ${{ needs.changes.outputs.needs_tests == 'true' }}
|
||||
name: e2e-${{ matrix.suite }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -312,6 +316,7 @@ jobs:
|
||||
- plugin-cloud-storage
|
||||
- plugin-form-builder
|
||||
- plugin-import-export
|
||||
- plugin-multi-tenant
|
||||
- plugin-nested-docs
|
||||
- plugin-seo
|
||||
- sort
|
||||
@@ -448,6 +453,7 @@ jobs:
|
||||
- plugin-cloud-storage
|
||||
- plugin-form-builder
|
||||
- plugin-import-export
|
||||
- plugin-multi-tenant
|
||||
- plugin-nested-docs
|
||||
- plugin-seo
|
||||
- sort
|
||||
@@ -520,24 +526,32 @@ jobs:
|
||||
# report-tag: ${{ matrix.suite }}
|
||||
# job-summary: true
|
||||
|
||||
# Build listed templates with packed local packages
|
||||
build-templates:
|
||||
# Build listed templates with packed local packages and then runs their int and e2e tests
|
||||
build-and-test-templates:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build
|
||||
needs: [changes, build]
|
||||
if: ${{ needs.changes.outputs.needs_build == 'true' }}
|
||||
name: build-template-${{ matrix.template }}-${{ matrix.database }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- template: blank
|
||||
database: mongodb
|
||||
|
||||
- template: website
|
||||
database: mongodb
|
||||
|
||||
- template: with-payload-cloud
|
||||
database: mongodb
|
||||
|
||||
- template: with-vercel-mongodb
|
||||
database: mongodb
|
||||
|
||||
# Postgres
|
||||
- template: with-postgres
|
||||
database: postgres
|
||||
|
||||
- template: with-vercel-postgres
|
||||
database: postgres
|
||||
|
||||
@@ -547,8 +561,6 @@ jobs:
|
||||
# - template: with-vercel-website
|
||||
# database: postgres
|
||||
|
||||
name: ${{ matrix.template }}-${{ matrix.database }}
|
||||
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -612,6 +624,45 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8096
|
||||
|
||||
- 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@v4
|
||||
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: Runs Template Int Tests
|
||||
run: pnpm --filter ${{ matrix.template }} run test:int
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8096
|
||||
PAYLOAD_DATABASE: ${{ matrix.database }}
|
||||
POSTGRES_URL: ${{ env.POSTGRES_URL }}
|
||||
MONGODB_URL: mongodb://localhost:27017/payloadtests
|
||||
|
||||
- name: Runs Template E2E Tests
|
||||
run: PLAYWRIGHT_JSON_OUTPUT_NAME=results_${{ matrix.template }}.json pnpm --filter ${{ matrix.template }} test:e2e
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8096
|
||||
PAYLOAD_DATABASE: ${{ matrix.database }}
|
||||
POSTGRES_URL: ${{ env.POSTGRES_URL }}
|
||||
MONGODB_URL: mongodb://localhost:27017/payloadtests
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
|
||||
tests-type-generation:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [changes, build]
|
||||
@@ -647,7 +698,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- build
|
||||
- build-templates
|
||||
- build-and-test-templates
|
||||
- tests-unit
|
||||
- tests-int
|
||||
- tests-e2e
|
||||
|
||||
2
.github/workflows/post-release-templates.yml
vendored
2
.github/workflows/post-release-templates.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 23.11.0
|
||||
PNPM_VERSION: 10.12.1
|
||||
PNPM_VERSION: 9.7.1
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
|
||||
|
||||
|
||||
2
.github/workflows/post-release.yml
vendored
2
.github/workflows/post-release.yml
vendored
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 23.11.0
|
||||
PNPM_VERSION: 10.12.1
|
||||
PNPM_VERSION: 9.7.1
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
|
||||
|
||||
|
||||
2
.github/workflows/publish-prerelease.yml
vendored
2
.github/workflows/publish-prerelease.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 23.11.0
|
||||
PNPM_VERSION: 10.12.1
|
||||
PNPM_VERSION: 9.7.1
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
pnpm 10.12.1
|
||||
pnpm 9.7.1
|
||||
nodejs 23.11.0
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -6,6 +6,8 @@
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"eslint.rules.customizations": [
|
||||
// Silence some warnings that will get auto-fixed
|
||||
{ "rule": "perfectionist/*", "severity": "off", "fixable": true },
|
||||
|
||||
@@ -45,7 +45,7 @@ There are a couple ways to do this:
|
||||
|
||||
- **Granularly** - you can run individual tests in vscode by installing the Jest Runner plugin and using that to run individual tests. Clicking the `debug` button will run the test in debug mode allowing you to set break points.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/int-debug.png" />
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/.github/assets/int-debug.png" />
|
||||
|
||||
- **Manually** - you can run all int tests in the `/test/_community/int.spec.ts` file by running the following command:
|
||||
|
||||
@@ -62,7 +62,7 @@ The easiest way to run E2E tests is to install
|
||||
|
||||
Once they are installed you can open the `testing` tab in vscode sidebar and drill down to the test you want to run, i.e. `/test/_community/e2e.spec.ts`
|
||||
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/e2e-debug.png" />
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/.github/assets/e2e-debug.png" />
|
||||
|
||||
#### Notes
|
||||
|
||||
|
||||
@@ -32,18 +32,18 @@ The Admin Panel serves as the entire HTTP layer for Payload, providing a full CR
|
||||
Once you [install Payload](../getting-started/installation), the following files and directories will be created in your app:
|
||||
|
||||
```plaintext
|
||||
app/
|
||||
├─ (payload)/
|
||||
├── admin/
|
||||
├─── [[...segments]]/
|
||||
app
|
||||
├─ (payload)
|
||||
├── admin
|
||||
├─── [[...segments]]
|
||||
├──── page.tsx
|
||||
├──── not-found.tsx
|
||||
├── api/
|
||||
├─── [...slug]/
|
||||
├── api
|
||||
├─── [...slug]
|
||||
├──── route.ts
|
||||
├── graphql/
|
||||
├── graphql
|
||||
├──── route.ts
|
||||
├── graphql-playground/
|
||||
├── graphql-playground
|
||||
├──── route.ts
|
||||
├── custom.scss
|
||||
├── layout.tsx
|
||||
@@ -84,29 +84,30 @@ import { buildConfig } from 'payload'
|
||||
|
||||
const config = buildConfig({
|
||||
// ...
|
||||
// highlight-start
|
||||
admin: {
|
||||
// highlight-line
|
||||
// ...
|
||||
},
|
||||
// highlight-end
|
||||
})
|
||||
```
|
||||
|
||||
The following options are available:
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`avatar`** | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
|
||||
| **`autoLogin`** | Used to automate log-in for dev and demonstration convenience. [More details](../authentication/overview). |
|
||||
| **`components`** | Component overrides that affect the entirety of the Admin Panel. [More details](../custom-components/overview). |
|
||||
| **`custom`** | Any custom properties you wish to pass to the Admin Panel. |
|
||||
| **`dateFormat`** | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
|
||||
| **`livePreview`** | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
|
||||
| **`meta`** | Base metadata to use for the Admin Panel. [More details](./metadata). |
|
||||
| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
|
||||
| **`suppressHydrationWarning`** | If set to `true`, suppresses React hydration mismatch warnings during the hydration of the root `<html>` tag. Defaults to `false`. |
|
||||
| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. |
|
||||
| **`timezones`** | Configure the timezone settings for the admin panel. [More details](#timezones) |
|
||||
| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
|
||||
| Option | Description |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `avatar` | Set account profile picture. Options: `gravatar`, `default` or a custom React component. |
|
||||
| `autoLogin` | Used to automate log-in for dev and demonstration convenience. [More details](../authentication/overview). |
|
||||
| `components` | Component overrides that affect the entirety of the Admin Panel. [More details](../custom-components/overview). |
|
||||
| `custom` | Any custom properties you wish to pass to the Admin Panel. |
|
||||
| `dateFormat` | The date format that will be used for all dates within the Admin Panel. Any valid [date-fns](https://date-fns.org/) format pattern can be used. |
|
||||
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |
|
||||
| `meta` | Base metadata to use for the Admin Panel. [More details](./metadata). |
|
||||
| `routes` | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
|
||||
| `suppressHydrationWarning` | If set to `true`, suppresses React hydration mismatch warnings during the hydration of the root `<html>` tag. Defaults to `false`. |
|
||||
| `theme` | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. |
|
||||
| `timezones` | Configure the timezone settings for the admin panel. [More details](#timezones) |
|
||||
| `user` | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
|
||||
|
||||
<Banner type="success">
|
||||
**Reminder:** These are the _root-level_ options for the Admin Panel. You can
|
||||
@@ -186,6 +187,12 @@ The following options are available:
|
||||
| `graphQL` | `/graphql` | The [GraphQL API](../graphql/overview) base path. |
|
||||
| `graphQLPlayground` | `/graphql-playground` | The GraphQL Playground. |
|
||||
|
||||
<Banner type="warning">
|
||||
**Important:** Changing Root-level Routes also requires a change to [Project
|
||||
Structure](#project-structure) to match the new route. [More
|
||||
details](#customizing-root-level-routes).
|
||||
</Banner>
|
||||
|
||||
<Banner type="success">
|
||||
**Tip:** You can easily add _new_ routes to the Admin Panel through [Custom
|
||||
Endpoints](../rest-api/overview#custom-endpoints) and [Custom
|
||||
@@ -196,13 +203,29 @@ The following options are available:
|
||||
|
||||
You can change the Root-level Routes as needed, such as to mount the Admin Panel at the root of your application.
|
||||
|
||||
Changing Root-level Routes also requires a change to [Project Structure](#project-structure) to match the new route. For example, if you set `routes.admin` to `/`, you would need to completely remove the `admin` directory from the project structure:
|
||||
This change, however, also requires a change to your [Project Structure](#project-structure) to match the new route.
|
||||
|
||||
For example, if you set `routes.admin` to `/`:
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
|
||||
const config = buildConfig({
|
||||
// ...
|
||||
routes: {
|
||||
admin: '/', // highlight-line
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Then you would need to completely remove the `admin` directory from the project structure:
|
||||
|
||||
```plaintext
|
||||
app/
|
||||
├─ (payload)/
|
||||
├── [[...segments]]/
|
||||
app
|
||||
├─ (payload)
|
||||
├── [[...segments]]
|
||||
├──── ...
|
||||
├── layout.tsx
|
||||
```
|
||||
|
||||
<Banner type="warning">
|
||||
|
||||
@@ -180,19 +180,22 @@ As Payload sets HTTP-only cookies, logging out cannot be done by just removing a
|
||||
**Example REST API logout**:
|
||||
|
||||
```ts
|
||||
const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
const res = await fetch(
|
||||
'http://localhost:3000/api/[collection-slug]/logout?allSessions=false',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
**Example GraphQL Mutation**:
|
||||
|
||||
```
|
||||
mutation {
|
||||
logout[collection-singular-label]
|
||||
logoutUser(allSessions: false)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -203,6 +206,10 @@ mutation {
|
||||
docs](../local-api/server-functions#reusable-payload-server-functions).
|
||||
</Banner>
|
||||
|
||||
#### Logging out with sessions enabled
|
||||
|
||||
By default, logging out will only end the session pertaining to the JWT that was used to log out with. However, you can pass `allSessions: true` to the logout operation in order to end all sessions for the user logging out.
|
||||
|
||||
## Refresh
|
||||
|
||||
Allows for "refreshing" JWTs. If your user has a token that is about to expire, but the user is still active and using the app, you might want to use the `refresh` operation to receive a new token by executing this operation via the authenticated user.
|
||||
|
||||
@@ -91,6 +91,7 @@ The following options are available:
|
||||
| **`strategies`** | Advanced - an array of custom authentication strategies to extend this collection's authentication with. [More details](./custom-strategies). |
|
||||
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
|
||||
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More details](./api-keys). |
|
||||
| **`useSessions`** | True by default. Set to `false` to use stateless JWTs for authentication instead of sessions. |
|
||||
| **`verify`** | Set to `true` or pass an object with verification options to require users to verify by email before they are allowed to log into your app. [More details](./email#email-verification). |
|
||||
|
||||
### Login With Username
|
||||
@@ -201,3 +202,43 @@ API Keys can be enabled on auth collections. These are particularly useful when
|
||||
### Custom Strategies
|
||||
|
||||
There are cases where these may not be enough for your application. Payload is extendable by design so you can wire up your own strategy when you need to. [More details](./custom-strategies).
|
||||
|
||||
### Access Control
|
||||
|
||||
Default auth fields including `email`, `username`, and `password` can be overridden by defining a custom field with the same name in your collection config. This allows you to customize the field — including access control — while preserving the underlying auth functionality. For example, you might want to restrict the `email` field from being updated once it is created, or only allow it to be read by certain user roles. You can achieve this by redefining the field and setting access rules accordingly.
|
||||
|
||||
Here's an example of how to restrict access to default auth fields:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Auth: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'email', // or 'username'
|
||||
type: 'text',
|
||||
access: {
|
||||
create: () => true,
|
||||
read: () => false,
|
||||
update: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'password', // this will be applied to all password-related fields including new password, confirm password.
|
||||
type: 'text',
|
||||
hidden: true, // needed only for the password field to prevent duplication in the Admin panel
|
||||
access: {
|
||||
update: () => false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
**Note:**
|
||||
|
||||
- Access functions will apply across the application — I.e. if `read` access is disabled on `email`, it will not appear in the Admin panel UI or API.
|
||||
- Restricting `read` on the `email` or `username` disables the **Unlock** action in the Admin panel as this function requires access to a user-identifying field.
|
||||
- When overriding the `password` field, you may need to include `hidden: true` to prevent duplicate fields being displayed in the Admin panel.
|
||||
|
||||
@@ -51,7 +51,7 @@ For more granular control, pass a configuration object instead. Payload exposes
|
||||
| Property | Description |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Component` \* | Pass in the component path that should be rendered when a user navigates to this route. |
|
||||
| `path` \* | Any valid URL path or array of paths that [`path-to-regexp`](https://www.npmjs.com/package/path-to-regex) understands. |
|
||||
| `path` \* | Any valid URL path or array of paths that [`path-to-regexp`](https://www.npmjs.com/package/path-to-regex) understands. Must begin with a forward slash (`/`). |
|
||||
| `exact` | Boolean. When true, will only match if the path matches the `usePathname()` exactly. |
|
||||
| `strict` | When true, a path that has a trailing slash will only match a `location.pathname` with a trailing slash. This has no effect when there are additional URL segments in the pathname. |
|
||||
| `sensitive` | When true, will match if the path is case sensitive. |
|
||||
|
||||
@@ -30,7 +30,6 @@ export const MyCollectionOrGlobalConfig: CollectionConfig = {
|
||||
// - api
|
||||
// - versions
|
||||
// - version
|
||||
// - livePreview
|
||||
// - [key: string]
|
||||
// See below for more details
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ export default buildConfig({
|
||||
| `collation` | Enable language-specific string comparison with customizable options. Available on MongoDB 3.4+. Defaults locale to "en". Example: `{ strength: 3 }`. For a full list of collation options and their definitions, see the [MongoDB documentation](https://www.mongodb.com/docs/manual/reference/collation/). |
|
||||
| `allowAdditionalKeys` | By default, Payload strips all additional keys from MongoDB data that don't exist in the Payload schema. If you have some data that you want to include to the result but it doesn't exist in Payload, you can set this to `true`. Be careful as Payload access control _won't_ work for this data. |
|
||||
| `allowIDOnCreate` | Set to `true` to use the `id` passed in data on the create API operations without using a custom ID field. |
|
||||
| `disableFallbackSort` | Set to `true` to disable the adapter adding a fallback sort when sorting by non-unique fields, this can affect performance in some cases but it ensures a consistent order of results. |
|
||||
|
||||
## Access to Mongoose models
|
||||
|
||||
|
||||
@@ -315,7 +315,8 @@ import type { Field } from 'payload'
|
||||
export const MyField: Field = {
|
||||
type: 'text',
|
||||
name: 'myField',
|
||||
validate: (value, {req: { t }}) => Boolean(value) || t('validation:required'), // highlight-line
|
||||
validate: (value, { req: { t } }) =>
|
||||
Boolean(value) || t('validation:required'), // highlight-line
|
||||
}
|
||||
```
|
||||
|
||||
@@ -350,7 +351,6 @@ import {
|
||||
code,
|
||||
date,
|
||||
email,
|
||||
group,
|
||||
json,
|
||||
number,
|
||||
point,
|
||||
|
||||
@@ -16,14 +16,15 @@ The labels you provide for your Collections and Globals are used to name the Gra
|
||||
|
||||
At the top of your Payload Config you can define all the options to manage GraphQL.
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mutations` | Any custom Mutations to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
|
||||
| `queries` | Any custom Queries to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
|
||||
| `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) |
|
||||
| `disablePlaygroundInProduction` | A boolean that if false will enable the GraphQL playground, defaults to true. [More](/docs/graphql/overview#graphql-playground) |
|
||||
| `disable` | A boolean that if true will disable the GraphQL entirely, defaults to false. |
|
||||
| `validationRules` | A function that takes the ExecutionArgs and returns an array of ValidationRules. |
|
||||
| Option | Description |
|
||||
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mutations` | Any custom Mutations to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
|
||||
| `queries` | Any custom Queries to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
|
||||
| `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) |
|
||||
| `disablePlaygroundInProduction` | A boolean that if false will enable the GraphQL playground in production environments, defaults to true. [More](/docs/graphql/overview#graphql-playground) |
|
||||
| `disableIntrospectionInProduction` | A boolean that if false will enable the GraphQL introspection in production environments, defaults to true. |
|
||||
| `disable` | A boolean that if true will disable the GraphQL entirely, defaults to false. |
|
||||
| `validationRules` | A function that takes the ExecutionArgs and returns an array of ValidationRules. |
|
||||
|
||||
## Collections
|
||||
|
||||
|
||||
@@ -393,7 +393,7 @@ export default function LoginForm() {
|
||||
|
||||
### Logout
|
||||
|
||||
Logs out the current user by clearing the authentication cookie.
|
||||
Logs out the current user by clearing the authentication cookie and current sessions.
|
||||
|
||||
#### Importing the `logout` function
|
||||
|
||||
@@ -401,7 +401,7 @@ Logs out the current user by clearing the authentication cookie.
|
||||
import { logout } from '@payloadcms/next/auth'
|
||||
```
|
||||
|
||||
Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below.
|
||||
Similar to the login function, you now need to pass your Payload config to this function and this cannot be done in a client component. Use a helper server function as shown below. To ensure all sessions are cleared, set `allSessions: true` in the options, if you wish to logout but keep current sessions active, you can set this to `false` or leave it `undefined`.
|
||||
|
||||
```ts
|
||||
'use server'
|
||||
@@ -411,7 +411,7 @@ import config from '@payload-config'
|
||||
|
||||
export async function logoutAction() {
|
||||
try {
|
||||
return await logout({ config })
|
||||
return await logout({ allSessions: true, config })
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
@@ -434,7 +434,7 @@ export default function LogoutButton() {
|
||||
|
||||
### Refresh
|
||||
|
||||
Refreshes the authentication token for the logged-in user.
|
||||
Refreshes the authentication token and current session for the logged-in user.
|
||||
|
||||
#### Importing the `refresh` function
|
||||
|
||||
@@ -453,7 +453,6 @@ import config from '@payload-config'
|
||||
export async function refreshAction() {
|
||||
try {
|
||||
return await refresh({
|
||||
collection: 'users', // pass your collection slug
|
||||
config,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -74,11 +74,7 @@ import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const config = buildConfig({
|
||||
collections: [Pages, Media],
|
||||
plugins: [
|
||||
sentryPlugin({
|
||||
Sentry,
|
||||
}),
|
||||
],
|
||||
plugins: [sentryPlugin({ Sentry })],
|
||||
})
|
||||
|
||||
export default config
|
||||
@@ -100,9 +96,7 @@ export default buildConfig({
|
||||
pool: { connectionString: process.env.DATABASE_URL },
|
||||
pg, // Inject the patched pg driver for Sentry instrumentation
|
||||
}),
|
||||
plugins: [
|
||||
sentryPlugin({ Sentry }),
|
||||
],
|
||||
plugins: [sentryPlugin({ Sentry })],
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ _An asterisk denotes that an option is required._
|
||||
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
|
||||
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
|
||||
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
|
||||
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
|
||||
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
|
||||
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
|
||||
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
|
||||
@@ -435,6 +436,24 @@ export const Media: CollectionConfig = {
|
||||
}
|
||||
```
|
||||
|
||||
You can also adjust server-side fetching at the upload level as well, this does not effect the `CORS` policy like the `pasteURL` option does, but it allows you to skip the safe fetch check for specific URLs.
|
||||
|
||||
```
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: {
|
||||
skipSafeFetch: [
|
||||
{
|
||||
hostname: 'example.com',
|
||||
pathname: '/images/*',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
##### Accepted Values for `pasteURL`
|
||||
|
||||
| Option | Description |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "website",
|
||||
"name": "astro-website",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -14,12 +14,12 @@ export const Header = () => {
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
|
||||
srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
|
||||
/>
|
||||
<Image
|
||||
alt="Payload Logo"
|
||||
height={30}
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-dark.svg"
|
||||
width={150}
|
||||
/>
|
||||
</picture>
|
||||
|
||||
@@ -27,12 +27,12 @@ export const Header = async () => {
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
|
||||
srcSet="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
|
||||
/>
|
||||
<Image
|
||||
alt="Payload Logo"
|
||||
height={30}
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-dark.svg"
|
||||
width={150}
|
||||
/>
|
||||
</picture>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { cn } from '@/utilities/ui'
|
||||
import useClickableCard from '@/utilities/useClickableCard'
|
||||
import Link from 'next/link'
|
||||
import { useLocale } from 'next-intl'
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import type { Post } from '@/payload-types'
|
||||
@@ -16,6 +17,7 @@ export const Card: React.FC<{
|
||||
showCategories?: boolean
|
||||
title?: string
|
||||
}> = (props) => {
|
||||
const locale = useLocale()
|
||||
const { card, link } = useClickableCard({})
|
||||
const { className, doc, relationTo, showCategories, title: titleFromProps } = props
|
||||
|
||||
@@ -25,7 +27,7 @@ export const Card: React.FC<{
|
||||
const hasCategories = categories && Array.isArray(categories) && categories.length > 0
|
||||
const titleToUse = titleFromProps || title
|
||||
const sanitizedDescription = description?.replace(/\s/g, ' ') // replace non-breaking space with white space
|
||||
const href = `/${relationTo}/${slug}`
|
||||
const href = `/${locale}/${relationTo}/${slug}`
|
||||
|
||||
return (
|
||||
<article
|
||||
|
||||
@@ -6,7 +6,7 @@ export const Logo = () => {
|
||||
<img
|
||||
alt="Payload Logo"
|
||||
className="max-w-[9.375rem] invert dark:invert-0"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function Footer({ locale }: { locale: TypedLocale }) {
|
||||
<img
|
||||
alt="Payload Logo"
|
||||
className="max-w-[6rem] invert-0"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
|
||||
/>
|
||||
</picture>
|
||||
</Link>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default buildConfig({
|
||||
admin: {
|
||||
components: {
|
||||
// The `BeforeLogin` component renders a message that you see while logging into your admin panel.
|
||||
// Feel free to delete this at any time. Simply remove the line below and the import `BeforeLogin` statement on line 15.
|
||||
// Feel free to delete this at any time. Simply remove the line below.
|
||||
beforeLogin: ['@/components/BeforeLogin'],
|
||||
afterDashboard: ['@/components/AfterDashboard'],
|
||||
},
|
||||
|
||||
@@ -14,9 +14,12 @@ export const superAdminOrTenantAdminAccess: Access = ({ req }) => {
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
in: getUserTenantIDs(req.user, 'tenant-admin'),
|
||||
},
|
||||
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||
const requestedTenant = req?.data?.tenant
|
||||
|
||||
if (requestedTenant && adminTenantAccessIDs.includes(requestedTenant)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
import type { User } from '../../../payload-types'
|
||||
import type { Tenant, User } from '../../../payload-types'
|
||||
|
||||
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||
@@ -14,9 +14,20 @@ export const createAccess: Access<User> = ({ req }) => {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isSuperAdmin(req.user) && req.data?.roles?.includes('super-admin')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||
|
||||
if (adminTenantAccessIDs.length) {
|
||||
const requestedTenants: Tenant['id'][] =
|
||||
req.data?.tenants?.map((t: { tenant: Tenant['id'] }) => t.tenant) ?? []
|
||||
|
||||
const hasAccessToAllRequestedTenants = requestedTenants.every((tenantID) =>
|
||||
adminTenantAccessIDs.includes(tenantID),
|
||||
)
|
||||
|
||||
if (hasAccessToAllRequestedTenants) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -11,11 +11,11 @@
|
||||
"bf": "pnpm run build:force",
|
||||
"build": "pnpm run build:core",
|
||||
"build:admin-bar": "turbo build --filter \"@payloadcms/admin-bar\"",
|
||||
"build:all": "turbo build",
|
||||
"build:all": "turbo build --filter \"!blank\" --filter \"!website\"",
|
||||
"build:app": "next build",
|
||||
"build:app:analyze": "cross-env ANALYZE=true next build",
|
||||
"build:clean": "pnpm clean:build",
|
||||
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\"",
|
||||
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\" --filter \"!blank\" --filter \"!website\"",
|
||||
"build:core:force": "pnpm clean:build && pnpm build:core --no-cache --force",
|
||||
"build:create-payload-app": "turbo build --filter create-payload-app",
|
||||
"build:db-mongodb": "turbo build --filter \"@payloadcms/db-mongodb\"",
|
||||
@@ -79,9 +79,9 @@
|
||||
"docker:start": "docker compose -f test/docker-compose.yml up -d",
|
||||
"docker:stop": "docker compose -f test/docker-compose.yml down",
|
||||
"force:build": "pnpm run build:core:force",
|
||||
"lint": "turbo run lint --log-order=grouped --continue",
|
||||
"lint": "turbo run lint --log-order=grouped --continue --filter \"!blank\" --filter \"!website\"",
|
||||
"lint-staged": "lint-staged",
|
||||
"lint:fix": "turbo run lint:fix --log-order=grouped --continue",
|
||||
"lint:fix": "turbo run lint:fix --log-order=grouped --continue --filter \"!blank\" --filter \"!website\"",
|
||||
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
|
||||
"prepare": "husky",
|
||||
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
|
||||
@@ -151,10 +151,9 @@
|
||||
"create-payload-app": "workspace:*",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"drizzle-kit": "0.31.0",
|
||||
"drizzle-orm": "0.43.1",
|
||||
"drizzle-kit": "0.31.4",
|
||||
"drizzle-orm": "0.44.2",
|
||||
"escape-html": "^1.0.3",
|
||||
"eslint": "9.22.0",
|
||||
"execa": "5.1.1",
|
||||
"form-data": "3.0.1",
|
||||
"fs-extra": "10.1.0",
|
||||
@@ -167,7 +166,7 @@
|
||||
"next": "15.3.2",
|
||||
"open": "^10.1.0",
|
||||
"p-limit": "^5.0.0",
|
||||
"pg": "8.11.3",
|
||||
"pg": "8.16.3",
|
||||
"playwright": "1.50.0",
|
||||
"playwright-core": "1.50.0",
|
||||
"prettier": "3.5.3",
|
||||
@@ -182,13 +181,13 @@
|
||||
"tempy": "1.0.1",
|
||||
"tstyche": "^3.1.1",
|
||||
"tsx": "4.19.2",
|
||||
"turbo": "^2.3.3",
|
||||
"turbo": "^2.5.4",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"packageManager": "pnpm@9.7.1",
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0",
|
||||
"pnpm": "^10.12.1"
|
||||
"pnpm": "^9.7.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/admin-bar",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "An admin bar for React apps using Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-payload-app",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from 'path'
|
||||
|
||||
import type { CliArgs, DbType, ProjectExample, ProjectTemplate } from '../types.js'
|
||||
|
||||
import { createProject } from './create-project.js'
|
||||
import { createProject, updatePackageJSONDependencies } from './create-project.js'
|
||||
import { dbReplacements } from './replacements.js'
|
||||
import { getValidTemplates } from './templates.js'
|
||||
|
||||
@@ -179,5 +179,37 @@ describe('createProject', () => {
|
||||
expect(content).toContain(dbReplacement.configReplacement().join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('updates package.json', () => {
|
||||
it('updates package name and bumps workspace versions', async () => {
|
||||
const latestVersion = '3.0.0'
|
||||
const initialJSON = {
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
dependencies: {
|
||||
'@payloadcms/db-mongodb': 'workspace:*',
|
||||
payload: 'workspace:*',
|
||||
'@payloadcms/ui': 'workspace:*',
|
||||
},
|
||||
}
|
||||
|
||||
const correctlyModifiedJSON = {
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
dependencies: {
|
||||
'@payloadcms/db-mongodb': `${latestVersion}`,
|
||||
payload: `${latestVersion}`,
|
||||
'@payloadcms/ui': `${latestVersion}`,
|
||||
},
|
||||
}
|
||||
|
||||
updatePackageJSONDependencies({
|
||||
latestVersion,
|
||||
packageJson: initialJSON,
|
||||
})
|
||||
|
||||
expect(initialJSON).toEqual(correctlyModifiedJSON)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -129,7 +129,11 @@ export async function createProject(
|
||||
const spinner = p.spinner()
|
||||
spinner.start('Checking latest Payload version...')
|
||||
|
||||
await updatePackageJSON({ projectDir, projectName })
|
||||
const payloadVersion = await getLatestPackageVersion({ packageName: 'payload' })
|
||||
|
||||
spinner.stop(`Found latest version of Payload ${payloadVersion}`)
|
||||
|
||||
await updatePackageJSON({ latestVersion: payloadVersion, projectDir, projectName })
|
||||
|
||||
if ('template' in args) {
|
||||
if (args.template.type === 'plugin') {
|
||||
@@ -177,17 +181,105 @@ export async function createProject(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the package.json file into an object and then does the following:
|
||||
* - Sets the `name` property to the provided `projectName`.
|
||||
* - Bumps the payload packages from workspace:* to the latest version.
|
||||
* - Writes the updated object back to the package.json file.
|
||||
*/
|
||||
export async function updatePackageJSON(args: {
|
||||
/**
|
||||
* The latest version of Payload to use in the package.json.
|
||||
*/
|
||||
latestVersion: string
|
||||
projectDir: string
|
||||
/**
|
||||
* The name of the project to set in package.json.
|
||||
*/
|
||||
projectName: string
|
||||
}): Promise<void> {
|
||||
const { projectDir, projectName } = args
|
||||
const { latestVersion, projectDir, projectName } = args
|
||||
const packageJsonPath = path.resolve(projectDir, 'package.json')
|
||||
try {
|
||||
const packageObj = await fse.readJson(packageJsonPath)
|
||||
packageObj.name = projectName
|
||||
|
||||
updatePackageJSONDependencies({
|
||||
latestVersion,
|
||||
packageJson: packageObj,
|
||||
})
|
||||
|
||||
await fse.writeJson(packageJsonPath, packageObj, { spaces: 2 })
|
||||
} catch (err: unknown) {
|
||||
warning(`Unable to update name in package.json. ${err instanceof Error ? err.message : ''}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively updates a JSON object to replace all instances of `workspace:` with the latest version pinned.
|
||||
*
|
||||
* Does not return and instead modifies the `packageJson` object in place.
|
||||
*/
|
||||
export function updatePackageJSONDependencies(args: {
|
||||
latestVersion: string
|
||||
packageJson: Record<string, unknown>
|
||||
}): void {
|
||||
const { latestVersion, packageJson } = args
|
||||
|
||||
const updatedDependencies = Object.entries(packageJson.dependencies || {}).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (typeof value === 'string' && value.startsWith('workspace:')) {
|
||||
acc[key] = `${latestVersion}`
|
||||
} else if (key === 'payload' || key.startsWith('@payloadcms')) {
|
||||
acc[key] = `${latestVersion}`
|
||||
} else {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
packageJson.dependencies = updatedDependencies
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest version of a package from the NPM registry.
|
||||
*
|
||||
* Used in determining the latest version of Payload to use in the generated templates.
|
||||
*/
|
||||
async function getLatestPackageVersion({
|
||||
packageName = 'payload',
|
||||
}: {
|
||||
/**
|
||||
* Package name to fetch the latest version for based on the NPM registry URL
|
||||
*
|
||||
* Eg. for `'payload'`, it will fetch the version from `https://registry.npmjs.org/payload`
|
||||
*
|
||||
* @default 'payload'
|
||||
*/
|
||||
packageName?: string
|
||||
}): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`)
|
||||
const data = await response.json()
|
||||
|
||||
// Monster chaining for type safety just checking for data.latest
|
||||
const latestVersion =
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'latest' in data &&
|
||||
data.latest &&
|
||||
typeof data.latest === 'string'
|
||||
? data.latest
|
||||
: null
|
||||
|
||||
if (!latestVersion) {
|
||||
throw new Error(`No latest version found for package: ${packageName}`)
|
||||
}
|
||||
|
||||
return latestVersion
|
||||
} catch (error) {
|
||||
console.error('Error fetching Payload version:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function downloadTemplate({
|
||||
}) {
|
||||
const branchOrTag = template.url.split('#')?.[1] || 'latest'
|
||||
const url = `https://codeload.github.com/payloadcms/payload/tar.gz/${branchOrTag}`
|
||||
const filter = `payload-${branchOrTag.replace(/^v/, '')}/templates/${template.name}/`
|
||||
const filter = `payload-${branchOrTag.replace(/^v/, '').replaceAll('/', '-')}/templates/${template.name}/`
|
||||
|
||||
if (debug) {
|
||||
debugLog(`Using template url: ${template.url}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-mongodb",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "The officially supported MongoDB database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -118,6 +118,13 @@ export interface Args {
|
||||
*/
|
||||
useFacet?: boolean
|
||||
} & ConnectOptions
|
||||
/**
|
||||
* We add a secondary sort based on `createdAt` to ensure that results are always returned in the same order when sorting by a non-unique field.
|
||||
* This is because MongoDB does not guarantee the order of results, however in very large datasets this could affect performance.
|
||||
*
|
||||
* Set to `true` to disable this behaviour.
|
||||
*/
|
||||
disableFallbackSort?: boolean
|
||||
/** Set to true to disable hinting to MongoDB to use 'id' as index. This is currently done when counting documents for pagination. Disabling this optimization might fix some problems with AWS DocumentDB. Defaults to false */
|
||||
disableIndexHints?: boolean
|
||||
/**
|
||||
@@ -131,6 +138,7 @@ export interface Args {
|
||||
*/
|
||||
mongoMemoryServer?: MongoMemoryReplSet
|
||||
prodMigrations?: Migration[]
|
||||
|
||||
transactionOptions?: false | TransactionOptions
|
||||
|
||||
/** The URL to connect to MongoDB or false to start payload and prevent connecting */
|
||||
@@ -198,6 +206,7 @@ export function mongooseAdapter({
|
||||
autoPluralization = true,
|
||||
collectionsSchemaOptions = {},
|
||||
connectOptions,
|
||||
disableFallbackSort = false,
|
||||
disableIndexHints = false,
|
||||
ensureIndexes = false,
|
||||
migrationDir: migrationDirArg,
|
||||
@@ -251,6 +260,7 @@ export function mongooseAdapter({
|
||||
deleteOne,
|
||||
deleteVersions,
|
||||
destroy,
|
||||
disableFallbackSort,
|
||||
find,
|
||||
findGlobal,
|
||||
findGlobalVersions,
|
||||
|
||||
121
packages/db-mongodb/src/queries/buildSortParam.spec.ts
Normal file
121
packages/db-mongodb/src/queries/buildSortParam.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Config, SanitizedConfig } from 'payload'
|
||||
|
||||
import { sanitizeConfig } from 'payload'
|
||||
|
||||
import { buildSortParam } from './buildSortParam.js'
|
||||
import { MongooseAdapter } from '../index.js'
|
||||
|
||||
let config: SanitizedConfig
|
||||
|
||||
describe('builds sort params', () => {
|
||||
beforeAll(async () => {
|
||||
config = await sanitizeConfig({
|
||||
localization: {
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
locales: ['en', 'es'],
|
||||
},
|
||||
} as Config)
|
||||
})
|
||||
it('adds a fallback on non-unique field', () => {
|
||||
const result = buildSortParam({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
locale: 'en',
|
||||
sort: 'order',
|
||||
timestamps: true,
|
||||
adapter: {
|
||||
disableFallbackSort: false,
|
||||
} as MongooseAdapter,
|
||||
})
|
||||
|
||||
expect(result).toStrictEqual({ order: 'asc', createdAt: 'desc' })
|
||||
})
|
||||
|
||||
it('adds a fallback when sort isnt provided', () => {
|
||||
const result = buildSortParam({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
locale: 'en',
|
||||
sort: undefined,
|
||||
timestamps: true,
|
||||
adapter: {
|
||||
disableFallbackSort: false,
|
||||
} as MongooseAdapter,
|
||||
})
|
||||
|
||||
expect(result).toStrictEqual({ createdAt: 'desc' })
|
||||
})
|
||||
|
||||
it('does not add a fallback on non-unique field when disableFallbackSort is true', () => {
|
||||
const result = buildSortParam({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
locale: 'en',
|
||||
sort: 'order',
|
||||
timestamps: true,
|
||||
adapter: {
|
||||
disableFallbackSort: true,
|
||||
} as MongooseAdapter,
|
||||
})
|
||||
|
||||
expect(result).toStrictEqual({ order: 'asc' })
|
||||
})
|
||||
|
||||
// This test should be true even when disableFallbackSort is false
|
||||
it('does not add a fallback on unique field', () => {
|
||||
const result = buildSortParam({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
type: 'number',
|
||||
unique: true, // Marking this field as unique
|
||||
},
|
||||
],
|
||||
locale: 'en',
|
||||
sort: 'order',
|
||||
timestamps: true,
|
||||
adapter: {
|
||||
disableFallbackSort: false,
|
||||
} as MongooseAdapter,
|
||||
})
|
||||
|
||||
expect(result).toStrictEqual({ order: 'asc' })
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ type Args = {
|
||||
fields: FlattenedField[]
|
||||
locale?: string
|
||||
parentIsLocalized?: boolean
|
||||
sort: Sort
|
||||
sort?: Sort
|
||||
sortAggregation?: PipelineStage[]
|
||||
timestamps: boolean
|
||||
versions?: boolean
|
||||
@@ -77,6 +77,9 @@ const relationshipSort = ({
|
||||
) {
|
||||
const relationshipPath = segments.slice(0, i + 1).join('.')
|
||||
let sortFieldPath = segments.slice(i + 1, segments.length).join('.')
|
||||
if (sortFieldPath.endsWith('.id')) {
|
||||
sortFieldPath = sortFieldPath.split('.').slice(0, -1).join('.')
|
||||
}
|
||||
if (Array.isArray(field.relationTo)) {
|
||||
throw new APIError('Not supported')
|
||||
}
|
||||
@@ -150,6 +153,12 @@ export const buildSortParam = ({
|
||||
sort = [sort]
|
||||
}
|
||||
|
||||
// We use this flag to determine if the sort is unique or not to decide whether to add a fallback sort.
|
||||
const isUniqueSort = sort.some((item) => {
|
||||
const field = getFieldByPath({ fields, path: item })
|
||||
return field?.field?.unique
|
||||
})
|
||||
|
||||
// In the case of Mongo, when sorting by a field that is not unique, the results are not guaranteed to be in the same order each time.
|
||||
// So we add a fallback sort to ensure that the results are always in the same order.
|
||||
let fallbackSort = '-id'
|
||||
@@ -158,7 +167,12 @@ export const buildSortParam = ({
|
||||
fallbackSort = '-createdAt'
|
||||
}
|
||||
|
||||
if (!(sort.includes(fallbackSort) || sort.includes(fallbackSort.replace('-', '')))) {
|
||||
const includeFallbackSort =
|
||||
!adapter.disableFallbackSort &&
|
||||
!isUniqueSort &&
|
||||
!(sort.includes(fallbackSort) || sort.includes(fallbackSort.replace('-', '')))
|
||||
|
||||
if (includeFallbackSort) {
|
||||
sort.push(fallbackSort)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
try {
|
||||
if (returning === false) {
|
||||
await Model.updateOne(query, data, options)
|
||||
transform({ adapter: this, data, fields, operation: 'read' })
|
||||
return null
|
||||
} else {
|
||||
result = await Model.findOneAndUpdate(query, data, options)
|
||||
|
||||
@@ -277,7 +277,9 @@ const stripFields = ({
|
||||
continue
|
||||
}
|
||||
|
||||
for (const data of localeData) {
|
||||
let hasNull = false
|
||||
for (let i = 0; i < localeData.length; i++) {
|
||||
const data = localeData[i]
|
||||
let fields: FlattenedField[] | null = null
|
||||
|
||||
if (field.type === 'array') {
|
||||
@@ -286,11 +288,17 @@ const stripFields = ({
|
||||
let maybeBlock: FlattenedBlock | undefined = undefined
|
||||
|
||||
if (field.blockReferences) {
|
||||
const maybeBlockReference = field.blockReferences.find(
|
||||
(each) => typeof each === 'object' && each.slug === data.blockType,
|
||||
)
|
||||
if (maybeBlockReference && typeof maybeBlockReference === 'object') {
|
||||
maybeBlock = maybeBlockReference
|
||||
const maybeBlockReference = field.blockReferences.find((each) => {
|
||||
const slug = typeof each === 'string' ? each : each.slug
|
||||
return slug === data.blockType
|
||||
})
|
||||
|
||||
if (maybeBlockReference) {
|
||||
if (typeof maybeBlockReference === 'object') {
|
||||
maybeBlock = maybeBlockReference
|
||||
} else {
|
||||
maybeBlock = config.blocks?.find((each) => each.slug === maybeBlockReference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +308,9 @@ const stripFields = ({
|
||||
|
||||
if (maybeBlock) {
|
||||
fields = maybeBlock.flattenedFields
|
||||
} else {
|
||||
localeData[i] = null
|
||||
hasNull = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,6 +321,10 @@ const stripFields = ({
|
||||
stripFields({ config, data, fields, reservedKeys })
|
||||
}
|
||||
|
||||
if (hasNull) {
|
||||
fieldData[localeKey] = localeData.filter(Boolean)
|
||||
}
|
||||
|
||||
continue
|
||||
} else {
|
||||
stripFields({ config, data: localeData, fields: field.flattenedFields, reservedKeys })
|
||||
@@ -323,7 +338,10 @@ const stripFields = ({
|
||||
continue
|
||||
}
|
||||
|
||||
for (const data of fieldData) {
|
||||
let hasNull = false
|
||||
|
||||
for (let i = 0; i < fieldData.length; i++) {
|
||||
const data = fieldData[i]
|
||||
let fields: FlattenedField[] | null = null
|
||||
|
||||
if (field.type === 'array') {
|
||||
@@ -332,12 +350,17 @@ const stripFields = ({
|
||||
let maybeBlock: FlattenedBlock | undefined = undefined
|
||||
|
||||
if (field.blockReferences) {
|
||||
const maybeBlockReference = field.blockReferences.find(
|
||||
(each) => typeof each === 'object' && each.slug === data.blockType,
|
||||
)
|
||||
const maybeBlockReference = field.blockReferences.find((each) => {
|
||||
const slug = typeof each === 'string' ? each : each.slug
|
||||
return slug === data.blockType
|
||||
})
|
||||
|
||||
if (maybeBlockReference && typeof maybeBlockReference === 'object') {
|
||||
maybeBlock = maybeBlockReference
|
||||
if (maybeBlockReference) {
|
||||
if (typeof maybeBlockReference === 'object') {
|
||||
maybeBlock = maybeBlockReference
|
||||
} else {
|
||||
maybeBlock = config.blocks?.find((each) => each.slug === maybeBlockReference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +370,9 @@ const stripFields = ({
|
||||
|
||||
if (maybeBlock) {
|
||||
fields = maybeBlock.flattenedFields
|
||||
} else {
|
||||
fieldData[i] = null
|
||||
hasNull = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +383,10 @@ const stripFields = ({
|
||||
stripFields({ config, data, fields, reservedKeys })
|
||||
}
|
||||
|
||||
if (hasNull) {
|
||||
data[field.name] = fieldData.filter(Boolean)
|
||||
}
|
||||
|
||||
continue
|
||||
} else {
|
||||
stripFields({ config, data: fieldData, fields: field.flattenedFields, reservedKeys })
|
||||
@@ -387,7 +417,7 @@ export const transform = ({
|
||||
|
||||
if (operation === 'read') {
|
||||
delete data['__v']
|
||||
data.id = data._id
|
||||
data.id = data._id || data.id
|
||||
delete data['_id']
|
||||
|
||||
if (data.id instanceof Types.ObjectId) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-postgres",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "The officially supported Postgres database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
@@ -78,9 +78,9 @@
|
||||
"@payloadcms/drizzle": "workspace:*",
|
||||
"@types/pg": "8.10.2",
|
||||
"console-table-printer": "2.12.1",
|
||||
"drizzle-kit": "0.31.1",
|
||||
"drizzle-kit": "0.31.4",
|
||||
"drizzle-orm": "0.44.2",
|
||||
"pg": "8.11.3",
|
||||
"pg": "8.16.3",
|
||||
"prompts": "2.4.2",
|
||||
"to-snake-case": "1.0.0",
|
||||
"uuid": "10.0.0"
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
updateMany,
|
||||
updateOne,
|
||||
updateVersion,
|
||||
upsert,
|
||||
} from '@payloadcms/drizzle'
|
||||
import {
|
||||
columnToCodeConverter,
|
||||
@@ -207,7 +208,7 @@ export function postgresAdapter(args: Args): DatabaseAdapterObj<PostgresAdapter>
|
||||
updateMany,
|
||||
updateOne,
|
||||
updateVersion,
|
||||
upsert: updateOne,
|
||||
upsert,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-sqlite",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "The officially supported SQLite database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
@@ -76,7 +76,7 @@
|
||||
"@libsql/client": "0.14.0",
|
||||
"@payloadcms/drizzle": "workspace:*",
|
||||
"console-table-printer": "2.12.1",
|
||||
"drizzle-kit": "0.31.1",
|
||||
"drizzle-kit": "0.31.4",
|
||||
"drizzle-orm": "0.44.2",
|
||||
"prompts": "2.4.2",
|
||||
"to-snake-case": "1.0.0",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
updateMany,
|
||||
updateOne,
|
||||
updateVersion,
|
||||
upsert,
|
||||
} from '@payloadcms/drizzle'
|
||||
import { like, notLike } from 'drizzle-orm'
|
||||
import { createDatabaseAdapter, defaultBeginTransaction } from 'payload'
|
||||
@@ -189,7 +190,7 @@ export function sqliteAdapter(args: Args): DatabaseAdapterObj<SQLiteAdapter> {
|
||||
updateGlobalVersion,
|
||||
updateOne,
|
||||
updateVersion,
|
||||
upsert: updateOne,
|
||||
upsert,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-vercel-postgres",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "Vercel Postgres adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
@@ -78,9 +78,9 @@
|
||||
"@payloadcms/drizzle": "workspace:*",
|
||||
"@vercel/postgres": "^0.9.0",
|
||||
"console-table-printer": "2.12.1",
|
||||
"drizzle-kit": "0.31.1",
|
||||
"drizzle-kit": "0.31.4",
|
||||
"drizzle-orm": "0.44.2",
|
||||
"pg": "8.11.3",
|
||||
"pg": "8.16.3",
|
||||
"prompts": "2.4.2",
|
||||
"to-snake-case": "1.0.0",
|
||||
"uuid": "10.0.0"
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
updateMany,
|
||||
updateOne,
|
||||
updateVersion,
|
||||
upsert,
|
||||
} from '@payloadcms/drizzle'
|
||||
import {
|
||||
columnToCodeConverter,
|
||||
@@ -202,7 +203,7 @@ export function vercelPostgresAdapter(args: Args = {}): DatabaseAdapterObj<Verce
|
||||
updateMany,
|
||||
updateOne,
|
||||
updateVersion,
|
||||
upsert: updateOne,
|
||||
upsert,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/drizzle",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "A library of shared functions used by different payload database adapters",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -80,7 +80,7 @@ export const findMany = async function find({
|
||||
if (orderBy) {
|
||||
for (const key in selectFields) {
|
||||
const column = selectFields[key]
|
||||
if (column.primary) {
|
||||
if (!column || column.primary) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ export const traverseFields = ({
|
||||
const subQueryAlias = `${columnName}_subquery`
|
||||
|
||||
let sqlWhere = eq(
|
||||
adapter.tables[currentTableName].id,
|
||||
sql.raw(`"${currentTableName}"."id"`),
|
||||
sql.raw(`"${subQueryAlias}"."${onPath}"`),
|
||||
)
|
||||
|
||||
@@ -577,19 +577,23 @@ export const traverseFields = ({
|
||||
|
||||
let joinQueryWhere: Where
|
||||
|
||||
const currentIDRaw = sql.raw(
|
||||
`"${getNameFromDrizzleTable(currentIDColumn.table)}"."${currentIDColumn.name}"`,
|
||||
)
|
||||
|
||||
if (Array.isArray(field.targetField.relationTo)) {
|
||||
joinQueryWhere = {
|
||||
[field.on]: {
|
||||
equals: {
|
||||
relationTo: collectionSlug,
|
||||
value: rawConstraint(currentIDColumn),
|
||||
value: rawConstraint(currentIDRaw),
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
joinQueryWhere = {
|
||||
[field.on]: {
|
||||
equals: rawConstraint(currentIDColumn),
|
||||
equals: rawConstraint(currentIDRaw),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ export { updateJobs } from './updateJobs.js'
|
||||
export { updateMany } from './updateMany.js'
|
||||
export { updateOne } from './updateOne.js'
|
||||
export { updateVersion } from './updateVersion.js'
|
||||
export { upsert } from './upsert.js'
|
||||
export { upsertRow } from './upsertRow/index.js'
|
||||
export { buildCreateMigration } from './utilities/buildCreateMigration.js'
|
||||
export { buildIndexName } from './utilities/buildIndexName.js'
|
||||
|
||||
@@ -24,20 +24,26 @@ export const columnToCodeConverter: ColumnToCodeConverter = ({
|
||||
|
||||
const columnBuilderArgsArray: string[] = []
|
||||
|
||||
if (column.type === 'timestamp') {
|
||||
columnBuilderArgsArray.push(`mode: '${column.mode}'`)
|
||||
if (column.withTimezone) {
|
||||
columnBuilderArgsArray.push('withTimezone: true')
|
||||
switch (column.type) {
|
||||
case 'bit':
|
||||
case 'halfvec':
|
||||
case 'sparsevec':
|
||||
case 'vector': {
|
||||
if (column.dimensions) {
|
||||
columnBuilderArgsArray.push(`dimensions: ${column.dimensions}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'timestamp': {
|
||||
columnBuilderArgsArray.push(`mode: '${column.mode}'`)
|
||||
if (column.withTimezone) {
|
||||
columnBuilderArgsArray.push('withTimezone: true')
|
||||
}
|
||||
|
||||
if (typeof column.precision === 'number') {
|
||||
columnBuilderArgsArray.push(`precision: ${column.precision}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (column.type === 'vector') {
|
||||
if (column.dimensions) {
|
||||
columnBuilderArgsArray.push(`dimensions: ${column.dimensions}`)
|
||||
if (typeof column.precision === 'number') {
|
||||
columnBuilderArgsArray.push(`precision: ${column.precision}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ForeignKeyBuilder, IndexBuilder } from 'drizzle-orm/pg-core'
|
||||
|
||||
import {
|
||||
bit,
|
||||
boolean,
|
||||
foreignKey,
|
||||
halfvec,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
serial,
|
||||
sparsevec,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
@@ -44,6 +47,14 @@ export const buildDrizzleTable = ({
|
||||
|
||||
for (const [key, column] of Object.entries(rawTable.columns)) {
|
||||
switch (column.type) {
|
||||
case 'bit': {
|
||||
const builder = bit(column.name, { dimensions: column.dimensions })
|
||||
|
||||
columns[key] = builder
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'enum':
|
||||
if ('locale' in column) {
|
||||
columns[key] = adapter.enums.enum__locales(column.name)
|
||||
@@ -56,6 +67,21 @@ export const buildDrizzleTable = ({
|
||||
}
|
||||
break
|
||||
|
||||
case 'halfvec': {
|
||||
const builder = halfvec(column.name, { dimensions: column.dimensions })
|
||||
|
||||
columns[key] = builder
|
||||
break
|
||||
}
|
||||
|
||||
case 'sparsevec': {
|
||||
const builder = sparsevec(column.name, { dimensions: column.dimensions })
|
||||
|
||||
columns[key] = builder
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'timestamp': {
|
||||
let builder = timestamp(column.name, {
|
||||
mode: column.mode,
|
||||
|
||||
@@ -53,6 +53,7 @@ type Args = {
|
||||
fields: FlattenedField[]
|
||||
joins: BuildQueryJoinAliases
|
||||
locale?: string
|
||||
parentAliasTable?: PgTableWithColumns<any> | SQLiteTableWithColumns<any>
|
||||
parentIsLocalized: boolean
|
||||
pathSegments: string[]
|
||||
rootTableName?: string
|
||||
@@ -83,6 +84,7 @@ export const getTableColumnFromPath = ({
|
||||
fields,
|
||||
joins,
|
||||
locale: incomingLocale,
|
||||
parentAliasTable,
|
||||
parentIsLocalized,
|
||||
pathSegments: incomingSegments,
|
||||
rootTableName: incomingRootTableName,
|
||||
@@ -162,6 +164,7 @@ export const getTableColumnFromPath = ({
|
||||
table: adapter.tables[newTableName],
|
||||
})
|
||||
}
|
||||
|
||||
return getTableColumnFromPath({
|
||||
adapter,
|
||||
collectionPath,
|
||||
@@ -170,6 +173,7 @@ export const getTableColumnFromPath = ({
|
||||
fields: field.flattenedFields,
|
||||
joins,
|
||||
locale,
|
||||
parentAliasTable: aliasTable,
|
||||
parentIsLocalized: parentIsLocalized || field.localized,
|
||||
pathSegments: pathSegments.slice(1),
|
||||
rootTableName,
|
||||
@@ -548,7 +552,10 @@ export const getTableColumnFromPath = ({
|
||||
// Join in the relationships table
|
||||
if (locale && isFieldLocalized && adapter.payload.config.localization) {
|
||||
const conditions = [
|
||||
eq((aliasTable || adapter.tables[rootTableName]).id, aliasRelationshipTable.parent),
|
||||
eq(
|
||||
(parentAliasTable || aliasTable || adapter.tables[rootTableName]).id,
|
||||
aliasRelationshipTable.parent,
|
||||
),
|
||||
like(aliasRelationshipTable.path, `${constraintPath}${field.name}`),
|
||||
]
|
||||
|
||||
@@ -566,7 +573,10 @@ export const getTableColumnFromPath = ({
|
||||
// Join in the relationships table
|
||||
addJoinTable({
|
||||
condition: and(
|
||||
eq((aliasTable || adapter.tables[rootTableName]).id, aliasRelationshipTable.parent),
|
||||
eq(
|
||||
(parentAliasTable || aliasTable || adapter.tables[rootTableName]).id,
|
||||
aliasRelationshipTable.parent,
|
||||
),
|
||||
like(aliasRelationshipTable.path, `${constraintPath}${field.name}`),
|
||||
),
|
||||
joins,
|
||||
@@ -799,9 +809,10 @@ export const getTableColumnFromPath = ({
|
||||
`${tableName}_${tableNameSuffix}${toSnakeCase(field.name)}`,
|
||||
)
|
||||
|
||||
const idColumn = (aliasTable ?? adapter.tables[tableName]).id
|
||||
if (locale && isFieldLocalized && adapter.payload.config.localization) {
|
||||
const conditions = [
|
||||
eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
|
||||
eq(idColumn, adapter.tables[newTableName].parent),
|
||||
eq(adapter.tables[newTableName]._locale, locale),
|
||||
]
|
||||
|
||||
@@ -816,7 +827,7 @@ export const getTableColumnFromPath = ({
|
||||
})
|
||||
} else {
|
||||
addJoinTable({
|
||||
condition: eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
|
||||
condition: eq(idColumn, adapter.tables[newTableName].parent),
|
||||
joins,
|
||||
table: adapter.tables[newTableName],
|
||||
})
|
||||
|
||||
@@ -281,12 +281,30 @@ export type VectorRawColumn = {
|
||||
type: 'vector'
|
||||
} & BaseRawColumn
|
||||
|
||||
export type HalfVecRawColumn = {
|
||||
dimensions?: number
|
||||
type: 'halfvec'
|
||||
} & BaseRawColumn
|
||||
|
||||
export type SparseVecRawColumn = {
|
||||
dimensions?: number
|
||||
type: 'sparsevec'
|
||||
} & BaseRawColumn
|
||||
|
||||
export type BinaryVecRawColumn = {
|
||||
dimensions?: number
|
||||
type: 'bit'
|
||||
} & BaseRawColumn
|
||||
|
||||
export type RawColumn =
|
||||
| ({
|
||||
type: 'boolean' | 'geometry' | 'jsonb' | 'numeric' | 'serial' | 'text' | 'varchar'
|
||||
} & BaseRawColumn)
|
||||
| BinaryVecRawColumn
|
||||
| EnumRawColumn
|
||||
| HalfVecRawColumn
|
||||
| IntegerRawColumn
|
||||
| SparseVecRawColumn
|
||||
| TimestampRawColumn
|
||||
| UUIDRawColumn
|
||||
| VectorRawColumn
|
||||
|
||||
@@ -18,6 +18,7 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
data,
|
||||
joins: joinQuery,
|
||||
locale,
|
||||
options = { upsert: false },
|
||||
req,
|
||||
returning,
|
||||
select,
|
||||
@@ -66,6 +67,13 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
}
|
||||
}
|
||||
|
||||
if (!idToUpdate && !options.upsert) {
|
||||
// TODO: In 4.0, if returning === false, we should differentiate between:
|
||||
// - No document found to update
|
||||
// - Document found, but returning === false
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await upsertRow({
|
||||
id: idToUpdate,
|
||||
adapter: this,
|
||||
|
||||
20
packages/drizzle/src/upsert.ts
Normal file
20
packages/drizzle/src/upsert.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Upsert } from 'payload'
|
||||
|
||||
import type { DrizzleAdapter } from './types.js'
|
||||
|
||||
export const upsert: Upsert = async function upsert(
|
||||
this: DrizzleAdapter,
|
||||
{ collection, data, joins, locale, req, returning, select, where },
|
||||
) {
|
||||
return this.updateOne({
|
||||
collection,
|
||||
data,
|
||||
joins,
|
||||
locale,
|
||||
options: { upsert: true },
|
||||
req,
|
||||
returning,
|
||||
select,
|
||||
where,
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,13 @@ import { deleteExistingArrayRows } from './deleteExistingArrayRows.js'
|
||||
import { deleteExistingRowsByPath } from './deleteExistingRowsByPath.js'
|
||||
import { insertArrays } from './insertArrays.js'
|
||||
|
||||
/**
|
||||
* If `id` is provided, it will update the row with that ID.
|
||||
* If `where` is provided, it will update the row that matches the `where`
|
||||
* If neither `id` nor `where` is provided, it will create a new row.
|
||||
*
|
||||
* This function replaces the entire row and does not support partial updates.
|
||||
*/
|
||||
export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>({
|
||||
id,
|
||||
adapter,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-nodemailer",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "Payload Nodemailer Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-resend",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "Payload Resend Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/graphql",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -113,6 +113,7 @@ export function configToSchema(config: SanitizedConfig): {
|
||||
variables: args.variableValues,
|
||||
// onComplete: (complexity) => { console.log('Query Complexity:', complexity); },
|
||||
}),
|
||||
...(config.graphQL.disableIntrospectionInProduction ? [NoProductionIntrospection] : []),
|
||||
...(typeof config?.graphQL?.validationRules === 'function'
|
||||
? config.graphQL.validationRules(args)
|
||||
: []),
|
||||
@@ -123,3 +124,18 @@ export function configToSchema(config: SanitizedConfig): {
|
||||
validationRules,
|
||||
}
|
||||
}
|
||||
|
||||
const NoProductionIntrospection: GraphQL.ValidationRule = (context) => ({
|
||||
Field(node) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (node.name.value === '__schema' || node.name.value === '__type') {
|
||||
context.reportError(
|
||||
new GraphQL.GraphQLError(
|
||||
'GraphQL introspection is not allowed, but the query contained __schema or __type',
|
||||
{ nodes: [node] },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Context } from '../types.js'
|
||||
export function logout(collection: Collection): any {
|
||||
async function resolver(_, args, context: Context) {
|
||||
const options = {
|
||||
allSessions: args.allSessions,
|
||||
collection,
|
||||
req: isolateObjectProperty(context.req, 'transactionID'),
|
||||
}
|
||||
|
||||
@@ -487,6 +487,9 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
|
||||
graphqlResult.Mutation.fields[`logout${singularName}`] = {
|
||||
type: GraphQLString,
|
||||
args: {
|
||||
allSessions: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: logout(collection),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview-react",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "The official React SDK for Payload Live Preview",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview-vue",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "The official Vue SDK for Payload Live Preview",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"description": "The official live preview JavaScript SDK for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -67,5 +67,9 @@ export const handleMessage = async <T extends Record<string, any>>(args: {
|
||||
return mergedData
|
||||
}
|
||||
|
||||
return initialData
|
||||
if (!_payloadLivePreview.previousData) {
|
||||
_payloadLivePreview.previousData = initialData
|
||||
}
|
||||
|
||||
return _payloadLivePreview.previousData as T
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export const mergeData = async <T extends Record<string, any>>(args: {
|
||||
res = await requestHandler({
|
||||
apiPath: apiRoute || '/api',
|
||||
endpoint: encodeURI(
|
||||
`${collection}?depth=${depth}&where[id][in]=${Array.from(ids).join(',')}${locale ? `&locale=${locale}` : ''}`,
|
||||
`${collection}?depth=${depth}&limit=${ids.size}&where[id][in]=${Array.from(ids).join(',')}${locale ? `&locale=${locale}` : ''}`,
|
||||
),
|
||||
serverURL,
|
||||
}).then((res) => res.json())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/next",
|
||||
"version": "3.43.0",
|
||||
"version": "3.45.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -22,6 +22,10 @@
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./css": {
|
||||
"import": "./src/dummy.css",
|
||||
"default": "./src/dummy.css"
|
||||
},
|
||||
".": {
|
||||
"import": "./src/index.js",
|
||||
"types": "./src/index.js",
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import type { CollectionSlug } from 'payload'
|
||||
|
||||
import { cookies as getCookies } from 'next/headers.js'
|
||||
import { generatePayloadCookie, getPayload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
|
||||
|
||||
@@ -31,6 +30,7 @@ export async function login({ collection, config, email, password, username }: L
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
const authConfig = payload.collections[collection]?.config.auth
|
||||
|
||||
if (!authConfig) {
|
||||
throw new Error(`No auth config found for collection: ${collection}`)
|
||||
}
|
||||
@@ -61,27 +61,22 @@ export async function login({ collection, config, email, password, username }: L
|
||||
loginData = { email, password }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await payload.login({
|
||||
collection,
|
||||
data: loginData,
|
||||
const result = await payload.login({
|
||||
collection,
|
||||
data: loginData,
|
||||
})
|
||||
|
||||
if (result.token) {
|
||||
await setPayloadAuthCookie({
|
||||
authConfig,
|
||||
cookiePrefix: payload.config.cookiePrefix,
|
||||
token: result.token,
|
||||
})
|
||||
|
||||
if (result.token) {
|
||||
await setPayloadAuthCookie({
|
||||
authConfig,
|
||||
cookiePrefix: payload.config.cookiePrefix,
|
||||
token: result.token,
|
||||
})
|
||||
}
|
||||
|
||||
if ('removeTokenFromResponses' in config && config.removeTokenFromResponses) {
|
||||
delete result.token
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('Login error:', e)
|
||||
throw new Error(`${e}`)
|
||||
}
|
||||
|
||||
if ('removeTokenFromResponses' in config && config.removeTokenFromResponses) {
|
||||
delete result.token
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
'use server'
|
||||
|
||||
import type { SanitizedConfig } from 'payload'
|
||||
|
||||
import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js'
|
||||
import { getPayload } from 'payload'
|
||||
import { createLocalReq, getPayload, logoutOperation } from 'payload'
|
||||
|
||||
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
||||
|
||||
export async function logout({ config }: { config: any }) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const headers = await nextHeaders()
|
||||
const result = await payload.auth({ headers })
|
||||
export async function logout({
|
||||
allSessions = false,
|
||||
config,
|
||||
}: {
|
||||
allSessions?: boolean
|
||||
config: Promise<SanitizedConfig> | SanitizedConfig
|
||||
}) {
|
||||
const payload = await getPayload({ config })
|
||||
const headers = await nextHeaders()
|
||||
const authResult = await payload.auth({ headers })
|
||||
|
||||
if (!result.user) {
|
||||
return { message: 'User already logged out', success: true }
|
||||
}
|
||||
|
||||
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
||||
|
||||
if (existingCookie) {
|
||||
const cookies = await getCookies()
|
||||
cookies.delete(existingCookie.name)
|
||||
return { message: 'User logged out successfully', success: true }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Logout error:', e)
|
||||
throw new Error(`${e}`)
|
||||
if (!authResult.user) {
|
||||
return { message: 'User already logged out', success: true }
|
||||
}
|
||||
|
||||
const { user } = authResult
|
||||
const req = await createLocalReq({ user }, payload)
|
||||
const collection = payload.collections[user.collection]
|
||||
|
||||
const logoutResult = await logoutOperation({
|
||||
allSessions,
|
||||
collection,
|
||||
req,
|
||||
})
|
||||
|
||||
if (!logoutResult) {
|
||||
return { message: 'Logout failed', success: false }
|
||||
}
|
||||
|
||||
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
||||
if (existingCookie) {
|
||||
const cookies = await getCookies()
|
||||
cookies.delete(existingCookie.name)
|
||||
}
|
||||
|
||||
return { message: 'User logged out successfully', success: true }
|
||||
}
|
||||
|
||||
@@ -3,40 +3,48 @@
|
||||
import type { CollectionSlug } from 'payload'
|
||||
|
||||
import { headers as nextHeaders } from 'next/headers.js'
|
||||
import { getPayload } from 'payload'
|
||||
import { createLocalReq, getPayload, refreshOperation } from 'payload'
|
||||
|
||||
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
||||
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
|
||||
|
||||
export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const authConfig = payload.collections[collection]?.config.auth
|
||||
export async function refresh({ config }: { config: any }) {
|
||||
const payload = await getPayload({ config })
|
||||
const headers = await nextHeaders()
|
||||
const result = await payload.auth({ headers })
|
||||
|
||||
if (!authConfig) {
|
||||
throw new Error(`No auth config found for collection: ${collection}`)
|
||||
}
|
||||
|
||||
const { user } = await payload.auth({ headers: await nextHeaders() })
|
||||
if (!user) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
||||
|
||||
if (!existingCookie) {
|
||||
return { message: 'No valid token found', success: false }
|
||||
}
|
||||
|
||||
await setPayloadAuthCookie({
|
||||
authConfig,
|
||||
cookiePrefix: payload.config.cookiePrefix,
|
||||
token: existingCookie.value,
|
||||
})
|
||||
|
||||
return { message: 'Token refreshed successfully', success: true }
|
||||
} catch (e) {
|
||||
console.error('Refresh error:', e)
|
||||
throw new Error(`${e}`)
|
||||
if (!result.user) {
|
||||
throw new Error('Cannot refresh token: user not authenticated')
|
||||
}
|
||||
|
||||
const collection: CollectionSlug | undefined = result.user.collection
|
||||
const collectionConfig = payload.collections[collection]
|
||||
|
||||
if (!collectionConfig?.config.auth) {
|
||||
throw new Error(`No auth config found for collection: ${collection}`)
|
||||
}
|
||||
|
||||
const req = await createLocalReq({ user: result.user }, payload)
|
||||
|
||||
const refreshResult = await refreshOperation({
|
||||
collection: collectionConfig,
|
||||
req,
|
||||
})
|
||||
|
||||
if (!refreshResult) {
|
||||
return { message: 'Token refresh failed', success: false }
|
||||
}
|
||||
|
||||
const existingCookie = await getExistingAuthToken(payload.config.cookiePrefix)
|
||||
if (!existingCookie) {
|
||||
return { message: 'No valid token found to refresh', success: false }
|
||||
}
|
||||
|
||||
await setPayloadAuthCookie({
|
||||
authConfig: collectionConfig.config.auth,
|
||||
cookiePrefix: payload.config.cookiePrefix,
|
||||
token: existingCookie.value,
|
||||
})
|
||||
|
||||
return { message: 'Token refreshed successfully', success: true }
|
||||
}
|
||||
|
||||
@@ -28,32 +28,6 @@ export const getTabs = ({
|
||||
},
|
||||
viewPath: '/',
|
||||
},
|
||||
{
|
||||
tab: {
|
||||
condition: ({ collectionConfig, config, globalConfig }) => {
|
||||
if (collectionConfig) {
|
||||
return Boolean(
|
||||
config?.admin?.livePreview?.collections?.includes(collectionConfig.slug) ||
|
||||
collectionConfig?.admin?.livePreview,
|
||||
)
|
||||
}
|
||||
|
||||
if (globalConfig) {
|
||||
return Boolean(
|
||||
config?.admin?.livePreview?.globals?.includes(globalConfig.slug) ||
|
||||
globalConfig?.admin?.livePreview,
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
href: '/preview',
|
||||
label: ({ t }) => t('general:livePreview'),
|
||||
order: 200,
|
||||
...(customViews?.['livePreview']?.tab || {}),
|
||||
},
|
||||
viewPath: '/preview',
|
||||
},
|
||||
{
|
||||
tab: {
|
||||
condition: ({ collectionConfig, globalConfig, permissions }) =>
|
||||
|
||||
@@ -146,7 +146,7 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
|
||||
|
||||
return (
|
||||
<EntityVisibilityProvider visibleEntities={visibleEntities}>
|
||||
<BulkUploadProvider>
|
||||
<BulkUploadProvider drawerSlugPrefix={collectionSlug}>
|
||||
<ActionsProvider Actions={Actions}>
|
||||
{RenderServerComponent({
|
||||
clientProps,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User } from 'payload'
|
||||
import type { TypedUser } from 'payload'
|
||||
|
||||
import { formatAdminURL } from 'payload/shared'
|
||||
import * as qs from 'qs-esm'
|
||||
@@ -7,7 +7,7 @@ type Args = {
|
||||
config
|
||||
route: string
|
||||
searchParams: { [key: string]: string | string[] }
|
||||
user?: User
|
||||
user?: TypedUser
|
||||
}
|
||||
|
||||
export const handleAuthRedirect = ({ config, route, searchParams, user }: Args): string => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
PayloadRequest,
|
||||
SanitizedConfig,
|
||||
SanitizedPermissions,
|
||||
User,
|
||||
TypedUser,
|
||||
} from 'payload'
|
||||
|
||||
import { initI18n } from '@payloadcms/translations'
|
||||
@@ -37,7 +37,7 @@ type PartialResult = {
|
||||
languageCode: AcceptedLanguages
|
||||
payload: Payload
|
||||
responseHeaders: Headers
|
||||
user: null | User
|
||||
user: null | TypedUser
|
||||
}
|
||||
|
||||
// Create cache instances for different parts of our application
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { User } from 'payload'
|
||||
import type { TypedUser } from 'payload'
|
||||
|
||||
import { Button, ConfirmationModal, toast, useModal, useTranslation } from '@payloadcms/ui'
|
||||
import * as qs from 'qs-esm'
|
||||
@@ -9,7 +9,7 @@ const confirmResetModalSlug = 'confirm-reset-modal'
|
||||
|
||||
export const ResetPreferences: React.FC<{
|
||||
readonly apiRoute: string
|
||||
readonly user?: User
|
||||
readonly user?: TypedUser
|
||||
}> = ({ apiRoute, user }) => {
|
||||
const { openModal } = useModal()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { I18n } from '@payloadcms/translations'
|
||||
import type { BasePayload, Config, LanguageOptions, User } from 'payload'
|
||||
import type { BasePayload, Config, LanguageOptions, TypedUser } from 'payload'
|
||||
|
||||
import { FieldLabel } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
@@ -17,7 +17,7 @@ export const Settings: React.FC<{
|
||||
readonly languageOptions: LanguageOptions
|
||||
readonly payload: BasePayload
|
||||
readonly theme: Config['admin']['theme']
|
||||
readonly user?: User
|
||||
readonly user?: TypedUser
|
||||
}> = (props) => {
|
||||
const { className, i18n, languageOptions, payload, theme, user } = props
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { ViewToRender } from './index.js'
|
||||
|
||||
import { APIView as DefaultAPIView } from '../API/index.js'
|
||||
import { EditView as DefaultEditView } from '../Edit/index.js'
|
||||
import { LivePreviewView as DefaultLivePreviewView } from '../LivePreview/index.js'
|
||||
import { UnauthorizedViewWithGutter } from '../Unauthorized/index.js'
|
||||
import { VersionView as DefaultVersionView } from '../Version/index.js'
|
||||
import { VersionsView as DefaultVersionsView } from '../Versions/index.js'
|
||||
@@ -112,7 +111,6 @@ export const getDocumentView = ({
|
||||
}
|
||||
|
||||
// --> /collections/:collectionSlug/:id/api
|
||||
// --> /collections/:collectionSlug/:id/preview
|
||||
// --> /collections/:collectionSlug/:id/versions
|
||||
// --> /collections/:collectionSlug/:id/<custom-segment>
|
||||
case 4: {
|
||||
@@ -125,17 +123,6 @@ export const getDocumentView = ({
|
||||
break
|
||||
}
|
||||
|
||||
case 'preview': {
|
||||
// --> /collections/:collectionSlug/:id/preview
|
||||
if (
|
||||
(collectionConfig && collectionConfig?.admin?.livePreview) ||
|
||||
config?.admin?.livePreview?.collections?.includes(collectionConfig?.slug)
|
||||
) {
|
||||
View = getCustomViewByKey(views, 'livePreview') || DefaultLivePreviewView
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'versions': {
|
||||
// --> /collections/:collectionSlug/:id/versions
|
||||
if (docPermissions?.readVersions) {
|
||||
@@ -234,7 +221,6 @@ export const getDocumentView = ({
|
||||
|
||||
case 3: {
|
||||
// --> /globals/:globalSlug/api
|
||||
// --> /globals/:globalSlug/preview
|
||||
// --> /globals/:globalSlug/versions
|
||||
// --> /globals/:globalSlug/<custom-segment>
|
||||
switch (segment3) {
|
||||
@@ -247,18 +233,6 @@ export const getDocumentView = ({
|
||||
break
|
||||
}
|
||||
|
||||
case 'preview': {
|
||||
// --> /globals/:globalSlug/preview
|
||||
if (
|
||||
(globalConfig && globalConfig?.admin?.livePreview) ||
|
||||
config?.admin?.livePreview?.globals?.includes(globalConfig?.slug)
|
||||
) {
|
||||
View = getCustomViewByKey(views, 'livePreview') || DefaultLivePreviewView
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'versions': {
|
||||
// --> /globals/:globalSlug/versions
|
||||
if (docPermissions?.readVersions) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { GenerateViewMetadata } from '../Root/index.js'
|
||||
import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
|
||||
import { generateAPIViewMetadata } from '../API/metadata.js'
|
||||
import { generateEditViewMetadata } from '../Edit/metadata.js'
|
||||
import { generateLivePreviewViewMetadata } from '../LivePreview/metadata.js'
|
||||
import { generateNotFoundViewMetadata } from '../NotFound/metadata.js'
|
||||
import { generateVersionViewMetadata } from '../Version/metadata.js'
|
||||
import { generateVersionsViewMetadata } from '../Versions/metadata.js'
|
||||
@@ -50,10 +49,6 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
|
||||
// `/:collection/:id/api`
|
||||
fn = generateAPIViewMetadata
|
||||
break
|
||||
case 'preview':
|
||||
// `/:collection/:id/preview`
|
||||
fn = generateLivePreviewViewMetadata
|
||||
break
|
||||
case 'versions':
|
||||
// `/:collection/:id/versions`
|
||||
fn = generateVersionsViewMetadata
|
||||
@@ -89,10 +84,6 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
|
||||
// `/:global/api`
|
||||
fn = generateAPIViewMetadata
|
||||
break
|
||||
case 'preview':
|
||||
// `/:global/preview`
|
||||
fn = generateLivePreviewViewMetadata
|
||||
break
|
||||
case 'versions':
|
||||
// `/:global/versions`
|
||||
fn = generateVersionsViewMetadata
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type {
|
||||
AdminViewServerProps,
|
||||
CollectionPreferences,
|
||||
Data,
|
||||
DocumentViewClientProps,
|
||||
DocumentViewServerProps,
|
||||
DocumentViewServerPropsOnly,
|
||||
EditViewComponent,
|
||||
LivePreviewConfig,
|
||||
PayloadComponent,
|
||||
RenderDocumentVersionsProperties,
|
||||
} from 'payload'
|
||||
|
||||
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
|
||||
import {
|
||||
DocumentInfoProvider,
|
||||
EditDepthProvider,
|
||||
HydrateAuthProvider,
|
||||
LivePreviewProvider,
|
||||
} from '@payloadcms/ui'
|
||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||
import { isEditing as getIsEditing } from '@payloadcms/ui/shared'
|
||||
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
|
||||
@@ -21,6 +28,7 @@ import React from 'react'
|
||||
import type { GenerateEditViewMetadata } from './getMetaBySegment.js'
|
||||
|
||||
import { DocumentHeader } from '../../elements/DocumentHeader/index.js'
|
||||
import { getPreferences } from '../../utilities/getPreferences.js'
|
||||
import { NotFoundView } from '../NotFound/index.js'
|
||||
import { getDocPreferences } from './getDocPreferences.js'
|
||||
import { getDocumentData } from './getDocumentData.js'
|
||||
@@ -119,6 +127,7 @@ export const renderDocument = async ({
|
||||
docPreferences,
|
||||
{ docPermissions, hasPublishPermission, hasSavePermission },
|
||||
{ currentEditor, isLocked, lastUpdateTime },
|
||||
entityPreferences,
|
||||
] = await Promise.all([
|
||||
// Get document preferences
|
||||
getDocPreferences({
|
||||
@@ -146,8 +155,18 @@ export const renderDocument = async ({
|
||||
isEditing,
|
||||
req,
|
||||
}),
|
||||
|
||||
// get entity preferences
|
||||
getPreferences<CollectionPreferences>(
|
||||
collectionSlug ? `collection-${collectionSlug}` : `global-${globalSlug}`,
|
||||
payload,
|
||||
req.user.id,
|
||||
req.user.collection,
|
||||
),
|
||||
])
|
||||
|
||||
const operation = (collectionSlug && idFromArgs) || globalSlug ? 'update' : 'create'
|
||||
|
||||
const [
|
||||
{ hasPublishedDoc, mostRecentVersionIsAutosaved, unpublishedVersionCount, versionCount },
|
||||
{ state: formState },
|
||||
@@ -171,7 +190,7 @@ export const renderDocument = async ({
|
||||
fallbackLocale: false,
|
||||
globalSlug,
|
||||
locale: locale?.code,
|
||||
operation: (collectionSlug && idFromArgs) || globalSlug ? 'update' : 'create',
|
||||
operation,
|
||||
renderAllFields: true,
|
||||
req,
|
||||
schemaPath: collectionSlug || globalSlug,
|
||||
@@ -310,6 +329,28 @@ export const renderDocument = async ({
|
||||
viewType,
|
||||
}
|
||||
|
||||
const livePreviewConfig: LivePreviewConfig = {
|
||||
...(config.admin.livePreview || {}),
|
||||
...(collectionConfig?.admin?.livePreview || {}),
|
||||
...(globalConfig?.admin?.livePreview || {}),
|
||||
}
|
||||
|
||||
const livePreviewURL =
|
||||
typeof livePreviewConfig?.url === 'function'
|
||||
? await livePreviewConfig.url({
|
||||
collectionConfig,
|
||||
data: doc,
|
||||
globalConfig,
|
||||
locale,
|
||||
req,
|
||||
/**
|
||||
* @deprecated
|
||||
* Use `req.payload` instead. This will be removed in the next major version.
|
||||
*/
|
||||
payload: initPageResult.req.payload,
|
||||
})
|
||||
: livePreviewConfig?.url
|
||||
|
||||
return {
|
||||
data: doc,
|
||||
Document: (
|
||||
@@ -337,24 +378,31 @@ export const renderDocument = async ({
|
||||
unpublishedVersionCount={unpublishedVersionCount}
|
||||
versionCount={versionCount}
|
||||
>
|
||||
{showHeader && !drawerSlug && (
|
||||
<DocumentHeader
|
||||
collectionConfig={collectionConfig}
|
||||
globalConfig={globalConfig}
|
||||
i18n={i18n}
|
||||
payload={payload}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
<HydrateAuthProvider permissions={permissions} />
|
||||
<EditDepthProvider>
|
||||
{RenderServerComponent({
|
||||
clientProps,
|
||||
Component: View,
|
||||
importMap,
|
||||
serverProps: documentViewServerProps,
|
||||
})}
|
||||
</EditDepthProvider>
|
||||
<LivePreviewProvider
|
||||
breakpoints={livePreviewConfig?.breakpoints}
|
||||
isLivePreviewing={entityPreferences?.value?.editViewType === 'live-preview'}
|
||||
operation={operation}
|
||||
url={livePreviewURL}
|
||||
>
|
||||
{showHeader && !drawerSlug && (
|
||||
<DocumentHeader
|
||||
collectionConfig={collectionConfig}
|
||||
globalConfig={globalConfig}
|
||||
i18n={i18n}
|
||||
payload={payload}
|
||||
permissions={permissions}
|
||||
/>
|
||||
)}
|
||||
<HydrateAuthProvider permissions={permissions} />
|
||||
<EditDepthProvider>
|
||||
{RenderServerComponent({
|
||||
clientProps,
|
||||
Component: View,
|
||||
importMap,
|
||||
serverProps: documentViewServerProps,
|
||||
})}
|
||||
</EditDepthProvider>
|
||||
</LivePreviewProvider>
|
||||
</DocumentInfoProvider>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ListPreferences, ListQuery, ServerFunction, VisibleEntities } from 'payload'
|
||||
import type { CollectionPreferences, ListQuery, ServerFunction, VisibleEntities } from 'payload'
|
||||
|
||||
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
|
||||
import { headers as getHeaders } from 'next/headers.js'
|
||||
@@ -8,7 +8,7 @@ import { renderListView } from './index.js'
|
||||
|
||||
type RenderListResult = {
|
||||
List: React.ReactNode
|
||||
preferences: ListPreferences
|
||||
preferences: CollectionPreferences
|
||||
}
|
||||
|
||||
export const renderListHandler: ServerFunction<
|
||||
@@ -92,7 +92,7 @@ export const renderListHandler: ServerFunction<
|
||||
importMap: payload.importMap,
|
||||
})
|
||||
|
||||
const preferencesKey = `${collectionSlug}-list`
|
||||
const preferencesKey = `collection-${collectionSlug}`
|
||||
|
||||
const preferences = await payload
|
||||
.find({
|
||||
@@ -119,7 +119,7 @@ export const renderListHandler: ServerFunction<
|
||||
],
|
||||
},
|
||||
})
|
||||
.then((res) => res.docs[0]?.value as ListPreferences)
|
||||
.then((res) => res.docs[0]?.value as CollectionPreferences)
|
||||
|
||||
const visibleEntities: VisibleEntities = {
|
||||
collections: payload.config.collections
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
AdminViewServerProps,
|
||||
CollectionPreferences,
|
||||
ColumnPreference,
|
||||
DefaultDocumentIDType,
|
||||
ListPreferences,
|
||||
ListQuery,
|
||||
ListViewClientProps,
|
||||
ListViewServerPropsOnly,
|
||||
@@ -98,8 +98,8 @@ export const renderListView = async (
|
||||
* This will ensure that prefs are only updated when explicitly set by the user
|
||||
* This could potentially be done by injecting a `sessionID` into the params and comparing it against a session cookie
|
||||
*/
|
||||
const listPreferences = await upsertPreferences<ListPreferences>({
|
||||
key: `${collectionSlug}-list`,
|
||||
const collectionPreferences = await upsertPreferences<CollectionPreferences>({
|
||||
key: `collection-${collectionSlug}`,
|
||||
req,
|
||||
value: {
|
||||
columns,
|
||||
@@ -120,10 +120,10 @@ export const renderListView = async (
|
||||
|
||||
const page = isNumber(query?.page) ? Number(query.page) : 0
|
||||
|
||||
const limit = listPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
|
||||
const limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
|
||||
|
||||
const sort =
|
||||
listPreferences?.sort ||
|
||||
collectionPreferences?.sort ||
|
||||
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined)
|
||||
|
||||
let where = mergeListSearchAndWhere({
|
||||
@@ -150,10 +150,10 @@ export const renderListView = async (
|
||||
let queryPreset: QueryPreset | undefined
|
||||
let queryPresetPermissions: SanitizedCollectionPermission | undefined
|
||||
|
||||
if (listPreferences?.preset) {
|
||||
if (collectionPreferences?.preset) {
|
||||
try {
|
||||
queryPreset = (await payload.findByID({
|
||||
id: listPreferences?.preset,
|
||||
id: collectionPreferences?.preset,
|
||||
collection: 'payload-query-presets',
|
||||
depth: 0,
|
||||
overrideAccess: false,
|
||||
@@ -194,7 +194,7 @@ export const renderListView = async (
|
||||
const { columnState, Table } = renderTable({
|
||||
clientCollectionConfig,
|
||||
collectionConfig,
|
||||
columnPreferences: listPreferences?.columns,
|
||||
columnPreferences: collectionPreferences?.columns,
|
||||
columns,
|
||||
customCellProps,
|
||||
docs: data.docs,
|
||||
@@ -230,7 +230,7 @@ export const renderListView = async (
|
||||
data,
|
||||
i18n,
|
||||
limit,
|
||||
listPreferences,
|
||||
listPreferences: collectionPreferences,
|
||||
listSearchableFields: collectionConfig.admin.listSearchableFields,
|
||||
locale: fullLocale,
|
||||
params,
|
||||
@@ -264,7 +264,7 @@ export const renderListView = async (
|
||||
data={data}
|
||||
defaultLimit={limit}
|
||||
defaultSort={sort}
|
||||
listPreferences={listPreferences}
|
||||
listPreferences={collectionPreferences}
|
||||
modifySearchParams={!isInDrawer}
|
||||
orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined}
|
||||
>
|
||||
@@ -278,7 +278,7 @@ export const renderListView = async (
|
||||
disableQueryPresets,
|
||||
enableRowSelections,
|
||||
hasCreatePermission,
|
||||
listPreferences,
|
||||
listPreferences: collectionPreferences,
|
||||
newDocumentURL,
|
||||
queryPreset,
|
||||
queryPresetPermissions,
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
'use client'
|
||||
import type { FormProps } from '@payloadcms/ui'
|
||||
import type {
|
||||
ClientCollectionConfig,
|
||||
ClientConfig,
|
||||
ClientField,
|
||||
ClientGlobalConfig,
|
||||
ClientUser,
|
||||
Data,
|
||||
DocumentSlots,
|
||||
FormState,
|
||||
LivePreviewConfig,
|
||||
} from 'payload'
|
||||
|
||||
import {
|
||||
DocumentControls,
|
||||
DocumentFields,
|
||||
DocumentLocked,
|
||||
DocumentTakeOver,
|
||||
Form,
|
||||
LeaveWithoutSaving,
|
||||
OperationProvider,
|
||||
SetDocumentStepNav,
|
||||
SetDocumentTitle,
|
||||
useAuth,
|
||||
useConfig,
|
||||
useDocumentDrawerContext,
|
||||
useDocumentEvents,
|
||||
useDocumentInfo,
|
||||
useEditDepth,
|
||||
useRouteTransition,
|
||||
useServerFunctions,
|
||||
useTranslation,
|
||||
useUploadEdits,
|
||||
} from '@payloadcms/ui'
|
||||
import {
|
||||
abortAndIgnore,
|
||||
handleAbortRef,
|
||||
handleBackToDashboard,
|
||||
handleGoBack,
|
||||
handleTakeOver,
|
||||
} from '@payloadcms/ui/shared'
|
||||
import { useRouter, useSearchParams } from 'next/navigation.js'
|
||||
import { formatAdminURL } from 'payload/shared'
|
||||
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useLivePreviewContext } from './Context/context.js'
|
||||
import './index.scss'
|
||||
import { LivePreviewProvider } from './Context/index.js'
|
||||
import { LivePreview } from './Preview/index.js'
|
||||
import { usePopupWindow } from './usePopupWindow.js'
|
||||
|
||||
const baseClass = 'live-preview'
|
||||
|
||||
type Props = {
|
||||
readonly apiRoute: string
|
||||
readonly collectionConfig?: ClientCollectionConfig
|
||||
readonly config: ClientConfig
|
||||
readonly fields: ClientField[]
|
||||
readonly globalConfig?: ClientGlobalConfig
|
||||
readonly schemaPath: string
|
||||
readonly serverURL: string
|
||||
} & DocumentSlots
|
||||
|
||||
const getAbsoluteUrl = (url) => {
|
||||
try {
|
||||
return new URL(url, window.location.origin).href
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
const PreviewView: React.FC<Props> = ({
|
||||
BeforeDocumentControls,
|
||||
collectionConfig,
|
||||
config,
|
||||
Description,
|
||||
EditMenuItems,
|
||||
fields,
|
||||
globalConfig,
|
||||
PreviewButton,
|
||||
PublishButton,
|
||||
SaveButton,
|
||||
SaveDraftButton,
|
||||
schemaPath,
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
action,
|
||||
AfterDocument,
|
||||
AfterFields,
|
||||
apiURL,
|
||||
BeforeFields,
|
||||
collectionSlug,
|
||||
currentEditor,
|
||||
disableActions,
|
||||
disableLeaveWithoutSaving,
|
||||
docPermissions,
|
||||
documentIsLocked,
|
||||
getDocPermissions,
|
||||
getDocPreferences,
|
||||
globalSlug,
|
||||
hasPublishPermission,
|
||||
hasSavePermission,
|
||||
incrementVersionCount,
|
||||
initialData,
|
||||
initialState,
|
||||
isEditing,
|
||||
isInitializing,
|
||||
lastUpdateTime,
|
||||
setCurrentEditor,
|
||||
setDocumentIsLocked,
|
||||
unlockDocument,
|
||||
updateDocumentEditor,
|
||||
updateSavedDocumentData,
|
||||
} = useDocumentInfo()
|
||||
|
||||
const { onSave: onSaveFromContext } = useDocumentDrawerContext()
|
||||
|
||||
const operation = id ? 'update' : 'create'
|
||||
|
||||
const {
|
||||
config: {
|
||||
admin: { user: userSlug },
|
||||
routes: { admin: adminRoute },
|
||||
},
|
||||
} = useConfig()
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const locale = params.get('locale')
|
||||
const { t } = useTranslation()
|
||||
const { previewWindowType } = useLivePreviewContext()
|
||||
const { refreshCookieAsync, user } = useAuth()
|
||||
const { reportUpdate } = useDocumentEvents()
|
||||
const { resetUploadEdits } = useUploadEdits()
|
||||
const { getFormState } = useServerFunctions()
|
||||
const { startRouteTransition } = useRouteTransition()
|
||||
|
||||
const docConfig = collectionConfig || globalConfig
|
||||
|
||||
const entitySlug = collectionConfig?.slug || globalConfig?.slug
|
||||
|
||||
const depth = useEditDepth()
|
||||
|
||||
const lockDocumentsProp = docConfig?.lockDocuments !== undefined ? docConfig?.lockDocuments : true
|
||||
const isLockingEnabled = lockDocumentsProp !== false
|
||||
|
||||
const lockDurationDefault = 300 // Default 5 minutes in seconds
|
||||
const lockDuration =
|
||||
typeof lockDocumentsProp === 'object' ? lockDocumentsProp.duration : lockDurationDefault
|
||||
const lockDurationInMilliseconds = lockDuration * 1000
|
||||
|
||||
const autosaveEnabled = Boolean(
|
||||
(collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
|
||||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave),
|
||||
)
|
||||
|
||||
const preventLeaveWithoutSaving =
|
||||
typeof disableLeaveWithoutSaving !== 'undefined' ? !disableLeaveWithoutSaving : !autosaveEnabled
|
||||
|
||||
const [isReadOnlyForIncomingUser, setIsReadOnlyForIncomingUser] = useState(false)
|
||||
const [showTakeOverModal, setShowTakeOverModal] = useState(false)
|
||||
|
||||
const abortOnChangeRef = useRef<AbortController>(null)
|
||||
const abortOnSaveRef = useRef<AbortController>(null)
|
||||
|
||||
const [editSessionStartTime, setEditSessionStartTime] = useState(Date.now())
|
||||
|
||||
const lockExpiryTime = lastUpdateTime + lockDurationInMilliseconds
|
||||
|
||||
const isLockExpired = Date.now() > lockExpiryTime
|
||||
|
||||
const documentLockStateRef = useRef<{
|
||||
hasShownLockedModal: boolean
|
||||
isLocked: boolean
|
||||
user: ClientUser | number | string
|
||||
} | null>({
|
||||
hasShownLockedModal: false,
|
||||
isLocked: false,
|
||||
user: null,
|
||||
})
|
||||
|
||||
const onSave = useCallback(
|
||||
async (json): Promise<FormState> => {
|
||||
const controller = handleAbortRef(abortOnSaveRef)
|
||||
|
||||
reportUpdate({
|
||||
id,
|
||||
entitySlug,
|
||||
updatedAt: json?.result?.updatedAt || new Date().toISOString(),
|
||||
})
|
||||
|
||||
// If we're editing the doc of the logged-in user,
|
||||
// Refresh the cookie to get new permissions
|
||||
if (user && collectionSlug === userSlug && id === user.id) {
|
||||
void refreshCookieAsync()
|
||||
}
|
||||
|
||||
incrementVersionCount()
|
||||
|
||||
if (typeof updateSavedDocumentData === 'function') {
|
||||
void updateSavedDocumentData(json?.doc || {})
|
||||
}
|
||||
|
||||
if (typeof onSaveFromContext === 'function') {
|
||||
void onSaveFromContext({
|
||||
...json,
|
||||
operation: id ? 'update' : 'create',
|
||||
})
|
||||
}
|
||||
|
||||
if (!isEditing && depth < 2) {
|
||||
// Redirect to the same locale if it's been set
|
||||
const redirectRoute = formatAdminURL({
|
||||
adminRoute,
|
||||
path: `/collections/${collectionSlug}/${json?.doc?.id}${locale ? `?locale=${locale}` : ''}`,
|
||||
})
|
||||
|
||||
startRouteTransition(() => router.push(redirectRoute))
|
||||
} else {
|
||||
resetUploadEdits()
|
||||
}
|
||||
|
||||
await getDocPermissions(json)
|
||||
|
||||
if ((id || globalSlug) && !autosaveEnabled) {
|
||||
const docPreferences = await getDocPreferences()
|
||||
|
||||
const { state } = await getFormState({
|
||||
id,
|
||||
collectionSlug,
|
||||
data: json?.doc || json?.result,
|
||||
docPermissions,
|
||||
docPreferences,
|
||||
globalSlug,
|
||||
operation,
|
||||
renderAllFields: true,
|
||||
returnLockStatus: false,
|
||||
schemaPath: entitySlug,
|
||||
signal: controller.signal,
|
||||
skipValidation: true,
|
||||
})
|
||||
|
||||
// Unlock the document after save
|
||||
if (isLockingEnabled) {
|
||||
setDocumentIsLocked(false)
|
||||
}
|
||||
|
||||
abortOnSaveRef.current = null
|
||||
|
||||
return state
|
||||
}
|
||||
},
|
||||
[
|
||||
adminRoute,
|
||||
collectionSlug,
|
||||
depth,
|
||||
docPermissions,
|
||||
entitySlug,
|
||||
getDocPermissions,
|
||||
getDocPreferences,
|
||||
getFormState,
|
||||
globalSlug,
|
||||
id,
|
||||
incrementVersionCount,
|
||||
isEditing,
|
||||
isLockingEnabled,
|
||||
locale,
|
||||
onSaveFromContext,
|
||||
operation,
|
||||
refreshCookieAsync,
|
||||
reportUpdate,
|
||||
resetUploadEdits,
|
||||
router,
|
||||
setDocumentIsLocked,
|
||||
updateSavedDocumentData,
|
||||
startRouteTransition,
|
||||
user,
|
||||
userSlug,
|
||||
autosaveEnabled,
|
||||
],
|
||||
)
|
||||
|
||||
const onChange: FormProps['onChange'][0] = useCallback(
|
||||
async ({ formState: prevFormState, submitted }) => {
|
||||
const controller = handleAbortRef(abortOnChangeRef)
|
||||
|
||||
const currentTime = Date.now()
|
||||
const timeSinceLastUpdate = currentTime - editSessionStartTime
|
||||
|
||||
const updateLastEdited = isLockingEnabled && timeSinceLastUpdate >= 10000 // 10 seconds
|
||||
|
||||
if (updateLastEdited) {
|
||||
setEditSessionStartTime(currentTime)
|
||||
}
|
||||
|
||||
const docPreferences = await getDocPreferences()
|
||||
|
||||
const { lockedState, state } = await getFormState({
|
||||
id,
|
||||
collectionSlug,
|
||||
docPermissions,
|
||||
docPreferences,
|
||||
formState: prevFormState,
|
||||
globalSlug,
|
||||
operation,
|
||||
returnLockStatus: isLockingEnabled ? true : false,
|
||||
schemaPath,
|
||||
signal: controller.signal,
|
||||
skipValidation: !submitted,
|
||||
updateLastEdited,
|
||||
})
|
||||
|
||||
setDocumentIsLocked(true)
|
||||
|
||||
if (isLockingEnabled) {
|
||||
const previousOwnerID =
|
||||
typeof documentLockStateRef.current?.user === 'object'
|
||||
? documentLockStateRef.current?.user?.id
|
||||
: documentLockStateRef.current?.user
|
||||
|
||||
if (lockedState) {
|
||||
const lockedUserID =
|
||||
typeof lockedState.user === 'string' || typeof lockedState.user === 'number'
|
||||
? lockedState.user
|
||||
: lockedState.user.id
|
||||
|
||||
if (!documentLockStateRef.current || lockedUserID !== previousOwnerID) {
|
||||
if (previousOwnerID === user.id && lockedUserID !== user.id) {
|
||||
setShowTakeOverModal(true)
|
||||
documentLockStateRef.current.hasShownLockedModal = true
|
||||
}
|
||||
|
||||
documentLockStateRef.current = documentLockStateRef.current = {
|
||||
hasShownLockedModal: documentLockStateRef.current?.hasShownLockedModal || false,
|
||||
isLocked: true,
|
||||
user: lockedState.user as ClientUser,
|
||||
}
|
||||
|
||||
setCurrentEditor(lockedState.user as ClientUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abortOnChangeRef.current = null
|
||||
|
||||
return state
|
||||
},
|
||||
[
|
||||
editSessionStartTime,
|
||||
isLockingEnabled,
|
||||
getDocPreferences,
|
||||
getFormState,
|
||||
id,
|
||||
collectionSlug,
|
||||
docPermissions,
|
||||
globalSlug,
|
||||
operation,
|
||||
schemaPath,
|
||||
setDocumentIsLocked,
|
||||
user?.id,
|
||||
setCurrentEditor,
|
||||
],
|
||||
)
|
||||
|
||||
// Clean up when the component unmounts or when the document is unlocked
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!isLockingEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname
|
||||
|
||||
const documentID = id || globalSlug
|
||||
|
||||
// Routes where we do NOT want to unlock the document
|
||||
const stayWithinDocumentPaths = ['preview', 'api', 'versions']
|
||||
|
||||
const isStayingWithinDocument = stayWithinDocumentPaths.some((path) =>
|
||||
currentPath.includes(path),
|
||||
)
|
||||
|
||||
// Unlock the document only if we're actually navigating away from the document
|
||||
if (documentID && documentIsLocked && !isStayingWithinDocument) {
|
||||
// Check if this user is still the current editor
|
||||
if (
|
||||
typeof documentLockStateRef.current?.user === 'object'
|
||||
? documentLockStateRef.current?.user?.id === user?.id
|
||||
: documentLockStateRef.current?.user === user?.id
|
||||
) {
|
||||
void unlockDocument(id, collectionSlug ?? globalSlug)
|
||||
setDocumentIsLocked(false)
|
||||
setCurrentEditor(null)
|
||||
}
|
||||
}
|
||||
|
||||
setShowTakeOverModal(false)
|
||||
}
|
||||
}, [
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
id,
|
||||
unlockDocument,
|
||||
user,
|
||||
setCurrentEditor,
|
||||
isLockingEnabled,
|
||||
documentIsLocked,
|
||||
setDocumentIsLocked,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const abortOnChange = abortOnChangeRef.current
|
||||
const abortOnSave = abortOnSaveRef.current
|
||||
|
||||
return () => {
|
||||
abortAndIgnore(abortOnChange)
|
||||
abortAndIgnore(abortOnSave)
|
||||
}
|
||||
})
|
||||
|
||||
const shouldShowDocumentLockedModal =
|
||||
documentIsLocked &&
|
||||
currentEditor &&
|
||||
(typeof currentEditor === 'object'
|
||||
? currentEditor.id !== user?.id
|
||||
: currentEditor !== user?.id) &&
|
||||
!isReadOnlyForIncomingUser &&
|
||||
!showTakeOverModal &&
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
!documentLockStateRef.current?.hasShownLockedModal &&
|
||||
!isLockExpired
|
||||
|
||||
return (
|
||||
<OperationProvider operation={operation}>
|
||||
<Form
|
||||
action={action}
|
||||
className={`${baseClass}__form`}
|
||||
disabled={isReadOnlyForIncomingUser || !hasSavePermission}
|
||||
initialState={initialState}
|
||||
isInitializing={isInitializing}
|
||||
method={id ? 'PATCH' : 'POST'}
|
||||
onChange={[onChange]}
|
||||
onSuccess={onSave}
|
||||
>
|
||||
{isLockingEnabled && shouldShowDocumentLockedModal && !isReadOnlyForIncomingUser && (
|
||||
<DocumentLocked
|
||||
handleGoBack={() => handleGoBack({ adminRoute, collectionSlug, router })}
|
||||
isActive={shouldShowDocumentLockedModal}
|
||||
onReadOnly={() => {
|
||||
setIsReadOnlyForIncomingUser(true)
|
||||
setShowTakeOverModal(false)
|
||||
}}
|
||||
onTakeOver={() =>
|
||||
handleTakeOver(
|
||||
id,
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
user,
|
||||
false,
|
||||
updateDocumentEditor,
|
||||
setCurrentEditor,
|
||||
documentLockStateRef,
|
||||
isLockingEnabled,
|
||||
)
|
||||
}
|
||||
updatedAt={lastUpdateTime}
|
||||
user={currentEditor}
|
||||
/>
|
||||
)}
|
||||
{isLockingEnabled && showTakeOverModal && (
|
||||
<DocumentTakeOver
|
||||
handleBackToDashboard={() => handleBackToDashboard({ adminRoute, router })}
|
||||
isActive={showTakeOverModal}
|
||||
onReadOnly={() => {
|
||||
setIsReadOnlyForIncomingUser(true)
|
||||
setShowTakeOverModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isReadOnlyForIncomingUser && preventLeaveWithoutSaving && <LeaveWithoutSaving />}
|
||||
<SetDocumentStepNav
|
||||
collectionSlug={collectionSlug}
|
||||
globalLabel={globalConfig?.label}
|
||||
globalSlug={globalSlug}
|
||||
id={id}
|
||||
pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
|
||||
useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
|
||||
view={t('general:livePreview')}
|
||||
/>
|
||||
<SetDocumentTitle
|
||||
collectionConfig={collectionConfig}
|
||||
config={config}
|
||||
fallback={id?.toString() || ''}
|
||||
globalConfig={globalConfig}
|
||||
/>
|
||||
<DocumentControls
|
||||
apiURL={apiURL}
|
||||
BeforeDocumentControls={BeforeDocumentControls}
|
||||
customComponents={{
|
||||
PreviewButton,
|
||||
PublishButton,
|
||||
SaveButton,
|
||||
SaveDraftButton,
|
||||
}}
|
||||
data={initialData}
|
||||
disableActions={disableActions}
|
||||
EditMenuItems={EditMenuItems}
|
||||
hasPublishPermission={hasPublishPermission}
|
||||
hasSavePermission={hasSavePermission}
|
||||
id={id}
|
||||
isEditing={isEditing}
|
||||
onTakeOver={() =>
|
||||
handleTakeOver(
|
||||
id,
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
user,
|
||||
true,
|
||||
updateDocumentEditor,
|
||||
setCurrentEditor,
|
||||
documentLockStateRef,
|
||||
isLockingEnabled,
|
||||
setIsReadOnlyForIncomingUser,
|
||||
)
|
||||
}
|
||||
permissions={docPermissions}
|
||||
readOnlyForIncomingUser={isReadOnlyForIncomingUser}
|
||||
slug={collectionConfig?.slug || globalConfig?.slug}
|
||||
user={currentEditor}
|
||||
/>
|
||||
<div
|
||||
className={[baseClass, previewWindowType === 'popup' && `${baseClass}--detached`]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
`${baseClass}__main`,
|
||||
previewWindowType === 'popup' && `${baseClass}__main--popup-open`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<DocumentFields
|
||||
AfterFields={AfterFields}
|
||||
BeforeFields={BeforeFields}
|
||||
Description={Description}
|
||||
docPermissions={docPermissions}
|
||||
fields={fields}
|
||||
forceSidebarWrap
|
||||
readOnly={isReadOnlyForIncomingUser || !hasSavePermission}
|
||||
schemaPathSegments={[collectionSlug || globalSlug]}
|
||||
/>
|
||||
{AfterDocument}
|
||||
</div>
|
||||
<LivePreview collectionSlug={collectionSlug} globalSlug={globalSlug} />
|
||||
</div>
|
||||
</Form>
|
||||
</OperationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const LivePreviewClient: React.FC<
|
||||
{
|
||||
readonly breakpoints: LivePreviewConfig['breakpoints']
|
||||
readonly initialData: Data
|
||||
readonly url: string
|
||||
} & DocumentSlots
|
||||
> = (props) => {
|
||||
const { breakpoints, url: incomingUrl } = props
|
||||
const { collectionSlug, globalSlug } = useDocumentInfo()
|
||||
|
||||
const {
|
||||
config,
|
||||
config: {
|
||||
routes: { api: apiRoute },
|
||||
serverURL,
|
||||
},
|
||||
getEntityConfig,
|
||||
} = useConfig()
|
||||
|
||||
const url =
|
||||
incomingUrl.startsWith('http://') || incomingUrl.startsWith('https://')
|
||||
? incomingUrl
|
||||
: getAbsoluteUrl(incomingUrl)
|
||||
|
||||
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
|
||||
eventType: 'payload-live-preview',
|
||||
url,
|
||||
})
|
||||
|
||||
const collectionConfig = getEntityConfig({ collectionSlug })
|
||||
|
||||
const globalConfig = getEntityConfig({ globalSlug })
|
||||
|
||||
const schemaPath = collectionSlug || globalSlug
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<LivePreviewProvider
|
||||
breakpoints={breakpoints}
|
||||
fieldSchema={collectionConfig?.fields || globalConfig?.fields}
|
||||
isPopupOpen={isPopupOpen}
|
||||
openPopupWindow={openPopupWindow}
|
||||
popupRef={popupRef}
|
||||
url={url}
|
||||
>
|
||||
<PreviewView
|
||||
apiRoute={apiRoute}
|
||||
BeforeDocumentControls={props.BeforeDocumentControls}
|
||||
collectionConfig={collectionConfig}
|
||||
config={config}
|
||||
Description={props.Description}
|
||||
EditMenuItems={props.EditMenuItems}
|
||||
fields={(collectionConfig || globalConfig)?.fields}
|
||||
globalConfig={globalConfig}
|
||||
PreviewButton={props.PreviewButton}
|
||||
PublishButton={props.PublishButton}
|
||||
SaveButton={props.SaveButton}
|
||||
SaveDraftButton={props.SaveDraftButton}
|
||||
schemaPath={schemaPath}
|
||||
serverURL={serverURL}
|
||||
Upload={props.Upload}
|
||||
/>
|
||||
</LivePreviewProvider>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import type {
|
||||
BeforeDocumentControlsServerPropsOnly,
|
||||
DocumentViewServerProps,
|
||||
EditMenuItemsServerPropsOnly,
|
||||
LivePreviewConfig,
|
||||
ServerProps,
|
||||
} from 'payload'
|
||||
|
||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||
import React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
import { LivePreviewClient } from './index.client.js'
|
||||
export async function LivePreviewView(props: DocumentViewServerProps) {
|
||||
const { doc, initPageResult } = props
|
||||
|
||||
const { collectionConfig, globalConfig, locale, req } = initPageResult
|
||||
|
||||
let livePreviewConfig: LivePreviewConfig = req.payload.config?.admin?.livePreview
|
||||
|
||||
const serverProps: ServerProps = {
|
||||
i18n: req.i18n,
|
||||
payload: req.payload,
|
||||
user: req.user,
|
||||
// TODO: Add remaining serverProps
|
||||
}
|
||||
|
||||
if (collectionConfig) {
|
||||
livePreviewConfig = {
|
||||
...(livePreviewConfig || {}),
|
||||
...(collectionConfig.admin.livePreview || {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (globalConfig) {
|
||||
livePreviewConfig = {
|
||||
...(livePreviewConfig || {}),
|
||||
...(globalConfig.admin.livePreview || {}),
|
||||
}
|
||||
}
|
||||
|
||||
const BeforeDocumentControls =
|
||||
collectionConfig?.admin?.components?.edit?.beforeDocumentControls ||
|
||||
globalConfig?.admin?.components?.elements?.beforeDocumentControls
|
||||
|
||||
const EditMenuItems = collectionConfig?.admin?.components?.edit?.editMenuItems
|
||||
|
||||
const breakpoints: LivePreviewConfig['breakpoints'] = [
|
||||
...(livePreviewConfig?.breakpoints || []),
|
||||
{
|
||||
name: 'responsive',
|
||||
height: '100%',
|
||||
label: 'Responsive',
|
||||
width: '100%',
|
||||
},
|
||||
]
|
||||
|
||||
const url =
|
||||
typeof livePreviewConfig?.url === 'function'
|
||||
? await livePreviewConfig.url({
|
||||
collectionConfig,
|
||||
data: doc,
|
||||
globalConfig,
|
||||
locale,
|
||||
req,
|
||||
/**
|
||||
* @deprecated
|
||||
* Use `req.payload` instead. This will be removed in the next major version.
|
||||
*/
|
||||
payload: initPageResult.req.payload,
|
||||
})
|
||||
: livePreviewConfig?.url
|
||||
|
||||
return (
|
||||
<LivePreviewClient
|
||||
BeforeDocumentControls={
|
||||
BeforeDocumentControls
|
||||
? RenderServerComponent({
|
||||
Component: BeforeDocumentControls,
|
||||
importMap: req.payload.importMap,
|
||||
serverProps: serverProps satisfies BeforeDocumentControlsServerPropsOnly,
|
||||
})
|
||||
: null
|
||||
}
|
||||
breakpoints={breakpoints}
|
||||
Description={props.Description}
|
||||
EditMenuItems={
|
||||
EditMenuItems
|
||||
? RenderServerComponent({
|
||||
Component: EditMenuItems,
|
||||
importMap: req.payload.importMap,
|
||||
serverProps: serverProps satisfies EditMenuItemsServerPropsOnly,
|
||||
})
|
||||
: null
|
||||
}
|
||||
initialData={doc}
|
||||
PreviewButton={props.PreviewButton}
|
||||
PublishButton={props.PublishButton}
|
||||
SaveButton={props.SaveButton}
|
||||
SaveDraftButton={props.SaveDraftButton}
|
||||
Upload={props.Upload}
|
||||
url={url}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import type { GenerateEditViewMetadata } from '../Document/getMetaBySegment.js'
|
||||
|
||||
import { generateEditViewMetadata } from '../Edit/metadata.js'
|
||||
|
||||
export const generateLivePreviewViewMetadata: GenerateEditViewMetadata = async ({
|
||||
collectionConfig,
|
||||
config,
|
||||
globalConfig,
|
||||
i18n,
|
||||
isEditing,
|
||||
}): Promise<Metadata> =>
|
||||
generateEditViewMetadata({
|
||||
collectionConfig,
|
||||
config,
|
||||
globalConfig,
|
||||
i18n,
|
||||
isEditing,
|
||||
view: 'livePreview',
|
||||
})
|
||||
@@ -22,9 +22,11 @@ export const getCustomViewByRoute = ({
|
||||
routes: { admin: adminRoute },
|
||||
} = config
|
||||
|
||||
const currentRoute = currentRouteWithAdmin.replace(adminRoute, '')
|
||||
let viewKey: string
|
||||
|
||||
const currentRoute =
|
||||
adminRoute === '/' ? currentRouteWithAdmin : currentRouteWithAdmin.replace(adminRoute, '')
|
||||
|
||||
const foundViewConfig =
|
||||
(views &&
|
||||
typeof views === 'object' &&
|
||||
|
||||
@@ -19,11 +19,6 @@ export function getDocumentViewInfo(segments: string[]): {
|
||||
documentSubViewType: 'versions',
|
||||
viewType: 'document',
|
||||
}
|
||||
} else if (tabSegment === 'preview') {
|
||||
return {
|
||||
documentSubViewType: 'livePreview',
|
||||
viewType: 'document',
|
||||
}
|
||||
} else if (tabSegment === 'api') {
|
||||
return {
|
||||
documentSubViewType: 'api',
|
||||
|
||||
@@ -291,7 +291,6 @@ export const getRouteData = ({
|
||||
// Collection Edit Views
|
||||
// --> /collections/:collectionSlug/:id
|
||||
// --> /collections/:collectionSlug/:id/api
|
||||
// --> /collections/:collectionSlug/:id/preview
|
||||
// --> /collections/:collectionSlug/:id/versions
|
||||
// --> /collections/:collectionSlug/:id/versions/:versionID
|
||||
initPageOptions.routeParams.id = segmentThree
|
||||
@@ -317,7 +316,6 @@ export const getRouteData = ({
|
||||
} else if (isGlobal && matchedGlobal) {
|
||||
// Global Edit Views
|
||||
// --> /globals/:globalSlug/versions
|
||||
// --> /globals/:globalSlug/preview
|
||||
// --> /globals/:globalSlug/versions/:versionID
|
||||
// --> /globals/:globalSlug/api
|
||||
initPageOptions.routeParams.global = matchedGlobal.slug
|
||||
|
||||
@@ -144,7 +144,6 @@ export const generatePageMetadata = async ({
|
||||
} else {
|
||||
// Collection Document Views
|
||||
// --> /collections/:collectionSlug/:id
|
||||
// --> /collections/:collectionSlug/:id/preview
|
||||
// --> /collections/:collectionSlug/:id/versions
|
||||
// --> /collections/:collectionSlug/:id/versions/:version
|
||||
// --> /collections/:collectionSlug/:id/api
|
||||
@@ -154,7 +153,6 @@ export const generatePageMetadata = async ({
|
||||
// Global Document Views
|
||||
// --> /globals/:globalSlug/versions
|
||||
// --> /globals/:globalSlug/versions/:version
|
||||
// --> /globals/:globalSlug/preview
|
||||
// --> /globals/:globalSlug/api
|
||||
meta = await generateDocumentViewMetadata({
|
||||
config,
|
||||
|
||||
@@ -238,6 +238,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
|
||||
<SelectComparison
|
||||
collectionSlug={collectionSlug}
|
||||
docID={originalDocID}
|
||||
globalSlug={globalSlug}
|
||||
onChange={onChangeVersionFrom}
|
||||
versionFromID={versionFromID}
|
||||
versionFromOptions={versionFromOptions}
|
||||
|
||||
@@ -24,11 +24,12 @@ export const formatVersionDrawerSlug = ({
|
||||
}) => `version-drawer_${depth}_${uuid}`
|
||||
|
||||
export const VersionDrawerContent: React.FC<{
|
||||
collectionSlug: string
|
||||
docID: number | string
|
||||
collectionSlug?: string
|
||||
docID?: number | string
|
||||
drawerSlug: string
|
||||
globalSlug?: string
|
||||
}> = (props) => {
|
||||
const { collectionSlug, docID, drawerSlug } = props
|
||||
const { collectionSlug, docID, drawerSlug, globalSlug } = props
|
||||
const { closeModal } = useModal()
|
||||
const searchParams = useSearchParams()
|
||||
const prevSearchParams = useRef(searchParams)
|
||||
@@ -46,12 +47,20 @@ export const VersionDrawerContent: React.FC<{
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const isGlobal = Boolean(globalSlug)
|
||||
const entitySlug = collectionSlug ?? globalSlug
|
||||
|
||||
const result = await renderDocument({
|
||||
collectionSlug,
|
||||
collectionSlug: entitySlug,
|
||||
docID,
|
||||
drawerSlug,
|
||||
paramsOverride: {
|
||||
segments: ['collections', collectionSlug, String(docID), 'versions'],
|
||||
segments: [
|
||||
isGlobal ? 'globals' : 'collections',
|
||||
entitySlug,
|
||||
isGlobal ? undefined : String(docID),
|
||||
'versions',
|
||||
].filter(Boolean),
|
||||
},
|
||||
redirectAfterDelete: false,
|
||||
redirectAfterDuplicate: false,
|
||||
@@ -75,7 +84,7 @@ export const VersionDrawerContent: React.FC<{
|
||||
|
||||
void fetchDocumentView()
|
||||
},
|
||||
[closeModal, collectionSlug, drawerSlug, renderDocument, searchParams, t],
|
||||
[closeModal, collectionSlug, globalSlug, drawerSlug, renderDocument, searchParams, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -93,11 +102,12 @@ export const VersionDrawerContent: React.FC<{
|
||||
return DocumentView
|
||||
}
|
||||
export const VersionDrawer: React.FC<{
|
||||
collectionSlug: string
|
||||
docID: number | string
|
||||
collectionSlug?: string
|
||||
docID?: number | string
|
||||
drawerSlug: string
|
||||
globalSlug?: string
|
||||
}> = (props) => {
|
||||
const { collectionSlug, docID, drawerSlug } = props
|
||||
const { collectionSlug, docID, drawerSlug, globalSlug } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@@ -107,7 +117,12 @@ export const VersionDrawer: React.FC<{
|
||||
slug={drawerSlug}
|
||||
title={t('version:selectVersionToCompare')}
|
||||
>
|
||||
<VersionDrawerContent collectionSlug={collectionSlug} docID={docID} drawerSlug={drawerSlug} />
|
||||
<VersionDrawerContent
|
||||
collectionSlug={collectionSlug}
|
||||
docID={docID}
|
||||
drawerSlug={drawerSlug}
|
||||
globalSlug={globalSlug}
|
||||
/>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -115,9 +130,11 @@ export const VersionDrawer: React.FC<{
|
||||
export const useVersionDrawer = ({
|
||||
collectionSlug,
|
||||
docID,
|
||||
globalSlug,
|
||||
}: {
|
||||
collectionSlug: string
|
||||
docID: number | string
|
||||
collectionSlug?: string
|
||||
docID?: number | string
|
||||
globalSlug?: string
|
||||
}) => {
|
||||
const drawerDepth = useEditDepth()
|
||||
const uuid = useId()
|
||||
@@ -147,9 +164,14 @@ export const useVersionDrawer = ({
|
||||
|
||||
const MemoizedDrawer = useMemo(() => {
|
||||
return () => (
|
||||
<VersionDrawer collectionSlug={collectionSlug} docID={docID} drawerSlug={drawerSlug} />
|
||||
<VersionDrawer
|
||||
collectionSlug={collectionSlug}
|
||||
docID={docID}
|
||||
drawerSlug={drawerSlug}
|
||||
globalSlug={globalSlug}
|
||||
/>
|
||||
)
|
||||
}, [collectionSlug, docID, drawerSlug])
|
||||
}, [collectionSlug, docID, drawerSlug, globalSlug])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -17,13 +17,14 @@ export const SelectComparison: React.FC<Props> = memo((props) => {
|
||||
const {
|
||||
collectionSlug,
|
||||
docID,
|
||||
globalSlug,
|
||||
onChange: onChangeFromProps,
|
||||
versionFromID,
|
||||
versionFromOptions,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { Drawer, openDrawer } = useVersionDrawer({ collectionSlug, docID })
|
||||
const { Drawer, openDrawer } = useVersionDrawer({ collectionSlug, docID, globalSlug })
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { PaginatedDocs, SanitizedCollectionConfig } from 'payload'
|
||||
import type { CompareOption } from '../Default/types.js'
|
||||
|
||||
export type Props = {
|
||||
collectionSlug: string
|
||||
docID: number | string
|
||||
collectionSlug?: string
|
||||
docID?: number | string
|
||||
globalSlug?: string
|
||||
onChange: (val: CompareOption) => void
|
||||
versionFromID?: string
|
||||
versionFromOptions: CompareOption[]
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
type PayloadRequest,
|
||||
type SelectType,
|
||||
type Sort,
|
||||
type TypedUser,
|
||||
type TypeWithVersion,
|
||||
type User,
|
||||
type Where,
|
||||
} from 'payload'
|
||||
|
||||
@@ -28,7 +28,7 @@ export const fetchVersion = async <TVersionData extends object = object>({
|
||||
overrideAccess?: boolean
|
||||
req: PayloadRequest
|
||||
select?: SelectType
|
||||
user?: User
|
||||
user?: TypedUser
|
||||
}): Promise<null | TypeWithVersion<TVersionData>> => {
|
||||
try {
|
||||
if (collectionSlug) {
|
||||
@@ -88,7 +88,7 @@ export const fetchVersions = async <TVersionData extends object = object>({
|
||||
req: PayloadRequest
|
||||
select?: SelectType
|
||||
sort?: Sort
|
||||
user?: User
|
||||
user?: TypedUser
|
||||
where?: Where
|
||||
}): Promise<null | PaginatedDocs<TypeWithVersion<TVersionData>>> => {
|
||||
const where: Where = { and: [...(whereFromArgs ? [whereFromArgs] : [])] }
|
||||
@@ -160,7 +160,7 @@ export const fetchLatestVersion = async <TVersionData extends object = object>({
|
||||
req: PayloadRequest
|
||||
select?: SelectType
|
||||
status: 'draft' | 'published'
|
||||
user?: User
|
||||
user?: TypedUser
|
||||
where?: Where
|
||||
}): Promise<null | TypeWithVersion<TVersionData>> => {
|
||||
const and: Where[] = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user