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
This commit is contained in:
Alessio Gravili
2025-09-18 15:01:12 -07:00
committed by GitHub
parent b1e5bd9962
commit 1c89291fac
61 changed files with 1773 additions and 820 deletions

View File

@@ -286,6 +286,8 @@ jobs:
- group-by
- folders
- hooks
- lexical__collections___LexicalFullyFeatured
- lexical__collections__OnDemandForm
- lexical__collections__Lexical__e2e__main
- lexical__collections__Lexical__e2e__blocks
- lexical__collections__Lexical__e2e__blocks#config.blockreferences.ts
@@ -424,6 +426,8 @@ jobs:
- group-by
- folders
- hooks
- lexical__collections___LexicalFullyFeatured
- lexical__collections__OnDemandForm
- lexical__collections__Lexical__e2e__main
- lexical__collections__Lexical__e2e__blocks
- lexical__collections__Lexical__e2e__blocks#config.blockreferences.ts

View File

@@ -0,0 +1,116 @@
---
title: Rendering On Demand
label: Rendering On Demand
order: 50
desc: Rendering rich text on demand
keywords: lexical, rich text, editor, headless cms, render, rendering
---
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 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.
To simplify rendering richtext on demand, <RenderLexical />, that renders a Lexical editor 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 function. That server render gives Lexical everything it needs (including nested field schemas) and returns a ready-to-hydrate editor.
<Banner type="warning">
`RenderLexical` and the underlying `render-field` server function are
experimental and may change in minor releases.
</Banner>
## Inside an existing Form
If you have an existing Form and want to render a richtext field within it, you can use the `RenderLexical` component like this:
```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 (
<RenderLexical
field={{
name: 'myFieldName' /* Make sure this matches the field name present in your form */,
}}
initialValue={buildEditorState({ text: 'default value' })}
schemaPath={`collection.${lexicalFullyFeaturedSlug}.richText`}
/>
)
}
```
## Outside of a Form (you control state)
```tsx
'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) => {
// Manually manage the editor state
const [value, setValue] = useState<DefaultTypedEditorState | undefined>(() =>
buildEditorState({ text: 'state default' }),
)
const handleReset = React.useCallback(() => {
setValue(buildEditorState({ text: 'state default' }))
}, [])
return (
<div>
<RenderLexical
field={{ name: 'myField' }}
initialValue={buildEditorState({ text: 'default value' })}
schemaPath={`collection.${lexicalFullyFeaturedSlug}.richText`}
setValue={setValue as any}
value={value}
/>
<button onClick={handleReset} style={{ marginTop: 8 }} type="button">
Reset Editor State
</button>
</div>
)
}
```
## Choosing the schemaPath
`schemaPath` tells the server which richText field to render. This gives the server the exact nested field schemas (blocks, relationship drawers, upload fields, tables, etc.).
Format:
- `collection.<collectionSlug>.<fieldPath>`
- `global.<globalSlug>.<fieldPath>`
Example (top level): `collection.posts.richText`
Example (nested in a group/tab): `collection.posts.content.richText`
<Banner type="info">
**Tip:** If your target editor lives deep in arrays/blocks and you're unsure of the exact path, you can define a **hidden top-level richText** purely as a "render anchor":
```ts
{
name: 'onDemandAnchor',
type: 'richText',
admin: { hidden: true }
}
```
Then use `schemaPath="collection.posts.onDemandAnchor"`
</Banner>

View File

@@ -147,8 +147,8 @@
"@types/jest": "29.5.12",
"@types/minimist": "1.2.5",
"@types/node": "22.15.30",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@types/shelljs": "0.8.15",
"chalk": "^4.1.2",
"comment-json": "^4.2.3",
@@ -175,8 +175,8 @@
"playwright": "1.54.1",
"playwright-core": "1.54.1",
"prettier": "3.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"rimraf": "6.0.1",
"sharp": "0.32.6",
"shelljs": "0.8.5",
@@ -184,7 +184,7 @@
"sort-package-json": "^2.10.0",
"swc-plugin-transform-remove-imports": "4.0.4",
"tempy": "1.0.1",
"tstyche": "^3.1.1",
"tstyche": "3.5.0",
"tsx": "4.19.2",
"turbo": "^2.5.4",
"typescript": "5.7.3"

View File

@@ -42,8 +42,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -46,8 +46,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -120,10 +120,10 @@
"@next/eslint-plugin-next": "15.4.4",
"@payloadcms/eslint-config": "workspace:*",
"@types/busboy": "1.5.4",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@types/uuid": "10.0.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"esbuild": "0.25.5",
"esbuild-sass-plugin": "3.3.1",
"payload": "workspace:*",

View File

@@ -1,6 +1,6 @@
import type { ServerFunction, ServerFunctionHandler } from 'payload'
import { copyDataFromLocaleHandler } from '@payloadcms/ui/rsc'
import { _internal_renderFieldHandler, copyDataFromLocaleHandler } from '@payloadcms/ui/rsc'
import { buildFormStateHandler } from '@payloadcms/ui/utilities/buildFormState'
import { buildTableStateHandler } from '@payloadcms/ui/utilities/buildTableState'
import { getFolderResultsComponentAndDataHandler } from '@payloadcms/ui/utilities/getFolderResultsComponentAndData'
@@ -11,19 +11,26 @@ import { renderDocumentSlotsHandler } from '../views/Document/renderDocumentSlot
import { renderListHandler } from '../views/List/handleServerFunction.js'
import { initReq } from './initReq.js'
const serverFunctions: Record<string, ServerFunction> = {
const baseServerFunctions: Record<string, ServerFunction<any, any>> = {
'copy-data-from-locale': copyDataFromLocaleHandler,
'form-state': buildFormStateHandler,
'get-folder-results-component-and-data': getFolderResultsComponentAndDataHandler,
'render-document': renderDocumentHandler,
'render-document-slots': renderDocumentSlotsHandler,
'render-field': _internal_renderFieldHandler,
'render-list': renderListHandler,
'schedule-publish': schedulePublishHandler,
'table-state': buildTableStateHandler,
}
export const handleServerFunctions: ServerFunctionHandler = async (args) => {
const { name: fnKey, args: fnArgs, config: configPromise, importMap } = args
const {
name: fnKey,
args: fnArgs,
config: configPromise,
importMap,
serverFunctions: extraServerFunctions,
} = args
const { req } = await initReq({
configPromise,
@@ -37,6 +44,11 @@ export const handleServerFunctions: ServerFunctionHandler = async (args) => {
req,
}
const serverFunctions = {
...baseServerFunctions,
...(extraServerFunctions || {}),
}
const fn = serverFunctions[fnKey]
if (!fn) {

View File

@@ -2,6 +2,7 @@ import type { MarkOptional } from 'ts-essentials'
import type { RowField, RowFieldClient } from '../../fields/config/types.js'
import type {
ClientComponentProps,
ClientFieldBase,
FieldClientComponent,
FieldPaths,
@@ -21,9 +22,7 @@ import type {
type RowFieldClientWithoutType = MarkOptional<RowFieldClient, 'type'>
type RowFieldBaseClientProps = {
readonly forceRender?: boolean
} & Omit<FieldPaths, 'path'>
type RowFieldBaseClientProps = Omit<FieldPaths, 'path'> & Pick<ClientComponentProps, 'forceRender'>
export type RowFieldClientProps = Omit<ClientFieldBase<RowFieldClientWithoutType>, 'path'> &
RowFieldBaseClientProps

View File

@@ -21,6 +21,13 @@ export type ClientFieldWithOptionalType = MarkOptional<ClientField, 'type'>
export type ClientComponentProps = {
customComponents?: FormField['customComponents']
field: ClientBlock | ClientField | ClientTab
/**
* Controls the rendering behavior of the fields, i.e. defers rendering until they intersect with the viewport using the Intersection Observer API.
*
* If true, the fields will be rendered immediately, rather than waiting for them to intersect with the viewport.
*
* If a number is provided, will immediately render fields _up to that index_.
*/
forceRender?: boolean
permissions?: SanitizedFieldPermissions
readOnly?: boolean

View File

@@ -36,6 +36,31 @@ export type ServerFunctionHandler = (
args: {
config: Promise<SanitizedConfig> | SanitizedConfig
importMap: ImportMap
/**
* A map of server function names to their implementations. These are
* registered alongside the base server functions and can be called
* using the useServerFunctions() hook.
*
* @example
* const { serverFunction } = useServerFunctions()
*
* const callServerFunction = useCallback(() => {
*
* async function call() {
* const result = (await serverFunction({
* name: 'record-key',
* args: {
* // Your args
* },
* }))
*
* // Do someting with the result
* }
*
* void call()
* }, [serverFunction])
*/
serverFunctions?: Record<string, ServerFunction<any, any>>
} & ServerFunctionClientArgs,
) => Promise<unknown>

View File

@@ -65,8 +65,8 @@
},
"devDependencies": {
"@types/find-node-modules": "^2.1.2",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -68,8 +68,8 @@
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/escape-html": "^1.0.4",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"payload": "workspace:*"

View File

@@ -65,8 +65,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -59,8 +59,8 @@
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -74,8 +74,8 @@
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/next": "workspace:*",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -72,8 +72,8 @@
"@payloadcms/eslint-config": "workspace:*",
"@payloadcms/next": "workspace:*",
"@types/lodash.get": "^4.4.7",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@types/uuid": "10.0.0",
"payload": "workspace:*"
},

View File

@@ -404,9 +404,9 @@
"@types/escape-html": "1.0.4",
"@types/json-schema": "7.0.15",
"@types/node": "22.15.30",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"babel-plugin-transform-remove-imports": "^1.8.0",
"esbuild": "0.25.5",
"esbuild-sass-plugin": "3.3.1",

View File

@@ -150,3 +150,6 @@ export { BlockEditButton } from '../../features/blocks/client/component/componen
export { BlockRemoveButton } from '../../features/blocks/client/component/components/BlockRemoveButton.js'
export { useBlockComponentContext } from '../../features/blocks/client/component/BlockContent.js'
export { getRestPopulateFn } from '../../features/converters/utilities/restPopulateFn.js'
export { RenderLexical } from '../../field/RenderLexical/index.js'
export { buildEditorState } from '../../utilities/buildEditorState.js'

View File

@@ -0,0 +1,127 @@
'use client'
import type { RichTextField } from 'payload'
import {
FieldContext,
FieldPathContext,
type FieldType,
type RenderFieldServerFnArgs,
ServerFunctionsContext,
type ServerFunctionsContextType,
ShimmerEffect,
useServerFunctions,
} from '@payloadcms/ui'
import React, { useCallback, useEffect, useRef } from 'react'
import type { DefaultTypedEditorState } from '../../nodeTypes.js'
/**
* Utility to render a lexical editor on the client.
*
* @experimental - may break in minor releases
* @todo - replace this with a general utility that works for all fields. Maybe merge with packages/ui/src/forms/RenderFields/RenderField.tsx
*/
export const RenderLexical: React.FC<
/**
* If value or setValue, or both, is provided, this component will manage its own value.
* If neither is passed, it will rely on the parent form to manage the value.
*/
{
/**
* Override the loading state while the field component is being fetched and rendered.
*/
Loading?: React.ReactElement
setValue?: FieldType<DefaultTypedEditorState | undefined>['setValue']
value?: FieldType<DefaultTypedEditorState | undefined>['value']
} & RenderFieldServerFnArgs
> = (args) => {
const { field, initialValue, Loading, path, schemaPath, setValue, value } = args
const [Component, setComponent] = React.useState<null | React.ReactNode>(null)
const serverFunctionContext = useServerFunctions()
const { _internal_renderField } = serverFunctionContext
const [entityType, entitySlug] = schemaPath.split('.')
const fieldPath = path ?? (field && 'name' in field ? field?.name : '') ?? ''
const renderLexical = useCallback(() => {
async function render() {
const { Field } = await _internal_renderField({
field: {
...((field as RichTextField) || {}),
type: 'richText',
admin: {
hidden: false,
},
},
initialValue: initialValue ?? undefined,
path,
schemaPath,
})
setComponent(Field)
}
void render()
}, [_internal_renderField, schemaPath, path, field, initialValue])
const mounted = useRef(false)
useEffect(() => {
if (mounted.current) {
return
}
mounted.current = true
void renderLexical()
}, [renderLexical])
if (!Component) {
return typeof Loading !== 'undefined' ? Loading : <ShimmerEffect />
}
/**
* By default, the lexical will make form state requests (e.g. to get drawer fields), passing in the arguments
* of the current field. However, we need to override those arguments to get it to make requests based on the
* *target* field. The server only knows the schema map of the target field.
*/
const adjustedServerFunctionContext: ServerFunctionsContextType = {
...serverFunctionContext,
getFormState: async (getFormStateArgs) => {
return serverFunctionContext.getFormState({
...getFormStateArgs,
collectionSlug: entityType === 'collection' ? entitySlug : undefined,
globalSlug: entityType === 'global' ? entitySlug : undefined,
})
},
}
if (typeof value === 'undefined' && !setValue) {
return (
<ServerFunctionsContext value={{ ...adjustedServerFunctionContext }}>
<FieldPathContext key={fieldPath} value={fieldPath}>
{Component}
</FieldPathContext>
</ServerFunctionsContext>
)
}
const fieldValue: FieldType<DefaultTypedEditorState | undefined> = {
disabled: false,
formInitializing: false,
formProcessing: false,
formSubmitted: false,
initialValue: value,
path: fieldPath,
setValue: setValue ?? (() => undefined),
showError: false,
value,
}
return (
<ServerFunctionsContext value={{ ...adjustedServerFunctionContext }}>
<FieldPathContext key={fieldPath} value={fieldPath}>
<FieldContext value={fieldValue}>{Component}</FieldContext>
</FieldPathContext>
</ServerFunctionsContext>
)
}

View File

@@ -814,6 +814,7 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
properties: {
type: {
type: 'string',
tsType: 'any',
},
version: {
type: 'integer',
@@ -1055,10 +1056,12 @@ export { populate } from './populateGraphQL/populate.js'
export type { LexicalEditorProps, LexicalFieldAdminProps, LexicalRichTextAdapter } from './types.js'
export { buildEditorState } from './utilities/buildEditorState.js'
export { createServerFeature } from './utilities/createServerFeature.js'
export { editorConfigFactory } from './utilities/editorConfigFactory.js'
export { editorConfigFactory } from './utilities/editorConfigFactory.js'
export type { FieldsDrawerProps } from './utilities/fieldsDrawer/Drawer.js'
export { extractPropsFromJSXPropsString } from './utilities/jsx/extractPropsFromJSXPropsString.js'
export {
@@ -1067,5 +1070,4 @@ export {
objectToFrontmatter,
propsToJSXString,
} from './utilities/jsx/jsx.js'
export { upgradeLexicalData } from './utilities/upgradeLexicalData/index.js'

View File

@@ -78,9 +78,17 @@ type RecursiveNodes<T extends SerializedLexicalNode, Depth extends number = 4> =
type DecrementDepth<N extends number> = [0, 0, 1, 2, 3, 4][N]
/**
* Alternative type to `SerializedEditorState` that automatically types your nodes
* more strictly, narrowing down nodes based on the `type` without having to manually
* type-cast.
*/
export type TypedEditorState<T extends SerializedLexicalNode = SerializedLexicalNode> =
SerializedEditorState<RecursiveNodes<T>>
/**
* All node types included by default in a lexical editor without configuration.
*/
export type DefaultNodeTypes =
| SerializedAutoLinkNode
//| SerializedBlockNode // Not included by default
@@ -97,5 +105,12 @@ export type DefaultNodeTypes =
| SerializedTextNode
| SerializedUploadNode
export type DefaultTypedEditorState<T extends SerializedLexicalNode = SerializedLexicalNode> =
TypedEditorState<DefaultNodeTypes | T>
/**
* Like `TypedEditorState` but includes all default node types.
* You can pass *additional* node types as a generic parameter.
*/
export type DefaultTypedEditorState<
TAdditionalNodeTypes extends null | SerializedLexicalNode = null,
> = [TAdditionalNodeTypes] extends null
? TypedEditorState<DefaultNodeTypes>
: TypedEditorState<DefaultNodeTypes | NonNullable<TAdditionalNodeTypes>>

View File

@@ -0,0 +1,99 @@
import type { SerializedLexicalNode } from 'lexical'
import type { DefaultTypedEditorState, TypedEditorState } from '../nodeTypes.js'
export function buildEditorState(args: {
nodes?: DefaultTypedEditorState['root']['children']
text?: string
}): DefaultTypedEditorState
export function buildEditorState<T extends SerializedLexicalNode>(args: {
// If you pass children typed for a specific schema T, the return is TypedEditorState<T>
nodes?: TypedEditorState<T>['root']['children']
text?: string
}): TypedEditorState<T>
/**
* Helper to build lexical editor state JSON from text and/or nodes.
*
* @param nodes - The nodes to include in the editor state. If you pass the `text` argument, this will append your nodes after the first paragraph node.
* @param text - The text content to include in the editor state. This will create a paragraph node with a text node for you and set that as the first node.
* @returns The constructed editor state JSON.
*
* @example
*
* just passing text:
*
* ```ts
* const editorState = buildEditorState({ text: 'Hello world' }) // result typed as DefaultTypedEditorState
* ```
*
* @example
*
* passing nodes:
*
* ```ts
* const editorState = // result typed as TypedEditorState<DefaultNodeTypes | SerializedBlockNode> (or TypedEditorState<SerializedBlockNode>)
* buildEditorState<DefaultNodeTypes | SerializedBlockNode>({ // or just buildEditorState<SerializedBlockNode> if you *only* want to allow block nodes
* nodes: [
* {
* type: 'block',
* fields: {
* id: 'id',
* blockName: 'Cool block',
* blockType: 'myBlock',
* },
* format: 'left',
* version: 1,
* }
* ],
* })
* ```
*/
export function buildEditorState<T extends SerializedLexicalNode>({
nodes,
text,
}: {
nodes?: DefaultTypedEditorState['root']['children'] | TypedEditorState<T>['root']['children']
text?: string
}): DefaultTypedEditorState | TypedEditorState<T> {
const editorJSON: DefaultTypedEditorState = {
root: {
type: 'root',
children: [],
direction: 'ltr',
format: '',
indent: 0,
version: 1,
},
}
if (text) {
editorJSON.root.children.push({
type: 'paragraph',
children: [
{
type: 'text',
detail: 0,
format: 0,
mode: 'normal',
style: '',
text,
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
version: 1,
})
}
if (nodes?.length) {
editorJSON.root.children.push(...(nodes as any))
}
return editorJSON
}

View File

@@ -67,8 +67,8 @@
"@payloadcms/eslint-config": "workspace:*",
"@types/is-hotkey": "^0.1.10",
"@types/node": "22.15.30",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -60,8 +60,8 @@
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@swc/core": "1.11.29",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"dotenv": "16.4.7",
"prettier": "3.5.3",
"typescript": "5.7.3"

View File

@@ -168,10 +168,10 @@
"@babel/preset-typescript": "7.27.1",
"@hyrious/esbuild-plugin-commonjs": "0.2.6",
"@payloadcms/eslint-config": "workspace:*",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@types/uuid": "10.0.0",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"esbuild": "0.25.5",
"esbuild-sass-plugin": "3.3.1",
"payload": "workspace:*"

View File

@@ -1,13 +1,16 @@
'use client'
import type { ClientComponentProps } from 'payload'
import React from 'react'
import { useIntersect } from '../../hooks/useIntersect.js'
export const RenderIfInViewport: React.FC<{
children: React.ReactNode
className?: string
forceRender?: boolean
}> = ({ children, className, forceRender }) => {
export const RenderIfInViewport: React.FC<
{
children: React.ReactNode
className?: string
} & Pick<ClientComponentProps, 'forceRender'>
> = ({ children, className, forceRender }) => {
const [hasRendered, setHasRendered] = React.useState(Boolean(forceRender))
const [intersectionRef, entry] = useIntersect(
{

View File

@@ -236,12 +236,13 @@ export type { FieldAction } from '../../forms/Form/types.js'
export { fieldReducer } from '../../forms/Form/fieldReducer.js'
export { NullifyLocaleField } from '../../forms/NullifyField/index.js'
export { RenderFields } from '../../forms/RenderFields/index.js'
export { RowLabel, type RowLabelProps } from '../../forms/RowLabel/index.js'
export { RowLabelProvider, useRowLabel } from '../../forms/RowLabel/Context/index.js'
export { FormSubmit } from '../../forms/Submit/index.js'
export { WatchChildErrors } from '../../forms/WatchChildErrors/index.js'
export { useField } from '../../forms/useField/index.js'
export { FieldContext, useField } from '../../forms/useField/index.js'
export type { FieldType, Options } from '../../forms/useField/types.js'
export { withCondition } from '../../forms/withCondition/index.js'
@@ -288,6 +289,8 @@ export { Warning as WarningIcon } from '../../providers/ToastContainer/icons/War
export {
type RenderDocumentResult,
type RenderDocumentServerFunction,
ServerFunctionsContext,
type ServerFunctionsContextType,
ServerFunctionsProvider,
useServerFunctions,
} from '../../providers/ServerFunctions/index.js'
@@ -408,3 +411,7 @@ export { parseSearchParams } from '../../utilities/parseSearchParams.js'
export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'
export { FieldDiffContainer } from '../../elements/FieldDiffContainer/index.js'
export { formatTimeToNow } from '../../utilities/formatDocTitle/formatDateTitle.js'
export type {
RenderFieldServerFnArgs,
RenderFieldServerFnReturnType,
} from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js'

View File

@@ -3,6 +3,7 @@ export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'
export { FolderTableCell } from '../../elements/FolderView/Cell/index.server.js'
export { FolderField } from '../../elements/FolderView/FolderField/index.server.js'
export { getHTMLDiffComponents } from '../../elements/HTMLDiff/index.js'
export { _internal_renderFieldHandler } from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js'
export { File } from '../../graphics/File/index.js'
export { CheckIcon } from '../../icons/Check/index.js'
export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js'

View File

@@ -1,5 +1,11 @@
'use client'
import type { ArrayField, ClientField, Row, SanitizedFieldPermissions } from 'payload'
import type {
ArrayField,
ClientComponentProps,
ClientField,
Row,
SanitizedFieldPermissions,
} from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
@@ -26,7 +32,6 @@ type ArrayRowProps = {
readonly duplicateRow: (rowIndex: number) => void
readonly errorCount: number
readonly fields: ClientField[]
readonly forceRender?: boolean
readonly hasMaxRows?: boolean
readonly isLoading?: boolean
readonly isSortable?: boolean
@@ -43,7 +48,8 @@ type ArrayRowProps = {
readonly rowIndex: number
readonly schemaPath: string
readonly setCollapse: (rowID: string, collapsed: boolean) => void
} & UseDraggableSortableReturn
} & Pick<ClientComponentProps, 'forceRender'> &
UseDraggableSortableReturn
export const ArrayRow: React.FC<ArrayRowProps> = ({
addRow,

View File

@@ -1,5 +1,6 @@
'use client'
import type {
ClientComponentProps,
ClientField,
ClientTab,
DocumentPreferences,
@@ -249,7 +250,6 @@ export const TabsField = withCondition(TabsFieldComponent)
type ActiveTabProps = {
readonly description: StaticDescription
readonly fields: ClientField[]
readonly forceRender?: boolean
readonly hidden: boolean
readonly label?: string
readonly parentIndexPath: string
@@ -258,7 +258,7 @@ type ActiveTabProps = {
readonly path: string
readonly permissions: SanitizedFieldPermissions
readonly readOnly: boolean
}
} & Pick<ClientComponentProps, 'forceRender'>
function TabContent({
description,

View File

@@ -1,16 +1,8 @@
import type { ClientField, SanitizedFieldPermissions } from 'payload'
import type { ClientComponentProps, ClientField, SanitizedFieldPermissions } from 'payload'
export type RenderFieldsProps = {
readonly className?: string
readonly fields: ClientField[]
/**
* Controls the rendering behavior of the fields, i.e. defers rendering until they intersect with the viewport using the Intersection Observer API.
*
* If true, the fields will be rendered immediately, rather than waiting for them to intersect with the viewport.
*
* If a number is provided, will immediately render fields _up to that index_.
*/
readonly forceRender?: boolean
readonly margins?: 'small' | false
readonly parentIndexPath: string
readonly parentPath: string
@@ -21,4 +13,4 @@ export type RenderFieldsProps = {
}
| SanitizedFieldPermissions
readonly readOnly?: boolean
}
} & Pick<ClientComponentProps, 'forceRender'>

View File

@@ -0,0 +1,119 @@
import { deepMerge, type Field, type FieldState, type ServerFunction } from 'payload'
import { getClientConfig } from '../../../utilities/getClientConfig.js'
import { getClientSchemaMap } from '../../../utilities/getClientSchemaMap.js'
import { getSchemaMap } from '../../../utilities/getSchemaMap.js'
import { renderField } from '../renderField.js'
export type RenderFieldServerFnArgs = {
/**
* Override field config pulled from schemaPath lookup
*/
field?: Partial<Field>
/**
* Pass the value this field will receive when rendering it on the server.
* For richText, this helps provide initial state for sub-fields that are immediately rendered (like blocks)
* so that we can avoid multiple waterfall requests for each block that renders on the client.
*/
initialValue?: unknown
/**
* Path to the field to render
* @default field name
*/
path?: string
/**
* Dot schema path to a richText field declared in your config.
* Format:
* "collection.<collectionSlug>.<fieldPath>"
* "global.<globalSlug>.<fieldPath>"
*
* Examples:
* "collection.posts.richText"
* "global.siteSettings.content"
*/
schemaPath: string
}
export type RenderFieldServerFnReturnType = {} & FieldState['customComponents']
/**
* @experimental - may break in minor releases
*/
export const _internal_renderFieldHandler: ServerFunction<
RenderFieldServerFnArgs,
Promise<RenderFieldServerFnReturnType>
// eslint-disable-next-line @typescript-eslint/require-await
> = async ({ field: fieldArg, initialValue, path, req, schemaPath }) => {
if (!req.user) {
throw new Error('Unauthorized')
}
const [entityType, entitySlug, ...fieldPath] = schemaPath.split('.')
const schemaMap = getSchemaMap({
collectionSlug: entityType === 'collection' ? entitySlug : undefined,
config: req.payload.config,
globalSlug: entityType === 'global' ? entitySlug : undefined,
i18n: req.i18n,
})
// Provide client schema map as it would have been provided if the target editor field would have been rendered.
// For lexical, only then will it contain all the lexical-internal entries
const clientSchemaMap = getClientSchemaMap({
collectionSlug: entityType === 'collection' ? entitySlug : undefined,
config: getClientConfig({
config: req.payload.config,
i18n: req.i18n,
importMap: req.payload.importMap,
user: req.user,
}),
globalSlug: entityType === 'global' ? entitySlug : undefined,
i18n: req.i18n,
payload: req.payload,
schemaMap,
})
const targetField = schemaMap.get(`${entitySlug}.${fieldPath.join('.')}`) as Field | undefined
if (!targetField) {
throw new Error(`Could not find target field at schemaPath: ${schemaPath}`)
}
const field: Field = fieldArg ? deepMerge(targetField, fieldArg, { clone: false }) : targetField
let data = {}
if (typeof initialValue !== 'undefined') {
if ('name' in field) {
data[field.name] = initialValue
} else {
data = initialValue
}
}
const fieldState: FieldState = {}
renderField({
clientFieldSchemaMap: clientSchemaMap,
collectionSlug: entityType === 'collection' && entitySlug ? entitySlug : '-',
data,
fieldConfig: field,
fieldSchemaMap: schemaMap,
fieldState, // TODO,
formState: {}, // TODO,
indexPath: '',
lastRenderedPath: '',
operation: 'create',
parentPath: '',
parentSchemaPath: '',
path: path ?? ('name' in field ? field.name : ''),
permissions: true,
preferences: {
fields: {},
},
previousFieldState: undefined,
renderAllFields: true,
req,
schemaPath: `${entitySlug}.${fieldPath.join('.')}`,
siblingData: data,
})
return fieldState.customComponents ?? {}
}

View File

@@ -1,7 +1,7 @@
'use client'
import type { PayloadRequest } from 'payload'
import { useCallback, useMemo, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import type { UPDATE } from '../Form/types.js'
import type { FieldType, Options } from './types.js'
@@ -24,12 +24,7 @@ import {
} from '../Form/context.js'
import { useFieldPath } from '../RenderFields/context.js'
/**
* Get and set the value of a form field.
*
* @see https://payloadcms.com/docs/admin/react-hooks#usefield
*/
export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
const useFieldInForm = <TValue,>(options?: Options): FieldType<TValue> => {
const {
disableFormData = false,
hasRows,
@@ -229,3 +224,50 @@ export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
return result
}
/**
* Context to allow providing useField value for fields directly, if managed outside the Form
*
* @experimental
*/
export const FieldContext = React.createContext<FieldType<unknown> | undefined>(undefined)
/**
* Get and set the value of a form field.
*
* @see https://payloadcms.com/docs/admin/react-hooks#usefield
*/
export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
const pathFromContext = useFieldPath()
const fieldContext = React.use(FieldContext) as FieldType<TValue> | undefined
// Lock the mode on first render so hook order is stable forever. This ensures
// that hooks are called in the same order each time a component renders => should
// not break the rule of hooks.
const hasFieldContext = React.useRef<false | null | true>(null)
if (hasFieldContext.current === null) {
// Use field context, if a field context exists **and** the path matches. If the path
// does not match, this could be the field context of a parent field => there likely is
// a nested <Form /> we should use instead => 'form'
const currentPath = options?.path || pathFromContext || options.potentiallyStalePath
hasFieldContext.current =
fieldContext && currentPath && fieldContext.path === currentPath ? true : false
}
if (hasFieldContext.current === true) {
if (!fieldContext) {
// Provider was removed after mount. That violates hook guarantees.
throw new Error('FieldContext was removed after mount. This breaks hook ordering.')
}
return fieldContext
}
// We intentionally guard this hook call with a mode that is fixed on first render.
// The order is consistent across renders. Silence the linters false positive.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/rules-of-hooks
return useFieldInForm<TValue>(options)
}

View File

@@ -16,6 +16,10 @@ import type {
import React, { createContext, useCallback } from 'react'
import type {
RenderFieldServerFnArgs,
RenderFieldServerFnReturnType,
} from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js'
import type { buildFormStateHandler } from '../../utilities/buildFormState.js'
import type { buildTableStateHandler } from '../../utilities/buildTableState.js'
import type { CopyDataFromLocaleArgs } from '../../utilities/copyDataFromLocale.js'
@@ -100,7 +104,9 @@ type GetFolderResultsComponentAndDataClient = (
} & Omit<GetFolderResultsComponentAndDataArgs, 'req'>,
) => ReturnType<typeof getFolderResultsComponentAndDataHandler>
type ServerFunctionsContextType = {
type RenderFieldClient = (args: RenderFieldServerFnArgs) => Promise<RenderFieldServerFnReturnType>
export type ServerFunctionsContextType = {
_internal_renderField: RenderFieldClient
copyDataFromLocale: CopyDataFromLocaleClient
getDocumentSlots: GetDocumentSlots
getFolderResultsComponentAndData: GetFolderResultsComponentAndDataClient
@@ -278,9 +284,26 @@ export const ServerFunctionsProvider: React.FC<{
[serverFunction],
)
const _internal_renderField = useCallback<RenderFieldClient>(
async (args) => {
try {
const result = (await serverFunction({
name: 'render-field',
args,
})) as RenderFieldServerFnReturnType
return result
} catch (_err) {
console.error(_err) // eslint-disable-line no-console
}
},
[serverFunction],
)
return (
<ServerFunctionsContext
value={{
_internal_renderField,
copyDataFromLocale,
getDocumentSlots,
getFolderResultsComponentAndData,

1118
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import React, { useCallback, useState } from 'react'
import { useForm, FormProvider } from 'react-hook-form'
import RichText from '@/components/RichText'
import { Button } from '@/components/ui/button'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
import { fields } from './fields'
import { getClientSideURL } from '@/utilities/getURL'
@@ -16,7 +16,7 @@ export type FormBlockType = {
blockType?: 'formBlock'
enableIntro: boolean
form: FormType
introContent?: SerializedEditorState
introContent?: DefaultTypedEditorState
}
export const FormBlock: React.FC<

View File

@@ -2,9 +2,9 @@ import RichText from '@/components/RichText'
import React from 'react'
import { Width } from '../Width'
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
export const Message: React.FC<{ message: SerializedEditorState }> = ({ message }) => {
export const Message: React.FC<{ message: DefaultTypedEditorState }> = ({ message }) => {
return (
<Width className="my-12" width="100">
{message && <RichText data={message} />}

View File

@@ -5,12 +5,12 @@ import RichText from '@/components/RichText'
import type { Post } from '@/payload-types'
import { Card } from '../../components/Card'
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
export type RelatedPostsProps = {
className?: string
docs?: Post[]
introContent?: SerializedEditorState
introContent?: DefaultTypedEditorState
}
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {

View File

@@ -154,7 +154,7 @@ export interface Page {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -219,7 +219,7 @@ export interface Post {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -265,7 +265,7 @@ export interface Media {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -401,7 +401,7 @@ export interface CallToActionBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -452,7 +452,7 @@ export interface ContentBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -509,7 +509,7 @@ export interface ArchiveBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -545,7 +545,7 @@ export interface FormBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -602,7 +602,7 @@ export interface Form {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -685,7 +685,7 @@ export interface Form {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -717,7 +717,7 @@ export interface Form {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -1682,7 +1682,7 @@ export interface BannerBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];

View File

@@ -6,7 +6,7 @@ import React, { useCallback, useState } from 'react'
import { useForm, FormProvider } from 'react-hook-form'
import RichText from '@/components/RichText'
import { Button } from '@/components/ui/button'
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
import { fields } from './fields'
import { getClientSideURL } from '@/utilities/getURL'
@@ -16,7 +16,7 @@ export type FormBlockType = {
blockType?: 'formBlock'
enableIntro: boolean
form: FormType
introContent?: SerializedEditorState
introContent?: DefaultTypedEditorState
}
export const FormBlock: React.FC<

View File

@@ -2,9 +2,9 @@ import RichText from '@/components/RichText'
import React from 'react'
import { Width } from '../Width'
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
export const Message: React.FC<{ message: SerializedEditorState }> = ({ message }) => {
export const Message: React.FC<{ message: DefaultTypedEditorState }> = ({ message }) => {
return (
<Width className="my-12" width="100">
{message && <RichText data={message} />}

View File

@@ -5,12 +5,12 @@ import RichText from '@/components/RichText'
import type { Post } from '@/payload-types'
import { Card } from '../../components/Card'
import { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
import { DefaultTypedEditorState } from '@payloadcms/richtext-lexical'
export type RelatedPostsProps = {
className?: string
docs?: Post[]
introContent?: SerializedEditorState
introContent?: DefaultTypedEditorState
}
export const RelatedPosts: React.FC<RelatedPostsProps> = (props) => {

View File

@@ -4,11 +4,12 @@ const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import type { FieldAccess } from 'payload'
import { buildEditorState } from '@payloadcms/richtext-lexical'
import type { Config, User } from './payload-types.js'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { textToLexicalJSON } from '../lexical/collections/LexicalLocalized/textToLexicalJSON.js'
import { Auth } from './collections/Auth/index.js'
import { Disabled } from './collections/Disabled/index.js'
import { Hooks } from './collections/hooks/index.js'
@@ -718,33 +719,33 @@ export default buildConfigWithDefaults(
await payload.create({
collection: 'regression1',
data: {
richText4: textToLexicalJSON({ text: 'Text1' }),
array: [{ art: textToLexicalJSON({ text: 'Text2' }) }],
arrayWithAccessFalse: [{ richText6: textToLexicalJSON({ text: 'Text3' }) }],
richText4: buildEditorState({ text: 'Text1' }),
array: [{ art: buildEditorState({ text: 'Text2' }) }],
arrayWithAccessFalse: [{ richText6: buildEditorState({ text: 'Text3' }) }],
group1: {
text: 'Text4',
richText1: textToLexicalJSON({ text: 'Text5' }),
richText1: buildEditorState({ text: 'Text5' }),
},
blocks: [
{
blockType: 'myBlock3',
richText7: textToLexicalJSON({ text: 'Text6' }),
richText7: buildEditorState({ text: 'Text6' }),
blockName: 'My Block 1',
},
],
blocks3: [
{
blockType: 'myBlock2',
richText5: textToLexicalJSON({ text: 'Text7' }),
richText5: buildEditorState({ text: 'Text7' }),
blockName: 'My Block 2',
},
],
tab1: {
richText2: textToLexicalJSON({ text: 'Text8' }),
richText2: buildEditorState({ text: 'Text8' }),
blocks2: [
{
blockType: 'myBlock',
richText3: textToLexicalJSON({ text: 'Text9' }),
richText3: buildEditorState({ text: 'Text9' }),
blockName: 'My Block 3',
},
],
@@ -757,12 +758,12 @@ export default buildConfigWithDefaults(
data: {
array: [
{
richText2: textToLexicalJSON({ text: 'Text1' }),
richText2: buildEditorState({ text: 'Text1' }),
},
],
group: {
text: 'Text2',
richText1: textToLexicalJSON({ text: 'Text3' }),
richText1: buildEditorState({ text: 'Text3' }),
},
},
})

View File

@@ -17,6 +17,8 @@ import { LexicalLocalizedFields } from './collections/LexicalLocalized/index.js'
import { LexicalMigrateFields } from './collections/LexicalMigrate/index.js'
import { LexicalObjectReferenceBugCollection } from './collections/LexicalObjectReferenceBug/index.js'
import { LexicalRelationshipsFields } from './collections/LexicalRelationships/index.js'
import { OnDemandForm } from './collections/OnDemandForm/index.js'
import { OnDemandOutsideForm } from './collections/OnDemandOutsideForm/index.js'
import RichTextFields from './collections/RichText/index.js'
import TextFields from './collections/Text/index.js'
import Uploads from './collections/Upload/index.js'
@@ -46,6 +48,8 @@ export const baseConfig: Partial<Config> = {
TextFields,
Uploads,
ArrayFields,
OnDemandForm,
OnDemandOutsideForm,
],
globals: [TabsWithRichText],

View File

@@ -1,60 +0,0 @@
import type { SerializedRelationshipNode } from '@payloadcms/richtext-lexical'
import type {
SerializedEditorState,
SerializedParagraphNode,
SerializedTextNode,
} from '@payloadcms/richtext-lexical/lexical'
import { lexicalLocalizedFieldsSlug } from '../../slugs.js'
export function textToLexicalJSON({
text,
lexicalLocalizedRelID,
}: {
lexicalLocalizedRelID?: number | string
text: string
}): any {
const editorJSON: SerializedEditorState = {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text,
type: 'text',
version: 1,
} as SerializedTextNode,
],
direction: 'ltr',
format: '',
indent: 0,
textFormat: 0,
type: 'paragraph',
textStyle: '',
version: 1,
} as SerializedParagraphNode,
],
},
}
if (lexicalLocalizedRelID) {
editorJSON.root.children.push({
format: '',
type: 'relationship',
version: 2,
relationTo: lexicalLocalizedFieldsSlug,
value: lexicalLocalizedRelID,
} as SerializedRelationshipNode)
}
return editorJSON
}

View File

@@ -0,0 +1,20 @@
'use client'
import type { JSONFieldClientComponent } from 'payload'
import { buildEditorState, RenderLexical } from '@payloadcms/richtext-lexical/client'
import { lexicalFullyFeaturedSlug } from '../../slugs.js'
export const Component: JSONFieldClientComponent = () => {
return (
<div>
Fully-Featured Component:
<RenderLexical
field={{ name: 'json' }}
initialValue={buildEditorState({ text: 'defaultValue' })}
schemaPath={`collection.${lexicalFullyFeaturedSlug}.richText`}
/>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import path from 'path'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone, saveDocAndAssert } from '../../../helpers.js'
import { initPayloadE2ENoConfig } from '../../../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../../../playwright.config.js'
import { LexicalHelpers } from '../utils.js'
const filename = fileURLToPath(import.meta.url)
const currentFolder = path.dirname(filename)
const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
const { serverURL } = await initPayloadE2ENoConfig({
dirname,
})
describe('Lexical On Demand', () => {
let lexical: LexicalHelpers
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
const page = await browser.newPage()
await ensureCompilationIsDone({ page, serverURL })
await page.close()
})
describe('within form', () => {
beforeEach(async ({ page }) => {
await reInitializeDB({
serverURL,
snapshotKey: 'lexicalTest',
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
})
const url = new AdminUrlUtil(serverURL, 'OnDemandForm')
lexical = new LexicalHelpers(page)
await page.goto(url.create)
await lexical.editor.first().focus()
})
test('lexical is rendered on demand within form', async ({ page }) => {
await page.keyboard.type('Hello')
await saveDocAndAssert(page)
await page.reload()
const paragraph = lexical.editor.locator('> p')
await expect(paragraph).toHaveText('Hello')
})
test('on-demand editor within form can render nested fields', async () => {
await lexical.slashCommand('table', false)
await expect(lexical.drawer.locator('#field-rows')).toHaveValue('5')
await expect(lexical.drawer.locator('#field-columns')).toHaveValue('5')
})
})
describe('outside form', () => {
beforeEach(async ({ page }) => {
await reInitializeDB({
serverURL,
snapshotKey: 'lexicalTest',
uploadsDir: [path.resolve(dirname, './collections/Upload/uploads')],
})
const url = new AdminUrlUtil(serverURL, 'OnDemandOutsideForm')
lexical = new LexicalHelpers(page)
await page.goto(url.create)
await lexical.editor.first().focus()
})
test('lexical is rendered on demand outside form', async ({ page }) => {
await page.keyboard.type('Hello')
const paragraph = lexical.editor.locator('> p')
await expect(paragraph).toHaveText('Hellostate default')
await saveDocAndAssert(page)
await page.reload()
const paragraphAfterSave = lexical.editor.locator('> p')
await expect(paragraphAfterSave).not.toHaveText('Hellostate default') // Outside Form => Not Saved
})
test('lexical value can be controlled outside form', async ({ page }) => {
await page.keyboard.type('Hello')
const paragraph = lexical.editor.locator('> p')
await expect(paragraph).toHaveText('Hellostate default')
// Click button with text
const button = page.getByRole('button', { name: 'Reset Editor State' })
await button.click()
await expect(paragraph).toHaveText('state default')
})
test('on-demand editor outside form can render nested fields', async () => {
await lexical.slashCommand('table', false)
await expect(lexical.drawer.locator('#field-rows')).toHaveValue('5')
await expect(lexical.drawer.locator('#field-columns')).toHaveValue('5')
})
})
})

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const OnDemandForm: CollectionConfig = {
slug: 'OnDemandForm',
fields: [
{
name: 'json',
type: 'json',
admin: {
components: {
Field: './collections/OnDemandForm/Component.js#Component',
},
},
},
],
}

View File

@@ -0,0 +1,35 @@
'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 = () => {
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: 'myField' }}
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>
)
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const OnDemandOutsideForm: CollectionConfig = {
slug: 'OnDemandOutsideForm',
fields: [
{
name: 'json',
type: 'json',
admin: {
components: {
Field: './collections/OnDemandOutsideForm/Component.js#Component',
},
},
},
],
}

View File

@@ -16,7 +16,7 @@ const dirname = path.resolve(currentFolder, '../../')
const { beforeAll, beforeEach, describe } = test
// Unlike the other suites, this one runs in parallel, as they run on the `lexical-fully-featured/create` URL and are "pure" tests
test.describe.configure({ mode: 'parallel' })
//test.describe.configure({ mode: 'parallel' })
const { serverURL } = await initPayloadE2ENoConfig({
dirname,
@@ -46,6 +46,7 @@ describe('Lexical Fully Featured', () => {
page,
}) => {
await lexical.slashCommand('block')
await expect(lexical.editor.locator('.lexical-block')).toBeVisible()
await lexical.slashCommand('relationship')
await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click()
await lexical.save('drawer')

View File

@@ -1,6 +1,7 @@
import type { Locator, Page } from 'playwright'
import { expect } from '@playwright/test'
import { wait } from 'payload/shared'
export class LexicalHelpers {
page: Page
@@ -98,16 +99,20 @@ export class LexicalHelpers {
async slashCommand(
// prettier-ignore
command: 'block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline'
| 'link' | 'ordered' | 'paragraph' | 'quote' | 'relationship' | 'unordered' | 'upload',
command: ('block' | 'check' | 'code' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' |'h6' | 'inline'
| 'link' | 'ordered' | 'paragraph' | 'quote' | 'relationship' | 'table' | 'unordered'|'upload') | ({} & string),
expectMenuToClose = true,
) {
await this.page.keyboard.press(`/`)
const slashMenuPopover = this.page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
await this.page.keyboard.type(command)
await wait(200)
await this.page.keyboard.press(`Enter`)
await expect(slashMenuPopover).toBeHidden()
if (expectMenuToClose) {
await expect(slashMenuPopover).toBeHidden()
}
}
get decorator() {

View File

@@ -1,16 +1,17 @@
/* eslint-disable jest/no-conditional-in-test */
import type {
SerializedBlockNode,
SerializedLinkNode,
SerializedRelationshipNode,
SerializedUploadNode,
} from '@payloadcms/richtext-lexical'
import type {
SerializedEditorState,
SerializedParagraphNode,
} from '@payloadcms/richtext-lexical/lexical'
import type { PaginatedDocs, Payload } from 'payload'
/* eslint-disable jest/no-conditional-in-test */
import {
buildEditorState,
type SerializedBlockNode,
type SerializedLinkNode,
type SerializedRelationshipNode,
type SerializedUploadNode,
} from '@payloadcms/richtext-lexical'
import path from 'path'
import { fileURLToPath } from 'url'
@@ -21,7 +22,6 @@ import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { NextRESTClient } from '../helpers/NextRESTClient.js'
import { lexicalDocData } from './collections/Lexical/data.js'
import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { richTextDocData } from './collections/RichText/data.js'
import { generateLexicalRichText } from './collections/RichText/generateLexicalRichText.js'
@@ -655,7 +655,7 @@ describe('Lexical', () => {
locale: 'en',
data: {
title: 'Localized Lexical hooks',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'some text' }),
lexicalBlocksLocalized: buildEditorState({ text: 'some text' }),
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',

View File

@@ -97,6 +97,8 @@ export interface Config {
'text-fields': TextField;
uploads: Upload;
'array-fields': ArrayField;
OnDemandForm: OnDemandForm;
OnDemandOutsideForm: OnDemandOutsideForm;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -118,6 +120,8 @@ export interface Config {
'text-fields': TextFieldsSelect<false> | TextFieldsSelect<true>;
uploads: UploadsSelect<false> | UploadsSelect<true>;
'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>;
OnDemandForm: OnDemandFormSelect<false> | OnDemandFormSelect<true>;
OnDemandOutsideForm: OnDemandOutsideFormSelect<false> | OnDemandOutsideFormSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -169,7 +173,7 @@ export interface LexicalFullyFeatured {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -193,7 +197,7 @@ export interface LexicalLinkFeature {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -217,7 +221,7 @@ export interface LexicalJsxConverter {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -242,7 +246,7 @@ export interface LexicalField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -257,7 +261,7 @@ export interface LexicalField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -272,7 +276,7 @@ export interface LexicalField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -298,7 +302,7 @@ export interface LexicalMigrateField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -313,7 +317,7 @@ export interface LexicalMigrateField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -328,7 +332,7 @@ export interface LexicalMigrateField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -345,7 +349,7 @@ export interface LexicalMigrateField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -364,7 +368,7 @@ export interface LexicalMigrateField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -396,7 +400,7 @@ export interface LexicalLocalizedField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -414,7 +418,7 @@ export interface LexicalLocalizedField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -438,7 +442,7 @@ export interface LexicalObjectReferenceBug {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -453,7 +457,7 @@ export interface LexicalObjectReferenceBug {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -477,7 +481,7 @@ export interface LexicalInBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -494,7 +498,7 @@ export interface LexicalInBlock {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -524,7 +528,7 @@ export interface LexicalAccessControl {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -548,7 +552,7 @@ export interface LexicalRelationshipField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -563,7 +567,7 @@ export interface LexicalRelationshipField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -588,7 +592,7 @@ export interface RichTextField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -607,7 +611,7 @@ export interface RichTextField {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -828,6 +832,42 @@ export interface ArrayField {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "OnDemandForm".
*/
export interface OnDemandForm {
id: string;
json?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "OnDemandOutsideForm".
*/
export interface OnDemandOutsideForm {
id: string;
json?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -915,6 +955,14 @@ export interface PayloadLockedDocument {
relationTo: 'array-fields';
value: string | ArrayField;
} | null)
| ({
relationTo: 'OnDemandForm';
value: string | OnDemandForm;
} | null)
| ({
relationTo: 'OnDemandOutsideForm';
value: string | OnDemandOutsideForm;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -1286,6 +1334,24 @@ export interface ArrayFieldsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "OnDemandForm_select".
*/
export interface OnDemandFormSelect<T extends boolean = true> {
json?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "OnDemandOutsideForm_select".
*/
export interface OnDemandOutsideFormSelect<T extends boolean = true> {
json?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
@@ -1351,7 +1417,7 @@ export interface TabsWithRichText {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];
@@ -1368,7 +1434,7 @@ export interface TabsWithRichText {
root: {
type: string;
children: {
type: string;
type: any;
version: number;
[k: string]: unknown;
}[];

View File

@@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url'
import { lexicalDocData } from './collections/Lexical/data.js'
import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
import { richTextBulletsDocData, richTextDocData } from './collections/RichText/data.js'
import {
@@ -23,6 +22,7 @@ import {
// import type { Payload } from 'payload'
import { buildEditorState } from '@payloadcms/richtext-lexical'
import { getFileByPath } from 'payload'
import { devUser } from '../credentials.js'
@@ -41,7 +41,6 @@ import { uploadsDoc } from './collections/Upload/shared.js'
// import { jsonDoc } from './collections/JSON/shared.js'
// import { lexicalDocData } from './collections/Lexical/data.js'
// import { generateLexicalLocalizedRichText } from './collections/LexicalLocalized/generateLexicalRichText.js'
// import { textToLexicalJSON } from './collections/LexicalLocalized/textToLexicalJSON.js'
// import { lexicalMigrateDocData } from './collections/LexicalMigrate/data.js'
// import { numberDoc } from './collections/Number/shared.js'
// import { pointDoc } from './collections/Point/shared.js'
@@ -215,7 +214,7 @@ export const seed = async (_payload: Payload) => {
collection: lexicalLocalizedFieldsSlug,
data: {
title: 'Localized Lexical en',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'English text' }),
lexicalBlocksLocalized: buildEditorState({ text: 'English text' }),
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'English text in block',
@@ -229,7 +228,7 @@ export const seed = async (_payload: Payload) => {
await _payload.create({
collection: lexicalRelationshipFieldsSlug,
data: {
richText: textToLexicalJSON({ text: 'English text' }),
richText: buildEditorState({ text: 'English text' }),
},
depth: 0,
overrideAccess: true,
@@ -240,7 +239,7 @@ export const seed = async (_payload: Payload) => {
id: lexicalLocalizedDoc1.id,
data: {
title: 'Localized Lexical es',
lexicalBlocksLocalized: textToLexicalJSON({ text: 'Spanish text' }),
lexicalBlocksLocalized: buildEditorState({ text: 'Spanish text' }),
lexicalBlocksSubLocalized: generateLexicalLocalizedRichText(
'Shared text',
'Spanish text in block',
@@ -257,13 +256,29 @@ export const seed = async (_payload: Payload) => {
data: {
title: 'Localized Lexical en 2',
lexicalBlocksLocalized: textToLexicalJSON({
lexicalBlocksLocalized: buildEditorState({
text: 'English text 2',
lexicalLocalizedRelID: lexicalLocalizedDoc1.id,
nodes: [
{
format: '',
type: 'relationship',
version: 2,
relationTo: lexicalLocalizedFieldsSlug,
value: lexicalLocalizedDoc1.id,
},
],
}),
lexicalBlocksSubLocalized: textToLexicalJSON({
lexicalBlocksSubLocalized: buildEditorState({
text: 'English text 2',
lexicalLocalizedRelID: lexicalLocalizedDoc1.id,
nodes: [
{
format: '',
type: 'relationship',
version: 2,
relationTo: lexicalLocalizedFieldsSlug,
value: lexicalLocalizedDoc1.id,
},
],
}),
},
locale: 'en',
@@ -277,9 +292,17 @@ export const seed = async (_payload: Payload) => {
data: {
title: 'Localized Lexical es 2',
lexicalBlocksLocalized: textToLexicalJSON({
lexicalBlocksLocalized: buildEditorState({
text: 'Spanish text 2',
lexicalLocalizedRelID: lexicalLocalizedDoc1.id,
nodes: [
{
format: '',
type: 'relationship',
version: 2,
relationTo: lexicalLocalizedFieldsSlug,
value: lexicalLocalizedDoc1.id,
},
],
}),
},
locale: 'es',
@@ -317,7 +340,7 @@ export const seed = async (_payload: Payload) => {
version: 2,
fields: {
id: '6773773284be8978db7a498d',
lexicalInBlock: textToLexicalJSON({ text: 'text' }),
lexicalInBlock: buildEditorState({ text: 'text' }),
blockName: '',
blockType: 'blockInLexical',
},
@@ -334,12 +357,12 @@ export const seed = async (_payload: Payload) => {
{
blockType: 'lexicalInBlock2',
blockName: '1',
lexical: textToLexicalJSON({ text: '1' }),
lexical: buildEditorState({ text: '1' }),
},
{
blockType: 'lexicalInBlock2',
blockName: '2',
lexical: textToLexicalJSON({ text: '2' }),
lexical: buildEditorState({ text: '2' }),
},
{
blockType: 'lexicalInBlock2',

View File

@@ -63,9 +63,9 @@
"@sentry/nextjs": "^8.33.1",
"@sentry/react": "^7.77.0",
"@types/jest": "29.5.12",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"comment-json": "^4.2.3",
"create-payload-app": "workspace:*",
"csv-parse": "^5.6.0",
@@ -87,8 +87,8 @@
"payload": "workspace:*",
"pg": "8.16.3",
"qs-esm": "7.0.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"sass": "1.77.4",
"server-only": "^0.0.1",
"sharp": "0.32.6",

View File

@@ -18,6 +18,11 @@ export default buildConfigWithDefaults({
type: 'text',
name: 'text',
},
{
type: 'richText',
name: 'richText',
required: true,
},
{
type: 'text',
name: 'title',

View File

@@ -142,6 +142,21 @@ export interface UserAuthOperations {
export interface Post {
id: string;
text?: string | null;
richText: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
title?: string | null;
selectField: MySelectOptions;
insideUnnamedGroup?: string | null;
@@ -193,6 +208,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;
}
/**
@@ -266,6 +288,7 @@ export interface PayloadMigration {
*/
export interface PostsSelect<T extends boolean = true> {
text?: T;
richText?: T;
title?: T;
selectField?: T;
insideUnnamedGroup?: T;
@@ -312,6 +335,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
@@ -375,6 +405,6 @@ export interface Auth {
declare module 'payload' {
// @ts-ignore
// @ts-ignore
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -1,3 +1,8 @@
import type {
DefaultNodeTypes,
DefaultTypedEditorState,
TypedEditorState,
} from '@payloadcms/richtext-lexical'
import type {
BulkOperationResult,
CustomDocumentViewConfig,
@@ -173,4 +178,107 @@ describe('Types testing', () => {
}>()
})
})
describe('lexical', () => {
type _Hardcoded_DefaultNodeTypes =
| 'autolink'
| 'heading'
| 'horizontalrule'
| 'linebreak'
| 'link'
| 'list'
| 'listitem'
| 'paragraph'
| 'quote'
| 'relationship'
| 'tab'
| 'text'
| 'upload'
test('ensure TypedEditorState node type without generic is string', () => {
expect<TypedEditorState['root']['children'][number]['type']>().type.toBe<string>()
})
test('ensure TypedEditorState<1 generic> node type is correct', () => {
expect<
TypedEditorState<{
type: 'custom-node'
version: 1
}>['root']['children'][number]['type']
>().type.toBe<'custom-node'>()
})
test('ensure TypedEditorState<2 generics> node type is correct', () => {
expect<
TypedEditorState<
| {
type: 'custom-node'
version: 1
}
| {
type: 'custom-node-2'
version: 1
}
>['root']['children'][number]['type']
>().type.toBe<'custom-node' | 'custom-node-2'>()
})
test('ensure DefaultTypedEditorState node type is a union of all possible node types', () => {
expect<
DefaultTypedEditorState['root']['children'][number]['type']
>().type.toBe<_Hardcoded_DefaultNodeTypes>()
})
test('ensure TypedEditorState<DefaultNodeTypes> node type is identical to DefaultTypedEditorState', () => {
expect<
TypedEditorState<DefaultNodeTypes>['root']['children'][number]['type']
>().type.toBe<_Hardcoded_DefaultNodeTypes>()
})
test('ensure DefaultTypedEditorState<custom node> adds custom node type to union of default nodes', () => {
expect<
DefaultTypedEditorState<{
type: 'custom-node'
version: 1
}>['root']['children'][number]['type']
>().type.toBe<'custom-node' | _Hardcoded_DefaultNodeTypes>()
})
test('ensure DefaultTypedEditorState<multiple custom nodes> adds custom node types to union of default nodes', () => {
expect<
DefaultTypedEditorState<
| {
type: 'custom-node'
version: 1
}
| {
type: 'custom-node-2'
version: 1
}
>['root']['children'][number]['type']
>().type.toBe<'custom-node' | 'custom-node-2' | _Hardcoded_DefaultNodeTypes>()
})
test("ensure link node automatically narrows type so that node accepts fields property if type === 'link' is checked", () => {
type NodeType = DefaultTypedEditorState['root']['children'][number]
const node = {
type: 'link',
} as NodeType
if (node.type === 'link') {
expect(node).type.toHaveProperty('fields')
} else {
expect(node).type.not.toHaveProperty('fields')
}
})
test('ensure generated richText types can be assigned to DefaultTypedEditorState type', () => {
// If there is a function that expects DefaultTypedEditorState, you should be able to assign the generated type to it
// This ensures that data can be passed directly form the payload local API to a function that expects DefaultTypedEditorState
type GeneratedRichTextType = Post['richText']
expect<DefaultTypedEditorState>().type.toBeAssignableWith<GeneratedRichTextType>()
})
})
})

View File

@@ -1,3 +1,4 @@
import { buildEditorState } from '@payloadcms/richtext-lexical'
import path from 'path'
import { getFileByPath, type Payload } from 'payload'
import { fileURLToPath } from 'url'
@@ -14,7 +15,6 @@ import {
media2CollectionSlug,
mediaCollectionSlug,
} from './slugs.js'
import { textToLexicalJSON } from './textToLexicalJSON.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -273,7 +273,7 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
textID: doc1ID,
updated: false,
}) as any,
richtextWithCustomDiff: textToLexicalJSON({ text: 'richtextWithCustomDiff' }),
richtextWithCustomDiff: buildEditorState({ text: 'richtextWithCustomDiff' }),
select: 'option1',
text: 'text',
textArea: 'textArea',
@@ -442,7 +442,7 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
textID: doc2ID,
updated: true,
}) as any,
richtextWithCustomDiff: textToLexicalJSON({ text: 'richtextWithCustomDiff2' }),
richtextWithCustomDiff: buildEditorState({ text: 'richtextWithCustomDiff2' }),
select: 'option2',
text: 'text2',
textArea: 'textArea2',

View File

@@ -1,41 +0,0 @@
import type {
SerializedEditorState,
SerializedParagraphNode,
SerializedTextNode,
} from '@payloadcms/richtext-lexical/lexical'
export function textToLexicalJSON({ text }: { text: string }): any {
const editorJSON: SerializedEditorState = {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
direction: 'ltr',
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text,
type: 'text',
version: 1,
} as SerializedTextNode,
],
direction: 'ltr',
format: '',
indent: 0,
textFormat: 0,
type: 'paragraph',
textStyle: '',
version: 1,
} as SerializedParagraphNode,
],
},
}
return editorJSON
}