Compare commits
3 Commits
revert/doc
...
chore/sani
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be26f6dd5 | ||
|
|
9d5f801488 | ||
|
|
5f6fad69fb |
34
.github/CODEOWNERS
vendored
34
.github/CODEOWNERS
vendored
@@ -1,34 +0,0 @@
|
||||
# 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: 9.7.1
|
||||
default: 10.12.1
|
||||
pnpm-run-install:
|
||||
description: Whether to run pnpm install
|
||||
required: false
|
||||
|
||||
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/.github/assets/int-debug.png" />
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/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/.github/assets/e2e-debug.png" />
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/e2e-debug.png" />
|
||||
|
||||
#### Notes
|
||||
|
||||
|
||||
66
.github/workflows/main.yml
vendored
66
.github/workflows/main.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: ci
|
||||
name: build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 23.11.0
|
||||
PNPM_VERSION: 9.7.1
|
||||
PNPM_VERSION: 10.12.1
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
|
||||
|
||||
@@ -163,7 +163,6 @@ jobs:
|
||||
needs: [changes, build]
|
||||
if: ${{ needs.changes.outputs.needs_tests == 'true' }}
|
||||
name: int-${{ matrix.database }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -175,7 +174,6 @@ jobs:
|
||||
- supabase
|
||||
- sqlite
|
||||
- sqlite-uuid
|
||||
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -260,7 +258,6 @@ jobs:
|
||||
needs: [changes, build]
|
||||
if: ${{ needs.changes.outputs.needs_tests == 'true' }}
|
||||
name: e2e-${{ matrix.suite }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -315,7 +312,6 @@ jobs:
|
||||
- plugin-cloud-storage
|
||||
- plugin-form-builder
|
||||
- plugin-import-export
|
||||
- plugin-multi-tenant
|
||||
- plugin-nested-docs
|
||||
- plugin-seo
|
||||
- sort
|
||||
@@ -452,7 +448,6 @@ jobs:
|
||||
- plugin-cloud-storage
|
||||
- plugin-form-builder
|
||||
- plugin-import-export
|
||||
- plugin-multi-tenant
|
||||
- plugin-nested-docs
|
||||
- plugin-seo
|
||||
- sort
|
||||
@@ -525,32 +520,24 @@ jobs:
|
||||
# report-tag: ${{ matrix.suite }}
|
||||
# job-summary: true
|
||||
|
||||
# Build listed templates with packed local packages and then runs their int and e2e tests
|
||||
build-and-test-templates:
|
||||
# Build listed templates with packed local packages
|
||||
build-templates:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [changes, build]
|
||||
if: ${{ needs.changes.outputs.needs_build == 'true' }}
|
||||
name: build-template-${{ matrix.template }}-${{ matrix.database }}
|
||||
needs: build
|
||||
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
|
||||
|
||||
@@ -560,6 +547,8 @@ jobs:
|
||||
# - template: with-vercel-website
|
||||
# database: postgres
|
||||
|
||||
name: ${{ matrix.template }}-${{ matrix.database }}
|
||||
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -623,45 +612,6 @@ 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]
|
||||
@@ -697,7 +647,7 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- build
|
||||
- build-and-test-templates
|
||||
- build-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: 9.7.1
|
||||
PNPM_VERSION: 10.12.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: 9.7.1
|
||||
PNPM_VERSION: 10.12.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: 9.7.1
|
||||
PNPM_VERSION: 10.12.1
|
||||
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
|
||||
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
pnpm 9.7.1
|
||||
pnpm 10.12.1
|
||||
nodejs 23.11.0
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -6,8 +6,6 @@
|
||||
"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/.github/assets/int-debug.png" />
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/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/.github/assets/e2e-debug.png" />
|
||||
<img src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/assets/images/github/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,30 +84,29 @@ 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
|
||||
@@ -187,12 +186,6 @@ 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
|
||||
@@ -203,29 +196,13 @@ 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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```plaintext
|
||||
app
|
||||
├─ (payload)
|
||||
├── [[...segments]]
|
||||
app/
|
||||
├─ (payload)/
|
||||
├── [[...segments]]/
|
||||
├──── ...
|
||||
├── layout.tsx
|
||||
```
|
||||
|
||||
<Banner type="warning">
|
||||
|
||||
@@ -180,22 +180,19 @@ 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?allSessions=false',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**Example GraphQL Mutation**:
|
||||
|
||||
```
|
||||
mutation {
|
||||
logoutUser(allSessions: false)
|
||||
logout[collection-singular-label]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -206,10 +203,6 @@ 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,7 +91,6 @@ 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
|
||||
@@ -202,43 +201,3 @@ 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. Must begin with a forward slash (`/`). |
|
||||
| `path` \* | Any valid URL path or array of paths that [`path-to-regexp`](https://www.npmjs.com/package/path-to-regex) understands. |
|
||||
| `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,6 +30,7 @@ export const MyCollectionOrGlobalConfig: CollectionConfig = {
|
||||
// - api
|
||||
// - versions
|
||||
// - version
|
||||
// - livePreview
|
||||
// - [key: string]
|
||||
// See below for more details
|
||||
},
|
||||
|
||||
@@ -41,7 +41,6 @@ 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,8 +315,7 @@ 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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -351,6 +350,7 @@ import {
|
||||
code,
|
||||
date,
|
||||
email,
|
||||
group,
|
||||
json,
|
||||
number,
|
||||
point,
|
||||
|
||||
@@ -393,7 +393,7 @@ export default function LoginForm() {
|
||||
|
||||
### Logout
|
||||
|
||||
Logs out the current user by clearing the authentication cookie and current sessions.
|
||||
Logs out the current user by clearing the authentication cookie.
|
||||
|
||||
#### Importing the `logout` function
|
||||
|
||||
@@ -401,7 +401,7 @@ Logs out the current user by clearing the authentication cookie and current sess
|
||||
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. 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`.
|
||||
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.
|
||||
|
||||
```ts
|
||||
'use server'
|
||||
@@ -411,7 +411,7 @@ import config from '@payload-config'
|
||||
|
||||
export async function logoutAction() {
|
||||
try {
|
||||
return await logout({ allSessions: true, config })
|
||||
return await logout({ 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 and current session for the logged-in user.
|
||||
Refreshes the authentication token for the logged-in user.
|
||||
|
||||
#### Importing the `refresh` function
|
||||
|
||||
@@ -453,6 +453,7 @@ import config from '@payload-config'
|
||||
export async function refreshAction() {
|
||||
try {
|
||||
return await refresh({
|
||||
collection: 'users', // pass your collection slug
|
||||
config,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -74,7 +74,11 @@ import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
const config = buildConfig({
|
||||
collections: [Pages, Media],
|
||||
plugins: [sentryPlugin({ Sentry })],
|
||||
plugins: [
|
||||
sentryPlugin({
|
||||
Sentry,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
@@ -96,7 +100,9 @@ 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,7 +109,6 @@ _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. |
|
||||
@@ -436,24 +435,6 @@ 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": "astro-website",
|
||||
"name": "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/main/packages/ui/src/assets/payload-logo-light.svg"
|
||||
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
|
||||
/>
|
||||
<Image
|
||||
alt="Payload Logo"
|
||||
height={30}
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-dark.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/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/main/packages/ui/src/assets/payload-logo-light.svg"
|
||||
srcSet="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg"
|
||||
/>
|
||||
<Image
|
||||
alt="Payload Logo"
|
||||
height={30}
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-dark.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg"
|
||||
width={150}
|
||||
/>
|
||||
</picture>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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'
|
||||
@@ -17,7 +16,6 @@ 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
|
||||
|
||||
@@ -27,7 +25,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 = `/${locale}/${relationTo}/${slug}`
|
||||
const href = `/${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/ui/src/assets/payload-logo-light.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/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/ui/src/assets/payload-logo-light.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
|
||||
/>
|
||||
</picture>
|
||||
</Link>
|
||||
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.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 --filter \"!blank\" --filter \"!website\"",
|
||||
"build:all": "turbo build",
|
||||
"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-*\" --filter \"!blank\" --filter \"!website\"",
|
||||
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\"",
|
||||
"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 --filter \"!blank\" --filter \"!website\"",
|
||||
"lint": "turbo run lint --log-order=grouped --continue",
|
||||
"lint-staged": "lint-staged",
|
||||
"lint:fix": "turbo run lint:fix --log-order=grouped --continue --filter \"!blank\" --filter \"!website\"",
|
||||
"lint:fix": "turbo run lint:fix --log-order=grouped --continue",
|
||||
"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 ..",
|
||||
@@ -154,6 +154,7 @@
|
||||
"drizzle-kit": "0.31.0",
|
||||
"drizzle-orm": "0.43.1",
|
||||
"escape-html": "^1.0.3",
|
||||
"eslint": "9.22.0",
|
||||
"execa": "5.1.1",
|
||||
"form-data": "3.0.1",
|
||||
"fs-extra": "10.1.0",
|
||||
@@ -181,13 +182,13 @@
|
||||
"tempy": "1.0.1",
|
||||
"tstyche": "^3.1.1",
|
||||
"tsx": "4.19.2",
|
||||
"turbo": "^2.5.4",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "5.7.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.7.1",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0",
|
||||
"pnpm": "^9.7.0"
|
||||
"pnpm": "^10.12.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/admin-bar",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.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.44.0",
|
||||
"version": "3.43.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, updatePackageJSONDependencies } from './create-project.js'
|
||||
import { createProject } from './create-project.js'
|
||||
import { dbReplacements } from './replacements.js'
|
||||
import { getValidTemplates } from './templates.js'
|
||||
|
||||
@@ -179,37 +179,5 @@ 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,11 +129,7 @@ export async function createProject(
|
||||
const spinner = p.spinner()
|
||||
spinner.start('Checking latest Payload version...')
|
||||
|
||||
const payloadVersion = await getLatestPackageVersion({ packageName: 'payload' })
|
||||
|
||||
spinner.stop(`Found latest version of Payload ${payloadVersion}`)
|
||||
|
||||
await updatePackageJSON({ latestVersion: payloadVersion, projectDir, projectName })
|
||||
await updatePackageJSON({ projectDir, projectName })
|
||||
|
||||
if ('template' in args) {
|
||||
if (args.template.type === 'plugin') {
|
||||
@@ -181,105 +177,17 @@ 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 { latestVersion, projectDir, projectName } = args
|
||||
const { 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/, '').replaceAll('/', '-')}/templates/${template.name}/`
|
||||
const filter = `payload-${branchOrTag.replace(/^v/, '')}/templates/${template.name}/`
|
||||
|
||||
if (debug) {
|
||||
debugLog(`Using template url: ${template.url}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-mongodb",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.0",
|
||||
"description": "The officially supported MongoDB database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -118,13 +118,6 @@ 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
|
||||
/**
|
||||
@@ -138,7 +131,6 @@ export interface Args {
|
||||
*/
|
||||
mongoMemoryServer?: MongoMemoryReplSet
|
||||
prodMigrations?: Migration[]
|
||||
|
||||
transactionOptions?: false | TransactionOptions
|
||||
|
||||
/** The URL to connect to MongoDB or false to start payload and prevent connecting */
|
||||
@@ -206,7 +198,6 @@ export function mongooseAdapter({
|
||||
autoPluralization = true,
|
||||
collectionsSchemaOptions = {},
|
||||
connectOptions,
|
||||
disableFallbackSort = false,
|
||||
disableIndexHints = false,
|
||||
ensureIndexes = false,
|
||||
migrationDir: migrationDirArg,
|
||||
@@ -260,7 +251,6 @@ export function mongooseAdapter({
|
||||
deleteOne,
|
||||
deleteVersions,
|
||||
destroy,
|
||||
disableFallbackSort,
|
||||
find,
|
||||
findGlobal,
|
||||
findGlobalVersions,
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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
|
||||
@@ -150,12 +150,6 @@ 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'
|
||||
@@ -164,12 +158,7 @@ export const buildSortParam = ({
|
||||
fallbackSort = '-createdAt'
|
||||
}
|
||||
|
||||
const includeFallbackSort =
|
||||
!adapter.disableFallbackSort &&
|
||||
!isUniqueSort &&
|
||||
!(sort.includes(fallbackSort) || sort.includes(fallbackSort.replace('-', '')))
|
||||
|
||||
if (includeFallbackSort) {
|
||||
if (!(sort.includes(fallbackSort) || sort.includes(fallbackSort.replace('-', '')))) {
|
||||
sort.push(fallbackSort)
|
||||
}
|
||||
|
||||
|
||||
@@ -277,9 +277,7 @@ const stripFields = ({
|
||||
continue
|
||||
}
|
||||
|
||||
let hasNull = false
|
||||
for (let i = 0; i < localeData.length; i++) {
|
||||
const data = localeData[i]
|
||||
for (const data of localeData) {
|
||||
let fields: FlattenedField[] | null = null
|
||||
|
||||
if (field.type === 'array') {
|
||||
@@ -288,17 +286,11 @@ const stripFields = ({
|
||||
let maybeBlock: FlattenedBlock | undefined = undefined
|
||||
|
||||
if (field.blockReferences) {
|
||||
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)
|
||||
}
|
||||
const maybeBlockReference = field.blockReferences.find(
|
||||
(each) => typeof each === 'object' && each.slug === data.blockType,
|
||||
)
|
||||
if (maybeBlockReference && typeof maybeBlockReference === 'object') {
|
||||
maybeBlock = maybeBlockReference
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,9 +300,6 @@ const stripFields = ({
|
||||
|
||||
if (maybeBlock) {
|
||||
fields = maybeBlock.flattenedFields
|
||||
} else {
|
||||
localeData[i] = null
|
||||
hasNull = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,10 +310,6 @@ const stripFields = ({
|
||||
stripFields({ config, data, fields, reservedKeys })
|
||||
}
|
||||
|
||||
if (hasNull) {
|
||||
fieldData[localeKey] = localeData.filter(Boolean)
|
||||
}
|
||||
|
||||
continue
|
||||
} else {
|
||||
stripFields({ config, data: localeData, fields: field.flattenedFields, reservedKeys })
|
||||
@@ -338,10 +323,7 @@ const stripFields = ({
|
||||
continue
|
||||
}
|
||||
|
||||
let hasNull = false
|
||||
|
||||
for (let i = 0; i < fieldData.length; i++) {
|
||||
const data = fieldData[i]
|
||||
for (const data of fieldData) {
|
||||
let fields: FlattenedField[] | null = null
|
||||
|
||||
if (field.type === 'array') {
|
||||
@@ -350,17 +332,12 @@ const stripFields = ({
|
||||
let maybeBlock: FlattenedBlock | undefined = undefined
|
||||
|
||||
if (field.blockReferences) {
|
||||
const maybeBlockReference = field.blockReferences.find((each) => {
|
||||
const slug = typeof each === 'string' ? each : each.slug
|
||||
return slug === data.blockType
|
||||
})
|
||||
const maybeBlockReference = field.blockReferences.find(
|
||||
(each) => typeof each === 'object' && each.slug === data.blockType,
|
||||
)
|
||||
|
||||
if (maybeBlockReference) {
|
||||
if (typeof maybeBlockReference === 'object') {
|
||||
maybeBlock = maybeBlockReference
|
||||
} else {
|
||||
maybeBlock = config.blocks?.find((each) => each.slug === maybeBlockReference)
|
||||
}
|
||||
if (maybeBlockReference && typeof maybeBlockReference === 'object') {
|
||||
maybeBlock = maybeBlockReference
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,9 +347,6 @@ const stripFields = ({
|
||||
|
||||
if (maybeBlock) {
|
||||
fields = maybeBlock.flattenedFields
|
||||
} else {
|
||||
fieldData[i] = null
|
||||
hasNull = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +357,6 @@ 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 })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-postgres",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.0",
|
||||
"description": "The officially supported Postgres database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-sqlite",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.0",
|
||||
"description": "The officially supported SQLite database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-vercel-postgres",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.0",
|
||||
"description": "Vercel Postgres adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/drizzle",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.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 || column.primary) {
|
||||
if (column.primary) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ export const traverseFields = ({
|
||||
const subQueryAlias = `${columnName}_subquery`
|
||||
|
||||
let sqlWhere = eq(
|
||||
sql.raw(`"${currentTableName}"."id"`),
|
||||
adapter.tables[currentTableName].id,
|
||||
sql.raw(`"${subQueryAlias}"."${onPath}"`),
|
||||
)
|
||||
|
||||
@@ -577,23 +577,19 @@ 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(currentIDRaw),
|
||||
value: rawConstraint(currentIDColumn),
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
joinQueryWhere = {
|
||||
[field.on]: {
|
||||
equals: rawConstraint(currentIDRaw),
|
||||
equals: rawConstraint(currentIDColumn),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ type Args = {
|
||||
fields: FlattenedField[]
|
||||
joins: BuildQueryJoinAliases
|
||||
locale?: string
|
||||
parentAliasTable?: PgTableWithColumns<any> | SQLiteTableWithColumns<any>
|
||||
parentIsLocalized: boolean
|
||||
pathSegments: string[]
|
||||
rootTableName?: string
|
||||
@@ -84,7 +83,6 @@ export const getTableColumnFromPath = ({
|
||||
fields,
|
||||
joins,
|
||||
locale: incomingLocale,
|
||||
parentAliasTable,
|
||||
parentIsLocalized,
|
||||
pathSegments: incomingSegments,
|
||||
rootTableName: incomingRootTableName,
|
||||
@@ -164,7 +162,6 @@ export const getTableColumnFromPath = ({
|
||||
table: adapter.tables[newTableName],
|
||||
})
|
||||
}
|
||||
|
||||
return getTableColumnFromPath({
|
||||
adapter,
|
||||
collectionPath,
|
||||
@@ -173,7 +170,6 @@ export const getTableColumnFromPath = ({
|
||||
fields: field.flattenedFields,
|
||||
joins,
|
||||
locale,
|
||||
parentAliasTable: aliasTable,
|
||||
parentIsLocalized: parentIsLocalized || field.localized,
|
||||
pathSegments: pathSegments.slice(1),
|
||||
rootTableName,
|
||||
@@ -552,10 +548,7 @@ export const getTableColumnFromPath = ({
|
||||
// Join in the relationships table
|
||||
if (locale && isFieldLocalized && adapter.payload.config.localization) {
|
||||
const conditions = [
|
||||
eq(
|
||||
(parentAliasTable || aliasTable || adapter.tables[rootTableName]).id,
|
||||
aliasRelationshipTable.parent,
|
||||
),
|
||||
eq((aliasTable || adapter.tables[rootTableName]).id, aliasRelationshipTable.parent),
|
||||
like(aliasRelationshipTable.path, `${constraintPath}${field.name}`),
|
||||
]
|
||||
|
||||
@@ -573,10 +566,7 @@ export const getTableColumnFromPath = ({
|
||||
// Join in the relationships table
|
||||
addJoinTable({
|
||||
condition: and(
|
||||
eq(
|
||||
(parentAliasTable || aliasTable || adapter.tables[rootTableName]).id,
|
||||
aliasRelationshipTable.parent,
|
||||
),
|
||||
eq((aliasTable || adapter.tables[rootTableName]).id, aliasRelationshipTable.parent),
|
||||
like(aliasRelationshipTable.path, `${constraintPath}${field.name}`),
|
||||
),
|
||||
joins,
|
||||
@@ -809,10 +799,9 @@ 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(idColumn, adapter.tables[newTableName].parent),
|
||||
eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
|
||||
eq(adapter.tables[newTableName]._locale, locale),
|
||||
]
|
||||
|
||||
@@ -827,7 +816,7 @@ export const getTableColumnFromPath = ({
|
||||
})
|
||||
} else {
|
||||
addJoinTable({
|
||||
condition: eq(idColumn, adapter.tables[newTableName].parent),
|
||||
condition: eq(adapter.tables[tableName].id, adapter.tables[newTableName].parent),
|
||||
joins,
|
||||
table: adapter.tables[newTableName],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-nodemailer",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.0",
|
||||
"description": "Payload Nodemailer Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-resend",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.0",
|
||||
"description": "Payload Resend Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/graphql",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,9 +487,6 @@ 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.44.0",
|
||||
"version": "3.43.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.44.0",
|
||||
"version": "3.43.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.44.0",
|
||||
"version": "3.43.0",
|
||||
"description": "The official live preview JavaScript SDK for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -67,9 +67,5 @@ export const handleMessage = async <T extends Record<string, any>>(args: {
|
||||
return mergedData
|
||||
}
|
||||
|
||||
if (!_payloadLivePreview.previousData) {
|
||||
_payloadLivePreview.previousData = initialData
|
||||
}
|
||||
|
||||
return _payloadLivePreview.previousData as T
|
||||
return initialData
|
||||
}
|
||||
|
||||
@@ -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}&limit=${ids.size}&where[id][in]=${Array.from(ids).join(',')}${locale ? `&locale=${locale}` : ''}`,
|
||||
`${collection}?depth=${depth}&where[id][in]=${Array.from(ids).join(',')}${locale ? `&locale=${locale}` : ''}`,
|
||||
),
|
||||
serverURL,
|
||||
}).then((res) => res.json())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/next",
|
||||
"version": "3.44.0",
|
||||
"version": "3.43.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -22,10 +22,6 @@
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./css": {
|
||||
"import": "./src/dummy.css",
|
||||
"default": "./src/dummy.css"
|
||||
},
|
||||
".": {
|
||||
"import": "./src/index.js",
|
||||
"types": "./src/index.js",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import type { CollectionSlug } from 'payload'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import { cookies as getCookies } from 'next/headers.js'
|
||||
import { generatePayloadCookie, getPayload } from 'payload'
|
||||
|
||||
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
|
||||
|
||||
@@ -30,7 +31,6 @@ 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,22 +61,27 @@ export async function login({ collection, config, email, password, username }: L
|
||||
loginData = { email, password }
|
||||
}
|
||||
|
||||
const result = await payload.login({
|
||||
collection,
|
||||
data: loginData,
|
||||
})
|
||||
|
||||
if (result.token) {
|
||||
await setPayloadAuthCookie({
|
||||
authConfig,
|
||||
cookiePrefix: payload.config.cookiePrefix,
|
||||
token: result.token,
|
||||
try {
|
||||
const result = await payload.login({
|
||||
collection,
|
||||
data: loginData,
|
||||
})
|
||||
}
|
||||
|
||||
if ('removeTokenFromResponses' in config && config.removeTokenFromResponses) {
|
||||
delete result.token
|
||||
}
|
||||
if (result.token) {
|
||||
await setPayloadAuthCookie({
|
||||
authConfig,
|
||||
cookiePrefix: payload.config.cookiePrefix,
|
||||
token: result.token,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
if ('removeTokenFromResponses' in config && config.removeTokenFromResponses) {
|
||||
delete result.token
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('Login error:', e)
|
||||
throw new Error(`${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
'use server'
|
||||
|
||||
import type { SanitizedConfig } from 'payload'
|
||||
|
||||
import { cookies as getCookies, headers as nextHeaders } from 'next/headers.js'
|
||||
import { createLocalReq, getPayload, logoutOperation } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
||||
|
||||
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 })
|
||||
export async function logout({ config }: { config: any }) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const headers = await nextHeaders()
|
||||
const result = await payload.auth({ headers })
|
||||
|
||||
if (!authResult.user) {
|
||||
return { message: 'User already logged out', success: true }
|
||||
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}`)
|
||||
}
|
||||
|
||||
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,48 +3,40 @@
|
||||
import type { CollectionSlug } from 'payload'
|
||||
|
||||
import { headers as nextHeaders } from 'next/headers.js'
|
||||
import { createLocalReq, getPayload, refreshOperation } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { getExistingAuthToken } from '../utilities/getExistingAuthToken.js'
|
||||
import { setPayloadAuthCookie } from '../utilities/setPayloadAuthCookie.js'
|
||||
|
||||
export async function refresh({ config }: { config: any }) {
|
||||
const payload = await getPayload({ config })
|
||||
const headers = await nextHeaders()
|
||||
const result = await payload.auth({ headers })
|
||||
export async function refresh({ collection, config }: { collection: CollectionSlug; config: any }) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const authConfig = payload.collections[collection]?.config.auth
|
||||
|
||||
if (!result.user) {
|
||||
throw new Error('Cannot refresh token: user not authenticated')
|
||||
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}`)
|
||||
}
|
||||
|
||||
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,6 +28,32 @@ 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 }) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TypedUser } from 'payload'
|
||||
import type { User } 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?: TypedUser
|
||||
user?: User
|
||||
}
|
||||
|
||||
export const handleAuthRedirect = ({ config, route, searchParams, user }: Args): string => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
PayloadRequest,
|
||||
SanitizedConfig,
|
||||
SanitizedPermissions,
|
||||
TypedUser,
|
||||
User,
|
||||
} from 'payload'
|
||||
|
||||
import { initI18n } from '@payloadcms/translations'
|
||||
@@ -37,7 +37,7 @@ type PartialResult = {
|
||||
languageCode: AcceptedLanguages
|
||||
payload: Payload
|
||||
responseHeaders: Headers
|
||||
user: null | TypedUser
|
||||
user: null | User
|
||||
}
|
||||
|
||||
// Create cache instances for different parts of our application
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { TypedUser } from 'payload'
|
||||
import type { User } 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?: TypedUser
|
||||
readonly user?: User
|
||||
}> = ({ apiRoute, user }) => {
|
||||
const { openModal } = useModal()
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { I18n } from '@payloadcms/translations'
|
||||
import type { BasePayload, Config, LanguageOptions, TypedUser } from 'payload'
|
||||
import type { BasePayload, Config, LanguageOptions, User } 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?: TypedUser
|
||||
readonly user?: User
|
||||
}> = (props) => {
|
||||
const { className, i18n, languageOptions, payload, theme, user } = props
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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'
|
||||
@@ -111,6 +112,7 @@ export const getDocumentView = ({
|
||||
}
|
||||
|
||||
// --> /collections/:collectionSlug/:id/api
|
||||
// --> /collections/:collectionSlug/:id/preview
|
||||
// --> /collections/:collectionSlug/:id/versions
|
||||
// --> /collections/:collectionSlug/:id/<custom-segment>
|
||||
case 4: {
|
||||
@@ -123,6 +125,17 @@ 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) {
|
||||
@@ -221,6 +234,7 @@ export const getDocumentView = ({
|
||||
|
||||
case 3: {
|
||||
// --> /globals/:globalSlug/api
|
||||
// --> /globals/:globalSlug/preview
|
||||
// --> /globals/:globalSlug/versions
|
||||
// --> /globals/:globalSlug/<custom-segment>
|
||||
switch (segment3) {
|
||||
@@ -233,6 +247,18 @@ 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,6 +6,7 @@ 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'
|
||||
@@ -49,6 +50,10 @@ 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
|
||||
@@ -84,6 +89,10 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
|
||||
// `/:global/api`
|
||||
fn = generateAPIViewMetadata
|
||||
break
|
||||
case 'preview':
|
||||
// `/:global/preview`
|
||||
fn = generateLivePreviewViewMetadata
|
||||
break
|
||||
case 'versions':
|
||||
// `/:global/versions`
|
||||
fn = generateVersionsViewMetadata
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AdminViewServerProps,
|
||||
CollectionPreferences,
|
||||
Data,
|
||||
DocumentViewClientProps,
|
||||
DocumentViewServerProps,
|
||||
@@ -10,12 +9,7 @@ import type {
|
||||
RenderDocumentVersionsProperties,
|
||||
} from 'payload'
|
||||
|
||||
import {
|
||||
DocumentInfoProvider,
|
||||
EditDepthProvider,
|
||||
HydrateAuthProvider,
|
||||
LivePreviewProvider,
|
||||
} from '@payloadcms/ui'
|
||||
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } 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'
|
||||
@@ -27,7 +21,6 @@ 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'
|
||||
@@ -91,7 +84,6 @@ export const renderDocument = async ({
|
||||
payload: {
|
||||
config,
|
||||
config: {
|
||||
admin: { livePreview: livePreviewConfig },
|
||||
routes: { admin: adminRoute, api: apiRoute },
|
||||
serverURL,
|
||||
},
|
||||
@@ -127,7 +119,6 @@ export const renderDocument = async ({
|
||||
docPreferences,
|
||||
{ docPermissions, hasPublishPermission, hasSavePermission },
|
||||
{ currentEditor, isLocked, lastUpdateTime },
|
||||
entityPreferences,
|
||||
] = await Promise.all([
|
||||
// Get document preferences
|
||||
getDocPreferences({
|
||||
@@ -155,18 +146,8 @@ 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 },
|
||||
@@ -190,7 +171,7 @@ export const renderDocument = async ({
|
||||
fallbackLocale: false,
|
||||
globalSlug,
|
||||
locale: locale?.code,
|
||||
operation,
|
||||
operation: (collectionSlug && idFromArgs) || globalSlug ? 'update' : 'create',
|
||||
renderAllFields: true,
|
||||
req,
|
||||
schemaPath: collectionSlug || globalSlug,
|
||||
@@ -329,22 +310,6 @@ export const renderDocument = async ({
|
||||
viewType,
|
||||
}
|
||||
|
||||
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: (
|
||||
@@ -372,31 +337,24 @@ export const renderDocument = async ({
|
||||
unpublishedVersionCount={unpublishedVersionCount}
|
||||
versionCount={versionCount}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
</DocumentInfoProvider>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CollectionPreferences, ListQuery, ServerFunction, VisibleEntities } from 'payload'
|
||||
import type { ListPreferences, 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: CollectionPreferences
|
||||
preferences: ListPreferences
|
||||
}
|
||||
|
||||
export const renderListHandler: ServerFunction<
|
||||
@@ -92,7 +92,7 @@ export const renderListHandler: ServerFunction<
|
||||
importMap: payload.importMap,
|
||||
})
|
||||
|
||||
const preferencesKey = `collection-${collectionSlug}`
|
||||
const preferencesKey = `${collectionSlug}-list`
|
||||
|
||||
const preferences = await payload
|
||||
.find({
|
||||
@@ -119,7 +119,7 @@ export const renderListHandler: ServerFunction<
|
||||
],
|
||||
},
|
||||
})
|
||||
.then((res) => res.docs[0]?.value as CollectionPreferences)
|
||||
.then((res) => res.docs[0]?.value as ListPreferences)
|
||||
|
||||
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 collectionPreferences = await upsertPreferences<CollectionPreferences>({
|
||||
key: `collection-${collectionSlug}`,
|
||||
const listPreferences = await upsertPreferences<ListPreferences>({
|
||||
key: `${collectionSlug}-list`,
|
||||
req,
|
||||
value: {
|
||||
columns,
|
||||
@@ -120,10 +120,10 @@ export const renderListView = async (
|
||||
|
||||
const page = isNumber(query?.page) ? Number(query.page) : 0
|
||||
|
||||
const limit = collectionPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
|
||||
const limit = listPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
|
||||
|
||||
const sort =
|
||||
collectionPreferences?.sort ||
|
||||
listPreferences?.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 (collectionPreferences?.preset) {
|
||||
if (listPreferences?.preset) {
|
||||
try {
|
||||
queryPreset = (await payload.findByID({
|
||||
id: collectionPreferences?.preset,
|
||||
id: listPreferences?.preset,
|
||||
collection: 'payload-query-presets',
|
||||
depth: 0,
|
||||
overrideAccess: false,
|
||||
@@ -194,7 +194,7 @@ export const renderListView = async (
|
||||
const { columnState, Table } = renderTable({
|
||||
clientCollectionConfig,
|
||||
collectionConfig,
|
||||
columnPreferences: collectionPreferences?.columns,
|
||||
columnPreferences: listPreferences?.columns,
|
||||
columns,
|
||||
customCellProps,
|
||||
docs: data.docs,
|
||||
@@ -230,7 +230,7 @@ export const renderListView = async (
|
||||
data,
|
||||
i18n,
|
||||
limit,
|
||||
listPreferences: collectionPreferences,
|
||||
listPreferences,
|
||||
listSearchableFields: collectionConfig.admin.listSearchableFields,
|
||||
locale: fullLocale,
|
||||
params,
|
||||
@@ -264,7 +264,7 @@ export const renderListView = async (
|
||||
data={data}
|
||||
defaultLimit={limit}
|
||||
defaultSort={sort}
|
||||
listPreferences={collectionPreferences}
|
||||
listPreferences={listPreferences}
|
||||
modifySearchParams={!isInDrawer}
|
||||
orderableFieldName={collectionConfig.orderable === true ? '_order' : undefined}
|
||||
>
|
||||
@@ -278,7 +278,7 @@ export const renderListView = async (
|
||||
disableQueryPresets,
|
||||
enableRowSelections,
|
||||
hasCreatePermission,
|
||||
listPreferences: collectionPreferences,
|
||||
listPreferences,
|
||||
newDocumentURL,
|
||||
queryPreset,
|
||||
queryPresetPermissions,
|
||||
|
||||
@@ -6,7 +6,7 @@ import type React from 'react'
|
||||
|
||||
import { createContext, use } from 'react'
|
||||
|
||||
import type { usePopupWindow } from '../../hooks/usePopupWindow.js'
|
||||
import type { usePopupWindow } from '../usePopupWindow.js'
|
||||
import type { SizeReducerAction } from './sizeReducer.js'
|
||||
|
||||
export interface LivePreviewContextType {
|
||||
@@ -16,10 +16,7 @@ export interface LivePreviewContextType {
|
||||
fieldSchemaJSON?: ReturnType<typeof fieldSchemaToJSON>
|
||||
iframeHasLoaded: boolean
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>
|
||||
isLivePreviewEnabled: boolean
|
||||
isLivePreviewing: boolean
|
||||
isPopupOpen: boolean
|
||||
listeningForMessages?: boolean
|
||||
measuredDeviceSize: {
|
||||
height: number
|
||||
width: number
|
||||
@@ -31,7 +28,6 @@ export interface LivePreviewContextType {
|
||||
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void
|
||||
setHeight: (height: number) => void
|
||||
setIframeHasLoaded: (loaded: boolean) => void
|
||||
setIsLivePreviewing: (isLivePreviewing: boolean) => void
|
||||
setMeasuredDeviceSize: (size: { height: number; width: number }) => void
|
||||
setPreviewWindowType: (previewWindowType: 'iframe' | 'popup') => void
|
||||
setSize: Dispatch<SizeReducerAction>
|
||||
@@ -57,8 +53,6 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
fieldSchemaJSON: undefined,
|
||||
iframeHasLoaded: false,
|
||||
iframeRef: undefined,
|
||||
isLivePreviewEnabled: undefined,
|
||||
isLivePreviewing: false,
|
||||
isPopupOpen: false,
|
||||
measuredDeviceSize: {
|
||||
height: 0,
|
||||
@@ -71,7 +65,6 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
|
||||
setBreakpoint: () => {},
|
||||
setHeight: () => {},
|
||||
setIframeHasLoaded: () => {},
|
||||
setIsLivePreviewing: () => {},
|
||||
setMeasuredDeviceSize: () => {},
|
||||
setPreviewWindowType: () => {},
|
||||
setSize: () => {},
|
||||
@@ -1,14 +1,13 @@
|
||||
'use client'
|
||||
import type { CollectionPreferences, LivePreviewConfig } from 'payload'
|
||||
import type { ClientField, LivePreviewConfig } from 'payload'
|
||||
|
||||
import { DndContext } from '@dnd-kit/core'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
import { fieldSchemaToJSON } from 'payload/shared'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import type { usePopupWindow } from '../usePopupWindow.js'
|
||||
|
||||
import { usePopupWindow } from '../../hooks/usePopupWindow.js'
|
||||
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
|
||||
import { usePreferences } from '../../providers/Preferences/index.js'
|
||||
import { useConfig } from '../Config/index.js'
|
||||
import { customCollisionDetection } from './collisionDetection.js'
|
||||
import { LivePreviewContext } from './context.js'
|
||||
import { sizeReducer } from './sizeReducer.js'
|
||||
@@ -21,76 +20,31 @@ export type LivePreviewProviderProps = {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
isLivePreviewing: boolean
|
||||
operation?: 'create' | 'update'
|
||||
url: string
|
||||
}
|
||||
|
||||
const getAbsoluteUrl = (url) => {
|
||||
try {
|
||||
return new URL(url, window.location.origin).href
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
fieldSchema: ClientField[]
|
||||
isPopupOpen?: boolean
|
||||
openPopupWindow?: ReturnType<typeof usePopupWindow>['openPopupWindow']
|
||||
popupRef?: React.RefObject<Window>
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
breakpoints: incomingBreakpoints,
|
||||
breakpoints,
|
||||
children,
|
||||
isLivePreviewing: incomingIsLivePreviewing,
|
||||
operation,
|
||||
url: incomingUrl,
|
||||
fieldSchema,
|
||||
isPopupOpen,
|
||||
openPopupWindow,
|
||||
popupRef,
|
||||
url,
|
||||
}) => {
|
||||
const [previewWindowType, setPreviewWindowType] = useState<'iframe' | 'popup'>('iframe')
|
||||
const [isLivePreviewing, setIsLivePreviewing] = useState(incomingIsLivePreviewing)
|
||||
|
||||
const breakpoints: LivePreviewConfig['breakpoints'] = useMemo(
|
||||
() => [
|
||||
...(incomingBreakpoints || []),
|
||||
{
|
||||
name: 'responsive',
|
||||
height: '100%',
|
||||
label: 'Responsive',
|
||||
width: '100%',
|
||||
},
|
||||
],
|
||||
[incomingBreakpoints],
|
||||
)
|
||||
|
||||
const [url, setURL] = useState<string>('')
|
||||
|
||||
// This needs to be done in a useEffect to prevent hydration issues
|
||||
// as the URL may not be absolute when passed in as a prop,
|
||||
// and getAbsoluteUrl requires the window object to be available
|
||||
useEffect(
|
||||
() =>
|
||||
setURL(
|
||||
incomingUrl?.startsWith('http://') || incomingUrl?.startsWith('https://')
|
||||
? incomingUrl
|
||||
: getAbsoluteUrl(incomingUrl),
|
||||
),
|
||||
[incomingUrl],
|
||||
)
|
||||
|
||||
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
|
||||
eventType: 'payload-live-preview',
|
||||
url,
|
||||
})
|
||||
|
||||
const [appIsReady, setAppIsReady] = useState(false)
|
||||
const [listeningForMessages, setListeningForMessages] = useState(false)
|
||||
|
||||
const { collectionSlug, globalSlug } = useDocumentInfo()
|
||||
|
||||
const isFirstRender = useRef(true)
|
||||
|
||||
const { setPreference } = usePreferences()
|
||||
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
|
||||
const [iframeHasLoaded, setIframeHasLoaded] = useState(false)
|
||||
|
||||
const { config, getEntityConfig } = useConfig()
|
||||
const { config } = useConfig()
|
||||
|
||||
const [zoom, setZoom] = useState(1)
|
||||
|
||||
@@ -103,12 +57,12 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
width: 0,
|
||||
})
|
||||
|
||||
const entityConfig = getEntityConfig({ collectionSlug, globalSlug })
|
||||
|
||||
const [breakpoint, setBreakpoint] =
|
||||
React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive')
|
||||
|
||||
const [fieldSchemaJSON] = useState(() => fieldSchemaToJSON(entityConfig?.fields || [], config))
|
||||
const [fieldSchemaJSON] = useState(() => {
|
||||
return fieldSchemaToJSON(fieldSchema, config)
|
||||
})
|
||||
|
||||
// The toolbar needs to freely drag and drop around the page
|
||||
const handleDragEnd = (ev) => {
|
||||
@@ -211,21 +165,6 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
}
|
||||
}, [previewWindowType, isPopupOpen, handleWindowChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false
|
||||
return
|
||||
}
|
||||
|
||||
void setPreference<CollectionPreferences>(
|
||||
collectionSlug ? `collection-${collectionSlug}` : `global-${globalSlug}`,
|
||||
{
|
||||
editViewType: isLivePreviewing ? 'live-preview' : 'default',
|
||||
},
|
||||
true,
|
||||
)
|
||||
}, [isLivePreviewing, setPreference, collectionSlug, globalSlug])
|
||||
|
||||
return (
|
||||
<LivePreviewContext
|
||||
value={{
|
||||
@@ -235,16 +174,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
fieldSchemaJSON,
|
||||
iframeHasLoaded,
|
||||
iframeRef,
|
||||
isLivePreviewEnabled: Boolean(
|
||||
(operation !== 'create' &&
|
||||
collectionSlug &&
|
||||
config?.admin?.livePreview?.collections?.includes(collectionSlug)) ||
|
||||
(globalSlug && config.admin?.livePreview?.globals?.includes(globalSlug)) ||
|
||||
entityConfig?.admin?.livePreview,
|
||||
),
|
||||
isLivePreviewing,
|
||||
isPopupOpen,
|
||||
listeningForMessages,
|
||||
measuredDeviceSize,
|
||||
openPopupWindow,
|
||||
popupRef,
|
||||
@@ -253,7 +183,6 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
setBreakpoint,
|
||||
setHeight,
|
||||
setIframeHasLoaded,
|
||||
setIsLivePreviewing,
|
||||
setMeasuredDeviceSize,
|
||||
setPreviewWindowType: handleWindowChange,
|
||||
setSize,
|
||||
@@ -267,7 +196,7 @@ export const LivePreviewProvider: React.FC<LivePreviewProviderProps> = ({
|
||||
}}
|
||||
>
|
||||
<DndContext collisionDetection={customCollisionDetection} onDragEnd={handleDragEnd}>
|
||||
{children}
|
||||
{listeningForMessages && children}
|
||||
</DndContext>
|
||||
</LivePreviewContext>
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
import { useResize } from '@payloadcms/ui'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { useResize } from '../../../hooks/useResize.js'
|
||||
import { useLivePreviewContext } from '../../../providers/LivePreview/context.js'
|
||||
import { useLivePreviewContext } from '../Context/context.js'
|
||||
|
||||
export const DeviceContainer: React.FC<{
|
||||
children: React.ReactNode
|
||||
@@ -16,9 +16,9 @@ export const DeviceContainer: React.FC<{
|
||||
|
||||
// Keep an accurate measurement of the actual device size as it is truly rendered
|
||||
// This is helpful when `sizes` are non-number units like percentages, etc.
|
||||
|
||||
// eslint-disable-next-line react-compiler/react-compiler -- TODO: fix
|
||||
const { size: measuredDeviceSize } = useResize(deviceFrameRef.current)
|
||||
|
||||
// eslint-disable-next-line react-compiler/react-compiler -- TODO: fix
|
||||
const { size: outerFrameSize } = useResize(outerFrameRef.current)
|
||||
|
||||
let deviceIsLargerThanFrame: boolean = false
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
import { useLivePreviewContext } from '../../../providers/LivePreview/context.js'
|
||||
import { useLivePreviewContext } from '../Context/context.js'
|
||||
|
||||
export const DeviceContainer: React.FC<{
|
||||
children: React.ReactNode
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
|
||||
import { useLivePreviewContext } from '../../../providers/LivePreview/context.js'
|
||||
import { useLivePreviewContext } from '../Context/context.js'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'live-preview-iframe'
|
||||
@@ -1,9 +1,8 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
@import '~@payloadcms/ui/scss';
|
||||
|
||||
@layer payload-default {
|
||||
.live-preview-window {
|
||||
background-color: var(--theme-bg);
|
||||
display: none;
|
||||
width: 60%;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
@@ -12,10 +11,6 @@
|
||||
height: calc(100vh - var(--doc-controls-height));
|
||||
overflow: hidden;
|
||||
|
||||
&--is-live-previewing {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2,14 +2,11 @@
|
||||
|
||||
import type { EditViewProps } from 'payload'
|
||||
|
||||
import { ShimmerEffect, useAllFormFields, useDocumentEvents, useLocale } from '@payloadcms/ui'
|
||||
import { reduceFieldsToValues } from 'payload/shared'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { useAllFormFields } from '../../../forms/Form/context.js'
|
||||
import { useDocumentEvents } from '../../../providers/DocumentEvents/index.js'
|
||||
import { useLivePreviewContext } from '../../../providers/LivePreview/context.js'
|
||||
import { useLocale } from '../../../providers/Locale/index.js'
|
||||
import { ShimmerEffect } from '../../ShimmerEffect/index.js'
|
||||
import { useLivePreviewContext } from '../Context/context.js'
|
||||
import { DeviceContainer } from '../Device/index.js'
|
||||
import { IFrame } from '../IFrame/index.js'
|
||||
import { LivePreviewToolbar } from '../Toolbar/index.js'
|
||||
@@ -17,14 +14,11 @@ import './index.scss'
|
||||
|
||||
const baseClass = 'live-preview-window'
|
||||
|
||||
export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
export const LivePreview: React.FC<EditViewProps> = (props) => {
|
||||
const {
|
||||
appIsReady,
|
||||
breakpoint,
|
||||
fieldSchemaJSON,
|
||||
iframeHasLoaded,
|
||||
iframeRef,
|
||||
isLivePreviewing,
|
||||
popupRef,
|
||||
previewWindowType,
|
||||
setIframeHasLoaded,
|
||||
@@ -35,23 +29,21 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
|
||||
const { mostRecentUpdate } = useDocumentEvents()
|
||||
|
||||
const { breakpoint, fieldSchemaJSON } = useLivePreviewContext()
|
||||
|
||||
const prevWindowType =
|
||||
React.useRef<ReturnType<typeof useLivePreviewContext>['previewWindowType']>(undefined)
|
||||
|
||||
const [formState] = useAllFormFields()
|
||||
const [fields] = useAllFormFields()
|
||||
|
||||
// For client-side apps, send data through `window.postMessage`
|
||||
// The preview could either be an iframe embedded on the page
|
||||
// Or it could be a separate popup window
|
||||
// We need to transmit data to both accordingly
|
||||
useEffect(() => {
|
||||
if (!isLivePreviewing) {
|
||||
return
|
||||
}
|
||||
|
||||
// For performance, do no reduce fields to values until after the iframe or popup has loaded
|
||||
if (formState && window && 'postMessage' in window && appIsReady) {
|
||||
const values = reduceFieldsToValues(formState, true)
|
||||
if (fields && window && 'postMessage' in window && appIsReady) {
|
||||
const values = reduceFieldsToValues(fields, true)
|
||||
|
||||
// To reduce on large `postMessage` payloads, only send `fieldSchemaToJSON` one time
|
||||
// To do this, the underlying JS function maintains a cache of this value
|
||||
@@ -81,7 +73,7 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
formState,
|
||||
fields,
|
||||
url,
|
||||
iframeHasLoaded,
|
||||
previewWindowType,
|
||||
@@ -92,17 +84,12 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
fieldSchemaJSON,
|
||||
mostRecentUpdate,
|
||||
locale,
|
||||
isLivePreviewing,
|
||||
])
|
||||
|
||||
// To support SSR, we transmit a `window.postMessage` event without a payload
|
||||
// This is because the event will ultimately trigger a server-side roundtrip
|
||||
// i.e., save, save draft, autosave, etc. will fire `router.refresh()`
|
||||
useEffect(() => {
|
||||
if (!isLivePreviewing) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'payload-document-event',
|
||||
}
|
||||
@@ -116,14 +103,13 @@ export const LivePreviewWindow: React.FC<EditViewProps> = (props) => {
|
||||
if (previewWindowType === 'iframe' && iframeRef.current) {
|
||||
iframeRef.current.contentWindow?.postMessage(message, url)
|
||||
}
|
||||
}, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url, isLivePreviewing])
|
||||
}, [mostRecentUpdate, iframeRef, popupRef, previewWindowType, url])
|
||||
|
||||
if (previewWindowType === 'iframe') {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
baseClass,
|
||||
isLivePreviewing && `${baseClass}--is-live-previewing`,
|
||||
breakpoint && breakpoint !== 'responsive' && `${baseClass}--has-breakpoint`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
@import '~@payloadcms/ui/scss';
|
||||
|
||||
@layer payload-default {
|
||||
.live-preview-toolbar-controls {
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
import type { EditViewProps } from 'payload'
|
||||
|
||||
import { ChevronIcon, LinkIcon, Popup, PopupList, useTranslation, XIcon } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
import { ChevronIcon } from '../../../../icons/Chevron/index.js'
|
||||
import { ExternalLinkIcon } from '../../../../icons/ExternalLink/index.js'
|
||||
import { XIcon } from '../../../../icons/X/index.js'
|
||||
import { useLivePreviewContext } from '../../../../providers/LivePreview/context.js'
|
||||
import { useTranslation } from '../../../../providers/Translation/index.js'
|
||||
import { Popup, PopupList } from '../../../Popup/index.js'
|
||||
import { useLivePreviewContext } from '../../Context/context.js'
|
||||
import { PreviewFrameSizeInput } from '../SizeInput/index.js'
|
||||
import './index.scss'
|
||||
|
||||
@@ -19,7 +15,6 @@ const zoomOptions = [50, 75, 100, 125, 150, 200]
|
||||
export const ToolbarControls: React.FC<EditViewProps> = () => {
|
||||
const { breakpoint, breakpoints, setBreakpoint, setPreviewWindowType, setZoom, url, zoom } =
|
||||
useLivePreviewContext()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const customOption = {
|
||||
@@ -119,11 +114,9 @@ export const ToolbarControls: React.FC<EditViewProps> = () => {
|
||||
e.preventDefault()
|
||||
setPreviewWindowType('popup')
|
||||
}}
|
||||
target="_blank"
|
||||
title="Open in new window"
|
||||
type="button"
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
<LinkIcon />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
|
||||
import { useLivePreviewContext } from '../../../../providers/LivePreview/context.js'
|
||||
import { useLivePreviewContext } from '../../Context/context.js'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'toolbar-input'
|
||||
@@ -1,4 +1,4 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
@import '~@payloadcms/ui/scss';
|
||||
|
||||
@layer payload-default {
|
||||
.live-preview-toolbar {
|
||||
@@ -2,10 +2,10 @@
|
||||
import type { EditViewProps } from 'payload'
|
||||
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { DragHandleIcon } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
import { DragHandleIcon } from '../../../icons/DragHandle/index.js'
|
||||
import { useLivePreviewContext } from '../../../providers/LivePreview/context.js'
|
||||
import { useLivePreviewContext } from '../Context/context.js'
|
||||
import { ToolbarControls } from './Controls/index.js'
|
||||
import './index.scss'
|
||||
|
||||
629
packages/next/src/views/LivePreview/index.client.tsx
Normal file
629
packages/next/src/views/LivePreview/index.client.tsx
Normal file
@@ -0,0 +1,629 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
105
packages/next/src/views/LivePreview/index.tsx
Normal file
105
packages/next/src/views/LivePreview/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
21
packages/next/src/views/LivePreview/metadata.ts
Normal file
21
packages/next/src/views/LivePreview/metadata.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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',
|
||||
})
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
import type React from 'react'
|
||||
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useConfig } from '../providers/Config/index.js'
|
||||
|
||||
export interface PopupMessage {
|
||||
searchParams: {
|
||||
[key: string]: string | undefined
|
||||
@@ -28,11 +27,9 @@ export const usePopupWindow = (props: {
|
||||
const { eventType, onMessage, url } = props
|
||||
const isReceivingMessage = useRef(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const {
|
||||
config: { serverURL },
|
||||
} = useConfig()
|
||||
|
||||
const popupRef = useRef<null | Window>(null)
|
||||
|
||||
// Optionally broadcast messages back out to the parent component
|
||||
@@ -22,11 +22,9 @@ 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,6 +19,11 @@ 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,6 +291,7 @@ 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
|
||||
@@ -316,6 +317,7 @@ 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,6 +144,7 @@ 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
|
||||
@@ -153,6 +154,7 @@ 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,7 +238,6 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
|
||||
<SelectComparison
|
||||
collectionSlug={collectionSlug}
|
||||
docID={originalDocID}
|
||||
globalSlug={globalSlug}
|
||||
onChange={onChangeVersionFrom}
|
||||
versionFromID={versionFromID}
|
||||
versionFromOptions={versionFromOptions}
|
||||
|
||||
@@ -24,12 +24,11 @@ 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, globalSlug } = props
|
||||
const { collectionSlug, docID, drawerSlug } = props
|
||||
const { closeModal } = useModal()
|
||||
const searchParams = useSearchParams()
|
||||
const prevSearchParams = useRef(searchParams)
|
||||
@@ -47,20 +46,12 @@ export const VersionDrawerContent: React.FC<{
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const isGlobal = Boolean(globalSlug)
|
||||
const entitySlug = collectionSlug ?? globalSlug
|
||||
|
||||
const result = await renderDocument({
|
||||
collectionSlug: entitySlug,
|
||||
collectionSlug,
|
||||
docID,
|
||||
drawerSlug,
|
||||
paramsOverride: {
|
||||
segments: [
|
||||
isGlobal ? 'globals' : 'collections',
|
||||
entitySlug,
|
||||
isGlobal ? undefined : String(docID),
|
||||
'versions',
|
||||
].filter(Boolean),
|
||||
segments: ['collections', collectionSlug, String(docID), 'versions'],
|
||||
},
|
||||
redirectAfterDelete: false,
|
||||
redirectAfterDuplicate: false,
|
||||
@@ -84,7 +75,7 @@ export const VersionDrawerContent: React.FC<{
|
||||
|
||||
void fetchDocumentView()
|
||||
},
|
||||
[closeModal, collectionSlug, globalSlug, drawerSlug, renderDocument, searchParams, t],
|
||||
[closeModal, collectionSlug, drawerSlug, renderDocument, searchParams, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,12 +93,11 @@ 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, globalSlug } = props
|
||||
const { collectionSlug, docID, drawerSlug } = props
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@@ -117,12 +107,7 @@ export const VersionDrawer: React.FC<{
|
||||
slug={drawerSlug}
|
||||
title={t('version:selectVersionToCompare')}
|
||||
>
|
||||
<VersionDrawerContent
|
||||
collectionSlug={collectionSlug}
|
||||
docID={docID}
|
||||
drawerSlug={drawerSlug}
|
||||
globalSlug={globalSlug}
|
||||
/>
|
||||
<VersionDrawerContent collectionSlug={collectionSlug} docID={docID} drawerSlug={drawerSlug} />
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -130,11 +115,9 @@ export const VersionDrawer: React.FC<{
|
||||
export const useVersionDrawer = ({
|
||||
collectionSlug,
|
||||
docID,
|
||||
globalSlug,
|
||||
}: {
|
||||
collectionSlug?: string
|
||||
docID?: number | string
|
||||
globalSlug?: string
|
||||
collectionSlug: string
|
||||
docID: number | string
|
||||
}) => {
|
||||
const drawerDepth = useEditDepth()
|
||||
const uuid = useId()
|
||||
@@ -164,14 +147,9 @@ export const useVersionDrawer = ({
|
||||
|
||||
const MemoizedDrawer = useMemo(() => {
|
||||
return () => (
|
||||
<VersionDrawer
|
||||
collectionSlug={collectionSlug}
|
||||
docID={docID}
|
||||
drawerSlug={drawerSlug}
|
||||
globalSlug={globalSlug}
|
||||
/>
|
||||
<VersionDrawer collectionSlug={collectionSlug} docID={docID} drawerSlug={drawerSlug} />
|
||||
)
|
||||
}, [collectionSlug, docID, drawerSlug, globalSlug])
|
||||
}, [collectionSlug, docID, drawerSlug])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -17,14 +17,13 @@ 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, globalSlug })
|
||||
const { Drawer, openDrawer } = useVersionDrawer({ collectionSlug, docID })
|
||||
|
||||
const options = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -3,9 +3,8 @@ import type { PaginatedDocs, SanitizedCollectionConfig } from 'payload'
|
||||
import type { CompareOption } from '../Default/types.js'
|
||||
|
||||
export type Props = {
|
||||
collectionSlug?: string
|
||||
docID?: number | string
|
||||
globalSlug?: string
|
||||
collectionSlug: string
|
||||
docID: number | 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?: TypedUser
|
||||
user?: User
|
||||
}): 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?: TypedUser
|
||||
user?: User
|
||||
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?: TypedUser
|
||||
user?: User
|
||||
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