Compare commits

...

7 Commits

Author SHA1 Message Date
Jarrod Flesch
7d36e23311 fix(plugin-nested-docs): update with db adapter on create 2025-09-02 11:38:07 -04:00
Riley Langbein
fdab2712c0 feat(richtext-lexical): add options to hide block handles (#13647)
### What?

Currently the `DraggableBlockPlugin` and `AddBlockHandlePlugin`
components are automatically applied to every editor. For flexibility
purposes, we want to allow these to be optionally removed when needed.

### Why?

There are scenarios where you may want to enforce certain limitations on
an editor, such as only allowing a single line of text. The draggable
block element and add block button wouldn't play nicely with this
scenario.

Previously in order to do this, you needed to use custom css to hide the
elements, which still technically allows them to be accessible to the
end-user if they removed the CSS. This implementation ensures the
handlers are properly removed when not wanted.

### How?

Add `hideDraggableBlockElement` and `hideAddBlockButton` options to the
lexical `admin` property. When these are set to `true`, the
`DraggableBlockPlugin` and `AddBlockHandlePlugin` are not rendered to
the DOM.

Addresses #13636
2025-08-31 05:51:00 +00:00
Jacob Fletcher
a231a05b7c fix(plugin-nested-docs): prevent phantom breadcrumb row (#13628)
When saving a doc and regenerating the breadcrumbs array, a phantom row
will append itself to the end of the array on save. This is because of
fixes made in #13551 changed the way we merge array and block rows from
the server.

To fix this we need to ensure that row IDs are consistent across form
state invocations, i.e. the hooks that mutate the array rows _cannot_
discard the row IDs.

Before:


https://github.com/user-attachments/assets/db715801-b4fd-4114-b39b-8d9b37fad979

After:


https://github.com/user-attachments/assets/6da63a31-cd5d-43c1-a15e-caddbc540d56

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211175452200168
2025-08-28 16:12:47 -04:00
Jacob Fletcher
b99c324f1e perf(plugin-search): reindex collections in parallel, up to 80% faster (#13608)
Reindexing all collections in the search plugin was previously done in
sequence which is slow and risks timing out under certain network
conditions. By running these requests in parallel we are able to save
**on average ~80%** in compute time.

This test includes reindexing 2000 documents in total across 2
collections, 3 times over. The indexes themselves are relatively simple,
containing only a couple simple fields each, so the savings would only
increase with more complexity and/or more documents.

Before:
Attempt 1: 38434.87ms
Attempt 2: 47852.61ms
Attempt 3: 28407.79ms
Avg: 38231.75ms

After:
Attempt 1: 7834.29ms
Attempt 2: 7744.40ms
Attempt 3: 7918.58ms
Avg: 7832.42ms

Total savings: ~79.51%

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211162504205343
2025-08-28 16:12:35 -04:00
Patrik
426f99ca99 fix(ui): json field type ignoring editorOptions (#13630)
### What?

Fix `JSON` field so that it respects `admin.editorOptions` (e.g.
`tabSize`, `insertSpaces`, etc.), matching the behavior of the `code`
field. Also refactor `CodeEditor` to set indentation and whitespace
options per-model instead of globally.

### Why?

- Previously, the JSON field ignored `editorOptions` and always
serialized with spaces (`tabSize: 2`). This caused inconsistencies when
comparing JSON and code fields configured with the same options.
- Monaco’s global defaults were being overridden in a way that leaked
settings between editors, making per-field customization unreliable.

### How?

- Updated `JSON` field to extract `tabSize` from `editorOptions` and
pass it through consistently when serializing and mounting the editor.
- Refactored CodeEditor to:
  - Disable `detectIndentation` globally.
- Apply `insertSpaces`, `tabSize`, and `trimAutoWhitespace` on a
per-model basis inside onMount.
  - Preserve all other `editorOptions` as before.

Fixes #13583 


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

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2025-08-28 12:57:16 -07:00
Jarrod Flesch
4600c94cac fix(plugin-nested-docs): crumbs not syncing on non-versioned collections (#13629)
Fixes https://github.com/payloadcms/payload/issues/13563

When using the nested docs plugin with collections that do not have
drafts enabled. It was not syncing breadcrumbs from parent changes.

The root cause was returning early on any document that did not meet
`doc._status !== 'published'` check, which was **_always_** the case for
non-draft collections.

### Before
```ts
if (doc._status !== 'published') {
  return
}
```
### After
```ts
if (collection.versions.drafts && doc._status !== 'published') {
  return
}
```
2025-08-28 15:43:31 -04:00
Elliot DeNolf
c1b4960795 chore(release): v3.54.0 [skip ci] 2025-08-28 09:47:54 -04:00
61 changed files with 393 additions and 223 deletions

7
.vscode/launch.json vendored
View File

@@ -160,6 +160,13 @@
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts plugin-search",
"cwd": "${workspaceFolder}",
"name": "Run Dev plugin-search",
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm run test:int live-preview",
"cwd": "${workspaceFolder}",

View File

@@ -269,11 +269,13 @@ Lexical does not generate accurate type definitions for your richText fields for
The Rich Text Field editor configuration has an `admin` property with the following options:
| Property | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------- |
| **`placeholder`** | Set this property to define a placeholder string for the field. |
| **`hideGutter`** | Set this property to `true` to hide this field's gutter within the Admin Panel. |
| **`hideInsertParagraphAtEnd`** | Set this property to `true` to hide the "+" button that appears at the end of the editor |
| Property | Description |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **`placeholder`** | Set this property to define a placeholder string for the field. |
| **`hideGutter`** | Set this property to `true` to hide this field's gutter within the Admin Panel. |
| **`hideInsertParagraphAtEnd`** | Set this property to `true` to hide the "+" button that appears at the end of the editor. |
| **`hideDraggableBlockElement`** | Set this property to `true` to hide the draggable element that appears when you hover a node in the editor. |
| **`hideAddBlockButton`** | Set this property to `true` to hide the "+" button that appears when you hover a node in the editor. |
### Disable the gutter

1
next-env.d.ts vendored
View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.53.0",
"version": "3.54.0",
"private": true,
"type": "module",
"workspaces": [

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
"version": "3.53.0",
"version": "3.54.0",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.53.0",
"version": "3.54.0",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.53.0",
"version": "3.54.0",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.53.0",
"version": "3.54.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.53.0",
"version": "3.54.0",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.53.0",
"version": "3.54.0",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-import-export",
"version": "3.53.0",
"version": "3.54.0",
"description": "Import-Export plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-multi-tenant",
"version": "3.53.0",
"version": "3.54.0",
"description": "Multi Tenant plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.53.0",
"version": "3.54.0",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -0,0 +1,19 @@
import type { CollectionBeforeChangeHook } from 'payload'
import type { NestedDocsPluginConfig } from '../types.js'
import { populateBreadcrumbs } from '../utilities/populateBreadcrumbs.js'
export const populateBreadcrumbsBeforeChange =
(pluginConfig: NestedDocsPluginConfig): CollectionBeforeChangeHook =>
async ({ collection, data, originalDoc, req }) =>
populateBreadcrumbs({
breadcrumbsFieldName: pluginConfig.breadcrumbsFieldSlug,
collection,
data,
generateLabel: pluginConfig.generateLabel,
generateURL: pluginConfig.generateURL,
originalDoc,
parentFieldName: pluginConfig.parentFieldSlug,
req,
})

View File

@@ -1,10 +1,4 @@
import type {
CollectionAfterChangeHook,
CollectionConfig,
JsonObject,
PayloadRequest,
ValidationError,
} from 'payload'
import type { CollectionAfterChangeHook, JsonObject, ValidationError } from 'payload'
import { APIError, ValidationErrorName } from 'payload'
@@ -12,21 +6,16 @@ import type { NestedDocsPluginConfig } from '../types.js'
import { populateBreadcrumbs } from '../utilities/populateBreadcrumbs.js'
type ResaveArgs = {
collection: CollectionConfig
doc: JsonObject
draft: boolean
pluginConfig: NestedDocsPluginConfig
req: PayloadRequest
}
export const resaveChildren =
(pluginConfig: NestedDocsPluginConfig): CollectionAfterChangeHook =>
async ({ collection, doc, req }) => {
if (collection.versions.drafts && doc._status !== 'published') {
// If the parent is a draft, don't resave children
return
}
const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs) => {
const parentSlug = pluginConfig?.parentFieldSlug || 'parent'
const parentSlug = pluginConfig?.parentFieldSlug || 'parent'
if (draft) {
// If the parent is a draft, don't resave children
return
} else {
const initialDraftChildren = await req.payload.find({
collection: collection.slug,
depth: 0,
@@ -74,7 +63,7 @@ const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs)
})
})
if (sortedChildren) {
if (sortedChildren.length) {
try {
for (const child of sortedChildren) {
const isDraft = child._status !== 'published'
@@ -82,7 +71,14 @@ const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs)
await req.payload.update({
id: child.id,
collection: collection.slug,
data: populateBreadcrumbs(req, pluginConfig, collection, child),
data: populateBreadcrumbs({
collection,
data: child,
generateLabel: pluginConfig.generateLabel,
generateURL: pluginConfig.generateURL,
parentFieldName: pluginConfig.parentFieldSlug,
req,
}),
depth: 0,
draft: isDraft,
locale: req.locale,
@@ -106,19 +102,6 @@ const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs)
}
}
}
}
}
export const resaveChildren =
(pluginConfig: NestedDocsPluginConfig, collection: CollectionConfig): CollectionAfterChangeHook =>
async ({ doc, req }) => {
await resave({
collection,
doc,
draft: doc._status === 'published' ? false : true,
pluginConfig,
req,
})
return undefined
}

View File

@@ -1,4 +1,4 @@
import type { CollectionAfterChangeHook, CollectionConfig } from 'payload'
import type { CollectionAfterChangeHook } from 'payload'
import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js'
@@ -6,8 +6,8 @@ import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js'
// so that we can build its breadcrumbs with the newly created document's ID.
export const resaveSelfAfterCreate =
(pluginConfig: NestedDocsPluginConfig, collection: CollectionConfig): CollectionAfterChangeHook =>
async ({ doc, operation, req }) => {
(pluginConfig: NestedDocsPluginConfig): CollectionAfterChangeHook =>
async ({ collection, doc, operation, req }) => {
if (operation !== 'create') {
return undefined
}
@@ -16,13 +16,8 @@ export const resaveSelfAfterCreate =
const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs'
const breadcrumbs = doc[breadcrumbSlug] as unknown as Breadcrumb[]
const updateAsDraft =
typeof collection.versions === 'object' &&
collection.versions.drafts &&
doc._status !== 'published'
try {
await payload.update({
await payload.db.updateOne({
id: doc.id,
collection: collection.slug,
data: {
@@ -32,9 +27,8 @@ export const resaveSelfAfterCreate =
doc: breadcrumbs.length === i + 1 ? doc.id : crumb.doc,
})) || [],
},
depth: 0,
draft: updateAsDraft,
locale,
draft: collection.versions.drafts && doc._status !== 'published',
locale: locale || undefined,
req,
})
} catch (err: unknown) {

View File

@@ -5,10 +5,10 @@ import type { NestedDocsPluginConfig } from './types.js'
import { createBreadcrumbsField } from './fields/breadcrumbs.js'
import { createParentField } from './fields/parent.js'
import { parentFilterOptions } from './fields/parentFilterOptions.js'
import { populateBreadcrumbsBeforeChange } from './hooks/populateBreadcrumbsBeforeChange.js'
import { resaveChildren } from './hooks/resaveChildren.js'
import { resaveSelfAfterCreate } from './hooks/resaveSelfAfterCreate.js'
import { getParents } from './utilities/getParents.js'
import { populateBreadcrumbs } from './utilities/populateBreadcrumbs.js'
export { createBreadcrumbsField, createParentField, getParents }
@@ -53,14 +53,13 @@ export const nestedDocsPlugin =
hooks: {
...(collection.hooks || {}),
afterChange: [
resaveChildren(pluginConfig, collection),
resaveSelfAfterCreate(pluginConfig, collection),
...(collection?.hooks?.afterChange || []),
resaveChildren(pluginConfig),
resaveSelfAfterCreate(pluginConfig),
],
beforeChange: [
async ({ data, originalDoc, req }) =>
populateBreadcrumbs(req, pluginConfig, collection, data, originalDoc),
...(collection?.hooks?.beforeChange || []),
populateBreadcrumbsBeforeChange(pluginConfig),
],
},
}

View File

@@ -1,23 +1,37 @@
import type { CollectionConfig } from 'payload'
import type { SanitizedCollectionConfig } from 'payload'
import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js'
import type { Breadcrumb, GenerateLabel, GenerateURL } from '../types.js'
export const formatBreadcrumb = (
pluginConfig: NestedDocsPluginConfig,
collection: CollectionConfig,
docs: Array<Record<string, unknown>>,
): Breadcrumb => {
type Args = {
/**
* Existing breadcrumb, if any, to base the new breadcrumb on.
* This ensures that row IDs are maintained across updates, etc.
*/
breadcrumb?: Breadcrumb
collection: SanitizedCollectionConfig
docs: Record<string, unknown>[]
generateLabel?: GenerateLabel
generateURL?: GenerateURL
}
export const formatBreadcrumb = ({
breadcrumb,
collection,
docs,
generateLabel,
generateURL,
}: Args): Breadcrumb => {
let url: string | undefined = undefined
let label: string
const lastDoc = docs[docs.length - 1]!
if (typeof pluginConfig?.generateURL === 'function') {
url = pluginConfig.generateURL(docs, lastDoc)
if (typeof generateURL === 'function') {
url = generateURL(docs, lastDoc)
}
if (typeof pluginConfig?.generateLabel === 'function') {
label = pluginConfig.generateLabel(docs, lastDoc)
if (typeof generateLabel === 'function') {
label = generateLabel(docs, lastDoc)
} else {
const title = collection.admin?.useAsTitle ? lastDoc[collection.admin.useAsTitle] : ''
@@ -25,6 +39,7 @@ export const formatBreadcrumb = (
}
return {
...(breadcrumb || {}),
doc: lastDoc.id as string,
label,
url,

View File

@@ -1,14 +1,14 @@
import type { CollectionConfig, PayloadRequest } from 'payload'
import type { CollectionConfig, Document, PayloadRequest } from 'payload'
import type { NestedDocsPluginConfig } from '../types.js'
export const getParents = async (
req: PayloadRequest,
pluginConfig: NestedDocsPluginConfig,
pluginConfig: Pick<NestedDocsPluginConfig, 'generateLabel' | 'generateURL' | 'parentFieldSlug'>,
collection: CollectionConfig,
doc: Record<string, unknown>,
docs: Array<Record<string, unknown>> = [],
): Promise<Array<Record<string, unknown>>> => {
): Promise<Document[]> => {
const parentSlug = pluginConfig?.parentFieldSlug || 'parent'
const parent = doc[parentSlug]
let retrievedParent: null | Record<string, unknown> = null

View File

@@ -1,42 +1,62 @@
import type { CollectionConfig } from 'payload'
import type { Data, Document, PayloadRequest, SanitizedCollectionConfig } from 'payload'
import type { NestedDocsPluginConfig } from '../types.js'
import type { GenerateLabel, GenerateURL } from '../types.js'
import { formatBreadcrumb } from './formatBreadcrumb.js'
import { getParents } from './getParents.js'
import { getParents as getAllParentDocuments } from './getParents.js'
export const populateBreadcrumbs = async (
req: any,
pluginConfig: NestedDocsPluginConfig,
collection: CollectionConfig,
data: any,
originalDoc?: any,
): Promise<any> => {
type Args = {
breadcrumbsFieldName?: string
collection: SanitizedCollectionConfig
data: Data
generateLabel?: GenerateLabel
generateURL?: GenerateURL
originalDoc?: Document
parentFieldName?: string
req: PayloadRequest
}
export const populateBreadcrumbs = async ({
breadcrumbsFieldName = 'breadcrumbs',
collection,
data,
generateLabel,
generateURL,
originalDoc,
parentFieldName,
req,
}: Args): Promise<Data> => {
const newData = data
const breadcrumbDocs = [
...(await getParents(req, pluginConfig, collection, {
...originalDoc,
...data,
})),
]
const currentDocBreadcrumb = {
const currentDocument = {
...originalDoc,
...data,
id: originalDoc?.id ?? data?.id,
}
if (originalDoc?.id) {
currentDocBreadcrumb.id = originalDoc?.id
}
breadcrumbDocs.push(currentDocBreadcrumb)
const breadcrumbs = breadcrumbDocs.map((_, i) =>
formatBreadcrumb(pluginConfig, collection, breadcrumbDocs.slice(0, i + 1)),
const allParentDocuments: Document[] = await getAllParentDocuments(
req,
{
generateLabel,
generateURL,
parentFieldSlug: parentFieldName,
},
collection,
currentDocument,
)
return {
...newData,
[pluginConfig?.breadcrumbsFieldSlug || 'breadcrumbs']: breadcrumbs,
}
allParentDocuments.push(currentDocument)
const breadcrumbs = allParentDocuments.map((_, i) =>
formatBreadcrumb({
breadcrumb: currentDocument[breadcrumbsFieldName]?.[i],
collection,
docs: allParentDocuments.slice(0, i + 1),
generateLabel,
generateURL,
}),
)
newData[breadcrumbsFieldName] = breadcrumbs
return newData
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.53.0",
"version": "3.54.0",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.53.0",
"version": "3.54.0",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -149,17 +149,23 @@ export const generateReindexHandler =
await initTransaction(req)
for (const collection of collections) {
try {
await deleteIndexes(collection)
await reindexCollection(collection)
} catch (err) {
const message = t('error:unableToReindexCollection', { collection })
payload.logger.error({ err, msg: message })
try {
const promises = collections.map(async (collection) => {
try {
await deleteIndexes(collection)
await reindexCollection(collection)
} catch (err) {
const message = t('error:unableToReindexCollection', { collection })
payload.logger.error({ err, msg: message })
await killTransaction(req)
return Response.json({ message }, { headers, status: 500 })
}
await killTransaction(req)
throw new Error(message)
}
})
await Promise.all(promises)
} catch (err: any) {
return Response.json({ message: err.message }, { headers, status: 500 })
}
const message = t('general:successfullyReindexed', {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-sentry",
"version": "3.53.0",
"version": "3.54.0",
"description": "Sentry plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.53.0",
"version": "3.54.0",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.53.0",
"version": "3.54.0",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -85,6 +85,12 @@ export const RscEntryLexicalField: React.FC<
if (args.admin?.hideInsertParagraphAtEnd) {
admin.hideInsertParagraphAtEnd = true
}
if (args.admin?.hideAddBlockButton) {
admin.hideAddBlockButton = true
}
if (args.admin?.hideDraggableBlockElement) {
admin.hideDraggableBlockElement = true
}
const props: LexicalRichTextFieldProps = {
clientFeatures,

View File

@@ -131,8 +131,12 @@ export const LexicalEditor: React.FC<
<React.Fragment>
{!isSmallWidthViewport && editor.isEditable() && (
<React.Fragment>
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
<AddBlockHandlePlugin anchorElem={floatingAnchorElem} />
{editorConfig.admin?.hideDraggableBlockElement ? null : (
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
)}
{editorConfig.admin?.hideAddBlockButton ? null : (
<AddBlockHandlePlugin anchorElem={floatingAnchorElem} />
)}
</React.Fragment>
)}
{editorConfig.features.plugins?.map((plugin) => {

View File

@@ -20,6 +20,14 @@ import type { SanitizedServerEditorConfig } from './lexical/config/types.js'
import type { InitialLexicalFormState } from './utilities/buildInitialState.js'
export type LexicalFieldAdminProps = {
/**
* Controls if the add block button should be hidden. @default false
*/
hideAddBlockButton?: boolean
/**
* Controls if the draggable block element should be hidden. @default false
*/
hideDraggableBlockElement?: boolean
/**
* Controls if the gutter (padding to the left & gray vertical line) should be hidden. @default false
*/

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
"version": "3.53.0",
"version": "3.54.0",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
"version": "3.53.0",
"version": "3.54.0",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/translations",
"version": "3.53.0",
"version": "3.54.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/ui",
"version": "3.53.0",
"version": "3.54.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -15,6 +15,9 @@ const baseClass = 'code-editor'
const CodeEditor: React.FC<Props> = (props) => {
const { className, maxHeight, minHeight, options, readOnly, ...rest } = props
const MIN_HEIGHT = minHeight ?? 56 // equivalent to 3 lines
// Extract per-model settings to avoid global conflicts
const { insertSpaces, tabSize, trimAutoWhitespace, ...editorOptions } = options || {}
const paddingFromProps = options?.padding
? (options.padding.top || 0) + (options.padding?.bottom || 0)
: 0
@@ -36,8 +39,9 @@ const CodeEditor: React.FC<Props> = (props) => {
className={classes}
loading={<ShimmerEffect height={dynamicHeight} />}
options={{
detectIndentation: true,
detectIndentation: false, // use the tabSize on the model, set onMount
hideCursorInOverviewRuler: true,
insertSpaces: false,
minimap: {
enabled: false,
},
@@ -47,9 +51,9 @@ const CodeEditor: React.FC<Props> = (props) => {
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
tabSize: 2,
trimAutoWhitespace: false,
wordWrap: 'on',
...options,
...editorOptions,
}}
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
{...rest}
@@ -64,6 +68,17 @@ const CodeEditor: React.FC<Props> = (props) => {
}}
onMount={(editor, monaco) => {
rest.onMount?.(editor, monaco)
// Set per-model options to avoid global conflicts
const model = editor.getModel()
if (model) {
model.updateOptions({
insertSpaces: insertSpaces ?? true,
tabSize: tabSize ?? 4,
trimAutoWhitespace: trimAutoWhitespace ?? true,
})
}
setDynamicHeight(
Math.max(MIN_HEIGHT, editor.getValue().split('\n').length * 18 + 2 + paddingFromProps),
)

View File

@@ -1,7 +1,7 @@
'use client'
import type { CodeFieldClientComponent } from 'payload'
import React, { useCallback, useMemo } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { CodeEditor } from '../../elements/CodeEditor/index.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
@@ -36,6 +36,9 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
validate,
} = props
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
const [editorKey, setEditorKey] = useState<string>('')
const memoizedValidate = useCallback(
(value, options) => {
if (typeof validate === 'function') {
@@ -48,15 +51,47 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
const {
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
disabled,
initialValue,
path,
setValue,
showError,
value,
} = useField({
} = useField<string>({
potentiallyStalePath: pathFromProps,
validate: memoizedValidate,
})
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
(value || initialValue) !== undefined ? (value ?? initialValue) : undefined,
)
const handleChange = useCallback(
(val) => {
if (readOnly || disabled) {
return
}
inputChangeFromRef.current = 'user'
try {
setValue(val ? val : null)
} catch (e) {
setValue(val ? val : null)
}
},
[readOnly, disabled, setValue],
)
useEffect(() => {
if (inputChangeFromRef.current === 'system') {
setInitialStringValue(
(value || initialValue) !== undefined ? (value ?? initialValue) : undefined,
)
setEditorKey(`${path}-${new Date().toString()}`)
}
inputChangeFromRef.current = 'system'
}, [initialValue, path, value])
const styles = useMemo(() => mergeFieldStyles(field), [field])
return (
@@ -86,11 +121,15 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
{BeforeInput}
<CodeEditor
defaultLanguage={prismToMonacoLanguageMap[language] || language}
onChange={readOnly || disabled ? () => null : (val) => setValue(val)}
key={editorKey}
onChange={handleChange}
onMount={onMount}
options={editorOptions}
readOnly={readOnly || disabled}
value={(value as string) || ''}
value={initialStringValue}
wrapperProps={{
id: `field-${path?.replace(/\./g, '__')}`,
}}
/>
{AfterInput}
</div>

View File

@@ -33,6 +33,8 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
validate,
} = props
const { tabSize = 2 } = editorOptions || {}
const [jsonError, setJsonError] = useState<string>()
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
const [editorKey, setEditorKey] = useState<string>('')
@@ -61,7 +63,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
(value || initialValue) !== undefined
? JSON.stringify(value ?? initialValue, null, 2)
? JSON.stringify(value ?? initialValue, null, tabSize)
: undefined,
)
@@ -86,10 +88,14 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
: `${uri}?${crypto.randomUUID ? crypto.randomUUID() : uuidv4()}`
editor.setModel(
monaco.editor.createModel(JSON.stringify(value, null, 2), 'json', monaco.Uri.parse(newUri)),
monaco.editor.createModel(
JSON.stringify(value, null, tabSize),
'json',
monaco.Uri.parse(newUri),
),
)
},
[jsonSchema, value],
[jsonSchema, tabSize, value],
)
const handleChange = useCallback(
@@ -114,14 +120,14 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
if (inputChangeFromRef.current === 'system') {
setInitialStringValue(
(value || initialValue) !== undefined
? JSON.stringify(value ?? initialValue, null, 2)
? JSON.stringify(value ?? initialValue, null, tabSize)
: undefined,
)
setEditorKey(new Date().toString())
setEditorKey(`${path}-${new Date().toString()}`)
}
inputChangeFromRef.current = 'system'
}, [initialValue, value])
}, [initialValue, path, tabSize, value])
const styles = useMemo(() => mergeFieldStyles(field), [field])

View File

@@ -84,7 +84,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
defaultIDType: string;
};
globals: {
menu: Menu;
@@ -124,7 +124,7 @@ export interface UserAuthOperations {
* via the `definition` "posts".
*/
export interface Post {
id: number;
id: string;
title?: string | null;
content?: {
root: {
@@ -149,7 +149,7 @@ export interface Post {
* via the `definition` "media".
*/
export interface Media {
id: number;
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -193,7 +193,7 @@ export interface Media {
* via the `definition` "users".
*/
export interface User {
id: number;
id: string;
updatedAt: string;
createdAt: string;
email: string;
@@ -217,24 +217,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
id: string;
document?:
| ({
relationTo: 'posts';
value: number | Post;
value: string | Post;
} | null)
| ({
relationTo: 'media';
value: number | Media;
value: string | Media;
} | null)
| ({
relationTo: 'users';
value: number | User;
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
updatedAt: string;
createdAt: string;
@@ -244,10 +244,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
id: string;
user: {
relationTo: 'users';
value: number | User;
value: string | User;
};
key?: string | null;
value?:
@@ -267,7 +267,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -393,7 +393,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
* via the `definition` "menu".
*/
export interface Menu {
id: number;
id: string;
globalText?: string | null;
updatedAt?: string | null;
createdAt?: string | null;

View File

@@ -76,24 +76,10 @@ export const PostsCollection: CollectionConfig = {
name: 'array',
type: 'array',
admin: {
description: 'If there is no value, a default row will be added by a beforeChange hook',
components: {
RowLabel: './collections/Posts/ArrayRowLabel.js#ArrayRowLabel',
},
},
hooks: {
beforeChange: [
({ value }) =>
!value?.length
? [
{
defaultTextField: 'This is a computed value.',
customTextField: 'This is a computed value.',
},
]
: value,
],
},
fields: [
{
name: 'customTextField',
@@ -111,5 +97,31 @@ export const PostsCollection: CollectionConfig = {
},
],
},
{
name: 'computedArray',
type: 'array',
admin: {
description:
'If there is no value, a default row will be added by a beforeChange hook. Otherwise, modifies the rows on save.',
},
hooks: {
beforeChange: [
({ value }) =>
!value?.length
? [
{
text: 'This is a computed value.',
},
]
: value,
],
},
fields: [
{
name: 'text',
type: 'text',
},
],
},
],
}

View File

@@ -312,22 +312,18 @@ test.describe('Form State', () => {
// Now test array rows, as their merge logic is different
await page.locator('#field-array #array-row-0').isVisible()
await page.locator('#field-computedArray #computedArray-row-0').isVisible()
await removeArrayRow(page, { fieldName: 'array' })
await removeArrayRow(page, { fieldName: 'computedArray' })
await page.locator('#field-array .array-row-0').isHidden()
await page.locator('#field-computedArray #computedArray-row-0').isHidden()
await saveDocAndAssert(page)
await expect(page.locator('#field-array #array-row-0')).toBeVisible()
await expect(page.locator('#field-computedArray #computedArray-row-0')).toBeVisible()
await expect(
page.locator('#field-array #array-row-0 #field-array__0__customTextField'),
).toHaveValue('This is a computed value.')
await expect(
page.locator('#field-array #array-row-0 #field-array__0__defaultTextField'),
page.locator('#field-computedArray #computedArray-row-0 #field-computedArray__0__text'),
).toHaveValue('This is a computed value.')
})

View File

@@ -144,9 +144,6 @@ export interface Post {
}
)[]
| null;
/**
* If there is no value, a default row will be added by a beforeChange hook
*/
array?:
| {
customTextField?: string | null;
@@ -154,6 +151,15 @@ export interface Post {
id?: string | null;
}[]
| null;
/**
* If there is no value, a default row will be added by a beforeChange hook. Otherwise, modifies the rows on save.
*/
computedArray?:
| {
text?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@@ -288,6 +294,12 @@ export interface PostsSelect<T extends boolean = true> {
defaultTextField?: T;
id?: T;
};
computedArray?:
| T
| {
text?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}

View File

@@ -179,19 +179,15 @@ describe('@payloadcms/plugin-nested-docs', () => {
})
.then(({ docs }) => docs[0])
if (!updatedChild) {
return
}
// breadcrumbs should be updated
expect(updatedChild.breadcrumbs).toHaveLength(2)
expect(updatedChild!.breadcrumbs).toHaveLength(2)
expect(updatedChild.breadcrumbs?.[0]?.url).toStrictEqual('/parent-updated')
expect(updatedChild.breadcrumbs?.[1]?.url).toStrictEqual('/parent-updated/child')
expect(updatedChild!.breadcrumbs?.[0]?.url).toStrictEqual('/parent-updated')
expect(updatedChild!.breadcrumbs?.[1]?.url).toStrictEqual('/parent-updated/child')
// no other data should be affected
expect(updatedChild.title).toEqual('child doc')
expect(updatedChild.slug).toEqual('child')
expect(updatedChild!.title).toEqual('child doc')
expect(updatedChild!.slug).toEqual('child')
})
})

View File

@@ -178,6 +178,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -295,6 +302,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -494,20 +494,22 @@ describe('@payloadcms/plugin-search', () => {
})
it('should reindex whole collections', async () => {
await payload.create({
collection: pagesSlug,
data: {
title: 'Test page title',
_status: 'published',
},
})
await payload.create({
collection: postsSlug,
data: {
title: 'Test page title',
_status: 'published',
},
})
await Promise.all([
payload.create({
collection: pagesSlug,
data: {
title: 'Test page title',
_status: 'published',
},
}),
payload.create({
collection: postsSlug,
data: {
title: 'Test page title',
_status: 'published',
},
}),
])
await wait(200)

View File

@@ -136,6 +136,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -300,6 +307,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema