mirror of
https://github.com/payloadcms/payload.git
synced 2026-09-14 20:07:19 +08:00
docs/tanstack-scaffold
1997 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c73553166a |
feat(ui): add livePreview.openByDefault config option (#17211)
Adds an `openByDefault` option to the Live Preview config that opens the
Live Preview panel automatically the first time a user views a document,
before they have manually toggled it on.
Previously, Live Preview only opened automatically once a user had
toggled it on for a given collection or global, which persisted their
choice to preferences. On the very first visit there was no way to have
it open by default.
A new `openByDefault` property is now available on the
`admin.livePreview` config:
```ts
export default buildConfig({
admin: {
livePreview: {
url: 'http://localhost:3000/live-preview',
openByDefault: true,
},
},
})
```
The user's stored preference always wins. `openByDefault` only applies
when no preference exists yet — once the user toggles Live Preview on or
off, their choice is respected on every subsequent visit and
`openByDefault` is ignored.
|
||
|
|
8ef16aeca1 |
feat(plugin-mcp): add MCP evals and afterToolCall hooks (#17206)
This adds a Payload MCP eval suite. A real agent works with Payload through MCP, and each eval confirms the result through the Payload Local API. The MCP eval uses the existing codegen dataset runner instead of introducing a separate MCP runner. The prompts are normal requests that could be expected by a real user, and are not trying to steer the agent to ensure the tests pass. ## MCP coverage - Create a post. - Find a post, then update that exact document by ID. - Find a post, then delete that exact document by ID without deleting another post. - Update a global. - Find an author, then create a post whose relationship field stores that exact author's ID. - Create a post with a complex Lexical document containing a heading, formatted paragraph text, and a bulleted list. ## Eval flow The new `bootConfig` dataset option starts the fixture's Payload config before the agent runs, and cases can create their own required test data. Each boot has a separate Payload cache key so it cannot reuse another case's closed database connection. The agent connects to Payload MCP over stdio, so no Next.js dev server is required. ## Verifying MCP was actually used The new `hooks.afterToolCall` MCP hook lets the fixture record completed tool calls in a temporary JSON file. Verifiers compare those calls with the final database state, including exact document IDs, so an agent cannot pass by recreating data through another API. Every MCP case starts with a score of `1` and halves it for each failed MCP call. Recovered errors do not fail a correct result, but they lower its score because they indicate less reliable MCP usage and the need for better tool description / schema / payload skill for the agent to use the mcp right the first time. ## Generic eval infrastructure changes These changes apply to all eval suites, not only MCP. **Before:** matching results were reused from a "cache" only after Vitest started, with no warning beforehand. To force a fresh result, you had to remember `--no-cache` or `EVAL_NO_CACHE=true`. Calling this a cache was confusing because these are saved eval results that you expect to find in the dashboard alongside previous runs. **Now:** there is one run history. Before starting Vitest, the CLI shows which selected cases already have the same result and lets you reuse them or rerun everything (see screenshot below). Reused cases appear as skipped, and `--rerun` is the clear way to request fresh results. **Why:** a previous eval result is history, not temporary cached data. Using one concept makes it clear what happened before and what will actually run next. Cases can also share a fixture config and create their own data through `setup`. That way, we don't need a one-fits-all onInit seed function in the payload config shared across mcp evals. <img width="1116" height="722" alt="screenshot 2026-07-06 at 00 12 46@2x" src="https://github.com/user-attachments/assets/d05aaead-30f1-423e-989c-6f42a320938f" /> |
||
|
|
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> |
||
|
|
f98e800d7c |
refactor!: remove deprecated afterOperation 'read' operation (#17180)
Removes the deprecated `'read'` value from the collection
`afterOperation` hook's `operation` argument. It has been unreachable
for some time — Payload dispatches `afterOperation` with the concrete
operation name (`'find'` or `'findByID'`), never `'read'` . It was only
kept for backwards compatibility on the public `AfterOperationHook`
type.
Includes a codemod and migration-guide entry so affected hooks can be
updated automatically.
## Changes
- **Remove** the `operation: 'read'` union member from
`AfterOperationArg`
(`packages/payload/src/collections/operations/utilities/types.ts`).
- **Add** the `migrate-after-operation-read` codemod to
`@payloadcms/codemod`, registered and documented.
- **Document** the breaking change in the v4 migration guide.
`beforeOperation` still uses `operation: 'read'` and is intentionally
left unchanged.
## Breaking Changes
The `'read'` value for the `operation` argument of collection
`afterOperation` hooks has been removed. Hooks that branch on `operation
=== 'read'` must handle `'find'` and `'findByID'` instead:
```diff
const afterOperation: CollectionAfterOperationHook = ({ operation, result }) => {
- if (operation === 'read') {
+ if (operation === 'find' || operation === 'findByID') {
// ...
}
return result
}
```
`'find'` resolves the paginated result (`result.docs`); `'findByID'`
resolves a single document — narrow on the specific operation if your
logic depends on the result shape.
### Migration
```bash
npx @payloadcms/codemod --transform migrate-after-operation-read
```
Rewrites `===`/`!==`/`==`/`!=` checks against a destructured, aliased,
or property-accessed `operation` argument. Non-inline hooks (referenced
by name) and `switch` statements with a `'read'` case are surfaced as
notes for manual review.
Co-authored-by: Patrik <35232443+patrikkozak@users.noreply.github.com>
|
||
|
|
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
|
||
|
|
787f9df59b |
feat: expose owning collection/global config to field access functions (#17051)
Adds `collection` and `global` to `FieldAccessArgs`, giving field access
functions (`create`, `read`, `update`) the same owning-entity context
that `FieldHookArgs` has always had.
The primary motivation is shared block references: a block defined once
and reused across multiple collections and globals has no way to
differentiate which entity its field access is being evaluated for. With
this change, `field.access.read({ collection, global, ... })` receives
the full `SanitizedCollectionConfig` or `SanitizedGlobalConfig` (the
other is `null`), exactly mirroring the existing `FieldHookArgs`
pattern.
**Before:**
```ts
// FieldAccessArgs had no entity context
field.access.read = ({ req, siblingData }) => { ... }
```
**After:**
```ts
field.access.read = ({ collection, global, req, siblingData }) => {
// collection is SanitizedCollectionConfig | null
// global is SanitizedGlobalConfig | null
if (collection?.slug === 'restricted-collection') return false
return true
}
```
Callsites updated across all entry paths (local API, REST, GraphQL, form
state builder):
- `fields/hooks/afterRead/promise.ts`
- `fields/hooks/beforeValidate/promise.ts`
- `collections/operations/findDistinct.ts`
- `utilities/getEntityPermissions/populateFieldPermissions.ts` +
`getEntityPermissions.ts`
- `packages/ui` — `addFieldStatePromise.ts` looks up the full config
from `req.payload` at the point of the access call; slug strings
continue to flow through the rest of the UI chain unchanged
Relationship traversal correctly passes the *related* collection's
config (not the parent's), so a field on a populated child always sees
its own owning collection.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
||
|
|
dc9cc3ab7d |
feat: add recently viewed activity dashboard widget (#16837)
## Summary Stacked on top of #16812. Adds an `activity` dashboard widget that lists the documents the current user has recently viewed in the admin, most recent first. <img width="690" height="235" alt="image" src="https://github.com/user-attachments/assets/c971cde1-28e0-4b9c-b0d3-d4ffa9db5914" /> - New per-user `recently-viewed` preference storing only identity + timestamp (`collectionSlug`, `id`, `viewedAt`), recorded server-side in `renderDocument` when a collection document is opened (full-page and drawer). The write is wrapped in try/catch + logger so it can never break a document view. - The widget computes display fields (title from `useAsTitle`, a collection type pill or an upload thumbnail, and a relative timestamp) at render time, querying each collection under access control (`overrideAccess: false`). Deleted or now-forbidden documents drop out of the list automatically. - Relative timestamps share a single utility (`formatRelativeDate` / `getRelativeTimeFormat`) with the `collection-query` widget, so both render dates identically and locale-aware (e.g. "5m ago"). - Collection filtering uses an `excludedCollections` exclusion list under the hood, surfaced to the user as an inclusion filter: every collection is checked by default and unchecking one adds it to the exclusions, so newly added collections show up automatically. - i18n keys added to `en.ts` + `clientKeys.ts` and translated across all locales. ## Test plan - [x] Unit test for the merge/dedup/cap helper (`recentlyViewed.spec.ts`). - [x] e2e: view two documents, return to the dashboard, assert they appear in the activity widget most-recent-first. - [x] e2e: collections filter renders inclusion checkboxes (all checked by default) and unchecking a collection excludes its documents from the widget. - [ ] Manual: open documents in different collections and confirm they appear, with type pills and thumbnails for uploads. ## Deferred to follow-ups - Per-row remove (X): needs a client component + preference mutation; this PR ships a read-only list. - A "recently edited" tab. ## Known limitations - Because display fields are computed under access control, deleted or now-forbidden docs drop out of the list (desired). Stale identity entries remain in the preference until they age out of the cap. --------- Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com> |
||
|
|
b288cd8b0e |
feat(ui): add collection query dashboard widget (#16812)
## Summary Adds a built-in `collection-query` dashboard widget that lets users show recent documents from a selected collection. https://github.com/user-attachments/assets/125ba1af-9904-4f56-aa4a-b48ffd2fcb21 The widget supports: - selecting a collection - filtering with the same where builder used by query presets - choosing a sort field and direction - setting a result limit The persisted widget config stores fragile schema references like collection slugs, sort fields, and filter paths as strings. I added coverage for schema drift cases where those strings fall out of sync with the current config, which should usually only happen after schema changes or migrations. Instead of letting the dashboard fail during the query, the widget renders a clear configuration error that points to the stale collection, sort field, or filter field. The default dashboard widgets are registered later in config sanitization so their generated collection options are based on the final collection list, including internal collections added during sanitization. The built-in widget fields are sanitized at that later point so they still go through the same field normalization as user-defined dashboard widget fields. ## Scope This first pass only includes the list-style widget. I am intentionally leaving out `count`, `table`, `summary`, and `gallery` variants for now: - `count` can be useful, but a number by itself usually needs more context, like comparison to a previous period. Those small variations would require extra fields and behavior, so it is better to wait for real usage patterns before generalizing it. - `table` should probably reuse the same table rendering used by collection list views, with configurable columns. That is useful, but it is a larger follow-up than this first PR. - `summary` needs a stronger use case and likely a configurable date field. For example, a scheduled publish card should show the scheduled publish date, not just `updatedAt`. - `gallery` could be useful for media-heavy collections, but it needs separate layout and image handling decisions. These can be revisited once we see how users configure dashboard widgets and which variants provide enough generic value. ## Test Plan - `pnpm run build:payload` - `pnpm run build:ui` - `pnpm exec eslint packages/payload/src/config/sanitize.ts packages/ui/src/widgets/CollectionQuery/index.tsx packages/ui/src/widgets/CollectionQuery/SortField/index.tsx packages/ui/src/widgets/CollectionQuery/shared.ts test/dashboard/config.ts` - `git diff --cached --check` --------- Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com> |
||
|
|
34ecc533cd |
refactor!: remove default config export (#17103)
Removed the export of config `defaults` exported from `payload`. ## Breaking Changes The `defaults` object exported from `payload` has been removed. It was deprecated because it was a single shared object and mutating it (or any config that used it as a base) leaked changes into every other consumer of the defaults. If you depended on reading a specific default value at runtime, read it from the sanitized config returned by `buildConfig` (or `payload.config`) instead of from the static `defaults` object. Alternatively, you can call `addDefaultsToConfig` to get the unsanitized config but populated with the default properties. --------- Co-authored-by: Alessio Gravili <github@gravili.net> |
||
|
|
e38ca5f1ad |
fix: follow redirects when fetching uploaded files for MIME type detection (#16709)
## Summary - When using S3 (or any storage adapter) with `signedDownloads` enabled, the `staticHandler` returns a `302` redirect to a pre-signed URL. Payload's `addDataAndFileToRequest` utility calls this handler internally to fetch back the just-uploaded file for MIME type detection, but Node.js `fetch` does not automatically follow redirects on a `Response` object returned from an internal handler call — resulting in an empty `ArrayBuffer` and a `DataView.getUint16()` bounds error. - The existing guard in the S3 `staticHandler` (`if (signedDownloads && !clientUploadContext)`) was meant to skip the redirect for internal upload processing, but fires anyway when `clientUploadContext` is `undefined` (i.e. when no upload context is configured for the collection). - Added redirect-following between receiving the handler response and consuming its body: if the response is a 3xx redirect, `addDataAndFileToRequest` now follows the `Location` header to retrieve the actual file bytes before processing. [3.x PR](https://github.com/payloadcms/payload/pull/16708) |
||
|
|
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
|
||
|
|
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>
|
||
|
|
b4813b3314 |
fix(sdk): use qs-esm allowEmptyArrays parameter (#17078)
Fixes https://github.com/payloadcms/payload/issues/17076 |
||
|
|
198e1a9e31 |
feat: reuse list view controls in query preset editor (#17023)
Rebuilds the Query Presets edit drawer to drive its group-by, columns,
and filter inputs with the list view's own controls — `GroupByButton`,
`ColumnSelectionButton`, and `WhereBuilder` — so editing a preset uses
the exact controls it configures.
Previously the drawer rendered a separate `GroupByBuilder` and a
`PillSelector`-based column picker that duplicated — and drifted from —
the real list controls. To share the real controls without state bleed,
they are first decoupled from `ListQuery`/`TableColumns` context and
turned into controlled components, then wired into the drawer via form
fields. `GroupByBuilder` and the old column picker are removed.
## Decoupling the controls from context
The list view controls — `WhereBuilder`, `GroupByButton`, and
`ColumnSelectionButton` — are decoupled from `ListQuery` and
`TableColumns` context, turning them into controlled components driven
purely by `value`/`columns` props and `onChange` callbacks.
Each control previously carried an internal `isFormMode` branch: when an
`onChange` prop was passed it wrote into form state, otherwise it
reached into `useListQuery`/`useTableColumns` and mutated the live
query. That dual path made the components hard to reuse and was the
source of state bleed between the list view and the Query Presets
drawer.
The context wiring now lives in three thin wrappers —
`ListWhereBuilder`, `ListGroupByButton`, and `ListColumnSelectionButton`
— that read from `useListQuery`/`useTableColumns` and pass
`value`/`columns` + `onChange` down to the underlying control. The list
view and `RelationshipTable` render the wrappers; the Query Presets
drawer renders the bare controls and binds them to form fields via
`useField`.
Before:
```tsx
// One component, two code paths
const isFormMode = typeof onChange === 'function'
const value = isFormMode ? valueProp : listQuery.query?.groupBy
const handleClear = useCallback(() => {
if (isFormMode) {
onChange('')
return
}
void listQuery.refineListData({ groupBy: '' })
}, [isFormMode, onChange, listQuery])
```
After:
```tsx
// GroupByButton — controlled, no context
const handleClear = useCallback(() => {
onChange('')
close()
}, [onChange, close])
// ListGroupByButton — wires context to the control
const { query, refineListData } = useListQuery()
return (
<GroupByButton
onChange={(groupBy) => refineListData({ groupBy, page: 1 })}
value={query?.groupBy ?? ''}
/>
)
```
`value`/`columns` and `onChange` are now required on the underlying
controls, so the call site always owns the state.
## Control composition
The Columns and Group By controls now share the same folder structure:
`index.tsx` exports the trigger button, `Popup.tsx` exports the panel
content.
- `ColumnsButton` → `ColumnSelection/index.tsx`
(`ColumnSelectionButton`)
- `ColumnSelector` → `ColumnSelection/Popup.tsx`
(`ColumnSelectionPopup`)
- `GroupByControl` → `GroupBy/index.tsx` (`GroupByButton`) +
`GroupBy/Popup.tsx` (`GroupByPopup`), splitting the previously inline
panel out of the button
- `ListColumnsButton` → `ListColumnSelectionButton`
- `ListGroupByControl` → `ListGroupByButton`
## Drawer layout
The drawer layout is regrouped to match the list controls: a `presets`
heading sits inline with the group-by and columns buttons via the new
`QueryPresetsHeading` UI field, and an `access` heading replaces the old
`Sharing settings` group label above the sharing controls.
## Drawer-specific `WhereBuilder` fixes
Fixes drawer-specific behavior surfaced by reusing the control:
- Condition mutations `structuredClone` the `value` prop before
splicing, so editing filters in the drawer no longer corrupts the list
view's `where` state that seeded it.
- `WhereBuilder` adds its first filter on a single click, where the
committed placeholder row was previously rejected by validation.
- Clearing a value on the drawer's uncommitted placeholder row no longer
re-commits an empty condition — empty `value` edits are ignored only on
that virtual first row, while field/operator picks still build it.
- The per-row remove button is now always rendered but **disabled** on
the drawer's uncommitted placeholder row (where there is nothing to
remove), rather than being hidden. A committed condition or a closable
panel keeps it enabled. This drops the previous `--no-actions`
grid-track collapse, keeping the layout stable.
- Renames the `WhereBuilder` `onClose` prop to `onEmpty`, since it
signals that the last condition was removed rather than closing anything
— the list view uses it to close the filter panel.
## Other supporting fixes
- `Popup` now detects a `position: fixed` ancestor (e.g. an open
`Drawer`) and positions itself with `fixed`, so popups no longer drift
when the background scrolls.
- The list `body` scroll-lock is reinforced in CSS, since faceless-ui's
JS lock could be cleared early when a sibling modal mounts closed.
- `DocumentDrawerHeader` skips the meta row entirely when there is no
status, timestamp, or autosave to show.
## Related
- Adds `ListWhereBuilder`, `ListGroupByButton`, and
`ListColumnSelectionButton` wrapper components.
- `QueryPresetsWhereField` now resolves `fields` from `getEntityConfig`
since the bare `WhereBuilder` no longer pulls them from context.
- Adds the `general:access` and `general:presets` translation keys
across all locales.
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1215779195608944
---------
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
|
||
|
|
da97094a95 |
fix: regenerate reused array and block row IDs during bulk update (#17017)
## What Fixes #13783. On relational databases (Postgres/SQLite) every array and block row lives in its own sub-table, and the row `id` is the **primary key, unique across the whole table** (not just within one document). A bulk edit applies the **same submitted data**, including the row ids the admin UI generated, to **every** matched document. The first document inserts fine; the next one tries to insert a row with an id that already exists, causing a duplicate-key `ValidationError` (Postgres `23505`). ## How In the bulk update operation, before applying the shared data to each matched document, we make a structural copy of the incoming data guided by the collection schema. During that single pass, for every array/block row we check the row `id` against the set of ids already stored in **that** document: - if the id already belongs to the document, we keep it and the row is updated in place; - if the id is not in the document, the row is new to that document, so we drop the client-supplied id and let Payload generate a fresh unique one (the same path as a row created without an id). Why this is safe on both fronts: - We never change an id that should stay: only ids absent from the document's stored rows are dropped, so an existing row keeps its id (and its saved values, including translations). - We never miss an id that should change: any row not already in the document gets a fresh id. This is scoped to the **bulk** update operation only. Single-document updates go through `updateByID` and are untouched, so there is no per-save cost on normal edits. The copy walks the schema recursively, so nested arrays/blocks, groups, and localized arrays are handled; the document's existing row ids are gathered with `traverseFields`. ## Tests Added integration tests in `test/array-update/int.spec.ts`: - A reused row id across two docs no longer collides; each doc receives its row with fresh, distinct ids (top-level and nested arrays). - A bulk update that reuses an existing row's id keeps that row's id and updates it in place, while other docs get fresh ids. - Localized arrays, a group with a nested array, and blocks with a nested array all get fresh ids per document on bulk update. Verified the new tests fail without the fix (duplicate-key errors on Postgres) and pass with it on both Postgres and MongoDB. --------- Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com> |
||
|
|
a8312b8b9f |
feat(ui): support grouped user menu settings sections (#17024)
Supports grouped `userMenuSettingsItems` registration across config and
UI, including optional localized group labels, merged group buckets by
label.
Updates the settings submenu rendering so grouped headings and child
rows follow the new separator + typography behavior, and adjusts MCP
registration/rendering to register under the `MCP` group as a row
button.
Includes import-map iteration support/tests for grouped settings entries
and a list-view `NoResults` slot key update to prevent duplicate-key
warnings when navigating through MCP settings.
### Example: registering custom settings menu items
```ts
import type { Config } from 'payload'
export const config: Config = {
// ...
admin: {
components: {
userMenu: {
// Flat entries are still supported and will be grouped under everything else
userMenuSettingsItems: [
'/components/UserMenuSettingsItemUngrouped#UserMenuSettingsItemUngrouped',
{
group: 'MCP',
items: [
'/components/UserMenuSettingsItemMCP#UserMenuSettingsItemMCP',
'/components/UserMenuSettingsItemLegacy#UserMenuSettingsItemLegacy',
],
},
{
group: ({ t }) => t('general:language'),
items: ['/components/UserMenuSettingsItemLanguage#UserMenuSettingsItemLanguage'],
},
{
// Group label is optional; these also fall back to "Other"
items: ['/components/UserMenuSettingsItemNoLabel#UserMenuSettingsItemNoLabel'],
},
],
},
},
},
}
```
### Example of settings menu items
<img width="457" height="377" alt="CleanShot 2026-06-17 at 00 38 32"
src="https://github.com/user-attachments/assets/fc1dd5ca-5ceb-4f8f-bce7-c10626320f00"
/>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
||
|
|
8efd0c6bbe |
fix(drizzle): preserve failing sub-table on unique-constraint ValidationError (#17014)
## What? When a database-level unique-constraint violation (Postgres `23505` / SQLite `SQLITE_CONSTRAINT_UNIQUE`) is raised on an array/block **sub-table** and converted into a `ValidationError` in `handleUpsertError`, only the bare column name (typically `id`) was preserved. The failing sub-table name and original constraint detail were discarded. As a result: - Editors see an unhelpful "The following field is invalid: id", with no indication of which array/block row or collection field is affected. - `afterError` hooks cannot enrich the message, because by the time they run `ValidationError.data` only contains `path: "id"`. Closes #16965. ## How? `handleUpsertError` already receives the failing `tableName`. This PR adds an optional `tableName` to `ValidationFieldError` and populates it when throwing the constraint `ValidationError`, so `afterError` hooks can map the failure back to a collection field/block. The dotted field path is intentionally **not** resolved at insert time. The previous attempt (#15754) did that by switching from a batch insert to per-row inserts to recover a per-row block path, and it was closed by its author because it caused data loss. This change is purely diagnostic and does **not** touch how rows are inserted. ## Notes - This is the error-message half of the array-id problem. The underlying id reuse (#14574, bulk update / #13783) is tracked and fixed separately. ## Tests - Adds `handleUpsertError.spec.ts` (unit): asserts a `23505` on an array sub-table throws a `ValidationError` carrying `tableName`, and that non-constraint errors are re-thrown unchanged. --------- Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com> |
||
|
|
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()`. |
||
|
|
fb447dc43c |
refactor!: remove publishSpecificLocale and snapshot version rows (#16945)
Removes `publishSpecificLocale`, the per-locale publish workaround that
predates `localizeStatus`. With per-locale status now auto-enabled in v4
for any localized collection/global with drafts, the mechanism is dead
code. Also removes snapshot version rows, which were created alongside
every `publishSpecificLocale` publish, doubled the version count, and
were always filtered from the UI.
## What changed
**Core removal:**
- Deletes `saveSnapshot.ts` and removes `snapshot`,
`publishSpecificLocale`, and `publishedLocale` from `saveVersion` params
- Removes the `publishSpecificLocale` branch from
`collections/operations/utilities/update.ts` and
`globals/operations/update.ts`
- Extracts the single-locale publish logic into
`versions/buildSingleLocalePublishData.ts`, which calls
`mergeLocalizedData` recursively to correctly merge nested groups,
blocks, and arrays across all locale depths
- Removes `versionSnapshotField` from `baseFields.ts`,
`buildCollectionFields.ts`, and `buildGlobalFields.ts` — the `snapshot`
column is no longer written for new rows
- Removes `snapshot: { not_equals: true }` filters from `getVersions.ts`
and `Versions/index.tsx` (snapshot rows no longer exist)
- Removes `publishSpecificLocale` from `PublishButton` query params and
all operation arg types
**Predefined migrations** (`migrateLocalizeStatus`) wired for all three
adapters (MongoDB, Postgres, SQLite). The Postgres/SQLite migration
reads the old `snapshot` column to reconstruct publish history, but
guards the `SELECT` with an `information_schema` existence check so
users who never used the feature (no column) are unaffected.
**Codemod** — `remove-publish-specific-locale` transform removes
`publishSpecificLocale` from `payload.update()` /
`payload.updateGlobal()` call sites, renaming it to `locale` when no
`locale` property already exists.
## 🚨 Breaking Changes
### ⚠️ Publishing now defaults to a single locale
This is the change most likely to affect existing code, even if you
never used `publishSpecificLocale`. Per-locale status is now
auto-enabled for any localized collection/global with drafts. When it is
on, publishing scopes to the request `locale` instead of publishing
every locale at once.
Where a publish previously affected all locales, it now affects only the
active locale. To restore the old behavior, pass `publishAllLocales:
true` explicitly:
```ts
await payload.update({
collection: 'posts',
id,
publishAllLocales: true,
data: { _status: 'published' },
})
```
The same applies to unpublishing — pass `unpublishAllLocales: true` to
unpublish every locale at once rather than just the active one:
```ts
await payload.update({
collection: 'posts',
id,
unpublishAllLocales: true,
data: { _status: 'draft' },
})
```
### 🚨 Data migration required
Per-locale status changes the stored shape of `_status` from a single
string to a per-locale object. New writes use the new shape
automatically, but existing version rows/documents keep the old shape
until migrated, leaving a mixed dataset. The migration is **not** run
automatically — you must generate and run it.
MongoDB does not auto-run migrations, and Postgres/SQLite `push` mode
does not cover this data transform, so the steps are the same for every
adapter. Generate the predefined migration for your adapter:
```bash
# MongoDB
payload migrate:create --file @payloadcms/db-mongodb/localize-status
# Postgres
payload migrate:create --file @payloadcms/db-postgres/localize-status
# SQLite
payload migrate:create --file @payloadcms/db-sqlite/localize-status
```
Then run it (typically in your build/deploy pipeline):
```bash
payload migrate
```
The migration converts each version's `_status` to a per-locale object
and removes the obsolete snapshot rows. On Postgres/SQLite it reads the
old `snapshot` column to reconstruct publish history, guarding the read
with an existence check so databases that never used the feature are
unaffected.
### Removal of `publishSpecificLocale`
Affects any code that passes `publishSpecificLocale` to
`payload.update()`, `payload.updateGlobal()`, or their REST/local API
equivalents.
Passing `locale` already scopes the publish to that locale, so
`publishSpecificLocale` was a redundant override of the same thing.
Remove it:
```diff
await payload.update({
collection: 'posts',
id,
locale: 'en',
- publishSpecificLocale: 'en',
data: { _status: 'published' },
})
```
#### Codemod
To migrate usages of `publishSpecificLocale`, there's a codemod for this
change available by running:
```bash
npx @payloadcms/codemod --transform remove-publish-specific-locale
```
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
||
|
|
6ca20012cb | fix(ui): convert Version SCSS to CSS and fix linked-cell button styling (#16983) | ||
|
|
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> |
||
|
|
4ae01e6824 |
feat: export parseParams and related sanitizers (#16788)
## Summary - Exports `parseParams` from the main `payload` entrypoint along with its `RawParams` / `ParsedParams` types - Exports `sanitizeSortParams` (the remaining sanitizer used inside `parseParams`) and the `JoinParams` type for completeness alongside the already-exported `sanitizeJoinParams` / `sanitizePopulateParam` / `sanitizeSelectParam` ## Why Custom REST endpoints that shadow a built-in route (e.g. a collection endpoint at `/versions/:id` with additional customization) need the same query-string coercion the default handlers run. `parseParams` already implements all of this, but it is not part of the package's `exports` map, which forces consumers to either deep-import via `payload/dist/...` or vendor a copy. Exposing it lets endpoint authors stay aligned with core behavior automatically. `sanitizeSortParams` is the only sanitizer used by `parseParams` that was not already re-exported, and `JoinParams` is the input type of the already-exported `sanitizeJoinParams` — including both rounds out the surface. |
||
|
|
6ed9b8507a |
feat(ui): redesign user menu popup and move logout out of nav (#16950)
Redesigns the admin `UserMenu` popup with nested `theme`, `language`, and `settings` submenus, including in-place mobile submenu navigation with a back header. Removes logout from `nav__controls` and routes logout through `UserMenu`, so top-level nav controls now only render settings actions. Updates e2e flows that targeted `nav__controls` logout links to use the `UserMenu` trigger/logout path. https://github.com/user-attachments/assets/48d8af36-8a35-4f26-a868-1b73d664d978 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
ce898ea7c7 |
fix: prevent reordering from unpublishing documents that have a newer draft (#16968)
## Summary
Reordering a document via the `orderable` drag-and-drop list view could
unpublish it when the document had both a published version and a newer
draft.
The reorder endpoint updated each moved document with `payload.update`
without a `draft` flag. With `draft` defaulting to `false`, the update
loaded the document's latest version (the draft) as its base and wrote
that draft's `_status: 'draft'` into the published main row, effectively
unpublishing the document. A subsequent `status: published` query then
returned 404 and no "currently published" version remained.
This fix detects, for drafts-enabled collections, whether the document's
latest version is a draft and, if so, performs the order update with
`draft: true`. This preserves the published main document (no unpublish)
while still updating the order on the version the list view reads (the
list queries with `draft: true`). Published-only documents keep the
existing behavior.
Reported via support ticket; reproduced on `3.84.1`.
## Changes
- `packages/payload/src/config/orderable/index.ts`: in the reorder
endpoint, look up the latest version for drafts-enabled collections and
pass `draft: true` when the latest version is a draft.
- `test/sort/int.spec.ts`: add an integration test covering reordering a
published document that has a newer draft, asserting it stays published.
## Test plan
- [x] New test fails before the fix (`expected 'draft' to be
'published'`) and passes after.
- [x] Existing reorder tests pass (`pnpm test:int:sqlite sort -t
reorder`).
- [ ] Verify on MongoDB (`pnpm test:int sort`) in CI.
- [ ] Manual check: collection with `orderable: true` and `versions: {
drafts: true }`; publish a doc, edit it into a newer draft, drag-reorder
it in the list view, and confirm it stays published.
Note: the unrelated `should sort by createdAt as fallback` test on
`nonUniqueSortSlug` is flaky on SQLite (createdAt tie-breaking) and is
not affected by this change.
Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com>
|
||
|
|
ce8308fc35 |
refactor!: consolidate blocks and blockReferences properties (#16951)
**BREAKING:** This PR consolidates the `blocks` and `blockReferences` field config properties into a single `blocks` property. `blocks` now accepts both inline block configs and block reference strings. This affects you in two ways: - If you were using `blockReferences`, rename it to `blocks` - If you read Payload config directly, TypeScript will now require you to handle string block references in `blocks` --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1215559100838374 |
||
|
|
337cf539c3 |
feat(ui)!: make groupBy a per-user UI preference (#16947)
Makes collection list-view grouping a per-user UI preference instead of
a per-collection config flag, and removes its experimental status.
Previously, `groupBy` had to be enabled per collection via
`admin.groupBy: true` (marked `@experimental`), which gated whether the
toolbar control rendered at all. Now the control is available on every
collection list view that has at least one groupable field, and
self-hides when none exist. Whether a list is grouped is the existing
per-user `groupBy` preference (a field path; empty means not grouping),
so it is off by default until a user picks a field from the toolbar. No
preference-shape migration is required.
The field-level `admin.disabled.groupBy` (which excludes a single field
from the group-by selector) is unchanged.
## Breaking Changes
This only affects configs that set `admin.groupBy` on a collection. The
property no longer exists, so referencing it is a type error.
```diff
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
- groupBy: true,
useAsTitle: 'title',
},
fields: [
// ...
],
}
```
Delete the line. Grouping is now available to every user from the
list-view toolbar (off until they select a field), so no replacement
config is needed.
#### Codemod
To migrate automatically, there's a codemod for this change available by
running:
```bash
npx @payloadcms/codemod --transform remove-group-by-true
```
|
||
|
|
82d720c6f9 |
feat: collection-level disableBulkDelete (#16944)
## Summary Adds a collection-level `disableBulkDelete` config option, completing the work started in #12850 (which added collection-level `disableBulkEdit`). Previously, `disableBulkDelete` only existed as a List View UI prop, so setting it hid the bulk delete button but did **not** prevent bulk deletes via the REST, GraphQL, or Local API. A user with `delete` permission could still delete every document at once through `payload.delete({ where })` or `DELETE /api/:collection?where=...`. This brings `disableBulkDelete` to full parity with `disableBulkEdit`: - New collection config option `disableBulkDelete`. - Server-side enforcement: the bulk `deleteOperation` now throws a `403` when `disableBulkDelete` is set and access is not overridden, mirroring the existing guard in `updateOperation`. - The bulk delete UI action is hidden automatically, because the option is threaded into the List view props (which also covers the trash and hierarchy views, since they all route through `renderListView`). Single-document deletes (`deleteByID`) are unaffected, so "delete one, not delete many" works out of the box without a custom `access.delete` check. ## Background and timeline `disableBulkEdit` and `disableBulkDelete` both started life as List View component props, but only `disableBulkEdit` was ever promoted to a real collection config option with API enforcement. This PR closes that asymmetry. | Layer | `disableBulkEdit` | `disableBulkDelete` (before) | `disableBulkDelete` (this PR) | | ----------------------------------------- | ----------------- | ---------------------------- | ----------------------------- | | List View UI prop (hides the button) | Yes, since #2346 | Yes, since #7796 | Yes (unchanged) | | Collection config option | Yes, since #12850 | No | Yes (new) | | Server / API enforcement (403 on bulk op) | Yes, since #12850 | No | Yes (new) | Timeline: 1. #2346 (`feat: bulk-operations`): `disableBulkEdit` introduced as a UI prop. 2. #7796: `disableBulkDelete` introduced as a List View UI prop (UI only, never promoted). 3. #12850 (`feat: collection-level disableBulkEdit`): `disableBulkEdit` promoted to a collection config option with API enforcement. `disableBulkDelete` was left behind. 4. This PR: promotes `disableBulkDelete` to the same level. ## Note on trash / soft delete This scopes `disableBulkDelete` to the bulk delete operation, exactly mirroring how `disableBulkEdit` guards the bulk update operation. In Payload, bulk "move to trash" is a bulk update (it sets `deletedAt`), so it runs through `updateOperation` and is therefore governed by `disableBulkEdit`, not `disableBulkDelete`. Restore is also an update. A collection that wants to lock down both bulk edits and bulk removals can set both flags, and they compose cleanly. Finer-grained trash UX can be a follow-up if desired. ## Test plan - [x] Added a `disabled-bulk-delete-docs` collection with `disableBulkDelete: true` in `test/collections-rest`. - [x] Added an integration test mirroring the existing `disableBulkEdit` test: REST bulk `DELETE` returns `403`, Local API bulk delete with `overrideAccess: false` rejects with `APIError`, single-document delete by `id` still works, and bulk delete still works when access is overridden. - [x] `pnpm run test:int collections-rest -t "bulk"` passes. Co-authored-by: German Jablonski <GermanJablo@users.noreply.github.com> |
||
|
|
386805c344 |
feat!: scope default access control to the admin user collection (#16906)
Scopes Payload's default Access Control to the collection used to access the Admin Panel. `defaultAccess` now returns `true` only when a user is present on the request **and** that user belongs to the `config.admin.user` collection, instead of granting access to any authenticated user. Previously, adding a second [Authentication](https://payloadcms.com/docs/authentication/overview)-enabled collection silently granted its users access to every collection, global, locked document, query preset, and job that relied on the default. This closes that gap: secondary auth collections start with no default access and must opt in via their own Access Control functions. ## Breaking Changes This only affects projects with more than one auth-enabled collection that rely on Payload's default Access Control (i.e. collections, globals, or other entities with no custom `access` functions defined). Users authenticated from a collection other than `config.admin.user` are now denied by default. The default function changed as follows: ```diff - const defaultAccess = ({ req: { user } }) => Boolean(user) + const defaultAccess = ({ req: { payload, user } }) => + Boolean(user) && user.collection === payload.config.admin.user ``` To restore the previous behavior for an entity, define an explicit Access Control function that checks only for a user: ```ts export const MyCollection: CollectionConfig = { slug: 'my-collection', access: { read: ({ req: { user } }) => Boolean(user), create: ({ req: { user } }) => Boolean(user), update: ({ req: { user } }) => Boolean(user), delete: ({ req: { user } }) => Boolean(user), }, fields: [ // ... ], } ``` --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1214259839775112 |
||
|
|
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.
|
||
|
|
619536e0a5 |
feat!: enable versions by default on collections and globals (#16871)
## Summary
- Versions (without drafts, capped at 100 per document/global) are now
**enabled by default** for all collections and globals, removing the
need to opt in
- `versions: false` must be set explicitly to opt out; a codemod is
provided to automate the migration with zero schema changes
- A second codemod strips the now-redundant bare `versions: true` from
existing configs
## What changed
**Core behaviour**
- `packages/payload/src/collections/config/defaults.ts` — `versions`
default changed from `false` to `true`
- `packages/payload/src/globals/config/sanitize.ts` — added
`global.versions = global.versions ?? true` so globals follow the same
default
**System collections/globals that must not accumulate version history**
— `versions: false` added to each:
- Built-in users collection (`auth/defaultUser.ts`)
- `payload-migrations`, `payload-preferences`,
`payload-locked-documents`, `payload-kv`, `payload-jobs`,
`payload-jobs-stats`, `payload-query-presets`
**Templates** — `versions: false` added to all Users collections and all
globals (Header/Footer across ecommerce, website, with-vercel-website
templates)
**Codemods** (`packages/codemod/`)
- New `migrate-versions-default` transform — adds `versions: false` to
every `CollectionConfig` and `GlobalConfig` that does not already have a
`versions` property; covers `: Type`, `satisfies Type`, and `as Type`
annotation forms
- New `remove-versions-true` transform — removes the now-redundant bare
`versions: true` boolean from `CollectionConfig` and `GlobalConfig`
objects (object-form configs such as `versions: { drafts: true }` are
left untouched)
- Both transforms registered in `registry.ts` and documented in
`README.md`
**Docs**
- `docs/versions/overview.mdx` — updated banner and collection/global
config sections to reflect the opt-out model; added code examples for
both
- `docs/migration-guide/v4.mdx` — new breaking-change entry covering
both collections and globals, explaining the schema impact, and
documenting both codemod commands
|
||
|
|
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'
```
|
||
|
|
aba3e7b663 |
feat: bin script to generate import map and types on build (#16861)
# Overview
Add a `payload build` command that generates the import map and TS types
before running `next build`, so production builds for projects using
Payload always bundle an up-to-date import map.
The import map is statically imported at compile time, so it must exist
on disk before `next build` bundles it. Today projects must remember to
run `payload generate:importmap` manually before building; this couples
the two into one command.
_This also adds an opportunity for intelligently running the proper
build command for tanstack in the future without having to update the
templates accordingly._
```diff
"scripts": {
- "build": "next build"
+ "build": "payload build"
}
```
## Key Changes
- **New `payload build` bin command**
- Generates the Import Map, then the types (skippable with
`--no-types`), then spawns the project's `next build`.
- Resolves the project's `next` binary from `next/package.json`, so it
works under both npm scripts and `npx`.
- Forwards extra arguments verbatim (e.g. `payload build --turbopack`)
and propagates `next build`'s exit code so CI fails on a failed build.
Aborts with a non-zero exit (without spawning) if generation fails.
- **Templates and examples adopt `payload build`**
- Updated the `build` script across templates and examples, including
the nested Payload apps in `examples/astro` and `examples/remix`. The
standalone `generate:importmap` / `generate:types` scripts are kept for
manual use.
- **Consistent build logging**
- The import map generator now logs through the Payload logger, matching
the types generator, so `payload build` output is uniform across both
pre-build steps.
- **Codemod for existing projects**
- A `migrate-build-script` transform flips `next build` to `payload
build`. Details below.
## Codemod
To migrate automatically, there's a codemod for this change available by
running:
```bash
npx @payloadcms/codemod <project-path> --transform migrate-build-script
```
## Design Decisions
A dedicated `payload build` command was chosen over template-only script
chaining (`payload generate:importmap && next build`) to provide a
single reusable entry point with centralized error handling and room to
own further pre-build steps later.
`payload build` always regenerates the Import Map regardless of
`admin.importMap.autoGenerate`, matching the existing behavior of the
explicit `generate:importmap` command. An explicit build command implies
explicit intent.
The codemod targets `package.json` rather than TypeScript source, which
the existing ts-morph-based framework did not handle. The framework was
extended (rather than adding a separate tool) so the migration ships
through the same `payload-codemod` CLI. Transforms remain
filesystem-free, with the CLI loading, diffing, and writing
`package.json` files in parallel to how it already handles source files.
## Overall Flow
```mermaid
sequenceDiagram
participant npm as npm "build" script
participant payload as payload build
participant gen as Import Map / types generators
participant next as next build
npm->>payload: run "payload build [args]"
payload->>gen: generate Import Map (+ types unless --no-types)
alt generation fails
gen-->>payload: error
payload-->>npm: exit 1 (next build never runs)
else generation succeeds
gen-->>payload: files written to disk
payload->>next: spawn next build with forwarded args
next-->>payload: exit code
payload-->>npm: propagate exit code
end
```
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1215426886664366
|
||
|
|
9d907930bb |
refactor!: use $defs instead of definitions in generated JSON Schema (#16868)
**BREAKING**: The JSON Schema that Payload builds to generate your types now uses the modern `$defs` keyword instead of the old `definitions` keyword. All internal references now use `#/$defs/...` instead of `#/definitions/...`. ## Why `$defs` is the keyword in the current JSON Schema spec - `definitions` is the legacy name. `json-schema-to-typescript` supports `$defs`, so we use the modern form. See https://github.com/bcherny/json-schema-to-typescript/issues/470#issuecomment-1207323381 ## Does this affect me? Your generated `payload-types.ts` is **exactly the same** - nothing changes there. You are only affected if your own code reads or writes the schema directly, usually through a `config.typescript.schema` or `field.jsonSchema` function. ## How to migrate Swap `definitions` for `$defs`: ```diff - jsonSchema.definitions.MyType = { type: 'string' } + jsonSchema.$defs.MyType = { type: 'string' } ``` ```diff - { $ref: '#/definitions/posts' } + { $ref: '#/$defs/posts' } ``` --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1215385258435225 |
||
|
|
91f681c085 |
fix: fix draft save and duplicate behaviour on upload-enabled collections (#16844)
## Summary Fixes two related bugs affecting collections with both `upload: true` and `versions.drafts: true` enabled. ### Bug 1 — Published file deleted when saving a draft over a published document Fixes https://github.com/payloadcms/payload/issues/16633 When saving a draft on a document whose current (main) version is `published`, `updateDocument` was calling `deleteAssociatedFiles` unconditionally. This deleted the file still actively referenced by the published document, breaking its file reference even though no publish was happening. **Fix:** Skip `deleteAssociatedFiles` when `isSavingDraft && docWithLocales._status === 'published'`. The deletion is safe to perform when the latest version is already a draft (the old draft file is being replaced), but not when the main document is still published. ### Bug 2 — Duplicating a published document creates a published copy instead of a draft Fixes https://github.com/payloadcms/payload/issues/16831 When duplicating via `payload.create({ duplicateFromID })` with `draft: true`, `generateFileData` uses `originalDoc` as the base for the returned document data (to carry over file metadata). Because `originalDoc._status` is `'published'`, that value survived the merge and overwrote the `_status: 'draft'` set earlier in `create.ts`. **Fix:** Added a `draft` parameter to `generateFileData`. When `draft: true`, the function enforces `_status: 'draft'` on the returned data after the file metadata merge. `create.ts` passes `isSavingDraft` as this argument. |
||
|
|
da6c6583eb |
feat!: always generate a top-level interface for blocks (#16784)
**BREAKING:** Blocks used to get a top-level interface in `payload-types.ts` only when `interfaceName` was set; otherwise the fields were inlined wherever the block appeared. Now every block always generates a top-level interface. `interfaceName` becomes an **override** of the auto-derived name rather than the switch that enables generation. The auto-derived name is a PascalCase form of the slug via `toWords(slug, true)` (`'content-block'` → `ContentBlock`). Reason for this change: as part of lexical type gen improvements, we're outputting generated types that look like this: `SerializedBlockNode<Block1 | Block2 | Block3>`. If the blocks (Block1,Block2,Block3) do not exist as top-level interfaces, we cannot reference them in generics like these. And due to a limitation in the `json-schema-to-typescript` package, we cannot output block types inline within generics. We can output them inline anywhere else, which is what we have been doing so far if no `interfaceName` was set. But now there is a need for outputting them within generics, which is not possible inline |
||
|
|
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> |
||
|
|
7af6c370d9 |
feat(plugin-mcp)!: mcp plugin refactor, add stdio mcp (#16726)
This PR refactors `@payloadcms/plugin-mcp`. The public API and main
ideas stay mostly the same, but the config shape, access model, and
internals all changed. The new architecture lands in one PR; follow-up
improvements are planned separately.
## Breaking changes
The plugin config API changed across the board, so most setups need
updating.
### Collections and globals are opt-out, not opt-in
Installing the plugin is now enough to get a working MCP server: every
collection and global is exposed by default with the standard CRUD tools
(find, create, update, delete). You opt OUT of what you don't want
instead of opting IN to each piece.
After upgrading, collections you never listed before are reachable over
MCP. Review what's exposed and disable anything that shouldn't be.
```diff
mcpPlugin({
collections: {
- posts: { enabled: true },
- users: { enabled: { find: true } },
+ // posts is exposed automatically, no entry needed
+ users: { tools: { create: false, update: false, delete: false } }, // find only
},
})
```
### A consistent shape for tools, prompts and resources
Registering an MCP capability used to mean juggling three different API
shapes:
- `collections.<slug>.enabled.<op>`: built-in CRUD. Not keyed under
`tools`.
- `mcp.tools[]`: custom tools. Keyed under `tools`, but as an array.
- `experimental.tools.<kind>`: auth and codegen. Keyed under `tools`
again, but as a nested map per kind.
Now there's one shape everywhere: a `tools` / `prompts` / `resources`
map, applied either nested under a collection or global
(`config.collections[slug].tools`, `config.globals[slug].tools`) or at
the top level (`config.tools`).
**Before.**
```ts
mcpPlugin({
collections: {
posts: { enabled: { find: true, create: true } },
},
mcp: {
tools: [
{ name: 'diceRoll', parameters: z.object({ sides: z.number() }).shape, handler: (args, req) => ({...}) },
],
prompts: [
{ name: 'echo', argsSchema: z.object({ msg: z.string() }), handler: (args, req) => ({...}) },
],
resources: [
{ name: 'data', uri: 'data://app', handler: (uri, req) => ({...}) },
],
},
experimental: {
tools: {
auth: { enabled: true },
collections: { enabled: true, collectionsDirPath: './src/collections' },
jobs: { enabled: true, jobsDirPath: './src/jobs' },
},
},
})
```
**After.** One `name: value` map at two scopes. The same map holds
built-in tools, overrides, opt-in auth tools, and custom tools side by
side:
```ts
mcpPlugin({
collections: {
posts: {
tools: {
find: { description: 'Find published posts' }, // built-in override
publish: defineCollectionTool({ // custom collection tool
input: z.object({ id: z.string() }),
}).handler(async ({ input, req }) => ({...})),
},
},
users: {
tools: { login: true, verify: true }, // opt-in built-in auth tools
},
},
tools: { diceRoll: defineTool({ input: z.object({ sides: z.number() }) }).handler(({ input }) => ({...})) },
prompts: { echo: definePrompt({ argsSchema: z.object({ msg: z.string() }) }).handler(({ input }) => ({...})) },
resources: { data: { uri: 'data://app', handler: ({ uri }) => ({...}) } },
})
```
### Fully typed tool handlers
The schema you pass as `input` flows into the handler's argument:
```ts
defineTool({
input: z.object({
sides: z.number().int().min(2).max(100),
label: z.string().optional(),
}),
}).handler(({ input }) => {
input.sides // number
input.label // string | undefined
})
```
Same inference for all four builders (`defineTool`,
`defineCollectionTool`, `defineGlobalTool`, `definePrompt`). Inputs
accept any Standard Schema (Zod, Valibot, etc.) or a raw JSON Schema.
The old `parameters: ZodRawShape` is gone, and handlers now take named
arguments instead of positional ones.
### Nested instead of hoisted
Two places in the old plugin packed unrelated concepts into the same
top-level namespace, which made name conflicts easy. Both are now nested
by kind.
**Tool inputs.** The built-in `create` and `update` tools used to place
document fields right next to option fields like `depth`, `draft`,
`locale` and `select`. A field literally called `draft` or `depth` would
collide with the option of the same name. Document fields now live under
their own `data` key:
```ts
// arguments to a `createPosts` tool call
// before
{ title: 'Hello', _status: 'draft', draft: true, depth: 2 }
// after
{ data: { title: 'Hello' }, draft: true, depth: 2 }
```
`_status`, `id`, `createdAt` and `updatedAt` are also stripped from
`data` since Payload manages them.
**API key access document.** The old document hung every collection,
global, tool, prompt and resource off the root, with custom items wedged
under awkward `payload-mcp-tool` / `payload-mcp-resource` /
`payload-mcp-prompt` keys. Everything now goes into one `access` JSON
field, nested by kind:
```ts
// before (excerpt of the api key doc)
{
posts: { create: true, find: true },
'site-settings': { find: true, update: false },
'payload-mcp-tool': { diceRoll: true },
'payload-mcp-resource': { data: true },
'payload-mcp-prompt': { echo: true },
// Properties unrelated to access
user: 123,
description: 'Test',
}
// after
{
access: {
collections: { posts: { create: true } },
globals: { 'site-settings': { update: false } },
tools: { diceRoll: true },
resources: { data: true },
prompts: { echo: true },
},
// Properties unrelated to access
user: 123,
description: 'Test',
}
```
### Auth tools are scoped and opt-in
The auth tools (`login`, `verify`, `forgotPassword`, `resetPassword`,
`unlock`, `auth`) used to be flat, development-only tools that took a
`collection` argument. They're now per-collection and bound to it:
```diff
- mcpPlugin({ experimental: { tools: { auth: { enabled: true } } } })
- // wire name: `login`, called with { collection: 'users', email, password }
+ mcpPlugin({ collections: { users: { tools: { login: true } } } })
+ // wire name: `loginUsers`, called with { email, password }
```
### A leaner public surface
A handful of options got removed:
- `mcp.handlerOptions` is gone. `verboseLogs` survived as
`mcp.verboseLogs`; `onEvent`, `maxDuration`, `disableSse`, `redisUrl`
and `basePath` were dropped (the new server has no SSE/Redis path).
- `experimental.tools` (the tools that scaffolded and edited collection,
job and config files on disk) was removed entirely, with no replacement.
- The `GET /api/mcp` route was dropped; only `POST` remains.
### API keys must be recreated
The `payload-mcp-api-keys` collection keeps its slug, but its fields
changed completely. Access used to be defined via multiple group and
checkbox fields. Now, the whole tree now lives in a single `access` JSON
field with a custom checkbox UI in the admin panel.
The old layout meant that on a postgres/drizzle db, adding or removing a
collection, tool, prompt or resource changed the schema, requiring a
migration. With the access tree kept as JSON, the table schema stays
stable and no migrations are required.
To migrate, delete your existing API keys after upgrading and create
fresh ones.
### Dependencies
Bundling `plugin-mcp/src/index.ts` with esbuild (externalizing
`payload`, `@payloadcms/ui`, `react`):
| Branch | Bundled `src/index.ts` (minified) |
| ---------------- | --------------------------------- |
| `origin/main` | **4,781 KB** |
| `feat/mcp-local` | **537 KB** |
About a **9x smaller bundle, ~4.3 MB shaved off**. The biggest single
contributor was the runtime use of `convertCollectionSchemaToZod`, which
called `import * as ts from 'typescript'`. We were **shipping the entire
TypeScript package** so we could transpile a generated source-code
string at runtime and new Function()-eval it into a zod schema - now,
`z.fromJSONSchema()` from zod v4 can now do this cleanly in a single
call.
The rest came from swapping the SDK. `@modelcontextprotocol/sdk@1.x` was
a bloated kitchen sink: Express 5, Hono, `@hono/node-server`, `cors`,
`express-rate-limit`, `jose`, `ajv` + `ajv-formats`, `pkce-challenge`.
**29 MB** unpacked with dependencies, plus `mcp-handler` (which added
Redis). `@modelcontextprotocol/server@2.0.0-alpha.2` is one runtime
dependency (`zod`) and one optional peer (`@cfworker/json-schema`). **6
MB** unpacked with dependencies.
In the next 2.0 alpha release, we'll be able to get rid of
@cfworker/json-schema to further cut bundle size.
@modelcontextprotocol/sdk@1.:
<img width="2984" height="1972" alt="screenshot 2026-05-22 at 18 45
23@2x"
src="https://github.com/user-attachments/assets/0dbb1434-e084-48e4-964c-346191c743d9"
/>
@modelcontextprotocol/server@2 alpha:
<img width="2976" height="1960" alt="screenshot 2026-05-22 at 18 45
50@2x"
src="https://github.com/user-attachments/assets/ca8c057b-7d17-4456-9c27-2a48b5c284e8"
/>
## What else is new
- **A stdio transport with near-zero setup.** Install the plugin and
point a local AI client at `npx payload-mcp`. No `plugins` array entry,
HTTP server, or API key required.
## The internal refactor
The biggest internal wins are that the public API is now used
internally, an improved, simpler folder structure, and the up-front
sanitization into a flat `items` array that's much easier to work with.
That collapses several parallel flows that used to coexist: a 545-line
`getMcpHandler.ts` that hand-registered every kind, a `tools/resource/*`
family that ran `new Function('z', ...)` per request, three
filesystem-codegen families (`tools/collection/*`, `tools/config/*`,
`tools/job/*`, now gone), and inline auth checks scattered across tool
files. Now the plugin runs through:
1. **Init**: `sanitizeMCPConfig` walks the user config and the
`builtinTools.ts` registry, producing a flat `items: MCPItem[]`.
2. **Request**: `mcpEndpoint` calls `getAuthorizedMCP` to resolve the
API key (or dev-mode session) and filter `items` against the access
document, then `buildMcpServer` iterates and registers each on mcp item.
Auth lives in one single file (`endpoint/access.ts`).
3. **stdio**: same as 2, but `stdio.ts` synthesizes a full-access
`AuthorizedMCP` and connects a `StdioServerTransport`.
### Per-request work, old vs new
The single biggest reduction is how tools get registered: the old plugin
had a separate iteration and code branch for every kind of thing it
could expose; the new one sanitizes all of them into one flat array at
boot and walks it once.
**Old: everything happens per request.** Each config source resolves to
its own bespoke tool file, and `mcpAccessSettings` (a flat per-slug
permissions object) is consulted at every register site to decide
whether to expose that specific tool.
```mermaid
flowchart TB
subgraph perReqOld["Per request"]
req[POST /api/mcp]
req --> auth["getDefaultMcpAccessSettings<br/>Bearer + API key lookup"]
auth --> mas["mcpAccessSettings<br/>(flat per-slug permissions)"]
mas --> setup["mcp-handler invokes setup callback"]
setup --> col["collections.<slug>.enabled.<op>"]
setup --> glb["globals.<slug>.enabled.<op>"]
setup --> exp["experimental.tools.{auth, collections, config, jobs}"]
setup --> mct["mcp.tools[]"]
setup --> mcpP["mcp.prompts[]"]
setup --> mcr["mcp.resources[]"]
col --> tr["tools/resource/{create,find,update,delete}.ts<br/>+ convertCollectionSchemaToZod (eval)"]
glb --> tg["tools/global/{find,update}.ts"]
exp --> texp["tools/auth/* · tools/collection/* (codegen)<br/>tools/config/* (codegen) · tools/job/* (codegen)"]
mct --> uh1["user handler"]
mcpP --> uh2["user handler"]
mcr --> uh3["user handler"]
mas -. access check .-> tr
mas -. access check .-> tg
mas -. access check .-> texp
mas -. access check .-> uh1
mas -. access check .-> uh2
mas -. access check .-> uh3
tr --> RT[server.registerTool]
tg --> RT
texp --> RT
uh1 --> RT
uh2 --> RP[server.registerPrompt]
uh3 --> RR[server.registerResource]
end
```
**New: sanitization during boot.**
```mermaid
flowchart TB
subgraph bootNew["Once at boot"]
cfg[plugin config + builtinTools registry] --> san[sanitizeMCPConfig]
san --> items["items: MCPItem[]"]
end
subgraph perReqNew["Per request"]
req[POST /api/mcp] --> auth["getAuthorizedMCP<br/>fetch API key document, filter flat items[] array"]
access["api-key.access JSON field"] --> auth
auth --> build["buildMcpServer<br/>one switch on item.type"]
build --> RT[server.registerTool]
build --> RP[server.registerPrompt]
build --> RR[server.registerResource]
end
items --> auth
```
The MCP plugin is now also enabled across the monorepo test suites, so
we exercise it internally.
|
||
|
|
5ca3afbe17 |
chore: 12x faster TypeScript type-checking across the monorepo (#16796)
TypeScript was very slow in this repo - worst when editing files in `test/`, and for ESLint (which runs TypeScript). This PR makes it a lot faster with a few small, type-only changes. ## Why it was slow Two simple root causes: 1. **A few core field helpers and config types made TypeScript build huge piles of throwaway types** over and over. The `Field` config types are big and self-referencing, and these helpers/types kept rebuilding them. A handful of one-line type changes stop that. 2. **Every test folder loaded the entire `test/` folder instead of its own files.** Each `test/<suite>/tsconfig.json` only said `extends`, which made it pull in all ~1,600 test files (plus 87 clashing type declarations) every time you opened one test file. ## What changed (4 small fixes) 1. **Field helper functions** (`fieldAffectsData` and ~8 siblings, used in 100+ places): they narrowed a type by _combining_ it with a big list of field types, which made TypeScript build hundreds of throwaway types on every call. Changed them to _pick_ from the types that already exist instead. Same result, almost no new types created. 2. **Dashboard widgets type**: the sanitized config ran the whole (big, recursive) field type through the DeepRequired helper, rebuilding it from scratch. 3. **Import-map helper (`hasKey`)**: same "combine with a big type" problem. Removed it (callers didn't need it and it's internal-only) 4. **Test folder tsconfigs**: gave each `test/<suite>/tsconfig.json` its own file list so it only loads its own folder, not all of `test/`. ## Benchmarks ### Per package (`tsc --noEmit`, each package's own code) ``` pnpm exec tsc -b packages/payload && pnpm exec tsc -p packages/payload/tsconfig.json --noEmit --extendedDiagnostics ``` | Package | Before | After | Faster | Types before → after | | ---------------- | ------ | --------- | -------- | -------------------- | | **next** | 62.7 s | **5.1 s** | **~12×** | 186,227 → 69,716 | | **payload** | 44.0 s | **5.1 s** | **~9×** | 492,198 → 110,403 | | **ui** | 28.7 s | **7.1 s** | **~4×** | 187,494 → 104,349 | | db-mongodb | 3.2 s | 1.8 s | ~1.8× | 69,486 → 68,060 | | drizzle | 2.3 s | 1.6 s | ~1.4× | 127,890 → 126,040 | | richtext-lexical | 3.6 s | 3.3 s | ~same | 74,602 → 73,993 | | db-postgres | 0.29 s | 0.29 s | same | 24,461 → 24,461 | | translations | 0.33 s | 0.30 s | same | 51,282 → 51,282 | The big wins (payload, next, ui) are the packages that use the field types heavily. ### Whole monorepo build ``` pnpm bf ``` | | Before | After | | ------------------ | ----------------------------------------------- | ------------------ | | Full build | 2m27 | **47s** | ### Test packages | | Before | After | | ----------------------------------------------- | ---------------------------------- | --------------------------------- | | `test/fields` suite | Ran out of memory | **20.0 s** | | Single test file that imports `payload` | **76.6 s**, 4.25 GB, 489,838 types | **4.6 s**, 0.62 GB, 113,079 types | Single-file: **~17× faster, ~7× less memory, ~4× fewer types.** ### Editor latency (real `tsserver` - open a file, time until errors show) | Open this file | Before (load / errors) | After (load / errors) | | ---------------------------------------------- | ---------------------- | --------------------- | | **a `test/` file** (`test/fields/int.spec.ts`) | 13.7 s / **14.0 s** | 2.6 s / **2.5 s** | | a `payload` source file (`config/client.ts`) | 1.5 s / 3.1 s | 1.2 s / 1.4 s | |
||
|
|
1b29f7c42a |
refactor(next)!: move templates and elements to @payloadcms/ui (#16765)
Requires #16763 and #16753. Moves all elements and templates defined in the `@payloadcms/next` package to `@payloadcms/ui`. This is in effort to convert the `@payloadcms/next` package into a framework adapter. **Relocated exports** — code physically moved from `@payloadcms/next` to `@payloadcms/ui`: | Component | Old source | New source | | -------------------------- | ----------------------------- | ---------------------- | | `DefaultTemplate` | `@payloadcms/next/templates` | `@payloadcms/ui/rsc` | | `DefaultTemplateProps` | `@payloadcms/next/templates` | `@payloadcms/ui/rsc` | | `MinimalTemplate` | `@payloadcms/next/templates` | `@payloadcms/ui/rsc` | | `MinimalTemplateProps` | `@payloadcms/next/templates` | `@payloadcms/ui/rsc` | | `DocumentHeader` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` | | `DefaultNav` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` | | `Logo` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` | | `HierarchyTypeFieldServer` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` | | `DefaultNavClient` | `@payloadcms/next/client` | `@payloadcms/ui` | | `HierarchyTypeField` | `@payloadcms/next/client` | `@payloadcms/ui` | | `NavSidebarToggle` | `@payloadcms/next/client` | `@payloadcms/ui` | | `NavWrapper` | `@payloadcms/next/client` | `@payloadcms/ui` | This PR also removes the now unneeded export aliases. These were in place for backwards compatibility during v3. Now, we enforce that all imports point to its canonical source, not the aliased export. | Component | Old source | New source | | -------------------------- | ------------------------- | --------------------- | | `CollectionCards` | `@payloadcms/next/rsc` | `@payloadcms/ui/rsc` | | `SlugField` | `@payloadcms/next/client` | `@payloadcms/ui` | | `QueryPresetsAccessCell` | `@payloadcms/next/client` | `@payloadcms/ui` | | `QueryPresetsColumnField` | `@payloadcms/next/client` | `@payloadcms/ui` | | `QueryPresetsColumnsCell` | `@payloadcms/next/client` | `@payloadcms/ui` | | `QueryPresetsGroupByCell` | `@payloadcms/next/client` | `@payloadcms/ui` | | `QueryPresetsGroupByField` | `@payloadcms/next/client` | `@payloadcms/ui` | | `QueryPresetsWhereCell` | `@payloadcms/next/client` | `@payloadcms/ui` | | `QueryPresetsWhereField` | `@payloadcms/next/client` | `@payloadcms/ui` | The `./client`, `./rsc`, and `./templates` subpath exports on `@payloadcms/next` are removed entirely. #### Codemod To migrate automatically, there's a codemod for this change available by running: ```bash npx @payloadcms/codemod --transform migrate-next-subpath-exports ``` |
||
|
|
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. |
||
|
|
a1b10a2271 |
feat!: rename field typescriptSchema to jsonSchema (#16783)
**BREAKING:** The `typescriptSchema` field property has been renamed to
`jsonSchema`.
```diff
{
name: 'tags',
type: 'json',
- typescriptSchema: [() => ({ type: 'array', items: { type: 'string' } })],
+ jsonSchema: [() => ({ type: 'array', items: { type: 'string' } })],
}
```
Reasoning:
- name is more accurate, since json schema is what this property expects
as return value
- with the coming plugin-mcp updates, the json schemas will also be used
for mcp tools & validation, not just for typescript generated types.
|
||
|
|
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. |
||
|
|
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),
// ...
}
```
|
||
|
|
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`.
|
||
|
|
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> |
||
|
|
ebdb3a7fd3 |
refactor!: remove allowLocalizedWithinLocalized compat flag and PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY env var (#16693)
## BREAKING
Removes `config.compatibility.allowLocalizedWithinLocalized` and the
`PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY` env var. Sanitize no longer
strips `localized: true` from fields nested under a localized parent -
`fieldShouldBeLocalized` decides this at runtime instead.
**Who is affected:**
- Users of `compatibility: { allowLocalizedWithinLocalized: true }`
- Anyone with `localized: true` nested under a localized parent - end
behavior is unchanged (Payload's own code already uses
`fieldShouldBeLocalized`), but `field.localized` is no longer being
deleted. Custom plugin/hook code that reads `field.localized` directly
will now see `true` where it previously saw `undefined`.
**How to migrate:**
- Remove the `compatibility` block from your config. If your Mongo data
still has the redundant nested-localized shape, flatten the config and
run a data migration
- Replace any direct `field.localized` reads in custom code with
`fieldShouldBeLocalized({ field, parentIsLocalized })` from
`payload/shared`.
## Why these existed
**`allowLocalizedWithinLocalized`**
([#7933](https://github.com/payloadcms/payload/pull/7933)) was an
opt-out for the new auto-stripping behavior, aimed at pre-3.0 Mongo
users with data already written under nested-localized configs. Always
marked for removal in 4.0.
**`PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY`** existed because of
block references. Blocks defined at the top of the config can be
referenced from both localized and non-localized parents, but sanitize
visits each block only once (`_sanitized = true`), so whichever parent
it sees first locks in the wrong answer for the other.
[#11207](https://github.com/payloadcms/payload/pull/11207) fixed this by
moving the check to runtime via `fieldShouldBeLocalized({ field,
parentIsLocalized })`.
Whether we sanitize the localized properties away or not does not have
an impact on this functionality. However, we had to set
`PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY` in our monorepo to test
against sanitization disabled, in order to guarantee correct runtime
handling through our tests.
Removing `PAYLOAD_DO_NOT_SANITIZE_LOCALIZED_PROPERTY` and making it the
default behavior ensures that the behavior users will encounter matches
what we have and test for in the payload monorepo.
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1214980013153702
|
||
|
|
d204daba51 |
feat!: move storage adapters to storage property instead of plugins for early initialisation (#16596)
## Summary
- Introduces a `storage` config key for Payload, letting storage
adapters (`@payloadcms/storage-s3`, `-azure`, `-gcs`, `-r2`,
`-vercel-blob`, `-uploadthing`) be declared at the top level of the
config rather than inside `plugins`
- Storage adapters now return a `StorageAdapter` object (`{ name,
collections, init }`) instead of a `Plugin` function; `buildConfig`
calls `adapter.init(config)` before any plugins run, guaranteeing upload
hooks are wired after plugins execute
- Adds a `migrate-storage-adapters-to-config` codemod to automate
migration: `npx @payloadcms/codemod --transform
migrate-storage-adapters-to-config`
## Migration
Existing `plugins` usage continues to work unchanged. To adopt the new
API, move storage adapters out of `plugins` and into `storage`:
```ts
// Before
export default buildConfig({
plugins: [
s3Storage({ bucket: '...', collections: { media: true }, config: { region: '...' } }),
otherPlugin(),
],
})
// After
export default buildConfig({
plugins: [otherPlugin()],
storage: [
s3Storage({ bucket: '...', collections: { media: true }, config: { region: '...' } }),
],
})
```
Run the codemod to automate this:
```sh
npx @payloadcms/codemod --transform migrate-storage-adapters-to-config
```
|
||
|
|
020ec5d4d9 |
chore!: remove sha1 algorithm check from auth strategies (#16628)
## Summary - Removes the sha1 HMAC fallback from API key authentication introduced in v3.46.0 - API keys are now matched exclusively against the sha256 index - Simplifies the where clause: the non-verify path uses a direct field constraint instead of an `or` array; the verify path uses a single `and` condition instead of nested `or` ## Breaking change Any API keys hashed with sha1 (created before v3.46.0 and never re-saved) will no longer authenticate. Users must regenerate their API key to receive a sha256 index. |
||
|
|
2277fdfe95 |
refactor!: merge FileSizeImproved into FileSize and align ImageSize.admin disabled API (#16593)
## Breaking changes
### 1. `FileSizeImproved` removed — use `FileSize`
`FileSizeImproved` has been merged into `FileSize`. The `url`, `width`,
`height`, `filesize`, `mimeType`, and `filename` properties now accept
`null` directly on `FileSize`, matching what the database actually
stores for sizes that were not generated.
**Migration:** Replace all `FileSizeImproved` references with
`FileSize`.
### 2. `ImageSize.admin` — `disableListColumn` / `disableListFilter` /
`disableGroupBy` removed
These three boolean props have been replaced with a single `disabled`
object, consistent with the shape used by all other fields.
**Migration (via codemod):**
```bash
npx @payloadcms/codemod migrate-disabled-fields
```
Manual migration:
```
// Before
admin: { disableListColumn: true, disableGroupBy: true }
// After
admin: { disabled: { column: true, groupBy: true } }
```
|
||
|
|
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 |