Commit Graph

471 Commits

Author SHA1 Message Date
Alessio Gravili dc3e6664f8 perf!: remove DeepRequired from sanitized config types (#17665)
Follow up of https://github.com/payloadcms/payload/pull/17541

This PR removes `DeepRequired` from `SanitizedConfig`. It uses shallow
`Pick`, `Omit`, and `Required` types so only properties populated during
root config sanitization are required.

## Why

`DeepRequired` recursively processes large admin, field, hook, GraphQL,
and plugin types, adding unnecessary TypeScript checker work.

It was also inaccurate. `admin.autoLogin`, `admin.livePreview`,
`graphQL.mutations`, `graphQL.queries`, `queryPresets`, and
`typescript.postProcess` could be undefined but were typed as required.
Meanwhile, `blocks` is always populated, `auth.jwtOrder` is supplied by
Payload despite previously being required in the incoming `Config`, and
`SanitizedConfig.paths` was typed but never existed at runtime.

The runtime defaults now also preserve the guarantees made by
`SanitizedConfig`: explicitly passing `undefined` cannot erase required
admin, GraphQL, route, TypeScript, or auth defaults, and localization
always receives `localeCodes` and `fallback` when enabled.

## Breaking change

Code using `SanitizedConfig` may now need to handle `undefined` for
properties Payload does not default. `Config.auth.jwtOrder` is now
optional for incoming configs, and the unused `SanitizedConfig.paths`
property has been removed.
2026-08-10 12:41:26 +01:00
Sasha a06593d030 fix(tanstack-start): hmr with payload config changes in dev (#17602)
Editing payload.config.ts, or anything it imports, had no effect until a
manual restart and logged nothing. Vite did re-evaluate the module, but
getPayload returns its cached instance unless a DevReloadStrategy marks
it stale, and endpoint routing reads payload.config off that stale
instance.

From there down this behaves exactly like Next.js: mark the instance
stale, then let the next getPayload call swap in the new config,
regenerate the import map and re-init the db. Only the signal and its
delivery differ.

Next.js gets that signal from defaultNextJsDevReloadStrategy, a
websocket to /_next/webpack-hmr watching for serverComponentChanges.
Nothing serves that path under Vite, so the connect failed and
ws.onerror swallowed it, which is where the silence came from. It is
also coarse: any server component change reloads Payload. The Vite
plugin instead uses the hotUpdate plugin hook, in-process and
socketless, and walks the changed module's importers so only edits that
actually reach the config trigger a reload.

Delivery differs because Next.js needs none. Its strategy is core's
built-in default, so it already covers the getPayload calls made inside
handleEndpoints and createPayloadRequest, which never see InitOptions.
An adapter has no such fallback, so core gains a globalThis-backed
strategy registry to give the plugin that same reach.

<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

## Which branch should this PR target?

- **`main`** — Payload v4 development. New features, enhancements, and
v4 bug fixes go here.
- **`3.x`** — Payload v3 maintenance. Bug fixes only. **New features are
not accepted against v3.** If this PR adds a feature to v3, please
retarget it at `main` (v4) instead.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1216949983441202
2026-08-05 08:53:20 -04:00
Alessio Gravili 555045d132 perf!: 37% faster config sanitization by making it synchronous (#17580)
## What?

This PR makes config sanitization synchronous:

- `sanitizeConfig`, `sanitizeField`, and `sanitizeFields` now return
their result directly instead of a promise.
- Collections and globals are sanitized synchronously.
- Lexical editor config and server features are sanitized synchronously.
- Rich-text fields are sanitized on the spot, instead of queueing up
promises to await at the end.

## Why?

Sanitizing the config is pure CPU work - it never touches a database,
the filesystem, or the network. So marking it `async` gave us nothing in
return, and it made the code harder to follow: every caller had to
`await`, and rich-text fields needed a list of pending promises just to
finish the job later. Returning values directly removes all of that, and
the lower-level sanitizer and editor-config APIs become easier to use
because you get a result immediately.

Being synchronous also sends a clearer signal to anyone writing a
rich-text adapter or a Lexical server feature: setting up a feature
should be cheap. When these callbacks are allowed to return promises, it
invites slow I/O into code that runs on every cold start.

Dropping the promise overhead also speeds up cold starts.

## Performance

Lambda cold-start measurements used a realistic 20-collection project
with deeply nested fields and complex Lexical content.

Cold starts were averaged within each deployment, and the final average
gives all three deployments equal weight.

| Deployment | Main (`4.0.0-canary.19`) | This PR
(`4.0.0-internal.d1cc1d3`) | Change |
| ---------- | --------------------------------: |
--------------------------------------: | -----: |
| 1          | 621.85 ms | 411.63 ms | -33.8% |
| 2          | 859.27 ms | 424.16 ms | -50.6% |
| 3          | 2,248.65 ms | 1,501.03 ms | -33.2% |
| **Average** | **1,243.25 ms** | **778.94 ms** | **-37.3%** |

Deployment 1 and 2 used the same config. Deployment 3 had additional
lexical fields and blocks added. We also measured the change in a
synthetic config-sanitization benchmark. The benchmark uses 925
collections and 15,153 field definitions:

|      | Before | After | Change |
| ---- | -----: | ----: | -----: |
| Mean | 75.29 ms | 69.42 ms | -7.8% |

The synthetic benchmark scales up the fields test suite by duplicating
its fields and was run on an M4 Max. The deployment benchmark instead
uses a realistic large config on a slower Lambda CPU, where the
improvement was more noticeable.

## Breaking change

Most projects are unaffected. You still use `buildConfig(...)` and
`lexicalEditor(...)` exactly as before, and `buildConfig` itself is
still `async`.

You are affected if you:

- Call the lower-level sanitizer APIs yourself.
- Ship a custom rich-text adapter that returns a promise.
- Ship a Lexical server feature callback that returns a promise.

If you call a sanitizer directly, drop the `await`:

```diff
- const sanitizedConfig = await sanitizeConfig(config)
+ const sanitizedConfig = sanitizeConfig(config)
```

Custom rich-text adapters and Lexical server feature callbacks now have
to return their value synchronously. If you were doing slow work or I/O
there, move it either into an async plugin that runs before
sanitization, or into a runtime hook.

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
2026-07-31 17:42:03 +00:00
Jake Fletcher 0776da590d feat: generate static slugs with unique fallback and optional source (#17458)
Makes the native `slug` field static: it's set once on create instead of
continuously tracking its source field. A `<singular>-<N>` fallback
guarantees the slug is present the moment the doc is created — including
on autosave drafts.

This is what live preview needs. The preview loads a document by its
slug on mount — but an autosave collection creates a draft on
navigation, before the user has typed a title. Without a slug at that
point, the preview has nothing to query and the page ultimately 404s.
The fallback gives that initial draft a stable, immediately discoverable
slug the page can query.

This also cuts iframe churn in dynamic live preview URLs. The preview
URL is built from the slug, so every slug change reloads the iframe
`src`. A static slug only changes when you explicitly generate it, so
creating an autosave document no longer thrashes the iframe.

The slug is only ever auto-filled while empty or when you explicitly
regenerate it:

- An empty slug is derived from its source field
- With no source, it falls back to `<singular>-<N>`, where `N` is the
first available integer, e.g. `posts-1`
- Every value is normalized through the field's `slugify`, e.g. `Hello
World` → `hello-world`

**This replaces the previous generate-while-you-type approach during
initial autosave doc creation.**

For context: one goal of #17215 was to remove the extra injected
checkbox field of the old `slugField()` util, which acted as the
auto-generation gate. Without that flag, inferring "follow the title
until the user edits the slug" has to be done statelessly — inherently
flaky, since the server can't reliably tell a genuine edit from an
override.

A static slug removes the guesswork without reintroducing that field:
it's computed once, never recomputed.

This works across localization. A localized slug is unique per locale —
you can reuse the same value across a document's own locales, but not
across documents within one locale. Every locale is populated on create,
so switching locales never lands on a blank slug, and generation is
locale-aware when the source is localized.

### Optional Source

**The `useAsSlug` field property is now optional.**

It previously threw `InvalidConfiguration` at sanitize time when
missing, because a slug had no way to populate itself without a source
field to derive from.

That constraint no longer holds. Every generation path now ends in a
guaranteed-unique fallback. So a slug field is fully functional with no
source: the user types an explicit value (slugified and
uniqueness-checked) or the field auto-increments a collision-free
fallback.

`useAsSlug` is now purely a convenience for deriving a human-readable
slug from a sibling field (e.g. `title`).

 ```ts
 {
  name: 'slug',
  type: 'slug',
  useAsSlug: 'title' // NOW OPTIONAL
}
 ```

### Final Result


https://github.com/user-attachments/assets/d313c4af-f9e3-49c1-91bb-3e43532f62c1
2026-07-24 15:00:30 -04:00
Jessica Rynkar e4ebd90f52 fix(ui): refresh join tables on update (#17366)
## What

Uses existing `useDocumentEvent` provider to sync join tables when
related documents change.

## Why

When a document contains multiple join fields that reference the same
related collection, updates made through one field (create/delete) can
leave the other field stale until a full page reload.

This causes inconsistent UI state and makes it look like changes did not
persist.

Syncing through shared document events ensures all related tables
reflect the same data immediately.


## How

Subscribes join/relationship tables to the latest document event.
2026-07-22 17:04:55 +01:00
Jake Fletcher b24fa023b7 refactor(ui): unify server function dispatch across adapters (#17419)
Removes all TanStack Start-specific server function wrappers,
consolidating server function dispatch into the single shared path every
adapter uses.

Each adapter maintained its own handler registration. Maintained
independently, the two lists were at significant risk of drifting — a
handler could be added to one adapter and silently omitted from the
other.

Now:

- `sharedServerFunctions` is now the single, complete registry
- `createServerFunctionHandler` is the one dispatcher for every adapter.

Adapters inject only what is genuinely framework-specific:

- `initReq` — how the Payload request is resolved (the frameworks do
this differently)
- `transformResult` — response post-processing (TanStack converts React
elements to RSC handles)

A handler added to the registry now reaches every adapter, and the lists
cannot fall out of sync again.
2026-07-21 09:58:07 -04:00
Jake Fletcher db520c1dd9 fix(tanstack-start): prevent duplicate server load on soft navigation to list view (#17286)
Navigating into the list view on TanStack Start runs the route loader
twice.

This is because on load, the list query provider syncs server-resolved
query defaults back to the URL on the client, e.g. `list`, `sort`, etc.
This does not inherently require a server roundtrip as one might expect,
as the data has already been resolved properly on the server. In
Next.js, we achieve this via `window.history.replaceState` which does
not notify Next.js' router of the mutation.

In TanStack Router, however, `history.replaceState` is monkeypatched to
subscribe to all updates, even those outside its own router methods.
This means that during the URL sync, the route loader is called twice,
once on initial load, and again when the URL is mutated.

The solution is to expose a new `replaceState` method to standardize
this behavior across router implementations:

- Next.js calls `window.history.replaceState` directly.
- TanStack wraps it in `history._ignoreSubscribers`, the flag TanStack
sets when flushing its own history, so the router isn't notified.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1216204011123980
2026-07-13 10:06:00 -04:00
Jake Fletcher 41c7976b62 feat!: add native slug field type (#17215)
Adds a first-class `slug` field type, replacing the experimental
`slugField()` helper. A slug is a unique, indexed, URL-friendly string
that identifies a document, auto-generated from another field
(`useAsSlug`, defaults to `title`).

```ts
{
  name: 'slug',
  type: 'slug',
  useAsSlug: 'title',
}
```

The field stores as a single string column across all adapters — no
companion `generateSlug` checkbox. Auto-tracking is now derived
statelessly on each save: the slug follows its source while it equals
`slugify(storedSource)`, and freezes permanently once the admin
overwrites it (the stored value keeps diverging from the source-derived
value). The lock/regenerate UI is preserved, and clicking generate
re-aligns the slug so tracking resumes.

Generation semantics:

- On `create` (non-autosave), the slug generates from the source unless
a value is explicitly provided.
- On `update` without versions, an existing slug is frozen; an empty one
back-fills once.
- For autosave drafts, the slug does **not** generate on the initial
draft (the user is still entering content), begins generating on
subsequent autosaves, and stabilizes after publish to protect live URLs.

`useAsSlug` and `slugify` sit as top-level field props alongside the
standard field options; slug defaults `required`, `unique`, and `index`
to `true` and renders in the `sidebar`. The custom `slugify` function is
resolved server-side and is stripped from the client field config, so it
never crosses the RSC boundary.

## Breaking Changes

This affects anyone importing the experimental `slugField()` helper from
`payload`.

```diff
- import { slugField } from 'payload'
-
  export const Posts: CollectionConfig = {
    fields: [
-     slugField({ useAsSlug: 'title' }),
+     { name: 'slug', type: 'slug', useAsSlug: 'title' },
    ],
  }
```

- The `slugField()` helper and its `SlugField` (function) type are
removed. `SlugField` now refers to the field-config type; `Slugify` and
`SlugFieldClientProps` are still exported.
- The persisted `generateSlug` checkbox (and any custom `checkboxName`)
no longer exists. Generation state is derived, not stored — remove any
config or queries referencing that field.
- Helper args map to field props: `useAsSlug`/`fieldToUse` →
`useAsSlug`, `disableUnique` → `unique: false`, `position` →
`admin.position`, and the `overrides` callback → standard field props
(`label`, `admin`, etc.) set directly on the field.
- `useAsSlug` is now **required** on the `slug` field — there is no
implicit `'title'` default. A collection without the named source field
fails at startup with a clear config error rather than silently
generating an empty slug. The codemod fills in `useAsSlug: 'title'` for
any `slugField()` call that didn't set it, preserving the old helper's
default.

#### Codemod

To migrate automatically, there's a codemod for this change available by
running:

```bash
npx @payloadcms/codemod --transform migrate-slug-field
```

Calls using `overrides` (or other options that can't be mapped cleanly)
are left in place with a note for manual migration.
2026-07-07 16:06:25 -04:00
Sasha 5081ad4786 feat: add TanStack Start framework adapter (#16139)
Adds `@payloadcms/tanstack-start` — the first non-Next.js framework
adapter for Payload's admin panel.

## Background

The framework adapter pattern already landed across four PRs:
- [Server adapter](#16753)
- [Router adapter](#16763)
- [View adapter](#16803)
- [Layout adapter](#16840)

This pushed every Next-specific concern (routing, request init, server
functions, HMR) behind typed contracts in `payload` and made
`@payloadcms/ui` framework-agnostic.

This PR is the payoff: a working adapter built entirely on that
abstraction, proving the admin panel renders on a non-Next stack with no
forks of the UI.

## Motivation

- **Prove the abstraction.** The adapter contracts are only as good as a
second implementation. TanStack Start exercises every seam — a different
renderer, a different server-function transport, a different build tool
— and validates that `@payloadcms/ui` carries no hidden Next.js
assumptions.
- **Meet users where they are.** Not every project is on Next.js.
Decoupling the admin panel opens Payload to the broader React SSR
ecosystem, with TanStack Start as the first proof point.
- **Keep one UI.** Both adapters render the same `@payloadcms/ui`
components and data fetchers. There is no TanStack fork of the admin
panel — only a thin adapter package that satisfies the contracts.

## How it differs from the Next.js adapter

Same UI, different plumbing behind the contracts:

| | Next.js | TanStack Start |
| ---------------- | -------------------------- |
--------------------------------- |
| Server rendering | RSC flight payloads | SSR + route loaders |
| Server functions | `'use server'` actions | `createServerFn` |
| Request init | `next/headers` | `@tanstack/react-start/server` |
| Build / HMR | Webpack / Turbopack | Vite |

## Integration touch points

Wiring Payload into a TanStack Start app is a handful of file routes,
similar to the Next.js app dir shipped since Payload v3:

```
app/
├── __root.tsx                      # root shell — withPayloadRoot swaps in the admin document on /admin
├── _frontend.tsx                   # your app's layout route
├── _frontend/                      # your app's routes
├── _payload.tsx                    # admin layout route — mounts Payload providers
└── _payload/
    ├── admin.index.tsx             # /admin
    ├── admin.$.tsx                 # /admin/* (splat)
    ├── api.$.ts                    # /api/* — Payload REST handlers
    └── server.functions.ts         # config + importMap injection; server functions
```

**Root shell** — This is the highest level touchpoint that affects your
app. In your root route file, add the `withPayloadRoot` shell component:

```tsx
// app/__root.tsx
import { withPayloadRoot } from "@payloadcms/tanstack-start/client";

export const Route = createRootRoute({
  shellComponent: withPayloadRoot(MarketingRoot),
});
```

For all other file contents, see the `app-tanstack` directory in the
monorepo (subject to change). Docs to be provided in the future.

## Status

Experimental. Ships as a new package alongside the Next.js adapter;
nothing in the existing Next path changes at runtime.

---------

Co-authored-by: Jake Fletcher <jacobsfletch@gmail.com>
2026-07-02 17:46:09 -04:00
Alessio Gravili b77493d41a feat!: replace TypedUser with User and add AuthenticatedUser (#17151)
## What

This completes the user-type cleanup planned for Payload 4.0:

- `UntypedUser` was deprecated for removal in 4.0.
- `TypedUser` was marked to be renamed to `User` in 4.0.

Previously, the public `User` type was the loose, deprecated
`UntypedUser`, while `TypedUser` was the generated type for auth-enabled
collections. `ClientUser` was also loose, and `req.user` did not include
the runtime auth fields `_strategy` and `_sid`.

The types now have clear roles:

| Type | Purpose |
| --- | --- |
| `User` | The generated user type for auth-enabled collections. This
replaces `TypedUser`. Without generated types, it falls back to a
documented shape containing Payload's built-in auth fields. Contains
both read and write fields. |
| `AuthenticatedUser` | `User` plus the optional runtime fields
`_strategy` and `_sid`. Used by `PayloadRequest.user`, `payload.auth()`,
auth strategy results, and auth internals. |
| `ClientUser` | The type used by `useAuth().user` and `me` responses.
It is now an alias of `AuthenticatedUser` |

I'm considering replacing `ClientUser` in favor of just
`AuthenticatedUser` in a separate PR.

## Breaking changes

- `TypedUser` has been removed. Use `User`.
- `UntypedUser` has been removed. Use `User` for a user document,
`AuthenticatedUser` for a signed-in request user, or `ClientUser` in
client code.
- `User` and `ClientUser` no longer have an `[key: string]: any` index
signature. Custom auth-collection fields require generated types or an
explicit augmented type.
- The Local API `user` option is now `User | null` instead of the loose
`Document` type for:
- collection `count`, `create`, `delete`, `duplicate`, `find`,
`findByID`, `findDistinct`, and `update`
- collection and global version count, find, find-by-ID, and restore
operations
  - global `findOne` and `update`
- `UserSession.createdAt` is now optional and nullable: `createdAt?:
Date | null | string`. This matches generated session types, but callers
must handle a missing value.

`AuthenticatedUser` is assignable to `User`, so passing `req.user` to
these Local API operations continues to work.

## Other changes

- Adds a strict untyped fallback containing all built-in user fields,
without an index signature. A type test verifies that generated user
types are assignable to this fallback.
- Types `payload.auth()` and login results with the runtime auth fields,
and uses `AuthenticatedUser` while login, `me`, refresh, and session
code build signed-in users.
- Fixes the session lookup ID type and updates session handling for
nullable `createdAt` values.
- Hardens refresh/session handling when a user or session is missing and
avoids mutating the in-memory user's `updatedAt` solely to control
database timestamps.
- Updates Payload, the admin UI, Lexical, tests, and first-party plugins
to use the new types:
  - `plugin-mcp` uses `User` for the authorized caller.
  - `plugin-import-export` removes obsolete `req.user.user` handling
- `plugin-multi-tenant` explicitly casts accesses to plugin-defined user
fields.
- `plugin-ecommerce` adds `UserWithCart` for the optional reverse `cart`
join that projects may
    define on their user collection.


## Migration

```diff
- import type { TypedUser, UntypedUser } from 'payload'
+ import type { User } from 'payload'
```

Use `User` for stored/read user documents and `AuthenticatedUser` when
code specifically receives the signed-in user from `req.user`,
`payload.auth()`, or an auth strategy.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1215866573673868
2026-07-02 16:13:05 -04:00
Alessio Gravili 02748a463a feat: generate separate input and output types for collections and globals (#17075)
Payload generated exactly **one** TypeScript interface per
collection/global, and it was an **output (read) shape**. The same type
was used for what you read back (`find`, `findByID`) and what you write
(`create`, `update`) - which is inaccurate for writes, and impossible to
fix after the fact for the most important case (relationship/upload
**depth**, a runtime argument).

This PR adds a second, write-shaped type per entity:

- `Post` - the **output** type (unchanged, fully backwards compatible)
- `PostInput` - the **input** type: relationships ID-only, no
auto-managed/virtual/join fields, `defaultValue` fields optional

On by default; set `typescript.generateInputTypes: false` to skip them.
Output types are byte-for-byte unchanged either way.
`@payloadcms/plugin-mcp` consumes the input shape directly (letting us
delete the schema post-processing it used to reconstruct it), and
consumers can opt into `PostInput` to strictly type writes. **The Local
API's `create`/`update` deliberately keep the read shape for now** -
wiring them to the input shape is a breaking change which would break
read-modify-write - see the dedicated section below.

## Why we need this - input ≠ output in more than just relationships

It's tempting to assume input and output differ only by relationship
population. They don't - there are 7 differences:

| # | Category | Output type | Input type | Why they differ | Type test
|
| --- | -------------------------------------- |
-------------------------------- | ---------------- |
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| --- |
| 1 | **Relationship / upload** | `(string \| null) \| User` | `string
\| null` | Depth can populate on read; you only ever _write_ an ID.
Covers single, `hasMany`, and the `value` of polymorphic `{ relationTo,
value }`. | [L1358](test/types/types.spec.ts#L1358) |
| 1b | **Rel/upload nodes inside `richText`** | populated node | ID-only
node | Lexical is variant-aware: input emits
`SerializedRelationshipNodeInput`/`SerializedUploadNodeInput` + `…Input`
blocks — see _Rich text_ below. |
[L1373](test/types/types.spec.ts#L1373) |
| 2 | **`id`** | required | optional | Read always has it; on create you
may supply a custom ID or let Payload generate one. |
[L1378](test/types/types.spec.ts#L1378) |
| 3 | **`createdAt` / `updatedAt`** | required | omitted | Auto-managed.
| [L1383](test/types/types.spec.ts#L1383) |
| 4 | **`_status`** (drafts) | present | omitted | Managed by the
versions/drafts system (set via the `draft` param, not data). |
[L1390](test/types/types.spec.ts#L1390) |
| 5 | **`defaultValue` fields** | required / present | **optional** |
The default fills it in if omitted. A `required` field _with_ a default
was wrongly mandatory before. | [L1395](test/types/types.spec.ts#L1395)
|
| 6 | **Virtual fields** | present | omitted | Computed /
relationship-derived, read-only. |
[L1400](test/types/types.spec.ts#L1400) |
| 7 | **Join fields** | present (paginated related docs) | omitted |
Computed from the _inverse_ relationship; not writable. |
[L1405](test/types/types.spec.ts#L1405) |
| 8 | **Auth `collection` discriminator** | present | omitted | The
read-side `User`-union discriminator; not part of create/update data. |
[L1410](test/types/types.spec.ts#L1410) |

## Before / after

Config:

```ts
{
  slug: 'posts',
  fields: [
    { name: 'title', type: 'text', required: true },
    { name: 'status', type: 'select', options: ['draft', 'published'], defaultValue: 'draft', required: true },
    { name: 'author', type: 'relationship', relationTo: 'authors' },
    { name: 'categories', type: 'relationship', relationTo: 'categories', hasMany: true },
  ],
}
```

**`Post` (output)** - unchanged:

```ts
export interface Post {
  id: string
  title: string
  status: 'draft' | 'published'
  author?: (string | null) | Author
  categories?: (string | Category)[] | null
  updatedAt: string
  createdAt: string
}
```

**`PostInput` (input)** - new:

```ts
export interface PostInput {
  id?: string // optional - custom ID or Payload-generated (optional, but never null)
  title: string // required (no default)
  status?: 'draft' | 'published' // optional now - has a defaultValue (optional, but never null)
  author?: string | null // ID only
  categories?: string[] | null // array of IDs only
  // no createdAt / updatedAt
}
```

The `Config` type exposes the input shapes alongside the existing maps:

```ts
export interface Config {
  collections: { posts: Post /* … */ }
  collectionsInput: { posts: PostInput /* … */ }
  globals: { menu: Menu /* … */ }
  globalsInput: { menu: MenuInput /* … */ }
  // …
}
```

## Implementation

A single `variant: 'input' | 'output'` (default `'output'`) is threaded
through the existing schema builders in
[configToJSONSchema.ts](packages/payload/src/utilities/configToJSONSchema.ts).

- **`fieldsToJSONSchema`** - relationship/upload fields emit ID-only
schemas for input; a field with a `defaultValue` is optional for input;
virtual and join fields are skipped; named interfaces
(groups/tabs/arrays/selects/blocks) get an `Input` suffix **only when
their write shape actually differs** - if byte-identical to the
read-shaped def they're shared (no redundant `MetaInput` twin for a
relationship-free `Meta`).
- **`entityToJSONSchema`** - for input: `id` is optional,
`createdAt`/`updatedAt`/`_status` are dropped, the auth `collection`
discriminator is dropped, and the title gets an `Input` suffix.
**Nullability follows the read shape**: a field the input only makes
_optional_ (`id`, `defaultValue` fields) stays non-null (`id?: string`,
not `id?: string | null`), so `*Input` is a true subset of the read type
- a `PostInput` value needs to be assignable to `create`/`update`
`data`.
- **`registerBlockInterface`** / **`entityToStandaloneJSONSchema`** -
take the `variant`. A `blocks` field yields both `Hero` (output) and
`HeroInput` (input) **when the block differs**; a relationship-free
block shares one `Hero` def.
- **`configToJSONSchema`** - unless `typescript.generateInputTypes` is
`false`, also emits `${slug}_input` defs and adds `collectionsInput` /
`globalsInput` to the `Config` schema. `json-schema-to-typescript`
compiles these to `PostInput`, `MenuInput`, etc.
- **Field-level `field.jsonSchema` transforms** receive `variant`, so a
custom transform can produce a different shape for input vs output.
- **The rich-text editor `jsonSchema` callback** receives `variant` —
see _Rich text_.
- A new public `SchemaVariant` type and the `generateInputTypes` config
flag are exported.

### Rich text

Lexical is fully variant-aware. Its generated TypeScript comes from
named `Serialized*Node` types via `tsType`:

```ts
LexicalNodes_...Input = … | SerializedParagraphNode<LexicalNodes_...Input>    // recursion auto-resolves to the input union
                      | SerializedBlockNode<CtaInput>                 // reused generic, input block fields
                      | SerializedUploadNodeInput<"media">;           // ID-only
```

Only the two node types that **bake** the populated arm into their
string — `SerializedRelationshipNode` and `SerializedUploadNode` - get a
hand-written id-only twin (`SerializedRelationshipNodeInput` /
`SerializedUploadNodeInput`). Everything else (text, paragraph, heading,
list, table, link, **block**) is reused. `variant` is threaded through
`getFieldToJSONSchema` and the relationship/upload/blocks/link feature
schemas.

Both also stop hardcoding `number | string` for the `value`: output
nodes emit `Config['collections'][TSlug]['id'] |
Config['collections'][TSlug]` and input nodes emit
`Config['collections'][TSlug]['id']`, so each collection's real ID type
is used (a new exported `IDTypeForCollectionSlug<TSlug>` helper backs
the runtime types).

The node union is content-hashed; an input-specific union is named
`LexicalNodes_<hash>_Input` so it's distinguishable from output unions
in the generated file, while a relationship-free editor - whose input
and output content hash identically - **shares a single**
`LexicalNodes_<hash>` (no `_Input` twin). Same principle for blocks and
named interfaces: **`Input`-suffixed only when the write shape genuinely
differs.**

### MCP cleanup


[`@payloadcms/plugin-mcp`](packages/plugin-mcp/src/utils/schemaConversion)
now calls `entityToStandaloneJSONSchema({ …, variant: 'input' })`, and
**all** the correctness post-processing is gone - the input variant
produces zero collection `$ref`s anywhere.

- **Deleted** `removeVirtualFieldsFromSchema`
- **Deleted** `removeManagedFields` (the input variant omits
`id`-managed fields).
- **Deleted** `relationshipsToIds` - now that lexical emits ID-only
relationship/upload values for input, no `oneOf: [id, $ref]` survives
for it to reduce.
- **Kept** only the genuinely MCP-specific ergonomic/size transforms
(point→object, const-union merge, dedup, name shortening).

## `create` / `update` deliberately keep the read shape (and do **not**
use the input types - yet)

The generated `*Input` types are **available** and consumed by MCP, but
the Local API - `payload.create` / `update` / `updateByID` / `duplicate`
/ `updateGlobal` - intentionally keeps typing its `data` against the
**read** shape (`Post`, not `PostInput`). Routing `data` through the
input shape was evaluated and I decided against it for these reasons:

1. **Read-modify-write is ubiquitous and valid at runtime.** Reading a
document and writing part of it back is one of the most common patterns
in a CMS. With input-typed `data` it stops compiling whenever the
document was read with `depth > 0`:

   ```ts
const post = await payload.findByID({ collection: 'posts', id, depth: 1
})
   // depth: 1 → post.author is the *populated* User document, not an ID
   await payload.update({
     collection: 'posts',
     id,
data: { author: post.author }, // ❌ input-typed data wants an ID, not a
populated doc
   })
   ```

Spreads (`data: { ...post, title }`) and rich text (`data: { richText:
post.richText }`) hit the same wall.

2. **It would be stricter than the runtime.** Payload accepts a
populated relationship on write and extracts its ID. Rejecting that in
the types rejects code that actually works - which pushes people toward
`as any`

We can consider moving our local API to use these new types in the
future, as this will need more thought. The new types can still safely
be used, as they are assignable to what the local API expects. Routing
the Local API's `data` through the input shape can be revisited in a
follow-up

### Where the input types are used safely

- **`@payloadcms/plugin-mcp`** consumes the input _schema_ directly
- **Opt-in for consumers**: `Config['collectionsInput']['posts']` and
the exported `PostInput` are there for anyone who wants to strictly type
a write helper, a form payload, or a seed script.

## Other comments

- **Server-managed fields stay in the write shape, as optional - by
design.** Upload metadata (`url`, `filename`, `sizes`, …) and auth
internals (`salt`, `hash`, `resetPasswordToken`, …) remain in `*Input`.
Omitting them was explored and deliberately rejected: they're
server-managed under normal access but **genuinely writable under
`overrideAccess`** (field access is bypassed), so the only "never
written" signal available - field-level `access: () => false` - is
leaky. Input types should contain all properties no matter if
overrideAccess is required or not. The input type is the **general**
write shape: anything writable in any mode stays, optional. Only *truly*
non-writable fields - `virtual` and `join` - are omitted, because no
access mode can write those.
- **Default-on.** `generateInputTypes` defaults to `true`, so
regenerating any project's types adds the input shapes. It's additive
(existing `Post`/`collections` consumers are untouched), only growing
the generated file, and should thus not be breaking; pass `false` to
skip. All `test/**/payload-types.ts` are regenerated in this PR


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1215338403644498
2026-06-29 15:35:27 -04:00
Paul 1564b590a1 feat(ui): redesign uploads document view and add support for file-specific previews (#17044)
## What

Redesigns the admin UI for upload-enabled collections, moving from the
stacked single-column layout to a side-by-side document view: file
management on the right, document fields on the left.

### Highlights

- **Side-by-side layout** — upload collection documents now render a
dedicated file side panel (`__upload-layout`) alongside the fields,
instead of stacking the upload above the fields.
- **New `FileManager` element** replaces the inline `Upload` in the edit
view, encapsulating the dropzone, preview, and toolbar.
- **Mini carousel** for switching between the original file and its
generated image sizes.
- **Type-aware previews** — dedicated preview components for images,
audio, video, and PDFs, with a fallback thumbnail for everything else.
- **File toolbar** with quick actions: open the current asset in a new
tab, download, crop/edit image, replace, and rename.
- **Upload-from-URL** and **rename-file** modals.
- New `Crop` and `Download` icons.
- **`TextInput` now accepts an optional `id` prop** (`@payloadcms/ui`) —
overrides the input's DOM `id` and its label's `htmlFor` (defaults to
`field-${path}`), so multiple inputs bound to the same `path` no longer
collide on a duplicate element id. The redesigned `FileManager` filename
editor uses it (`id="field-filemanager-filename"`) to avoid clashing
with the hidden auto-generated `filename` field.
- **`NumberInput` now accepts optional `prefix`/`suffix` props** to help
with with displaying text before and after the input itself


### New config API

Adds `upload.admin.components.filePreview`, letting collections override
the side-panel preview:

```ts
// single component for all files
upload: {
  admin: {
    components: {
      filePreview: '/components/MyPreview#MyPreview',
    },
  },
}

// or a MIME-type keyed map
upload: {
  admin: {
    components: {
      filePreview: {
        'video/*': '/components/VideoPreview#VideoPreview',
        'application/pdf': '/components/PdfPreview#PdfPreview',
        '*': '/components/Fallback#Fallback',
      },
    },
  },
}
```

Resolution priority for the map is **exact match → category wildcard
(`video/*`) → universal fallback (`*`)**, falling back to the default
thumbnail when nothing matches. A new `matchMimeType` helper (exported
from `payload/shared`) implements this, a `UploadFilePreview` document
slot and `UploadFilePreviewClientProps` type are added, and the
import-map generator now picks up `filePreview` components.

### i18n

Adds translation keys: `general:original`, `upload:fromURL`,
`upload:linkToFile`, `upload:renameFile`, `upload:replaceFile`.

## Notes

- Custom `Upload` components (`BeforeFields` / `CustomUpload`) continue
to render in the legacy single-column path, so existing overrides are
unaffected.



https://github.com/user-attachments/assets/afba06aa-02c7-4573-88ef-672c5a0341da

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
2026-06-25 22:41:28 -04:00
Alessio Gravili 6fe43ba2d6 feat(plugin-mcp)!: redesign API key management (#16986)
This PR improves MCP API key management in the admin panel.

## Admin redesign

MCP API keys move out of the main nav and into the user menu under
**Settings → Manage API keys**.

<img width="2962" height="2070" alt="screenshot 2026-06-14 at 18 52
33@2x"
src="https://github.com/user-attachments/assets/7c9bbdb0-4c6b-43af-8799-2bc352e633c9"
/>

<img width="858" height="606" alt="screenshot 2026-06-12 at 14 46 02@2x"
src="https://github.com/user-attachments/assets/79721def-7c2f-4fbc-8628-96019b45d03c"
/>

<img width="2416" height="634" alt="screenshot 2026-06-12 at 14 46
30@2x"
src="https://github.com/user-attachments/assets/e2b502c7-d160-464b-aa6f-c1dd26b25bef"
/>

## API key collection

The MCP API key collection is no longer auth-enabled.

MCP keys still belong to a Payload user, but the key collection itself
is no longer treated like a login-capable user collection. That keeps
generated auth user types cleaner.

## Migration

Existing MCP API keys should be recreated after upgrading. The
collection slug is unchanged, but the API key fields and access settings
changed.

The public `baseAPIKeyFields` export was replaced by
`createAPIKeyFields()`.
2026-06-15 12:06:28 +01:00
Jarrod Flesch 6ca20012cb fix(ui): convert Version SCSS to CSS and fix linked-cell button styling (#16983) 2026-06-12 14:37:36 -04:00
Jake Fletcher f2ea90a1fa fix(ui): respect admin.condition on row fields (#16981)
Same as https://github.com/payloadcms/payload/pull/16954, applied to
4.x.

Rows aren't respecting their admin.condition property.

This is because `path` was never forwarded to the row field, so its
`withCondition` wrapper looked up `passesCondition` at an undefined
path. The row fields never become hidden no matter what the
`admin.condition` evaluates to.

## Before


https://github.com/user-attachments/assets/3b859409-16ba-42c8-91ad-f82fca01ccd9

## After


https://github.com/user-attachments/assets/30cd5edc-f32c-447f-b74a-0cd95d13af2c

Co-authored-by: Amelia <44613453+LimChorngUan@users.noreply.github.com>
2026-06-12 13:02:55 -04:00
Alessio Gravili d4944285af feat(richtext-lexical)!: type-safe lexical schemas and generated types (#16782)
This PR makes `richText` fields properly typed in `payload-types.ts`.
Until now, every `richText` field was typed as a vague `{ [k: string]:
unknown }` blob. TypeScript couldn't help you do anything with rich text
content, and you had to manually type data you get from payload using
our `TypedEditorState` helpers.

Now, payload generates fully-typed editor state based on the nodes
enabled in a given richText editor. In order to deduplicate as many
types as possible, nodes generate their own individual, shared
interfaces (`SerializedTextNode`, `SerializedHeadingNode`,
`SerializedBlockNode`) that can be customized using generics. The
richText field is typed as a union of all the nodes its editor uses.

## What the generated types look like

**Before.**

```ts
content?: {
  [k: string]: unknown;
  root: { [k: string]: unknown };
} | null;
```

**After.**

```ts
content?: LexicalRichText<LexicalNodes_0BDB72B5> | null;

export type LexicalNodes_0BDB72B5 =
  | SerializedTextNode
  | SerializedTabNode
  | SerializedLineBreakNode
  | SerializedParagraphNode<LexicalNodes_0BDB72B5>
  | SerializedHorizontalRuleNode
  | SerializedUploadNode<'uploads' | 'uploads2'>
  | SerializedQuoteNode<LexicalNodes_0BDB72B5>
  | SerializedRelationshipNode<'posts' | 'users' | /* ... */>
  | SerializedAutoLinkNode<LexicalNodes_0BDB72B5>
  | SerializedLinkNode<LexicalNodes_0BDB72B5>
  | SerializedListNode<LexicalNodes_0BDB72B5>
  | SerializedListItemNode<LexicalNodes_0BDB72B5>
  | SerializedHeadingNode<LexicalNodes_0BDB72B5>;
```

The union name is a hash of its own contents, so two fields with the
same set of nodes share one alias instead of duplicating.

Relationship nodes only list non-upload collections - upload-enabled
collections show up under `SerializedUploadNode` instead, so they don't
appear in the relationship union.

## What this means for your app

The stored data shape hasn't changed, so there's nothing to migrate. You
regenerate `payload-types.ts` and your data still parses.

The types are stricter now, though, so TypeScript can start flagging
rich text code that used to slip through:

- Reading a nested node (e.g. `node.children`) without first narrowing
on `node.type` will error. Narrow by `type` and you get real
autocomplete.
- Reading arbitrary keys off rich text content no longer works - the old
fully-loose `{ [k: string]: unknown }` shape is gone.

## `TypedEditorState` / `DefaultTypedEditorState` are stricter

These are the helpers you use to hand-type rich text (converters, custom
renderers, and so on).

Element nodes are now generic over their `children`, and you pass the
node union into them yourself. Before, `TypedEditorState` rewrote each
node's `children` into the recursive union for you (an internal
`RecursiveNodes` helper, capped at a fixed depth). Now
`TypedEditorState<T>` uses `T` as-is, so a node's `children` come
straight from the generic you give it:

```diff
  import type {
    SerializedParagraphNode,
    SerializedTextNode,
    TypedEditorState,
  } from '@payloadcms/richtext-lexical'

- type MyNodes = SerializedParagraphNode | SerializedTextNode
+ type MyNodes = SerializedParagraphNode<MyNodes> | SerializedTextNode

  function renderRichText(state: TypedEditorState<MyNodes>) {
    // ...
  }
```

`SerializedParagraphNode` is an element node, so it takes the union
(`<MyNodes>`) to type its children. `SerializedTextNode` is a leaf with
no children, so it stays bare.

For the common case - the built-in nodes plus a few of your own -
`DefaultNodeTypesOf` does the threading for you, and
`DefaultTypedEditorState` with only built-in nodes needs no change:

```ts
type MyNodes = DefaultNodeTypesOf<MyNodes> | SerializedBlockNode<MyBlockData>
```

**Why the generic?** Rich text is a tree, so a node's children are nodes
from the same union - but that union is built out of the nodes, so it's
circular and a node can't just name "the union it belongs to". Making
each node generic over its children and having the union pass itself
breaks the cycle and types the tree at any depth. The old
`RecursiveNodes` helper expanded children a fixed number of levels and
then gave up; this has no depth limit.

## Breaking changes (custom adapters / features / type-gen scripts)

The rest only matters if you wrote a custom rich-text adapter, your own
type-generation script, or a custom lexical feature.

### `configToJSONSchema` returns an object now

It used to return a `JSONSchema4`. It now returns `{ jsonSchema,
typeStringDefinitions }`.

```diff
- const schema = configToJSONSchema(sanitizedConfig, 'text')
+ const { jsonSchema: schema, typeStringDefinitions } = configToJSONSchema(sanitizedConfig, 'text')
```

### `fieldsToJSONSchema` takes one object instead of 6 positional args

```diff
- fieldsToJSONSchema(
-   collectionIDFieldTypes,
-   fields,
-   interfaceNameDefinitions,
-   config,
-   i18n,
-   { forceInlineBlocks: true },
- )
+ fieldsToJSONSchema({
+   collectionIDFieldTypes,
+   config,
+   fields,
+   forceInlineBlocks: true,
+   i18n,
+   interfaceNameDefinitions,
+   typeStringDefinitions,
+ })
```

### `entityToJSONSchema` got a new required argument

`typeStringDefinitions` is now a required positional argument at
position 5. The old `opts` object becomes an optional
`forceInlineBlocks?: boolean` at the end.

```diff
  entityToJSONSchema(
    config,
    entity,
    interfaceNameDefinitions,
    defaultIDType,
+   typeStringDefinitions,
    collectionIDFieldTypes,
    i18n,
-   { forceInlineBlocks: true },
+   true,
  )
```

### Custom lexical features: `generatedTypes.modifyJSONSchema` is gone

Features used to contribute types by mutating the whole field schema
after the fact, through `generatedTypes.modifyJSONSchema` (and the
sanitized `modifyJSONSchemas` array). That's removed. Each node now
contributes its own schema through a `jsonSchema` function on
`createNode`, and the editor stitches them into the union for you (see
[How features contribute types](#how-features-contribute-types)).

```diff
  export const MyFeature = createServerFeature({
    feature: () => ({
-     generatedTypes: {
-       modifyJSONSchema: ({ currentSchema, interfaceNameDefinitions }) => currentSchema,
-     },
      nodes: [
        createNode({
          node: MyNode,
+         jsonSchema: ({ elementNodeSchema, nodeUnionName, typeStringDefinitions }) => {
+           typeStringDefinitions.add(`export interface SerializedMyNode<TChildren> { /* ... */ }`)
+           return elementNodeSchema({ nodeType: 'my', tsType: `SerializedMyNode<${nodeUnionName}>` })
+         },
        }),
      ],
    }),
  })
```

A node without a `jsonSchema` function falls back to `{ [k: string]:
unknown }`, so leaving it off is fine - that node just stays loosely
typed.

### Lexical: registering the same node twice now throws

`sanitizeServerFeatures` rejects two features registering the same node
type. Before, it silently kept the last one.

## How features contribute types

Each feature attaches a `jsonSchema` function to its node via
`createNode`. The function gets a helper for the shared element shape
and a `Set<string>` it can dump raw TS source into:

```ts
const SERIALIZED_QUOTE_NODE_TS = `export interface SerializedQuoteNode<TChildren> extends SerializedLexicalElementBase<TChildren> {
  type: 'quote';
}`

export const quoteNodeJSONSchema: JSONSchemaFn = ({
  elementNodeSchema,
  nodeUnionName,
  typeStringDefinitions,
}) => {
  typeStringDefinitions.add(SERIALIZED_QUOTE_NODE_TS)
  return elementNodeSchema({
    nodeType: 'quote',
    tsType: `SerializedQuoteNode<${nodeUnionName}>`,
  })
}
```

The same TS source string from many nodes only lands in the output once
- `Set<string>` deduplicates for free. Nodes without `jsonSchema` stay
as `{ [k: string]: unknown }`, so features can opt in node by node.

## Internal refactor changes

- Shared lexical types live in `types/builtInNodes.ts`
(`SerializedLexicalElementBase`, `LexicalElementFormat`,
`LexicalRichText`, …). Per-node helpers live next to their schemas under
`features/*/server/schema.ts`. `nodeTypes.ts` re-exports from the new
locations.
- For the MCP plugin, `payload` now exports
`entityToStandaloneJSONSchema`, which builds a self-contained schema for
a single collection/global (the entity plus only the definitions it
uses) instead of slicing the whole-config schema.
2026-06-05 17:58:03 +00:00
Jake Fletcher 95d1422ef4 feat!: admin view adapter (#16803)
Moves all admin views out of `@payloadcms/next` into `@payloadcms/ui`
and introduces an `AdminViewAdapter` contract so framework adapters can
plug into a shared view surface.

The `@payloadcms/next` package is now functionally an admin framework
adapter. It bootstraps Next-specific features and binds those deps to
ui-side renderers. The `views/` directory is gone. Page entrypoints and
metadata are now a thin shell over ui exports.

The adapter map is strongly typed:

```ts
// packages/payload/src/admin/adapters/views.ts
export type AdminViewKey =
  | 'account'
  | 'createFirstUser'
  | 'dashboard'
  | 'forgot'
  | 'login'
  | 'logout'
  | 'logoutInactivity'
  | 'notFound'
  | 'reset'
  | 'unauthorized'
  | 'unauthorizedWithGutter'
  | 'verify'

export type AdminView<TComponentProps = any, TMetadata = unknown> = {
  Component: React.ComponentType<TComponentProps>
  generateMetadata: (args: Parameters<GenerateMetadataDescriptor>[0]) => Promise<TMetadata>
}

export type AdminViewAdapter<TComponentProps = any, TMetadata = unknown> = Record<
  AdminViewKey,
  AdminView<TComponentProps, TMetadata>
>
```

## Implementing a Framework Adapter

A framework adapter has two pieces of view surface:

1. The **`adminViews` map** — every entry in `AdminViewAdapter`
(account, dashboard, logout, etc.). The map is consumed by `renderRoot`
(in ui) to dispatch to the correct view based on the current admin
route.
2. **Page entrypoints** — the `RootPage` and `NotFoundPage` functions
that the host framework's router calls. These are **not** part of the
adapter map; they are direct exports that the framework's page file
imports.

`@payloadcms/next` wires both in `packages/next/src/adapters/views.tsx`:

```tsx
// 1. Bind Next's `initReq` to the framework-specific server adapter once,
// then share across all renderers.
const boundInitReq = (args) => initReq({ ...args, serverAdapter: nextServerAdapter })

// 2. Implement the strongly-typed adapter map. Missing or misspelled keys are
// a compile error.
export const adminViews: AdminViewAdapter<AdminViewServerProps, MetaConfig> = {
  account: { Component: AccountView, generateMetadata: generateAccountMetadata },
  createFirstUser: { Component: CreateFirstUserView, generateMetadata: generateCreateFirstUserMetadata },
  dashboard: { Component: DashboardView, generateMetadata: generateDashboardMetadata },
  // ...one entry per `AdminViewKey`
}

// 3. Export page entrypoints as thin shells over ui's `renderRoot` /
// `renderNotFoundPage`. The shells inject the framework's `initReq`,
// `notFound`, `redirect`, and the `adminViews` map above.
export const RootPage = (props: PageProps) =>
  renderRoot({ ...props, adminViews, initReq: boundInitReq, notFound, redirect })

export const NotFoundPage = (props: PageProps) =>
  renderNotFoundPage({ ...props, initReq: boundInitReq })
```

Consumers wire the entrypoints into the framework's router (unchanged).

In Next.js:

```tsx
// app/(payload)/admin/[[...segments]]/page.tsx
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'

export { generatePageMetadata as generateMetadata }
export default RootPage
```

Other framework adapters (`@payloadcms/tanstack-start`, etc.) follow the
same shape: implement `AdminViewAdapter`, bind the framework's request
bootstrap, and export page entrypoints that compose ui's renderers.

## Breaking Changes

Affects anyone importing admin view components, view-related types, or
the metadata formatter from `@payloadcms/next`. Standard Payload
installs that only consume `RootPage`, `NotFoundPage`, and
`generatePageMetadata` from `@payloadcms/next/views` are unaffected.

**Per-view exports removed from `@payloadcms/next/views`:**

| Symbol | Old source | New source |
| ------ | ---------- | ---------- |
| `AccountView` | `@payloadcms/next/views` |
`@payloadcms/ui/views/Account` |
| `CreateFirstUserView` | `@payloadcms/next/views` |
`@payloadcms/ui/views/CreateFirstUser` |
| `DashboardView` | `@payloadcms/next/views` |
`@payloadcms/ui/views/Dashboard` |
| `DefaultDashboard` | `@payloadcms/next/views` |
`@payloadcms/ui/views/Dashboard` |
| `DashboardViewClientProps` (type) | `@payloadcms/next/views` |
`@payloadcms/ui/views/Dashboard` |
| `DashboardViewServerProps` (type) | `@payloadcms/next/views` |
`@payloadcms/ui/views/Dashboard` |
| `DashboardViewServerPropsOnly` (type) | `@payloadcms/next/views` |
`@payloadcms/ui/views/Dashboard` |
| `LoginView` | `@payloadcms/next/views` | `@payloadcms/ui/views/Login`
|
| `ListView` | `@payloadcms/next/views` | `@payloadcms/ui/views/List` |
| `renderListView` | `@payloadcms/next/views` |
`@payloadcms/ui/views/List` |
| `RenderListViewArgs` (type) | `@payloadcms/next/views` |
`@payloadcms/ui/views/List` |

```diff
- import { AccountView, DashboardView, DefaultDashboard, LoginView, ListView, renderListView } from '@payloadcms/next/views'
+ import { AccountView } from '@payloadcms/ui/views/Account'
+ import { DashboardView, DefaultDashboard } from '@payloadcms/ui/views/Dashboard'
+ import { LoginView } from '@payloadcms/ui/views/Login'
+ import { ListView, renderListView } from '@payloadcms/ui/views/List'
```
2026-06-04 16:27:32 +01:00
Jarrod Flesch 48c354227f feat(ui): add SmallIcon support to hierarchy collections for compact contexts (#16817)
## Summary

Hierarchy collections can now define a `SmallIcon` component alongside
the existing `Icon`. Previously a single icon was used everywhere, and
scaling it for compact contexts (sidebar tree nodes, table row cells,
pill buttons) required CSS workarounds that couldn't scale properly.

With this change, `Icon` is reserved for the hierarchy drawer subheader,
and `SmallIcon` is used in compact display contexts. If `SmallIcon` is
omitted it falls back to `Icon`, maintaining full backwards
compatibility. The split is threaded through all four entry points that
open the hierarchy drawer: the sidebar tab, the list view table rows,
the relationship cell pill button, and the doc header field button.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-01 14:17:39 -07:00
Alessio Gravili 66018c228f refactor(richtext-lexical)!: restructure index.ts, rename outputSchema to jsonSchema (#16785)
**Breaking:**
- `RichTextAdapter.outputSchema` → `jsonSchema` - matches change in
https://github.com/payloadcms/payload/pull/16783
- Custom-feature `generatedTypes.modifyOutputSchema` ->
`modifyJSONSchema` (and `modifyOutputSchemas` → `modifyJSONSchemas`),
same rationale.

Pure restructuring to make `@payloadcms/richtext-lexical`'s `index.ts`
manageable and to set up a clean `types/` module - landing this first
keeps the upcoming type-safe-lexical PR's diff focused on actual
behavior.

**Moves (no behavior change):**
- `getLexicalHooks` (the four `RichTextHooks`) extracted from `index.ts`
into `hooks.ts`.
- The field-level JSON Schema builder extracted into `types/schema.ts`
as `getFieldToJSONSchema`.
- `types.ts` → `types/index.ts` and `nodeTypes.ts` →
`types/nodeTypes.ts`, establishing a `types/` folder.
2026-05-29 01:33:17 +00:00
Jarrod Flesch 8fd92df1ce feat(ui): add TableSection abstraction for list views (#16756)
## Summary

Refactors hierarchy tables and group-by tables to use a shared layout
abstraction called `TableSection`. Previously these two table types had
inconsistent padding, headers, and dividers—hierarchy tables used custom
margins while group-by tables had their own styling. Now both share the
same compound component structure with consistent 48px headers, proper
dividers between grouped sections, and unified action slot positioning
for bulk selection and pagination.

The pagination controls in both table types now use `SimplePagination`,
a minimal prev/next component that fits within the table header. This
replaces the larger pagination component that was awkward in grouped
contexts.

Also adds `GroupByControl`, a dropdown for selecting the group-by field
that adapts to the current theme. The theme prop propagates from Popup
through PopupList to RadioGroup items, allowing the control to render
correctly in both light and dark mode contexts.

Minor fixes include preventing scroll jumps when list query params
update and handling undefined fields in WhereBuilder conditions.
2026-05-28 14:39:13 -04:00
Jake Fletcher ac6f86cf47 feat!: admin server adapter (#16753)
Decouples plugins and server components from Next.js APIs, e.g. headers,
cookies, server-side navigation, etc. This way we can fully support
alternative React frameworks other than Next.js, e.g. TanStack.

This includes:
 - `getCookies`
 - `getHeaders`
 - `redirect`
 - `notFound`

These methods are now accessible behind a new `server` object. Each
framework is responsible for adapting its own methods into this standard
format.

## Before

Before, you'd use direct imports from `next/*`:

```tsx
import { cookies, headers } from 'next/headers.js'
import { notFound, redirect } from 'next/navigation.js'

export const MyPluginView = async ({ payload }: ServerProps) => {
  const reqHeaders = await headers()
  const reqCookies = await cookies()
  const session = reqCookies.get('session')?.value

  if (!session) {
    redirect('/login')
  }
}
```

## After

After, there are now framework-agnostic methods accessible via
`req.server`:

```tsx
export const MyPluginView = async ({ req }: ServerProps) => {
  const reqHeaders = await req.server.getHeaders()
  const reqCookies = await req.server.getCookies()
  const session = reqCookies.get('session')?.value

  if (!session) {
    req.server.redirect('/login')
  }
}
```

In custom server components, this is provided to you as a new `server`
prop:

```tsx
const MyServerComponent: React.FC<TextFieldServerProps> = ({ server  }) => {
  const cookies = await server.getCookies()
  // ...
}
```

## Writing your own Server Adapter

To write a server adapter, you must provide these methods using your
framework's proprietary APIs.

Here's an example of what a Next.js server adapter might look like
(simplified):

```tsx
import type { ServerAdapter } from 'payload'

import { headers as getNextHeaders } from 'next/headers.js'

import {
  notFound as nextNotFound,
  redirect as nextRedirect,
} from 'next/navigation.js'

export const nextServerAdapter: ServerAdapter = {
  getHeaders: () => getNextHeaders(),
  notFound: () => nextNotFound(),
  redirect: (path) => nextRedirect(path),
  // ...
}
```
2026-05-28 12:16:15 -04:00
Jake Fletcher db6ae20d75 feat!: admin router adapter (#16763)
The `@payloadcms/ui` package no longer depends on `next` directly.

This PR establishes a pattern to replace the admin panel's router with
your own. This way you can power the admin panel with alternative React
frameworks than Next.js, e.g. TanStack.

A few key takeaways:

1. Removes the `next` package from `peerDependencies` within the
`@payloadcms/ui` package. Does so by providing a new router adapter
context that allow for the entire routing layer to be swapped out (see
next bullet).

1. Creates a new `RouterAdapter` component that abstracts away all
`next/navigation` usages within the `@payloadcms/ui` package. All
framework adapters will need to supply their own router's methods to the
adapter.
    
Here's what the `@payloadcms/next` adapter might look like (simplified):
   
    ```tsx
    // Next.js router adapter (simplified):
import { useRouter as useNextRouter, usePathname as useNextPathname }
from 'next/navigation'

const NextRouterAdapter: RouterAdapterComponent = ({ children }) => {
      const router = useNextRouter()
      const pathname = useNextPathname()

      return (
        <RouterAdapterContext value={{ router, pathname, ... }}>
          {children}
        </RouterAdapterContext>
      )
    }
    ```

All router methods are now standardized behind shared hooks that can be
used within any framework:

    ```tsx
    import {
      useRouter,
      usePathname,
      useSearchParams,
      useParams
    } from '@payloadcms/ui'
    ```

1. Removes all deprecated `Link` props. Fortunately, the existing `Link`
component from `@payloadcms/ui` is already router agnostic. Existing
Payload apps have been standardized around this component, meaning we
don't have to shim it. It already uses router methods directly, as
opposed to importing from `next/link`.
2026-05-28 11:12:51 -04:00
Patrik ba52b2ebeb feat(ui): update tables to match v4 design (#16707)
## Summary

Updates Admin UI tables to match v4 design with SCSS→CSS migrations and
design token adoption.

## Changes

### Tables
- Fixed column order: checkbox → drag handle → data columns
- Swapped sort buttons: descending chevron first
- Standardized header height to 48px
- Replaced zebra striping with borders + hover states
- Added selected row background (`--color-bg-selected`)

### SCSS → CSS Migrations
- `SortHeader`, `SortRow`, `SelectRow`, `SelectAll`
- `ColumnItem`, `HierarchyList`, `HierarchyTable`, `SlotTable`,
`TypeFilter`

### Design Tokens
- `var(--base)` → `--spacer-*` tokens
- `var(--theme-elevation-*)` → semantic color tokens (`--color-bg-*`,
`--color-text-*`, `--color-icon-*`)
- Added `--gutter-h`, `--breakpoint-m-width`, `--breakpoint-s-width` to
`spacing.css`

### Checkbox
- Added `variant="muted"` for lighter table checkboxes
- Applied to `SelectAll`, `SelectRow`, `SlotTable`, `ColumnItem`

### Other
- Added `orderable` test collection

### Reference
<img width="1207" height="1351" alt="Screenshot 2026-05-22 at 2 21
54 PM"
src="https://github.com/user-attachments/assets/161bfae7-e11a-4be6-ae60-674bc3a04b26"
/>
<img width="1212" height="1349" alt="Screenshot 2026-05-22 at 2 22
11 PM"
src="https://github.com/user-attachments/assets/f451869f-bf79-4a70-a9f3-68adc2b99cf6"
/>


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1214557558212692

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2026-05-22 12:59:53 -07:00
Paul fbf28a06ff refactor: address misc 4.0 deprecations (#16613)
## Summary

- **`getTranslation`** (`@payloadcms/translations`): tightened the
`i18n` parameter type from `Pick<I18n<any, any>, ...>` to `I18nClient`
as noted in the `@todo`, and removed the now-redundant internal cast
- **`RichTextAdapterBase.i18n`** (`payload`): removed the deprecated
`i18n` property from the richtext adapter type along with the
corresponding merge logic in both `config/sanitize.ts` and
`fields/config/sanitize.ts` (and cleaned up the now-unused
`deepMergeSimple` and `GenericLanguages` imports)
- **`PayloadRequest.transactionIDPromise`** (`payload`): removed the
deprecated, unused property — `transactionID` already covers the same
use case
- **Docs**: added migration guide entries to `v4.mdx` for the richtext
adapter `i18n` removal and `transactionIDPromise` removal
2026-05-19 18:05:02 +01:00
Jarrod Flesch 2d77a3ed68 feat: hierarchy with ui (#15769)
# Hierarchy Feature

This PR introduces a comprehensive hierarchy system for Payload that
enables collections to have parent-child relationships with automatic
path generation, dedicated sidebar navigation, and specialized UI
components for folder and tag patterns.

## Overview

The hierarchy feature allows any collection to define parent-child
relationships through a declarative `hierarchy` config property. When
enabled, Payload automatically handles relationship management, path
computation, circular reference prevention, and injects a dedicated
sidebar tab with a tree view for navigation.

At its core, hierarchy manages a `parentFieldName` relationship field
that references documents within the same collection. The system
automatically creates this field if it doesn't exist, adds validation to
prevent circular references (you can't move a folder into its own
subfolder), and computes virtual path fields that provide
breadcrumb-style paths from root to each document.

## Path Generation

Two virtual fields are automatically added to hierarchy-enabled
collections: `_h_slugPath` and `_h_titlePath`. These compute breadcrumb
paths by walking up the parent chain to root during read operations. For
example, a document nested three levels deep might have paths like
`engineering/frontend/components` and `Engineering / Frontend /
Components`. Path computation is cached per-request to avoid redundant
ancestor queries, and uses `overrideAccess: true` to ensure complete
paths even when users lack read permission on intermediate ancestors.

The field names are customizable via `slugPathFieldName` and
`titlePathFieldName` in the hierarchy config. Path generation also
respects localization, returning localized strings when the collection
uses localized title fields.

## Sidebar Tabs

The hierarchy feature builds on a new sidebar tabs system that allows
rendering custom tabs alongside the default Collections tab. Each
hierarchy collection automatically gets its own tab injected during
config resolution. The tab displays a tree view of the hierarchy with
expand/collapse functionality, search, and optional collection-type
filtering.

When you click a node in the tree, the list view filters to show only
that node's children and related documents. The URL updates with a
`?parent=<id>` parameter, and the tree highlights the currently selected
node. Expanded node state persists across sessions via
payload-preferences.

## Folder and Tag Presets

Instead of wrapping your collection config in a HOC function, you now
declare the preset directly on the collection config using the `folders`
or `tags` property.

**`folders` preset** configures a folder-style hierarchy where each
document can have only one parent (single-select). It enforces
`allowHasMany: false`, applies a default folder icon, and enables the
miller columns header button by default. The collection is hidden from
the main nav since it's accessed via its sidebar tab.

**`tags` preset** configures a tag-style hierarchy where documents can
have multiple parents (multi-select by default). This is useful for
categorization systems where items can belong to multiple categories.

Both presets accept `true` for defaults, or a config object for
customization.

**`createFolderField`** creates a relationship field for assigning a
single folder to documents in other collections. The field renders as a
header button that opens a miller columns drawer for folder selection
instead of the standard relationship dropdown.

**`createTagField`** creates a relationship field for assigning multiple
tags to documents. Unlike folder fields, tag fields use the standard
relationship UI with hierarchy-aware features.

## Setup

To add hierarchy to a collection, add the `hierarchy` property to your
collection config:

```typescript
const Categories: CollectionConfig = {
  slug: 'categories',
  admin: { useAsTitle: 'name' },
  fields: [{ name: 'name', type: 'text', required: true }],
  hierarchy: {
    parentFieldName: 'parent',
  },
}
```

For folder patterns, use the `folders` preset directly on the collection
config:

```typescript
import { createFolderField } from 'payload'

const Folders: CollectionConfig = {
  slug: 'folders',
  admin: { useAsTitle: 'name' },
  fields: [{ name: 'name', type: 'text', required: true }],
  folders: true, // or folders: { parentFieldName: 'folder', ... }
}

const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    { name: 'title', type: 'text', required: true },
    createFolderField({ relationTo: 'folders' }),
  ],
}
```

For tag patterns, use the `tags` preset:

```typescript
import { createTagField } from 'payload'

const Tags: CollectionConfig = {
  slug: 'tags',
  admin: { useAsTitle: 'name' },
  fields: [{ name: 'name', type: 'text', required: true }],
  tags: true, // or tags: { allowHasMany: true, parentFieldName: 'parent', ... }
}

const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    { name: 'title', type: 'text', required: true },
    createTagField({ relationTo: 'tags' }),
  ],
}
```

## Collection-Specific Folders

Folders can optionally restrict which collection types they accept.
Enable this with `collectionSpecific`:

```typescript
const Folders: CollectionConfig = {
  slug: 'folders',
  admin: { useAsTitle: 'name' },
  fields: [{ name: 'name', type: 'text', required: true }],
  folders: {
    collectionSpecific: true, // or { fieldName: 'allowedTypes' }
  },
}
```

This adds a multi-select field to folders where you can specify which
collections can be placed in that folder. When filtering the sidebar
tree, only folders that accept the current collection type are shown.

## Join Field

For querying all children of a hierarchy item (both nested items and
related documents from other collections), configure the `joinField`
option:

```typescript
folders: {
  parentFieldName: 'parent',
  joinField: { name: 'children' },
}
```

This creates a virtual join field that aggregates all documents
referencing each hierarchy item as their parent.

## ⚠️  Migration from Previous Folders Implementation

If you previously used `folders`, migration to the new hierarchy system
is straightforward, but you now need to define your folders collection
explicitly (it is no longer created for you).

To preserve existing folders data, keep the collection slug as
`payload-folders` and set `parentFieldName` to `folder`:

```typescript
import { createFolderField } from 'payload'
import type { CollectionConfig } from 'payload'

const Folders: CollectionConfig = {
  slug: 'payload-folders', // important if migrating
  admin: { useAsTitle: 'name' },
  fields: [{ name: 'name', type: 'text', required: true }],
  folders: {
    parentFieldName: 'folder', // important if migrating
    collectionSpecific: { fieldName: 'folderType' }, // if you were using this before
  },
}

// In collections that should be assignable to folders:
const Posts: CollectionConfig = {
  slug: 'posts',
  fields: [
    { name: 'title', type: 'text', required: true },
    createFolderField({ relationTo: 'payload-folders' }),
  ],
}
```

## Current UI

https://github.com/user-attachments/assets/ca5be0b1-ddcc-4b68-8d88-1358c13a70e8

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-05 12:51:30 -07:00
Paul 8fe5f04831 feat: allow client components to also be used as custom collection views (#16312)
Allows client components to also be used as custom collection views,
bringing it inline with internal views.

A continuation of https://github.com/payloadcms/payload/pull/16243
2026-04-17 19:52:09 +00:00
Paul 835a0ad490 feat(next): add support for custom collection views (#16243)
Originally this PR with extra changes:
https://github.com/payloadcms/payload/pull/15410


- Adds ability to register custom views at the collection level via
`admin.components.views[key]` with a `Component` and `path` — resolves
#15386
- Folders take routing precedence over custom views when both are
defined on an upload collection
- Adds a startup `console.warn` when a custom view is misconfigured
without a `path` property

## Usage

```ts
{
  slug: 'products',
  admin: {
    components: {
      views: {
        grid: {
          Component: '/components/GridView',
          path: '/grid',
          exact: true,
        },
      },
    },
  },
}

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com>
Co-authored-by: Robin Scholz <robin@maas.engineering>
Co-authored-by: Robin Vey <mail@robinscholz.com>
2026-04-15 19:05:42 +01:00
Patrik 7a3f43ff51 fix: prevent silent data overwrites on concurrent edits (#15749)
### What

Adds stale data detection to prevent silent overwrites when multiple
users edit the same document.

### Why

Currently, when two users edit the same document, the last save wins
with no warning. User 1 can make changes, then User 2 saves, overwriting
User 1's work silently.

This is separate from document locking and addresses a specific
scenario:

1. Both users already have the same document open at the same time. 
2. User 1 makes changes and saves. 
3. User 2, still viewing their original version, then decides to make
edits.
4. Without this fix, User 2 would unknowingly overwrite User 1's changes
with no warning.

### How

Now we track the document's `updatedAt` timestamp when it initially
loads. On first edit, check if the database version has a newer
timestamp.

If stale, show a modal with the option to reload (get latest). The check
only happens once per edit session to avoid excessive DB queries.

<img width="693" height="360" alt="Screenshot 2026-02-24 at 1 37 01 PM"
src="https://github.com/user-attachments/assets/1d63973c-fa40-4213-8718-9ea739f267c9"
/>

Fixes #15486
2026-02-26 06:47:33 -08:00
German Jablonski 0a123b51f2 feat(next, ui): widget fields (#15700)
## Summary

- Adds `fields` support to dashboard widgets, analogous to how Blocks
work — widgets can now declare configurable fields
- Widget data is editable from a new drawer UI (edit icon) when in
dashboard editing mode
- Full type generation: `WidgetInstance<T>` is now generic with typed
`data` and `width` based on the widget's field schema and min/max width
constraints
- `WidgetServerProps` is generic so widget components receive typed
`widgetData`

## Usage

```ts
import { buildConfig } from 'payload'

export default buildConfig({
  admin: {
    dashboard: {
      widgets: [
        {
          slug: 'sales-summary',
          ComponentPath: './components/SalesSummary.tsx#default',
          fields: [
            { name: 'title', type: 'text' },
            {
              name: 'timeframe',
              type: 'select',
              options: ['daily', 'weekly', 'monthly', 'yearly'],
            },
            { name: 'showTrend', type: 'checkbox' },
          ],
          minWidth: 'small',
          maxWidth: 'medium',
        },
      ],
    },
  },
})
```

```tsx
import type { WidgetServerProps } from 'payload'

import type { SalesSummaryWidget } from '../payload-types'

export default async function SalesSummaryWidgetComponent({
  widgetData,
}: WidgetServerProps<SalesSummaryWidget>) {
  const title = widgetData?.title ?? 'Sales Summary'
  const timeframe = widgetData?.timeframe ?? 'monthly'

  return (
    <div className="card">
      <h3>
        {title} ({timeframe})
      </h3>
    </div>
  )
}
```

## Demo



https://github.com/user-attachments/assets/b24d3635-8635-4d5f-84af-a6dcf801aa4f
2026-02-25 13:33:57 +00:00
Paul 2347cd9e9d feat: move trash out of beta and delete access can now be limited to trash only (#15210)
### Access control can be limited to only trash

You can now limit users to only trash without being able to permanently
delete:

```ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
  slug: 'posts',
  trash: true,
  access: {
    delete: ({ req: { user }, data }) => {
      // Not logged in - no access
      if (!user) {
        return false
      }

      // Admins can do anything (trash or permanently delete)
      if (user.roles?.includes('admin')) {
        return true
      }

      // Regular users: check what operation they're attempting
      // If data.deletedAt is being set, it's a trash operation - allow it
      if (data?.deletedAt) {
        return true
      }

      // Otherwise it's a permanent delete - deny for non-admins
      return false
    },
  },
  fields: [
    // ...
  ],
}
```

---------

Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 14:47:41 +00:00
Alessio Gravili 9c8be5cc96 perf(next): avoid re-calculating permissions in some server functions, pass missing args (#15428)
Requires https://github.com/payloadcms/payload/pull/15427 to be merged
first.

This PR avoids re-calculating permissions in certain server functions,
since through #15427, they are now available in server functions due to
an `initReq` that we already ran.

It also passes some missing properties to `serverProps` and render
handles that are now available.
2026-01-30 17:01:11 -05:00
Alessio Gravili ce13e97243 feat(next): pass full initReq context to server functions and dashboard widgets (#15427)
Currently, we only pass `importMap` and `req` to all server functions.
This PR extends that interface by passing the following **additional
arguments** to every server function and every dashboard widget:

- `cookies`
- `locale`
- `permissions`

This change comes at **no extra cost**, since all of these values are
already available via `initReq`, which we are already calling.

## Why?

I’m working on rendering the list view in a dashboard widget through a
server function, which requires context such as `permissions`.
Recomputing these values would be redundant and inefficient, given that
they’ve already been calculated in `handleServerFunctions` and we can
just pass them through.

By passing them through once, we avoid duplicate work and make server
functions easier to use.
2026-01-30 17:44:13 +00:00
Patrik 94254da4ac feat: add support for custom UnpublishButton component (#15400)
### What

Adds the ability to customize the `UnpublishButton` component in
collection and global configs, following the same pattern as
`PublishButton`.

### Why

`UnpublishButton` was hardcoded while other document control buttons
(`PublishButton`, `SaveButton`, etc.) could be customized.

### How

Added type definitions, config options, component resolution in
renderDocumentSlots, and updated DocumentControls to use the
RenderCustomComponent pattern.

---------

Co-authored-by: Alessio Gravili <github@gravili.net>
2026-01-29 10:01:48 -08:00
Elmo ef863e6b64 feat: add support for custom Status component in document controls (#11154)
### What?

This PR adds the ability to replace the Status section of the document
or global edit view, without the need to replace the whole Edit view.

### Why?

In certain cases there may be a need to add custom elements before or
after the original Status component. In our case, we need to replace the
whole Status component to support custom locale publishing logic and
also for additional status indicators for the users.

### How?

**In the `docs` package:**
- Added a description of the Status component to the components section
within both the collections and globals sections.

**In the `next` package:**
- Enabled rendering of the custom Status component.
- Modified the Live Preview components to include the Status component
prop.

**In the `payload` package:**
- Introduced type definitions for the Status component.
- Implemented importMap generation for the Status component.

**In the `ui` package:**
- Modified the Edit view to pass the Status component to
DocumentControls.
- Updated the DocumentControls component to accept and render a custom
Status component if defined.


Now supports the following Status component in admin

```ts
admin: {
  components: {
    edit: {
      Status: '/components/Status/index.tsx#Status',
    },
  },
},
```

---------

Co-authored-by: Elmo Egers <elmo.egers@smit.ee>
Co-authored-by: Elmo Egers <uplaymedia@gmail.com>
Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2026-01-13 19:11:32 +00:00
Jake d288752f68 fix: field schema map paths (#10852)
Continuation of #10638.

Field paths within the schema map are not correct.

For example, an unnamed tab containing a text field should have the
schema path:
- `_index-0.fieldWithinUnnamedTab`

However, within the schema map that path is formatted as:
- `fieldWithinUnnamedTab`

The leading index in the first example _should_ exist, as this field is
nested within another field, regardless of this field being unnamed.
Otherwise, it would be impossible to traverse the schema and lookup this
field.

This discrepancy is not an issue in the admin panel because fields are
_also_ provided the wrong schema paths. They can properly locate their
schema within the schema map because they match despite being incorrect.
Both are wrong and that's why it works.

Here's comprehensive example of how field paths _should_ be formatted:

```ts
{
  // ...
  fields: [
    {
      // path: 'topLevelNamedField'
      // schemaPath: 'topLevelNamedField'
      // indexPath: ''
      name: 'topLevelNamedField',
      type: 'text',
    },
    {
      // path: 'array'
      // schemaPath: 'array'
      // indexPath: ''
      name: 'array',
      type: 'array',
      fields: [
        {
          // path: 'array.[n].fieldWithinArray'
          // schemaPath: 'array.fieldWithinArray'
          // indexPath: ''
          name: 'fieldWithinArray',
          type: 'text',
        },
        {
          // path: 'array.[n].nestedArray'
          // schemaPath: 'array.nestedArray'
          // indexPath: ''
          name: 'nestedArray',
          type: 'array',
          fields: [
            {
              // path: 'array.[n].nestedArray.[n].fieldWithinNestedArray'
              // schemaPath: 'array.nestedArray.fieldWithinNestedArray'
              // indexPath: ''
              name: 'fieldWithinNestedArray',
              type: 'text',
            },
          ],
        },
        {
          // path: 'array.[n]._index-2'
          // schemaPath: 'array._index-2'
          // indexPath: '2'
          type: 'row',
          fields: [
            {
              // path: 'array.[n].fieldWithinRowWithinArray'
              // schemaPath: 'array._index-2.fieldWithinRowWithinArray'
              // indexPath: ''
              name: 'fieldWithinRowWithinArray',
              type: 'text',
            },
          ],
        },
      ],
    },
    {
      // path: '_index-2'
      // schemaPath: '_index-2'
      // indexPath: '2'
      type: 'row',
      fields: [
        {
          // path: 'fieldWithinRow'
          // schemaPath: '_index-2.fieldWithinRow'
          // indexPath: ''
          name: 'fieldWithinRow',
          type: 'text',
        },
      ],
    },
    {
      // path: '_index-3'
      // schemaPath: '_index-3'
      // indexPath: '3'
      type: 'tabs',
      tabs: [
        {
          // path: '_index-3-0'
          // schemaPath: '_index-3-0'
          // indexPath: '3-0'
          label: 'Unnamed Tab',
          fields: [
            {
              // path: 'fieldWithinUnnamedTab'
              // schemaPath: '_index-3-0.fieldWithinUnnamedTab'
              // indexPath: ''
              name: 'fieldWithinUnnamedTab',
              type: 'text',
            },
            {
              // path: '_index-3-0-1'
              // schemaPath: '_index-3-0-1'
              // indexPath: '3-0-1'
              type: 'tabs',
              tabs: [
                {
                  // path: '_index-3-0-1-0'
                  // schemaPath: '_index-3-0-1-0'
                  // indexPath: '3-0-1-0'
                  label: 'Nested Unnamed Tab',
                  fields: [
                    {
                      // path: 'fieldWithinNestedUnnamedTab'
                      // schemaPath: '_index-3-0-1-0.fieldWithinNestedUnnamedTab'
                      // indexPath: ''
                      name: 'fieldWithinNestedUnnamedTab',
                      type: 'text',
                    },
                  ],
                },
              ],
            },
          ],
        },
        {
          // path: 'namedTab'
          // schemaPath: '_index-3.namedTab'
          // indexPath: ''
          label: 'Named Tab',
          name: 'namedTab',
          fields: [
            {
              // path: 'namedTab.fieldWithinNamedTab'
              // schemaPath: '_index-3.namedTab.fieldWithinNamedTab'
              // indexPath: ''
              name: 'fieldWithinNamedTab',
              type: 'text',
            },
          ],
        },
      ],
    },
  ]
}
```
2026-01-09 17:21:17 -05:00
German Jablonski f1116247ed feat: modular dashboards - widgets (#13683)
[RFC Here](https://github.com/payloadcms/payload/discussions/11862).

You can test the feature by running `pnpm dev dashboard` on this branch.

<details>
<summary>Old (obsolete) example</summary>

See the [comment
below](https://github.com/payloadcms/payload/pull/13683#issuecomment-3581457901)
explaining the change in approach we took


https://github.com/user-attachments/assets/96157f83-c5d7-4350-9f31-c014daedb2a8

</details>

### New demo


https://github.com/user-attachments/assets/6c08d8d6-c989-4845-b56f-6d3fbd30b1af

## Future Work 

The following improvements are planned but will be added in the future: 

- fields: You'll be able to define `fields` that a widget receives,
which will serve as props in the component. Why might this be useful?
Imagine a chart widget that can have a weekly, daily, or yearly view. Or
a "count" widget that shows how many documents there are in a collection
(the collection could be a field).
- A11y (EDITED): Okay, I finally went the extra mile here, and you can
reorder and resize it with the keyboard. The screen reader works,
although there's room for improvement.
- Dashboard presets: we're planning to add the ability to create and
share dashboard presets, similar to how [query
presets](https://payloadcms.com/docs/query-presets/overview) work today.
For example, you could build dashboards that adjust based on a variable,
such as a "daily, weekly, or monthly" interval. You could also create
dashboards tailored to different focus areas, like "marketing, sales, or
product."
2025-12-19 15:22:03 +00:00
Jake Fletcher 59a1607a3f feat: support custom slugify functions (#14117)
Continuation of #14007.

Supports overriding the default slugify function of the slug field. This
is necessary if the slug requires special treatment, such as character
encoding, additional language support, etc.

For example, if you wanted to use the
[`slugify`](https://www.npmjs.com/package/slugify) package, you could do
something like this:

```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'
import slugify from 'slugify';

export const MyCollection: CollectionConfig = {
  // ...
  fields: [
    // ...
    slugField({
      slugify: ({ valueToSlugify }) => slugify(valueToSlugify, {
        // ...additional `slugify` options here
      })
    })
  ]
}
```

This PR also deprecates the old `fieldToUse` arg in favor of `useAsSlug`
which is more semantically clear, following the same convention as
`useAsTitle`.

Example:

```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'

export const MyCollection: CollectionConfig = {
  // ...
  fields: [
    // ...
    slugField({
      useAsSlug: 'myCustomTitle'
    })
  ]
}
```

In follow-up PRs, we should also:
- Improve the default slugify function to better handle special
characters on its own, etc.
- [Support nested
slugs](https://github.com/payloadcms/payload/pull/14783)
2025-12-02 17:14:48 +00:00
Jarrod Flesch 9f55254da3 fix: relationships should not fallback if fallbackLocale is false (#14641)
Relationship fields would fallback even if the parent of the request set
`fallbackLocale: false`. This was because of the logic in
sanitizeFallbackLocale which would transform `false` value into `null`,
and when a relationship populate function ran, it would transform the
`null` into the `localization.defaultLocale` value.

Added int test that failed before and passes after to cover.
2025-11-17 16:21:51 -05:00
Alessio Gravili 0cc06a1224 fix(ui): relationship field label not updated when document is updated from other drawer (#14609)
When updating a document from a document drawer that isn’t the one
associated with a relationship field, the relationship’s label doesn’t
update correctly.

## Reproduction

This issue is easily reproducible when two relationship fields link to
the same document. If you update the document from the drawer of one
field, the other field will not reflect the update. This happens because
each field only listens to the `onSave` event of its own document
drawer.

## Fix

Use the `useDocumentEvents` hook instead of the `onSave` hook.
`useDocumentEvents` listens for updates to all saved documents, ensuring
that all relationship fields stay in sync regardless of which drawer the
update originated from.

**Before:**


https://github.com/user-attachments/assets/eda152fe-3cda-4111-b0f3-9f0d5a48f37b


**After:**


https://github.com/user-attachments/assets/da252d3d-c0a9-43ee-91d8-508e302d323e
2025-11-17 13:21:24 +00:00
Slava Nossar 339a0c390d fix: use TSiblingData for previousSiblingDoc in FieldHook (#14503)
### What?
In field hooks, `previousSiblingDoc` is typed as `TData`.

### Why?
This value should actually be typed as `TSiblingData`, I've confirmed
the sibling data is actually what is returned when a field hook runs.

### How?
Updated `FieldHook` typing, as well as `RichText` field hook typings

Fixes #9735
2025-11-06 20:59:07 +00:00
Paul ad0e7b2252 fix(ui): preview button not responding to conditional URL (#14277)
Fixes #14241 and #11253.

Preview URL is now part of the form state as well so we can
conditionally show the preview button based on its value, previously
this was not possible because we were fetching the URL on click. Similar
to #14012.
2025-10-23 13:35:22 -04:00
Alessio Gravili 8b0ac01f41 docs: add jsdocs to RichText adapter (#14246)
Adds JSDocs to the RichText adapter return value.
2025-10-18 02:46:58 +03:00
Alessio Gravili 54224c3ab7 perf(richtext-lexical): do not return i18n from editor adapter (#14228)
This PR deprecates the `i18n` property returned from the editor adapter.
Instead, we now merge `i18n` directly into the `config`, since the
config is already available in the richtext adapter provider. This
simplifies the logic (only one line of code) and eliminates the need to
manually read and merge i18n from the adapter.

Benefits:
- **Simpler implementation**: Merging i18n into the config is
straightforward
- **Reduced memory usage**: Each editor no longer maintains its own i18n
object - only a single, merged `config.i18n` is kept.
- **Improved developer experience**: The sanitized field config is now
smaller and easier to inspect. Console logging the config no longer
floods the output with hundreds of translation lines.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211668727538425
2025-10-17 09:19:42 +01:00
Jessica Rynkar 623a1b8216 feat: accept multiple locales in fallbackLocale (#13822)
### What?
This change allows `fallbackLocale` to accept an **array of locales** in
find queries and locale configuration.

```ts
/** Local API **/
  await payload.findByID({
    id,
    collection,
    locale: 'en',
    fallbackLocale: ['fr', 'es'],
  })

/** REST API **/
  await fetch(`${baseURL}/api/${collectionSlug}?locale=en&fallbackLocale[]=fr&fallbackLocale[]=es`)

/** GraphQL **/
  await restClient.GRAPHQL_POST({
    body,
    query: { locale: 'en', fallbackLocale: ['fr', 'es']},
  })

/** Locale Configs **/
  locales: [
    {
      code: 'en',
      label: 'English',
      fallbackLocale: ['fr', 'es'],
    },
  ]
```

### Why?
This update is part of the planned [localization
enhancements](https://github.com/payloadcms/payload/discussions/10705).
Currently, only one fallback locale can be specified. If that locale
doesn’t contain data, the query returns nothing. To work around this,
users have to inspect the response themselves and then make additional
queries for other locales.

With this change, Payload handles that work automatically. Users only
need to provide a list of fallback locales in their preferred order, and
Payload will check each one until it finds a value.

### How?
Updates query handling across the local API, REST API, and GraphQL so
that `fallbackLocale` can accept either a **string** or an **array** of
locales. When an array is passed, Payload will iterate through them in
order and return the first found localized value. This behavior applies
to both collections and globals.

#### Feature request:
https://github.com/payloadcms/payload/discussions/13443

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2025-10-16 12:02:59 +01:00
Jacob Fletcher b09ae6772f feat: slug field (#14007)
Discussion #8859. Requires #14012.

Exports a new `slugField`. This is a wrapper around the text field that
you can drop into any field schema.

A slug is a unique, indexed, URL-friendly string that identifies a
particular document, often used to construct the URL of a webpage. Slugs
are a fundamental concept for seemingly every project.

Traditionally, you'd build this field from scratch, but there are many
edge cases and nice-to-haves to makes this difficult to maintain by
hand. For example, it needs to automatically generate based on the value
of another field, provide UI to lock and re-generate the slug on-demand,
etc.

Fixes #13938.

When autosave is enabled, the slug is only ever generated once after the
initial create, leading to single character, or incomplete slugs.

For example, it is expected that "My Title" → "my-title, however ends up
as "m".

This PR overhauls the field to feel a lot more natural. Now, we only
generate the slug through:
1. The `create` operation, unless the user has modified the slug
manually
2. The `update` operation, if:
  a. Autosave is _not_ enabled and there is no slug
b. Autosave _is_ enabled, the doc is unpublished, and the user has not
modified the slug manually

The slug should stabilize after all above criteria have been met,
because the URL is typically derived from the slug. This is to protect
modifying potentially live URLs, breaking links, etc. without explicit
intent.

This fix, along with all the other features, is now standardized behind
the new `slugField`:

```ts
import type { CollectionConfig } from 'payload'
import { slugField } from 'payload'

export const MyCollection: CollectionConfig = {
  // ...
  fields: [
   // ...
   slugField()
  ]
}
```

In the future we could also make this field smart enough to auto
increment itself when its generated slug is not unique.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211513433305005
2025-10-07 10:15:45 -04:00
Paul d826159fc0 fix(ui): add support back for custom live preview components (#14037)
Fixes https://github.com/payloadcms/payload/issues/13308

Adds support for a custom live preview component back, we previously
supported this and it was allowed via the config types but it wasn't
being rendered.

Now we export the `useLivePreviewContext` hook and the original
`LivePreviewWindow` component too so that end users can wrap the live
preview functionality with anything custom that they may need
2025-10-02 15:09:15 -04:00
Patrik 1d1240fd13 feat: adds admin.formatDocURL function to control list view linking (#13773)
### What?

Adds a new `formatDocURL` function to collection admin configuration
that allows users to control the linkable state and URLs of first column
fields in list views.

### Why?

To provide a way to disable automatic link creation from the first
column or provide custom URLs based on document data, user permissions,
view context, and document state.

### How?

- Added `formatDocURL` function type to `CollectionAdminOptions` that
receives document data, default URL, request context, collection slug,
and view type
- Modified `renderCell` to call the function when available and handle
three return types:
  - `null`: disables linking entirely
  - `string`: uses custom URL
  - other: falls back to no linking for safety
- Added function to server-only properties to prevent React Server
Components serialization issues
- Updated `DefaultCell` component to support custom `linkURL` prop


---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211211792037945
2025-09-24 12:20:54 -07:00
Jacob Fletcher 00a673e491 feat(next): regenerate live preview url on save (#13631)
Closes #12785.

Although your live preview URL can be dynamic based on document data, it
is never recalculated after initial mount. This means if your URL is
dependent of document data that was just changed, such as a "slug"
field, the URL of the iframe does not reflect that change as expected
until the window is refreshed or you navigate back.

This also means that server-side live preview will crash when your
front-end attempts to query using a slug that no longer exists. Here's
the general flow: slug changes, autosave runs, iframe refreshes (url has
old slug), 404.

Now, we execute your live preview function on submit within form state,
and the window responds to the new URL as expected, refreshing itself
without losing its connection.

Here's the result:


https://github.com/user-attachments/assets/7dd3b147-ab6c-4103-8b2f-14d6bc889625

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211094211063140
2025-09-23 09:37:15 -04:00
Alessio Gravili d8dace6509 feat: conditional blocks (#13801)
This PR introduces support for conditionally setting allowable block
types via a new `field.filterOptions` property on the blocks field.

Closes the following feature requests:
https://github.com/payloadcms/payload/discussions/5348,
https://github.com/payloadcms/payload/discussions/4668 (partly)

## Example

```ts
fields: [
  {
      name: 'enabledBlocks',
      type: 'text',
      admin: {
        description:
          "Change the value of this field to change the enabled blocks of the blocksWithDynamicFilterOptions field. If it's empty, all blocks are enabled.",
      },
    },
    {
      name: 'blocksWithFilterOptions',
      type: 'blocks',
      filterOptions: ['block1', 'block2'],
      blocks: [
        {
          slug: 'block1',
          fields: [
            {
              type: 'text',
              name: 'block1Text',
            },
          ],
        },
        {
          slug: 'block2',
          fields: [
            {
              type: 'text',
              name: 'block2Text',
            },
          ],
        },
        {
          slug: 'block3',
          fields: [
            {
              type: 'text',
              name: 'block3Text',
            },
          ],
        },
      ],
    },
    {
      name: 'blocksWithDynamicFilterOptions',
      type: 'blocks',
      filterOptions: ({ siblingData: _siblingData, data }) => {
        const siblingData = _siblingData as { enabledBlocks: string }

        if (siblingData?.enabledBlocks !== data?.enabledBlocks) {
          // Just an extra assurance that the field is working as intended
          throw new Error('enabledBlocks and siblingData.enabledBlocks must be identical')
        }
        return siblingData?.enabledBlocks?.length ? [siblingData.enabledBlocks] : true
      },
      blocks: [
        {
          slug: 'block1',
          fields: [
            {
              type: 'text',
              name: 'block1Text',
            },
          ],
        },
        {
          slug: 'block2',
          fields: [
            {
              type: 'text',
              name: 'block2Text',
            },
          ],
        },
        {
          slug: 'block3',
          fields: [
            {
              type: 'text',
              name: 'block3Text',
            },
          ],
        },
      ],
    },
]
```


https://github.com/user-attachments/assets/e38a804f-22fa-4fd2-a6af-ba9b0a5a04d2

# Rationale

## Why not `block.condition`?

- Individual blocks are often reused in multiple contexts, where the
logic for when they should be available may differ. It’s more
appropriate for the blocks field (typically tied to a single collection)
to determine availability.
- Hiding existing blocks when they no longer satisfy a condition would
cause issues - for example, reordering blocks would break or cause block
data to disappear. Instead, this implementation ensures consistency by
throwing a validation error if a block is no longer allowed. This aligns
with the behavior of `filterOptions` in relationship fields, rather than
`condition`.

## Why not call it `blocksFilterOptions`?

Although the type differs from relationship fields, this property is
named `filterOptions` (and not `blocksFilterOptions`) for consistency
across field types. For example, the Select field also uses
`filterOptions` despite its type being unique.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211334752795631
2025-09-22 14:20:25 -07:00
Alessio Gravili 1c89291fac feat(richtext-lexical): utility render lexical field on-demand (#13657)
## Why this exists

Lexical in Payload is a React Server Component (RSC). Historically that
created three headaches:

1. You couldn’t render the editor directly from the client.
2. Features like blocks, tables, upload and link drawers require the
server to know the shape of nested sub‑fields at render time. If you
tried to render on demand, the server didn’t know those schemas.
3. The rich text field is designed to live inside a Form. For simple use
cases, setting up a full form just to manage editor state was
cumbersome.

## What’s new

We now ship a client component, `<RenderLexical />`, that renders a
Lexical editor **on demand** while still covering the full feature set.
On mount, it calls a server action to render the editor on the server
using the new `render-field` server action. That server render gives
Lexical everything it needs (including nested field schemas) and returns
a ready‑to‑hydrate editor.

## Example - Rendering in custom component within existing Form

```tsx
'use client'

import type { JSONFieldClientComponent } from 'payload'

import { buildEditorState, RenderLexical } from '@payloadcms/richtext-lexical/client'

import { lexicalFullyFeaturedSlug } from '../../slugs.js'

export const Component: JSONFieldClientComponent = (args) => {
  return (
    <div>
      Fully-Featured Component:
      <RenderLexical
        field={{ name: 'json' }}
        initialValue={buildEditorState({ text: 'defaultValue' })}
        schemaPath={`collection.${lexicalFullyFeaturedSlug}.richText`}
      />
    </div>
  )
}
```

## Example - Rendering outside of Form, manually managing richText
values

```ts
'use client'

import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
import type { JSONFieldClientComponent } from 'payload'

import { buildEditorState, RenderLexical } from '@payloadcms/richtext-lexical/client'
import React, { useState } from 'react'

import { lexicalFullyFeaturedSlug } from '../../slugs.js'

export const Component: JSONFieldClientComponent = (args) => {
  const [value, setValue] = useState<DefaultTypedEditorState | undefined>(() =>
    buildEditorState({ text: 'state default' }),
  )

  const handleReset = React.useCallback(() => {
    setValue(buildEditorState({ text: 'state default' }))
  }, [])

  return (
    <div>
      Default Component:
      <RenderLexical
        field={{ name: 'json' }}
        initialValue={buildEditorState({ text: 'defaultValue' })}
        schemaPath={`collection.${lexicalFullyFeaturedSlug}.richText`}
        setValue={setValue as any}
        value={value}
      />
      <button onClick={handleReset} style={{ marginTop: 8 }} type="button">
        Reset Editor State
      </button>
    </div>
  )
}
```

## How it works (under the hood)

- On first render, `<RenderLexical />` calls the server function
`render-field` (wired into @payloadcms/next), passing a schemaPath.
- The server loads the exact field config and its client schema map for
that path, renders the Lexical editor server‑side (so nested features
like blocks/tables/relationships are fully known), and returns the
component tree.
- While waiting, the client shows a small shimmer skeleton.
- Inside Forms, RenderLexical plugs into the parent form via useField;
outside Forms, you can fully control the value by passing
value/setValue.

## Type Improvements

While implementing the `buildEditorState` helper function for our test
suite, I noticed some issues with our `TypedEditorState` type:
- nodes were no longer narrowed by their node.type types
- upon fixing this issue, the type was no longer compatible with the
generated types. To address this, I had to weaken the generated type a
bit.

In order to ensure the type will keep functioning as intended from now
on, this PR also adds some type tests

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211110462564644
2025-09-18 15:01:12 -07:00