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:
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
116
docs/rich-text/rendering-on-demand.mdx
Normal file
116
docs/rich-text/rendering-on-demand.mdx
Normal 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>
|
||||
10
package.json
10
package.json
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:*"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
127
packages/richtext-lexical/src/field/RenderLexical/index.tsx
Normal file
127
packages/richtext-lexical/src/field/RenderLexical/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>>
|
||||
|
||||
99
packages/richtext-lexical/src/utilities/buildEditorState.ts
Normal file
99
packages/richtext-lexical/src/utilities/buildEditorState.ts
Normal 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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 ?? {}
|
||||
}
|
||||
@@ -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 linter’s false positive.
|
||||
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useFieldInForm<TValue>(options)
|
||||
}
|
||||
|
||||
@@ -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
1118
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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<
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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' }),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
20
test/lexical/collections/OnDemandForm/Component.tsx
Normal file
20
test/lexical/collections/OnDemandForm/Component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
test/lexical/collections/OnDemandForm/e2e.spec.ts
Normal file
106
test/lexical/collections/OnDemandForm/e2e.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
16
test/lexical/collections/OnDemandForm/index.ts
Normal file
16
test/lexical/collections/OnDemandForm/index.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
35
test/lexical/collections/OnDemandOutsideForm/Component.tsx
Normal file
35
test/lexical/collections/OnDemandOutsideForm/Component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
test/lexical/collections/OnDemandOutsideForm/index.ts
Normal file
16
test/lexical/collections/OnDemandOutsideForm/index.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,6 +18,11 @@ export default buildConfigWithDefaults({
|
||||
type: 'text',
|
||||
name: 'text',
|
||||
},
|
||||
{
|
||||
type: 'richText',
|
||||
name: 'richText',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'title',
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user