mirror of
https://github.com/payloadcms/payload.git
synced 2026-09-14 20:07:19 +08:00
api-key-field-access
471 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dc3e6664f8 |
perf!: remove DeepRequired from sanitized config types (#17665)
Follow up of https://github.com/payloadcms/payload/pull/17541 This PR removes `DeepRequired` from `SanitizedConfig`. It uses shallow `Pick`, `Omit`, and `Required` types so only properties populated during root config sanitization are required. ## Why `DeepRequired` recursively processes large admin, field, hook, GraphQL, and plugin types, adding unnecessary TypeScript checker work. It was also inaccurate. `admin.autoLogin`, `admin.livePreview`, `graphQL.mutations`, `graphQL.queries`, `queryPresets`, and `typescript.postProcess` could be undefined but were typed as required. Meanwhile, `blocks` is always populated, `auth.jwtOrder` is supplied by Payload despite previously being required in the incoming `Config`, and `SanitizedConfig.paths` was typed but never existed at runtime. The runtime defaults now also preserve the guarantees made by `SanitizedConfig`: explicitly passing `undefined` cannot erase required admin, GraphQL, route, TypeScript, or auth defaults, and localization always receives `localeCodes` and `fallback` when enabled. ## Breaking change Code using `SanitizedConfig` may now need to handle `undefined` for properties Payload does not default. `Config.auth.jwtOrder` is now optional for incoming configs, and the unused `SanitizedConfig.paths` property has been removed. |
||
|
|
a06593d030 |
fix(tanstack-start): hmr with payload config changes in dev (#17602)
Editing payload.config.ts, or anything it imports, had no effect until a manual restart and logged nothing. Vite did re-evaluate the module, but getPayload returns its cached instance unless a DevReloadStrategy marks it stale, and endpoint routing reads payload.config off that stale instance. From there down this behaves exactly like Next.js: mark the instance stale, then let the next getPayload call swap in the new config, regenerate the import map and re-init the db. Only the signal and its delivery differ. Next.js gets that signal from defaultNextJsDevReloadStrategy, a websocket to /_next/webpack-hmr watching for serverComponentChanges. Nothing serves that path under Vite, so the connect failed and ws.onerror swallowed it, which is where the silence came from. It is also coarse: any server component change reloads Payload. The Vite plugin instead uses the hotUpdate plugin hook, in-process and socketless, and walks the changed module's importers so only edits that actually reach the config trigger a reload. Delivery differs because Next.js needs none. Its strategy is core's built-in default, so it already covers the getPayload calls made inside handleEndpoints and createPayloadRequest, which never see InitOptions. An adapter has no such fallback, so core gains a globalThis-backed strategy registry to give the plugin that same reach. <!-- Thank you for the PR! Please go through the checklist below and make sure you've completed all the steps. Please review the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository if you haven't already. ## Which branch should this PR target? - **`main`** — Payload v4 development. New features, enhancements, and v4 bug fixes go here. - **`3.x`** — Payload v3 maintenance. Bug fixes only. **New features are not accepted against v3.** If this PR adds a feature to v3, please retarget it at `main` (v4) instead. The following items will ensure that your PR is handled as smoothly as possible: - PR Title must follow conventional commits format. For example, `feat: my new feature`, `fix(plugin-seo): my fix`. - Minimal description explained as if explained to someone not immediately familiar with the code. - Provide before/after screenshots or code diffs if applicable. - Link any related issues/discussions from GitHub or Discord. - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Fixes # --> --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1216949983441202 |
||
|
|
555045d132 |
perf!: 37% faster config sanitization by making it synchronous (#17580)
## What? This PR makes config sanitization synchronous: - `sanitizeConfig`, `sanitizeField`, and `sanitizeFields` now return their result directly instead of a promise. - Collections and globals are sanitized synchronously. - Lexical editor config and server features are sanitized synchronously. - Rich-text fields are sanitized on the spot, instead of queueing up promises to await at the end. ## Why? Sanitizing the config is pure CPU work - it never touches a database, the filesystem, or the network. So marking it `async` gave us nothing in return, and it made the code harder to follow: every caller had to `await`, and rich-text fields needed a list of pending promises just to finish the job later. Returning values directly removes all of that, and the lower-level sanitizer and editor-config APIs become easier to use because you get a result immediately. Being synchronous also sends a clearer signal to anyone writing a rich-text adapter or a Lexical server feature: setting up a feature should be cheap. When these callbacks are allowed to return promises, it invites slow I/O into code that runs on every cold start. Dropping the promise overhead also speeds up cold starts. ## Performance Lambda cold-start measurements used a realistic 20-collection project with deeply nested fields and complex Lexical content. Cold starts were averaged within each deployment, and the final average gives all three deployments equal weight. | Deployment | Main (`4.0.0-canary.19`) | This PR (`4.0.0-internal.d1cc1d3`) | Change | | ---------- | --------------------------------: | --------------------------------------: | -----: | | 1 | 621.85 ms | 411.63 ms | -33.8% | | 2 | 859.27 ms | 424.16 ms | -50.6% | | 3 | 2,248.65 ms | 1,501.03 ms | -33.2% | | **Average** | **1,243.25 ms** | **778.94 ms** | **-37.3%** | Deployment 1 and 2 used the same config. Deployment 3 had additional lexical fields and blocks added. We also measured the change in a synthetic config-sanitization benchmark. The benchmark uses 925 collections and 15,153 field definitions: | | Before | After | Change | | ---- | -----: | ----: | -----: | | Mean | 75.29 ms | 69.42 ms | -7.8% | The synthetic benchmark scales up the fields test suite by duplicating its fields and was run on an M4 Max. The deployment benchmark instead uses a realistic large config on a slower Lambda CPU, where the improvement was more noticeable. ## Breaking change Most projects are unaffected. You still use `buildConfig(...)` and `lexicalEditor(...)` exactly as before, and `buildConfig` itself is still `async`. You are affected if you: - Call the lower-level sanitizer APIs yourself. - Ship a custom rich-text adapter that returns a promise. - Ship a Lexical server feature callback that returns a promise. If you call a sanitizer directly, drop the `await`: ```diff - const sanitizedConfig = await sanitizeConfig(config) + const sanitizedConfig = sanitizeConfig(config) ``` Custom rich-text adapters and Lexical server feature callbacks now have to return their value synchronously. If you were doing slow work or I/O there, move it either into an async plugin that runs before sanitization, or into a runtime hook. <sub>Stack created with <a href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub> |
||
|
|
0776da590d |
feat: generate static slugs with unique fallback and optional source (#17458)
Makes the native `slug` field static: it's set once on create instead of continuously tracking its source field. A `<singular>-<N>` fallback guarantees the slug is present the moment the doc is created — including on autosave drafts. This is what live preview needs. The preview loads a document by its slug on mount — but an autosave collection creates a draft on navigation, before the user has typed a title. Without a slug at that point, the preview has nothing to query and the page ultimately 404s. The fallback gives that initial draft a stable, immediately discoverable slug the page can query. This also cuts iframe churn in dynamic live preview URLs. The preview URL is built from the slug, so every slug change reloads the iframe `src`. A static slug only changes when you explicitly generate it, so creating an autosave document no longer thrashes the iframe. The slug is only ever auto-filled while empty or when you explicitly regenerate it: - An empty slug is derived from its source field - With no source, it falls back to `<singular>-<N>`, where `N` is the first available integer, e.g. `posts-1` - Every value is normalized through the field's `slugify`, e.g. `Hello World` → `hello-world` **This replaces the previous generate-while-you-type approach during initial autosave doc creation.** For context: one goal of #17215 was to remove the extra injected checkbox field of the old `slugField()` util, which acted as the auto-generation gate. Without that flag, inferring "follow the title until the user edits the slug" has to be done statelessly — inherently flaky, since the server can't reliably tell a genuine edit from an override. A static slug removes the guesswork without reintroducing that field: it's computed once, never recomputed. This works across localization. A localized slug is unique per locale — you can reuse the same value across a document's own locales, but not across documents within one locale. Every locale is populated on create, so switching locales never lands on a blank slug, and generation is locale-aware when the source is localized. ### Optional Source **The `useAsSlug` field property is now optional.** It previously threw `InvalidConfiguration` at sanitize time when missing, because a slug had no way to populate itself without a source field to derive from. That constraint no longer holds. Every generation path now ends in a guaranteed-unique fallback. So a slug field is fully functional with no source: the user types an explicit value (slugified and uniqueness-checked) or the field auto-increments a collision-free fallback. `useAsSlug` is now purely a convenience for deriving a human-readable slug from a sibling field (e.g. `title`). ```ts { name: 'slug', type: 'slug', useAsSlug: 'title' // NOW OPTIONAL } ``` ### Final Result https://github.com/user-attachments/assets/d313c4af-f9e3-49c1-91bb-3e43532f62c1 |
||
|
|
e4ebd90f52 |
fix(ui): refresh join tables on update (#17366)
## What Uses existing `useDocumentEvent` provider to sync join tables when related documents change. ## Why When a document contains multiple join fields that reference the same related collection, updates made through one field (create/delete) can leave the other field stale until a full page reload. This causes inconsistent UI state and makes it look like changes did not persist. Syncing through shared document events ensures all related tables reflect the same data immediately. ## How Subscribes join/relationship tables to the latest document event. |
||
|
|
b24fa023b7 |
refactor(ui): unify server function dispatch across adapters (#17419)
Removes all TanStack Start-specific server function wrappers, consolidating server function dispatch into the single shared path every adapter uses. Each adapter maintained its own handler registration. Maintained independently, the two lists were at significant risk of drifting — a handler could be added to one adapter and silently omitted from the other. Now: - `sharedServerFunctions` is now the single, complete registry - `createServerFunctionHandler` is the one dispatcher for every adapter. Adapters inject only what is genuinely framework-specific: - `initReq` — how the Payload request is resolved (the frameworks do this differently) - `transformResult` — response post-processing (TanStack converts React elements to RSC handles) A handler added to the registry now reaches every adapter, and the lists cannot fall out of sync again. |
||
|
|
db520c1dd9 |
fix(tanstack-start): prevent duplicate server load on soft navigation to list view (#17286)
Navigating into the list view on TanStack Start runs the route loader twice. This is because on load, the list query provider syncs server-resolved query defaults back to the URL on the client, e.g. `list`, `sort`, etc. This does not inherently require a server roundtrip as one might expect, as the data has already been resolved properly on the server. In Next.js, we achieve this via `window.history.replaceState` which does not notify Next.js' router of the mutation. In TanStack Router, however, `history.replaceState` is monkeypatched to subscribe to all updates, even those outside its own router methods. This means that during the URL sync, the route loader is called twice, once on initial load, and again when the URL is mutated. The solution is to expose a new `replaceState` method to standardize this behavior across router implementations: - Next.js calls `window.history.replaceState` directly. - TanStack wraps it in `history._ignoreSubscribers`, the flag TanStack sets when flushing its own history, so the router isn't notified. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1216204011123980 |
||
|
|
41c7976b62 |
feat!: add native slug field type (#17215)
Adds a first-class `slug` field type, replacing the experimental
`slugField()` helper. A slug is a unique, indexed, URL-friendly string
that identifies a document, auto-generated from another field
(`useAsSlug`, defaults to `title`).
```ts
{
name: 'slug',
type: 'slug',
useAsSlug: 'title',
}
```
The field stores as a single string column across all adapters — no
companion `generateSlug` checkbox. Auto-tracking is now derived
statelessly on each save: the slug follows its source while it equals
`slugify(storedSource)`, and freezes permanently once the admin
overwrites it (the stored value keeps diverging from the source-derived
value). The lock/regenerate UI is preserved, and clicking generate
re-aligns the slug so tracking resumes.
Generation semantics:
- On `create` (non-autosave), the slug generates from the source unless
a value is explicitly provided.
- On `update` without versions, an existing slug is frozen; an empty one
back-fills once.
- For autosave drafts, the slug does **not** generate on the initial
draft (the user is still entering content), begins generating on
subsequent autosaves, and stabilizes after publish to protect live URLs.
`useAsSlug` and `slugify` sit as top-level field props alongside the
standard field options; slug defaults `required`, `unique`, and `index`
to `true` and renders in the `sidebar`. The custom `slugify` function is
resolved server-side and is stripped from the client field config, so it
never crosses the RSC boundary.
## Breaking Changes
This affects anyone importing the experimental `slugField()` helper from
`payload`.
```diff
- import { slugField } from 'payload'
-
export const Posts: CollectionConfig = {
fields: [
- slugField({ useAsSlug: 'title' }),
+ { name: 'slug', type: 'slug', useAsSlug: 'title' },
],
}
```
- The `slugField()` helper and its `SlugField` (function) type are
removed. `SlugField` now refers to the field-config type; `Slugify` and
`SlugFieldClientProps` are still exported.
- The persisted `generateSlug` checkbox (and any custom `checkboxName`)
no longer exists. Generation state is derived, not stored — remove any
config or queries referencing that field.
- Helper args map to field props: `useAsSlug`/`fieldToUse` →
`useAsSlug`, `disableUnique` → `unique: false`, `position` →
`admin.position`, and the `overrides` callback → standard field props
(`label`, `admin`, etc.) set directly on the field.
- `useAsSlug` is now **required** on the `slug` field — there is no
implicit `'title'` default. A collection without the named source field
fails at startup with a clear config error rather than silently
generating an empty slug. The codemod fills in `useAsSlug: 'title'` for
any `slugField()` call that didn't set it, preserving the old helper's
default.
#### Codemod
To migrate automatically, there's a codemod for this change available by
running:
```bash
npx @payloadcms/codemod --transform migrate-slug-field
```
Calls using `overrides` (or other options that can't be mapped cleanly)
are left in place with a note for manual migration.
|
||
|
|
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> |
||
|
|
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
|
||
|
|
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>
|
||
|
|
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()`. |
||
|
|
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> |
||
|
|
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.
|
||
|
|
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'
```
|
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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 |
||
|
|
2d77a3ed68 |
feat: hierarchy with ui (#15769)
# Hierarchy Feature
This PR introduces a comprehensive hierarchy system for Payload that
enables collections to have parent-child relationships with automatic
path generation, dedicated sidebar navigation, and specialized UI
components for folder and tag patterns.
## Overview
The hierarchy feature allows any collection to define parent-child
relationships through a declarative `hierarchy` config property. When
enabled, Payload automatically handles relationship management, path
computation, circular reference prevention, and injects a dedicated
sidebar tab with a tree view for navigation.
At its core, hierarchy manages a `parentFieldName` relationship field
that references documents within the same collection. The system
automatically creates this field if it doesn't exist, adds validation to
prevent circular references (you can't move a folder into its own
subfolder), and computes virtual path fields that provide
breadcrumb-style paths from root to each document.
## Path Generation
Two virtual fields are automatically added to hierarchy-enabled
collections: `_h_slugPath` and `_h_titlePath`. These compute breadcrumb
paths by walking up the parent chain to root during read operations. For
example, a document nested three levels deep might have paths like
`engineering/frontend/components` and `Engineering / Frontend /
Components`. Path computation is cached per-request to avoid redundant
ancestor queries, and uses `overrideAccess: true` to ensure complete
paths even when users lack read permission on intermediate ancestors.
The field names are customizable via `slugPathFieldName` and
`titlePathFieldName` in the hierarchy config. Path generation also
respects localization, returning localized strings when the collection
uses localized title fields.
## Sidebar Tabs
The hierarchy feature builds on a new sidebar tabs system that allows
rendering custom tabs alongside the default Collections tab. Each
hierarchy collection automatically gets its own tab injected during
config resolution. The tab displays a tree view of the hierarchy with
expand/collapse functionality, search, and optional collection-type
filtering.
When you click a node in the tree, the list view filters to show only
that node's children and related documents. The URL updates with a
`?parent=<id>` parameter, and the tree highlights the currently selected
node. Expanded node state persists across sessions via
payload-preferences.
## Folder and Tag Presets
Instead of wrapping your collection config in a HOC function, you now
declare the preset directly on the collection config using the `folders`
or `tags` property.
**`folders` preset** configures a folder-style hierarchy where each
document can have only one parent (single-select). It enforces
`allowHasMany: false`, applies a default folder icon, and enables the
miller columns header button by default. The collection is hidden from
the main nav since it's accessed via its sidebar tab.
**`tags` preset** configures a tag-style hierarchy where documents can
have multiple parents (multi-select by default). This is useful for
categorization systems where items can belong to multiple categories.
Both presets accept `true` for defaults, or a config object for
customization.
**`createFolderField`** creates a relationship field for assigning a
single folder to documents in other collections. The field renders as a
header button that opens a miller columns drawer for folder selection
instead of the standard relationship dropdown.
**`createTagField`** creates a relationship field for assigning multiple
tags to documents. Unlike folder fields, tag fields use the standard
relationship UI with hierarchy-aware features.
## Setup
To add hierarchy to a collection, add the `hierarchy` property to your
collection config:
```typescript
const Categories: CollectionConfig = {
slug: 'categories',
admin: { useAsTitle: 'name' },
fields: [{ name: 'name', type: 'text', required: true }],
hierarchy: {
parentFieldName: 'parent',
},
}
```
For folder patterns, use the `folders` preset directly on the collection
config:
```typescript
import { createFolderField } from 'payload'
const Folders: CollectionConfig = {
slug: 'folders',
admin: { useAsTitle: 'name' },
fields: [{ name: 'name', type: 'text', required: true }],
folders: true, // or folders: { parentFieldName: 'folder', ... }
}
const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{ name: 'title', type: 'text', required: true },
createFolderField({ relationTo: 'folders' }),
],
}
```
For tag patterns, use the `tags` preset:
```typescript
import { createTagField } from 'payload'
const Tags: CollectionConfig = {
slug: 'tags',
admin: { useAsTitle: 'name' },
fields: [{ name: 'name', type: 'text', required: true }],
tags: true, // or tags: { allowHasMany: true, parentFieldName: 'parent', ... }
}
const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{ name: 'title', type: 'text', required: true },
createTagField({ relationTo: 'tags' }),
],
}
```
## Collection-Specific Folders
Folders can optionally restrict which collection types they accept.
Enable this with `collectionSpecific`:
```typescript
const Folders: CollectionConfig = {
slug: 'folders',
admin: { useAsTitle: 'name' },
fields: [{ name: 'name', type: 'text', required: true }],
folders: {
collectionSpecific: true, // or { fieldName: 'allowedTypes' }
},
}
```
This adds a multi-select field to folders where you can specify which
collections can be placed in that folder. When filtering the sidebar
tree, only folders that accept the current collection type are shown.
## Join Field
For querying all children of a hierarchy item (both nested items and
related documents from other collections), configure the `joinField`
option:
```typescript
folders: {
parentFieldName: 'parent',
joinField: { name: 'children' },
}
```
This creates a virtual join field that aggregates all documents
referencing each hierarchy item as their parent.
## ⚠️ Migration from Previous Folders Implementation
If you previously used `folders`, migration to the new hierarchy system
is straightforward, but you now need to define your folders collection
explicitly (it is no longer created for you).
To preserve existing folders data, keep the collection slug as
`payload-folders` and set `parentFieldName` to `folder`:
```typescript
import { createFolderField } from 'payload'
import type { CollectionConfig } from 'payload'
const Folders: CollectionConfig = {
slug: 'payload-folders', // important if migrating
admin: { useAsTitle: 'name' },
fields: [{ name: 'name', type: 'text', required: true }],
folders: {
parentFieldName: 'folder', // important if migrating
collectionSpecific: { fieldName: 'folderType' }, // if you were using this before
},
}
// In collections that should be assignable to folders:
const Posts: CollectionConfig = {
slug: 'posts',
fields: [
{ name: 'title', type: 'text', required: true },
createFolderField({ relationTo: 'payload-folders' }),
],
}
```
## Current UI
https://github.com/user-attachments/assets/ca5be0b1-ddcc-4b68-8d88-1358c13a70e8
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
|
||
|
|
8fe5f04831 |
feat: allow client components to also be used as custom collection views (#16312)
Allows client components to also be used as custom collection views, bringing it inline with internal views. A continuation of https://github.com/payloadcms/payload/pull/16243 |
||
|
|
835a0ad490 |
feat(next): add support for custom collection views (#16243)
Originally this PR with extra changes: https://github.com/payloadcms/payload/pull/15410 - Adds ability to register custom views at the collection level via `admin.components.views[key]` with a `Component` and `path` — resolves #15386 - Folders take routing precedence over custom views when both are defined on an upload collection - Adds a startup `console.warn` when a custom view is misconfigured without a `path` property ## Usage ```ts { slug: 'products', admin: { components: { views: { grid: { Component: '/components/GridView', path: '/grid', exact: true, }, }, }, }, } --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: robinscholz <8195463+robinscholz@users.noreply.github.com> Co-authored-by: Robin Scholz <robin@maas.engineering> Co-authored-by: Robin Vey <mail@robinscholz.com> |
||
|
|
7a3f43ff51 |
fix: prevent silent data overwrites on concurrent edits (#15749)
### What Adds stale data detection to prevent silent overwrites when multiple users edit the same document. ### Why Currently, when two users edit the same document, the last save wins with no warning. User 1 can make changes, then User 2 saves, overwriting User 1's work silently. This is separate from document locking and addresses a specific scenario: 1. Both users already have the same document open at the same time. 2. User 1 makes changes and saves. 3. User 2, still viewing their original version, then decides to make edits. 4. Without this fix, User 2 would unknowingly overwrite User 1's changes with no warning. ### How Now we track the document's `updatedAt` timestamp when it initially loads. On first edit, check if the database version has a newer timestamp. If stale, show a modal with the option to reload (get latest). The check only happens once per edit session to avoid excessive DB queries. <img width="693" height="360" alt="Screenshot 2026-02-24 at 1 37 01 PM" src="https://github.com/user-attachments/assets/1d63973c-fa40-4213-8718-9ea739f267c9" /> Fixes #15486 |
||
|
|
0a123b51f2 |
feat(next, ui): widget fields (#15700)
## Summary
- Adds `fields` support to dashboard widgets, analogous to how Blocks
work — widgets can now declare configurable fields
- Widget data is editable from a new drawer UI (edit icon) when in
dashboard editing mode
- Full type generation: `WidgetInstance<T>` is now generic with typed
`data` and `width` based on the widget's field schema and min/max width
constraints
- `WidgetServerProps` is generic so widget components receive typed
`widgetData`
## Usage
```ts
import { buildConfig } from 'payload'
export default buildConfig({
admin: {
dashboard: {
widgets: [
{
slug: 'sales-summary',
ComponentPath: './components/SalesSummary.tsx#default',
fields: [
{ name: 'title', type: 'text' },
{
name: 'timeframe',
type: 'select',
options: ['daily', 'weekly', 'monthly', 'yearly'],
},
{ name: 'showTrend', type: 'checkbox' },
],
minWidth: 'small',
maxWidth: 'medium',
},
],
},
},
})
```
```tsx
import type { WidgetServerProps } from 'payload'
import type { SalesSummaryWidget } from '../payload-types'
export default async function SalesSummaryWidgetComponent({
widgetData,
}: WidgetServerProps<SalesSummaryWidget>) {
const title = widgetData?.title ?? 'Sales Summary'
const timeframe = widgetData?.timeframe ?? 'monthly'
return (
<div className="card">
<h3>
{title} ({timeframe})
</h3>
</div>
)
}
```
## Demo
https://github.com/user-attachments/assets/b24d3635-8635-4d5f-84af-a6dcf801aa4f
|
||
|
|
2347cd9e9d |
feat: move trash out of beta and delete access can now be limited to trash only (#15210)
### Access control can be limited to only trash
You can now limit users to only trash without being able to permanently
delete:
```ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
trash: true,
access: {
delete: ({ req: { user }, data }) => {
// Not logged in - no access
if (!user) {
return false
}
// Admins can do anything (trash or permanently delete)
if (user.roles?.includes('admin')) {
return true
}
// Regular users: check what operation they're attempting
// If data.deletedAt is being set, it's a trash operation - allow it
if (data?.deletedAt) {
return true
}
// Otherwise it's a permanent delete - deny for non-admins
return false
},
},
fields: [
// ...
],
}
```
---------
Co-authored-by: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
|
||
|
|
9c8be5cc96 |
perf(next): avoid re-calculating permissions in some server functions, pass missing args (#15428)
Requires https://github.com/payloadcms/payload/pull/15427 to be merged first. This PR avoids re-calculating permissions in certain server functions, since through #15427, they are now available in server functions due to an `initReq` that we already ran. It also passes some missing properties to `serverProps` and render handles that are now available. |
||
|
|
ce13e97243 |
feat(next): pass full initReq context to server functions and dashboard widgets (#15427)
Currently, we only pass `importMap` and `req` to all server functions. This PR extends that interface by passing the following **additional arguments** to every server function and every dashboard widget: - `cookies` - `locale` - `permissions` This change comes at **no extra cost**, since all of these values are already available via `initReq`, which we are already calling. ## Why? I’m working on rendering the list view in a dashboard widget through a server function, which requires context such as `permissions`. Recomputing these values would be redundant and inefficient, given that they’ve already been calculated in `handleServerFunctions` and we can just pass them through. By passing them through once, we avoid duplicate work and make server functions easier to use. |
||
|
|
94254da4ac |
feat: add support for custom UnpublishButton component (#15400)
### What Adds the ability to customize the `UnpublishButton` component in collection and global configs, following the same pattern as `PublishButton`. ### Why `UnpublishButton` was hardcoded while other document control buttons (`PublishButton`, `SaveButton`, etc.) could be customized. ### How Added type definitions, config options, component resolution in renderDocumentSlots, and updated DocumentControls to use the RenderCustomComponent pattern. --------- Co-authored-by: Alessio Gravili <github@gravili.net> |
||
|
|
ef863e6b64 |
feat: add support for custom Status component in document controls (#11154)
### What?
This PR adds the ability to replace the Status section of the document
or global edit view, without the need to replace the whole Edit view.
### Why?
In certain cases there may be a need to add custom elements before or
after the original Status component. In our case, we need to replace the
whole Status component to support custom locale publishing logic and
also for additional status indicators for the users.
### How?
**In the `docs` package:**
- Added a description of the Status component to the components section
within both the collections and globals sections.
**In the `next` package:**
- Enabled rendering of the custom Status component.
- Modified the Live Preview components to include the Status component
prop.
**In the `payload` package:**
- Introduced type definitions for the Status component.
- Implemented importMap generation for the Status component.
**In the `ui` package:**
- Modified the Edit view to pass the Status component to
DocumentControls.
- Updated the DocumentControls component to accept and render a custom
Status component if defined.
Now supports the following Status component in admin
```ts
admin: {
components: {
edit: {
Status: '/components/Status/index.tsx#Status',
},
},
},
```
---------
Co-authored-by: Elmo Egers <elmo.egers@smit.ee>
Co-authored-by: Elmo Egers <uplaymedia@gmail.com>
Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
|
||
|
|
d288752f68 |
fix: field schema map paths (#10852)
Continuation of #10638. Field paths within the schema map are not correct. For example, an unnamed tab containing a text field should have the schema path: - `_index-0.fieldWithinUnnamedTab` However, within the schema map that path is formatted as: - `fieldWithinUnnamedTab` The leading index in the first example _should_ exist, as this field is nested within another field, regardless of this field being unnamed. Otherwise, it would be impossible to traverse the schema and lookup this field. This discrepancy is not an issue in the admin panel because fields are _also_ provided the wrong schema paths. They can properly locate their schema within the schema map because they match despite being incorrect. Both are wrong and that's why it works. Here's comprehensive example of how field paths _should_ be formatted: ```ts { // ... fields: [ { // path: 'topLevelNamedField' // schemaPath: 'topLevelNamedField' // indexPath: '' name: 'topLevelNamedField', type: 'text', }, { // path: 'array' // schemaPath: 'array' // indexPath: '' name: 'array', type: 'array', fields: [ { // path: 'array.[n].fieldWithinArray' // schemaPath: 'array.fieldWithinArray' // indexPath: '' name: 'fieldWithinArray', type: 'text', }, { // path: 'array.[n].nestedArray' // schemaPath: 'array.nestedArray' // indexPath: '' name: 'nestedArray', type: 'array', fields: [ { // path: 'array.[n].nestedArray.[n].fieldWithinNestedArray' // schemaPath: 'array.nestedArray.fieldWithinNestedArray' // indexPath: '' name: 'fieldWithinNestedArray', type: 'text', }, ], }, { // path: 'array.[n]._index-2' // schemaPath: 'array._index-2' // indexPath: '2' type: 'row', fields: [ { // path: 'array.[n].fieldWithinRowWithinArray' // schemaPath: 'array._index-2.fieldWithinRowWithinArray' // indexPath: '' name: 'fieldWithinRowWithinArray', type: 'text', }, ], }, ], }, { // path: '_index-2' // schemaPath: '_index-2' // indexPath: '2' type: 'row', fields: [ { // path: 'fieldWithinRow' // schemaPath: '_index-2.fieldWithinRow' // indexPath: '' name: 'fieldWithinRow', type: 'text', }, ], }, { // path: '_index-3' // schemaPath: '_index-3' // indexPath: '3' type: 'tabs', tabs: [ { // path: '_index-3-0' // schemaPath: '_index-3-0' // indexPath: '3-0' label: 'Unnamed Tab', fields: [ { // path: 'fieldWithinUnnamedTab' // schemaPath: '_index-3-0.fieldWithinUnnamedTab' // indexPath: '' name: 'fieldWithinUnnamedTab', type: 'text', }, { // path: '_index-3-0-1' // schemaPath: '_index-3-0-1' // indexPath: '3-0-1' type: 'tabs', tabs: [ { // path: '_index-3-0-1-0' // schemaPath: '_index-3-0-1-0' // indexPath: '3-0-1-0' label: 'Nested Unnamed Tab', fields: [ { // path: 'fieldWithinNestedUnnamedTab' // schemaPath: '_index-3-0-1-0.fieldWithinNestedUnnamedTab' // indexPath: '' name: 'fieldWithinNestedUnnamedTab', type: 'text', }, ], }, ], }, ], }, { // path: 'namedTab' // schemaPath: '_index-3.namedTab' // indexPath: '' label: 'Named Tab', name: 'namedTab', fields: [ { // path: 'namedTab.fieldWithinNamedTab' // schemaPath: '_index-3.namedTab.fieldWithinNamedTab' // indexPath: '' name: 'fieldWithinNamedTab', type: 'text', }, ], }, ], }, ] } ``` |
||
|
|
f1116247ed |
feat: modular dashboards - widgets (#13683)
[RFC Here](https://github.com/payloadcms/payload/discussions/11862). You can test the feature by running `pnpm dev dashboard` on this branch. <details> <summary>Old (obsolete) example</summary> See the [comment below](https://github.com/payloadcms/payload/pull/13683#issuecomment-3581457901) explaining the change in approach we took https://github.com/user-attachments/assets/96157f83-c5d7-4350-9f31-c014daedb2a8 </details> ### New demo https://github.com/user-attachments/assets/6c08d8d6-c989-4845-b56f-6d3fbd30b1af ## Future Work The following improvements are planned but will be added in the future: - fields: You'll be able to define `fields` that a widget receives, which will serve as props in the component. Why might this be useful? Imagine a chart widget that can have a weekly, daily, or yearly view. Or a "count" widget that shows how many documents there are in a collection (the collection could be a field). - A11y (EDITED): Okay, I finally went the extra mile here, and you can reorder and resize it with the keyboard. The screen reader works, although there's room for improvement. - Dashboard presets: we're planning to add the ability to create and share dashboard presets, similar to how [query presets](https://payloadcms.com/docs/query-presets/overview) work today. For example, you could build dashboards that adjust based on a variable, such as a "daily, weekly, or monthly" interval. You could also create dashboards tailored to different focus areas, like "marketing, sales, or product." |
||
|
|
59a1607a3f |
feat: support custom slugify functions (#14117)
Continuation of #14007. Supports overriding the default slugify function of the slug field. This is necessary if the slug requires special treatment, such as character encoding, additional language support, etc. For example, if you wanted to use the [`slugify`](https://www.npmjs.com/package/slugify) package, you could do something like this: ```ts import type { CollectionConfig } from 'payload' import { slugField } from 'payload' import slugify from 'slugify'; export const MyCollection: CollectionConfig = { // ... fields: [ // ... slugField({ slugify: ({ valueToSlugify }) => slugify(valueToSlugify, { // ...additional `slugify` options here }) }) ] } ``` This PR also deprecates the old `fieldToUse` arg in favor of `useAsSlug` which is more semantically clear, following the same convention as `useAsTitle`. Example: ```ts import type { CollectionConfig } from 'payload' import { slugField } from 'payload' export const MyCollection: CollectionConfig = { // ... fields: [ // ... slugField({ useAsSlug: 'myCustomTitle' }) ] } ``` In follow-up PRs, we should also: - Improve the default slugify function to better handle special characters on its own, etc. - [Support nested slugs](https://github.com/payloadcms/payload/pull/14783) |
||
|
|
9f55254da3 |
fix: relationships should not fallback if fallbackLocale is false (#14641)
Relationship fields would fallback even if the parent of the request set `fallbackLocale: false`. This was because of the logic in sanitizeFallbackLocale which would transform `false` value into `null`, and when a relationship populate function ran, it would transform the `null` into the `localization.defaultLocale` value. Added int test that failed before and passes after to cover. |
||
|
|
0cc06a1224 |
fix(ui): relationship field label not updated when document is updated from other drawer (#14609)
When updating a document from a document drawer that isn’t the one associated with a relationship field, the relationship’s label doesn’t update correctly. ## Reproduction This issue is easily reproducible when two relationship fields link to the same document. If you update the document from the drawer of one field, the other field will not reflect the update. This happens because each field only listens to the `onSave` event of its own document drawer. ## Fix Use the `useDocumentEvents` hook instead of the `onSave` hook. `useDocumentEvents` listens for updates to all saved documents, ensuring that all relationship fields stay in sync regardless of which drawer the update originated from. **Before:** https://github.com/user-attachments/assets/eda152fe-3cda-4111-b0f3-9f0d5a48f37b **After:** https://github.com/user-attachments/assets/da252d3d-c0a9-43ee-91d8-508e302d323e |
||
|
|
339a0c390d |
fix: use TSiblingData for previousSiblingDoc in FieldHook (#14503)
### What? In field hooks, `previousSiblingDoc` is typed as `TData`. ### Why? This value should actually be typed as `TSiblingData`, I've confirmed the sibling data is actually what is returned when a field hook runs. ### How? Updated `FieldHook` typing, as well as `RichText` field hook typings Fixes #9735 |
||
|
|
ad0e7b2252 |
fix(ui): preview button not responding to conditional URL (#14277)
Fixes #14241 and #11253. Preview URL is now part of the form state as well so we can conditionally show the preview button based on its value, previously this was not possible because we were fetching the URL on click. Similar to #14012. |
||
|
|
8b0ac01f41 |
docs: add jsdocs to RichText adapter (#14246)
Adds JSDocs to the RichText adapter return value. |
||
|
|
54224c3ab7 |
perf(richtext-lexical): do not return i18n from editor adapter (#14228)
This PR deprecates the `i18n` property returned from the editor adapter. Instead, we now merge `i18n` directly into the `config`, since the config is already available in the richtext adapter provider. This simplifies the logic (only one line of code) and eliminates the need to manually read and merge i18n from the adapter. Benefits: - **Simpler implementation**: Merging i18n into the config is straightforward - **Reduced memory usage**: Each editor no longer maintains its own i18n object - only a single, merged `config.i18n` is kept. - **Improved developer experience**: The sanitized field config is now smaller and easier to inspect. Console logging the config no longer floods the output with hundreds of translation lines. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211668727538425 |
||
|
|
623a1b8216 |
feat: accept multiple locales in fallbackLocale (#13822)
### What?
This change allows `fallbackLocale` to accept an **array of locales** in
find queries and locale configuration.
```ts
/** Local API **/
await payload.findByID({
id,
collection,
locale: 'en',
fallbackLocale: ['fr', 'es'],
})
/** REST API **/
await fetch(`${baseURL}/api/${collectionSlug}?locale=en&fallbackLocale[]=fr&fallbackLocale[]=es`)
/** GraphQL **/
await restClient.GRAPHQL_POST({
body,
query: { locale: 'en', fallbackLocale: ['fr', 'es']},
})
/** Locale Configs **/
locales: [
{
code: 'en',
label: 'English',
fallbackLocale: ['fr', 'es'],
},
]
```
### Why?
This update is part of the planned [localization
enhancements](https://github.com/payloadcms/payload/discussions/10705).
Currently, only one fallback locale can be specified. If that locale
doesn’t contain data, the query returns nothing. To work around this,
users have to inspect the response themselves and then make additional
queries for other locales.
With this change, Payload handles that work automatically. Users only
need to provide a list of fallback locales in their preferred order, and
Payload will check each one until it finds a value.
### How?
Updates query handling across the local API, REST API, and GraphQL so
that `fallbackLocale` can accept either a **string** or an **array** of
locales. When an array is passed, Payload will iterate through them in
order and return the first found localized value. This behavior applies
to both collections and globals.
#### Feature request:
https://github.com/payloadcms/payload/discussions/13443
---------
Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
|
||
|
|
b09ae6772f |
feat: slug field (#14007)
Discussion #8859. Requires #14012. Exports a new `slugField`. This is a wrapper around the text field that you can drop into any field schema. A slug is a unique, indexed, URL-friendly string that identifies a particular document, often used to construct the URL of a webpage. Slugs are a fundamental concept for seemingly every project. Traditionally, you'd build this field from scratch, but there are many edge cases and nice-to-haves to makes this difficult to maintain by hand. For example, it needs to automatically generate based on the value of another field, provide UI to lock and re-generate the slug on-demand, etc. Fixes #13938. When autosave is enabled, the slug is only ever generated once after the initial create, leading to single character, or incomplete slugs. For example, it is expected that "My Title" → "my-title, however ends up as "m". This PR overhauls the field to feel a lot more natural. Now, we only generate the slug through: 1. The `create` operation, unless the user has modified the slug manually 2. The `update` operation, if: a. Autosave is _not_ enabled and there is no slug b. Autosave _is_ enabled, the doc is unpublished, and the user has not modified the slug manually The slug should stabilize after all above criteria have been met, because the URL is typically derived from the slug. This is to protect modifying potentially live URLs, breaking links, etc. without explicit intent. This fix, along with all the other features, is now standardized behind the new `slugField`: ```ts import type { CollectionConfig } from 'payload' import { slugField } from 'payload' export const MyCollection: CollectionConfig = { // ... fields: [ // ... slugField() ] } ``` In the future we could also make this field smart enough to auto increment itself when its generated slug is not unique. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211513433305005 |
||
|
|
d826159fc0 |
fix(ui): add support back for custom live preview components (#14037)
Fixes https://github.com/payloadcms/payload/issues/13308 Adds support for a custom live preview component back, we previously supported this and it was allowed via the config types but it wasn't being rendered. Now we export the `useLivePreviewContext` hook and the original `LivePreviewWindow` component too so that end users can wrap the live preview functionality with anything custom that they may need |
||
|
|
1d1240fd13 |
feat: adds admin.formatDocURL function to control list view linking (#13773)
### What? Adds a new `formatDocURL` function to collection admin configuration that allows users to control the linkable state and URLs of first column fields in list views. ### Why? To provide a way to disable automatic link creation from the first column or provide custom URLs based on document data, user permissions, view context, and document state. ### How? - Added `formatDocURL` function type to `CollectionAdminOptions` that receives document data, default URL, request context, collection slug, and view type - Modified `renderCell` to call the function when available and handle three return types: - `null`: disables linking entirely - `string`: uses custom URL - other: falls back to no linking for safety - Added function to server-only properties to prevent React Server Components serialization issues - Updated `DefaultCell` component to support custom `linkURL` prop --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211211792037945 |
||
|
|
00a673e491 |
feat(next): regenerate live preview url on save (#13631)
Closes #12785. Although your live preview URL can be dynamic based on document data, it is never recalculated after initial mount. This means if your URL is dependent of document data that was just changed, such as a "slug" field, the URL of the iframe does not reflect that change as expected until the window is refreshed or you navigate back. This also means that server-side live preview will crash when your front-end attempts to query using a slug that no longer exists. Here's the general flow: slug changes, autosave runs, iframe refreshes (url has old slug), 404. Now, we execute your live preview function on submit within form state, and the window responds to the new URL as expected, refreshing itself without losing its connection. Here's the result: https://github.com/user-attachments/assets/7dd3b147-ab6c-4103-8b2f-14d6bc889625 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211094211063140 |
||
|
|
d8dace6509 |
feat: conditional blocks (#13801)
This PR introduces support for conditionally setting allowable block types via a new `field.filterOptions` property on the blocks field. Closes the following feature requests: https://github.com/payloadcms/payload/discussions/5348, https://github.com/payloadcms/payload/discussions/4668 (partly) ## Example ```ts fields: [ { name: 'enabledBlocks', type: 'text', admin: { description: "Change the value of this field to change the enabled blocks of the blocksWithDynamicFilterOptions field. If it's empty, all blocks are enabled.", }, }, { name: 'blocksWithFilterOptions', type: 'blocks', filterOptions: ['block1', 'block2'], blocks: [ { slug: 'block1', fields: [ { type: 'text', name: 'block1Text', }, ], }, { slug: 'block2', fields: [ { type: 'text', name: 'block2Text', }, ], }, { slug: 'block3', fields: [ { type: 'text', name: 'block3Text', }, ], }, ], }, { name: 'blocksWithDynamicFilterOptions', type: 'blocks', filterOptions: ({ siblingData: _siblingData, data }) => { const siblingData = _siblingData as { enabledBlocks: string } if (siblingData?.enabledBlocks !== data?.enabledBlocks) { // Just an extra assurance that the field is working as intended throw new Error('enabledBlocks and siblingData.enabledBlocks must be identical') } return siblingData?.enabledBlocks?.length ? [siblingData.enabledBlocks] : true }, blocks: [ { slug: 'block1', fields: [ { type: 'text', name: 'block1Text', }, ], }, { slug: 'block2', fields: [ { type: 'text', name: 'block2Text', }, ], }, { slug: 'block3', fields: [ { type: 'text', name: 'block3Text', }, ], }, ], }, ] ``` https://github.com/user-attachments/assets/e38a804f-22fa-4fd2-a6af-ba9b0a5a04d2 # Rationale ## Why not `block.condition`? - Individual blocks are often reused in multiple contexts, where the logic for when they should be available may differ. It’s more appropriate for the blocks field (typically tied to a single collection) to determine availability. - Hiding existing blocks when they no longer satisfy a condition would cause issues - for example, reordering blocks would break or cause block data to disappear. Instead, this implementation ensures consistency by throwing a validation error if a block is no longer allowed. This aligns with the behavior of `filterOptions` in relationship fields, rather than `condition`. ## Why not call it `blocksFilterOptions`? Although the type differs from relationship fields, this property is named `filterOptions` (and not `blocksFilterOptions`) for consistency across field types. For example, the Select field also uses `filterOptions` despite its type being unique. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211334752795631 |
||
|
|
1c89291fac |
feat(richtext-lexical): utility render lexical field on-demand (#13657)
## Why this exists
Lexical in Payload is a React Server Component (RSC). Historically that
created three headaches:
1. You couldn’t render the editor directly from the client.
2. Features like blocks, tables, upload and link drawers require the
server to know the shape of nested sub‑fields at render time. If you
tried to render on demand, the server didn’t know those schemas.
3. The rich text field is designed to live inside a Form. For simple use
cases, setting up a full form just to manage editor state was
cumbersome.
## What’s new
We now ship a client component, `<RenderLexical />`, that renders a
Lexical editor **on demand** while still covering the full feature set.
On mount, it calls a server action to render the editor on the server
using the new `render-field` server action. That server render gives
Lexical everything it needs (including nested field schemas) and returns
a ready‑to‑hydrate editor.
## Example - Rendering in custom component within existing Form
```tsx
'use client'
import type { JSONFieldClientComponent } from 'payload'
import { buildEditorState, RenderLexical } from '@payloadcms/richtext-lexical/client'
import { lexicalFullyFeaturedSlug } from '../../slugs.js'
export const Component: JSONFieldClientComponent = (args) => {
return (
<div>
Fully-Featured Component:
<RenderLexical
field={{ name: 'json' }}
initialValue={buildEditorState({ text: 'defaultValue' })}
schemaPath={`collection.${lexicalFullyFeaturedSlug}.richText`}
/>
</div>
)
}
```
## Example - Rendering outside of Form, manually managing richText
values
```ts
'use client'
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
import type { JSONFieldClientComponent } from 'payload'
import { buildEditorState, RenderLexical } from '@payloadcms/richtext-lexical/client'
import React, { useState } from 'react'
import { lexicalFullyFeaturedSlug } from '../../slugs.js'
export const Component: JSONFieldClientComponent = (args) => {
const [value, setValue] = useState<DefaultTypedEditorState | undefined>(() =>
buildEditorState({ text: 'state default' }),
)
const handleReset = React.useCallback(() => {
setValue(buildEditorState({ text: 'state default' }))
}, [])
return (
<div>
Default Component:
<RenderLexical
field={{ name: 'json' }}
initialValue={buildEditorState({ text: 'defaultValue' })}
schemaPath={`collection.${lexicalFullyFeaturedSlug}.richText`}
setValue={setValue as any}
value={value}
/>
<button onClick={handleReset} style={{ marginTop: 8 }} type="button">
Reset Editor State
</button>
</div>
)
}
```
## How it works (under the hood)
- On first render, `<RenderLexical />` calls the server function
`render-field` (wired into @payloadcms/next), passing a schemaPath.
- The server loads the exact field config and its client schema map for
that path, renders the Lexical editor server‑side (so nested features
like blocks/tables/relationships are fully known), and returns the
component tree.
- While waiting, the client shows a small shimmer skeleton.
- Inside Forms, RenderLexical plugs into the parent form via useField;
outside Forms, you can fully control the value by passing
value/setValue.
## Type Improvements
While implementing the `buildEditorState` helper function for our test
suite, I noticed some issues with our `TypedEditorState` type:
- nodes were no longer narrowed by their node.type types
- upon fixing this issue, the type was no longer compatible with the
generated types. To address this, I had to weaken the generated type a
bit.
In order to ensure the type will keep functioning as intended from now
on, this PR also adds some type tests
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
- https://app.asana.com/0/0/1211110462564644
|