Compare commits

..

3 Commits

Author SHA1 Message Date
Jacob Fletcher
6be26f6dd5 Merge branch 'main' into chore/sanitized-config-types 2025-06-23 12:29:49 -04:00
Jacob Fletcher
9d5f801488 more 2025-06-18 14:07:03 -04:00
Jacob Fletcher
5f6fad69fb poc 2025-06-18 12:25:54 -04:00
527 changed files with 3209 additions and 14143 deletions

34
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,2 +1,2 @@
pnpm 9.7.1
pnpm 10.12.1
nodejs 23.11.0

View File

@@ -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 },

View File

@@ -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

View File

@@ -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">

View File

@@ -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.

View File

@@ -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.

View File

@@ -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. |

View File

@@ -30,6 +30,7 @@ export const MyCollectionOrGlobalConfig: CollectionConfig = {
// - api
// - versions
// - version
// - livePreview
// - [key: string]
// See below for more details
},

View File

@@ -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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 }),
],
})
```

View File

@@ -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 |

View File

@@ -1,5 +1,5 @@
{
"name": "astro-website",
"name": "website",
"version": "0.0.1",
"type": "module",
"scripts": {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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"
/>
)
}

View File

@@ -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>

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.44.0",
"version": "3.43.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -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)
})
})
})
})

View File

@@ -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
}
}

View File

@@ -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}`)

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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' })
})
})

View File

@@ -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)
}

View File

@@ -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 })

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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
}

View File

@@ -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),
},
}
}

View File

@@ -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],
})

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.44.0",
"version": "3.43.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -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'),
}

View File

@@ -487,9 +487,6 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
graphqlResult.Mutation.fields[`logout${singularName}`] = {
type: GraphQLString,
args: {
allSessions: { type: GraphQLBoolean },
},
resolve: logout(collection),
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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
}

View File

@@ -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())

View File

@@ -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",

View File

@@ -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}`)
}
}

View File

@@ -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 }
}

View File

@@ -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 }
}

View File

@@ -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 }) =>

View File

@@ -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 => {

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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>
),
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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: () => {},

View File

@@ -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>
)

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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;

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
@import '../../../../scss/styles.scss';
@import '~@payloadcms/ui/scss';
@layer payload-default {
.live-preview-toolbar-controls {

View File

@@ -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>
)

View File

@@ -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'

View File

@@ -1,4 +1,4 @@
@import '../../../scss/styles.scss';
@import '~@payloadcms/ui/scss';
@layer payload-default {
.live-preview-toolbar {

View File

@@ -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'

View 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>
)
}

View 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}
/>
)
}

View 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',
})

View File

@@ -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

View File

@@ -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' &&

View File

@@ -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',

View File

@@ -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

View File

@@ -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,

View File

@@ -238,7 +238,6 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
<SelectComparison
collectionSlug={collectionSlug}
docID={originalDocID}
globalSlug={globalSlug}
onChange={onChangeVersionFrom}
versionFromID={versionFromID}
versionFromOptions={versionFromOptions}

View File

@@ -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(
() => ({

View File

@@ -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 [

View File

@@ -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[]

View File

@@ -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