Compare commits

..

32 Commits

Author SHA1 Message Date
Alessio Gravili
ce8fe5509a Merge remote-tracking branch 'origin/main' into feat/lexical-on-demand 2025-09-02 18:14:59 -07:00
Alessio Gravili
b47bfce1ad cleanup 2025-09-02 15:54:16 -07:00
Alessio Gravili
de8e345c7b new RenderLexical component!! 2025-09-02 15:48:51 -07:00
Alessio Gravili
701cd2dd97 improve return type 2025-09-02 15:27:02 -07:00
Alessio Gravili
185da7c8c4 improve API 2025-09-02 15:23:03 -07:00
Alessio Gravili
4e5f03d308 fix rerendering issues, fix test setup 2025-09-02 15:20:04 -07:00
Alessio Gravili
85e70338d6 Merge remote-tracking branch 'origin/main' into feat/lexical-on-demand 2025-09-02 15:08:25 -07:00
Alessio Gravili
d9b77d5c95 new render-field server action which works for all fields 2025-09-02 14:43:03 -07:00
Alessio Gravili
a311413e8f Merge remote-tracking branch 'origin/main' into feat/lexical-on-demand 2025-09-02 10:31:55 -07:00
Alessio Gravili
f8c92b5f69 Merge remote-tracking branch 'origin/main' into feat/lexical-on-demand 2025-09-01 23:32:47 -07:00
Alessio Gravili
673ecf596e fix type error 2025-09-01 23:32:15 -07:00
Alessio Gravili
d44d1d3da4 fix: field path issues when outside form 2025-09-01 23:02:57 -07:00
Alessio Gravili
048a168970 improve test suite 2025-09-01 22:27:38 -07:00
Alessio Gravili
5f7331cbe4 works! 2025-09-01 22:24:37 -07:00
Alessio Gravili
4b9a5ae7c2 1 2025-09-01 22:10:54 -07:00
Alessio Gravili
775c74bc0d move initialValue to renderLexical fn args, as the value is usually known by the time we call this fn 2025-09-01 20:38:58 -07:00
Alessio Gravili
3693d5c7f4 fix test suite setup 2025-09-01 20:28:12 -07:00
Alessio Gravili
78cdb2cd8c remove unused RichTextComponentClient component 2025-09-01 20:23:45 -07:00
Alessio Gravili
f1372d1687 fix memoization and rerendering 2025-09-01 20:22:42 -07:00
Alessio Gravili
70f22da627 working solution 2025-09-01 17:50:30 -07:00
Alessio Gravili
300bc55635 bump react and react compiler-related packages to ensure compatibility with latest version 2025-09-01 17:28:19 -07:00
Alessio Gravili
22c8328576 v2 2025-09-01 17:03:06 -07:00
Alessio Gravili
f4b0f9bee2 v1 2025-09-01 16:55:49 -07:00
Alessio Gravili
5b2b437c62 fix 2025-08-29 16:57:08 -07:00
Alessio Gravili
fc07ee80d7 fix 2025-08-29 16:56:52 -07:00
Alessio Gravili
4c6161a16e rename to buildEditorState 2025-08-29 16:55:05 -07:00
Alessio Gravili
369b3fe46d new, first-party import type textToEditorState helper 2025-08-29 16:53:41 -07:00
Alessio Gravili
ddc2b58f47 fix DefaultTypedEditorState type, add jsdocs 2025-08-29 16:52:04 -07:00
Alessio Gravili
9b159820e2 allow customizing lexical admin options, improve types 2025-08-29 16:18:15 -07:00
Alessio Gravili
3af759ea5d pick forceRender instead of re-defining it to include JSDocs everywhere 2025-08-29 16:13:14 -07:00
Alessio Gravili
447f5dd689 progress 2025-08-28 16:42:45 -07:00
Alessio Gravili
16d50f62bb initial simple utility for rendering lexical on-demand 2025-08-28 16:04:39 -07:00
117 changed files with 1939 additions and 2150 deletions

View File

@@ -1,54 +0,0 @@
# Payload Monorepo Agent Instructions
## Project Structure
- Packages are located in the `packages/` directory.
- The main Payload package is `packages/payload`. This contains the core functionality.
- Database adapters are in `packages/db-*`.
- The UI package is `packages/ui`.
- The Next.js integration is in `packages/next`.
- Rich text editor packages are in `packages/richtext-*`.
- Storage adapters are in `packages/storage-*`.
- Email adapters are in `packages/email-*`.
- Plugins which add additional functionality are in `packages/plugin-*`.
- Documentation is in the `docs/` directory.
- Monorepo tooling is in the `tools/` directory.
- Test suites and configs are in the `test/` directory.
- LLMS.txt is at URL: https://payloadcms.com/llms.txt
- LLMS-FULL.txt is at URL: https://payloadcms.com/llms-full.txt
## Dev environment tips
- Any package can be built using a `pnpm build:*` script defined in the root `package.json`. These typically follow the format `pnpm build:<directory_name>`. The options are all of the top-level directories inside the `packages/` directory. Ex `pnpm build:db-mongodb` which builds the `packages/db-mongodb` package.
- ALL packages can be built with `pnpm build:all`.
- Use `pnpm dev` to start the monorepo dev server. This loads the default config located at `test/_community/config.ts`.
- Specific dev configs for each package can be run with `pnpm dev <directory_name>`. The options are all of the top-level directories inside the `test/` directory. Ex `pnpm dev fields` which loads the `test/fields/config.ts` config. The directory name can either encompass a single area of functionality or be the name of a specific package.
## Testing instructions
- There are unit, integration, and e2e tests in the monorepo.
- Unit tests can be run with `pnpm test:unit`.
- Integration tests can be run with `pnpm test:int`. Individual test suites can be run with `pnpm test:int <directory_name>`, which will point at `test/<directory_name>/int.spec.ts`.
- E2E tests can be run with `pnpm test:e2e`.
- All tests can be run with `pnpm test`.
- Prefer running `pnpm test:int` for verifying local code changes.
## PR Guidelines
- This repository follows conventional commits for PR titles
- PR Title format: <type>(<scope>): <title>. Title must start with a lowercase letter.
- Valid types are build, chore, ci, docs, examples, feat, fix, perf, refactor, revert, style, templates, test
- Prefer `feat` for new features and `fix` for bug fixes.
- Valid scopes are the following regex patterns: cpa, db-\*, db-mongodb, db-postgres, db-vercel-postgres, db-sqlite, drizzle, email-\*, email-nodemailer, email-resend, eslint, graphql, live-preview, live-preview-react, next, payload-cloud, plugin-cloud, plugin-cloud-storage, plugin-form-builder, plugin-import-export, plugin-multi-tenant, plugin-nested-docs, plugin-redirects, plugin-search, plugin-sentry, plugin-seo, plugin-stripe, richtext-\*, richtext-lexical, richtext-slate, storage-\*, storage-azure, storage-gcs, storage-uploadthing, storage-vercel-blob, storage-s3, translations, ui, templates, examples(\/(\w|-)+)?, deps
- Scopes should be chosen based upon the package(s) being modified. If multiple packages are being modified, choose the most relevant one or no scope at all.
- Example PR titles:
- `feat(db-mongodb): add support for transactions`
- `feat(richtext-lexical): add options to hide block handles`
- `fix(ui): json field type ignoring editorOptions`
## Commit Guidelines
- This repository follows conventional commits for commit messages
- The first commit of a branch should follow the PR title format: <type>(<scope>): <title>. Follow the same rules as PR titles.
- Subsequent commits should prefer `chore` commits without a scope unless a specific package is being modified.
- These will eventually be squashed into the first commit when merging the PR.

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -131,29 +131,6 @@ localization: {
Since the filtering happens at the root level of the application and its result is not calculated every time you navigate to a new page, you may want to call `router.refresh` in a custom component that watches when values that affect the result change. In the example above, you would want to do this when `supportedLocales` changes on the tenant document.
## Experimental Options
Experimental options are features that may not be fully stable and may change or be removed in future releases.
These options can be enabled in your Payload Config under the `experimental` key. You can set them like this:
```ts
import { buildConfig } from 'payload'
export default buildConfig({
// ...
experimental: {
localizeStatus: true,
},
})
```
The following experimental options are available related to localization:
| Option | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`localizeStatus`** | **Boolean.** When `true`, shows document status per locale in the admin panel instead of always showing the latest overall status. Opt-in for backwards compatibility. Defaults to `false`. |
## Field Localization
Payload Localization works on a **field** level—not a document level. In addition to configuring the base Payload Config to support Localization, you need to specify each field that you would like to localize.

View File

@@ -70,7 +70,6 @@ The following options are available:
| **`admin`** | The configuration options for the Admin Panel, including Custom Components, Live Preview, etc. [More details](../admin/overview#admin-options). |
| **`bin`** | Register custom bin scripts for Payload to execute. [More Details](#custom-bin-scripts). |
| **`editor`** | The Rich Text Editor which will be used by `richText` fields. [More details](../rich-text/overview). |
| **`experimental`** | Configure experimental features for Payload. These may be unstable and may change or be removed in future releases. [More details](../experimental). |
| **`db`** \* | The Database Adapter which will be used by Payload. [More details](../database/overview). |
| **`serverURL`** | A string used to define the absolute URL of your app. This includes the protocol, for example `https://example.com`. No paths allowed, only protocol, domain and (optionally) port. |
| **`collections`** | An array of Collections for Payload to manage. [More details](./collections). |

View File

@@ -1,66 +0,0 @@
---
title: Experimental Features
label: Overview
order: 10
desc: Enable and configure experimental functionality within Payload. These featuresmay be unstable and may change or be removed without notice.
keywords: experimental, unstable, beta, preview, features, configuration, Payload, cms, headless, javascript, node, react, nextjs
---
Experimental features allow you to try out new functionality before it becomes a stable part of Payload. These features may still be in active development, may have incomplete functionality, and can change or be removed in future releases without warning.
## How It Works
Experimental features are configured via the root-level `experimental` property in your [Payload Config](../configuration/overview). This property contains individual feature flags, each flag can be configured independently, allowing you to selectively opt into specific functionality.
```ts
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
experimental: {
localizeStatus: true, // highlight-line
},
})
```
## Experimental Options
The following options are available:
| Option | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`localizeStatus`** | **Boolean.** When `true`, shows document status per locale in the admin panel instead of always showing the latest overall status. Opt-in for backwards compatibility. Defaults to `false`. |
This list may change without notice.
## When to Use Experimental Features
You might enable an experimental feature when:
- You want early access to new capabilities before their stable release.
- You can accept the risks of using potentially unstable functionality.
- You are testing new features in a development or staging environment.
- You wish to provide feedback to the Payload team on new functionality.
If you are working on a production application, carefully evaluate whether the benefits outweigh the risks. For most stable applications, it is recommended to wait until the feature is officially released.
<Banner type="success">
<strong>Tip:</strong> To stay up to date on experimental features or share
your feedback, visit the{' '}
<a
href="https://github.com/payloadcms/payload/discussions"
target="_blank"
rel="noopener noreferrer"
>
Payload GitHub Discussions
</a>{' '}
or{' '}
<a
href="https://github.com/payloadcms/payload/issues"
target="_blank"
rel="noopener noreferrer"
>
open an issue
</a>
.
</Banner>

1
next-env.d.ts vendored
View File

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

View File

@@ -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",

View File

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

View File

@@ -12,7 +12,6 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
autosave,
createdAt,
globalSlug,
localeStatus,
parent,
publishedLocale,
req,
@@ -34,7 +33,6 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
autosave,
createdAt,
latest: true,
localeStatus,
parent,
publishedLocale,
snapshot,

View File

@@ -12,7 +12,6 @@ export const createVersion: CreateVersion = async function createVersion(
autosave,
collectionSlug,
createdAt,
localeStatus,
parent,
publishedLocale,
req,
@@ -38,7 +37,6 @@ export const createVersion: CreateVersion = async function createVersion(
autosave,
createdAt,
latest: true,
localeStatus,
parent,
publishedLocale,
snapshot,

View File

@@ -179,13 +179,6 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
for (let i = 0; i < result.docs.length; i++) {
const id = result.docs[i].parent
const localeStatus = result.docs[i].localeStatus || {}
if (locale && localeStatus[locale]) {
result.docs[i].status = localeStatus[locale]
result.docs[i].version._status = localeStatus[locale]
}
result.docs[i] = result.docs[i].version ?? {}
result.docs[i].id = id
}

View File

@@ -15,7 +15,6 @@ export async function createGlobalVersion<T extends TypeWithID>(
autosave,
createdAt,
globalSlug,
localeStatus,
publishedLocale,
req,
returning,
@@ -36,7 +35,6 @@ export async function createGlobalVersion<T extends TypeWithID>(
autosave,
createdAt,
latest: true,
localeStatus,
publishedLocale,
snapshot,
updatedAt,

View File

@@ -15,7 +15,6 @@ export async function createVersion<T extends TypeWithID>(
autosave,
collectionSlug,
createdAt,
localeStatus,
parent,
publishedLocale,
req,
@@ -41,7 +40,6 @@ export async function createVersion<T extends TypeWithID>(
autosave,
createdAt,
latest: true,
localeStatus,
parent,
publishedLocale,
snapshot,

View File

@@ -36,17 +36,15 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
where: combinedWhere,
})
for (let i = 0; i < result.docs.length; i++) {
const id = result.docs[i].parent
const localeStatus = result.docs[i].localeStatus || {}
if (locale && localeStatus[locale]) {
result.docs[i].status = localeStatus[locale]
result.docs[i].version._status = localeStatus[locale]
}
return {
...result,
docs: result.docs.map((doc) => {
doc = {
id: doc.parent,
...doc.version,
}
result.docs[i] = result.docs[i].version ?? {}
result.docs[i].id = id
return doc
}),
}
return result
}

View File

@@ -37,7 +37,7 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "0.0.0-experimental-d331ba04-20250307",
"eslint-plugin-react-hooks": "0.0.0-experimental-b1b0955f-20250901",
"eslint-plugin-regexp": "2.7.0",
"globals": "16.0.0",
"typescript": "5.7.3",

View File

@@ -35,7 +35,7 @@
"eslint-plugin-jest-dom": "5.5.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-hooks": "0.0.0-experimental-d331ba04-20250307",
"eslint-plugin-react-hooks": "0.0.0-experimental-b1b0955f-20250901",
"eslint-plugin-regexp": "2.7.0",
"globals": "16.0.0",
"typescript": "5.7.3",

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,11 @@
import type {
AdminViewServerProps,
SanitizedDocumentPermissions,
SanitizedFieldsPermissions,
} from 'payload'
import type { AdminViewServerProps } from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import React from 'react'
import { getDocPreferences } from '../Document/getDocPreferences.js'
import { getDocumentData } from '../Document/getDocumentData.js'
import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
import { CreateFirstUserClient } from './index.client.js'
import './index.scss'
@@ -46,27 +43,18 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro
user: req.user,
})
const baseFields: SanitizedFieldsPermissions = Object.fromEntries(
collectionConfig.fields
.filter((f): f is { name: string } & typeof f => 'name' in f && typeof f.name === 'string')
.map((f) => [f.name, { create: true, read: true, update: true }]),
)
// In create-first-user we should always allow all fields
const docPermissionsForForm: SanitizedDocumentPermissions = {
create: true,
delete: true,
fields: baseFields,
read: true,
readVersions: true,
update: true,
}
// Get permissions
const { docPermissions } = await getDocumentPermissions({
collectionConfig,
data,
req,
})
// Build initial form state from data
const { state: formState } = await buildFormState({
collectionSlug: collectionConfig.slug,
data,
docPermissions: docPermissionsForForm,
docPermissions,
docPreferences,
locale: locale?.code,
operation: 'create',
@@ -81,7 +69,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro
<h1>{req.t('general:welcome')}</h1>
<p>{req.t('authentication:beginCreateFirstUser')}</p>
<CreateFirstUserClient
docPermissions={docPermissionsForForm}
docPermissions={docPermissions}
docPreferences={docPreferences}
initialState={formState}
loginWithUsername={loginWithUsername}

View File

@@ -19,7 +19,6 @@ type AutosaveCellProps = {
rowData: {
autosave?: boolean
id: number | string
localeStatus?: Record<string, 'draft' | 'published'>
publishedLocale?: string
version: {
_status: string

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ import { initTransaction } from '../../utilities/initTransaction.js'
import { killTransaction } from '../../utilities/killTransaction.js'
import { getFieldsToSign } from '../getFieldsToSign.js'
import { jwtSign } from '../jwt.js'
import { addSessionToUser } from '../sessions.js'
import { authenticateLocalStrategy } from '../strategies/local/authenticate.js'
import { generatePasswordSaltHash } from '../strategies/local/generatePasswordSaltHash.js'
@@ -144,25 +143,12 @@ export const resetPasswordOperation = async <TSlug extends CollectionSlug>(
await authenticateLocalStrategy({ doc, password: data.password })
const fieldsToSignArgs: Parameters<typeof getFieldsToSign>[0] = {
const fieldsToSign = getFieldsToSign({
collectionConfig,
email: user.email,
user,
}
const { sid } = await addSessionToUser({
collectionConfig,
payload,
req,
user,
})
if (sid) {
fieldsToSignArgs.sid = sid
}
const fieldsToSign = getFieldsToSign(fieldsToSignArgs)
const { token } = await jwtSign({
fieldsToSign,
secret,

View File

@@ -291,7 +291,6 @@ export const createOperation = async <
autosave,
collection: collectionConfig,
docWithLocales: result,
locale,
operation: 'create',
payload,
publishSpecificLocale,

View File

@@ -316,7 +316,6 @@ export const updateDocument = async <
collection: collectionConfig,
docWithLocales: result,
draft: shouldSaveDraft,
locale,
operation: 'update',
payload,
publishSpecificLocale,

View File

@@ -159,16 +159,6 @@ export const createClientConfig = ({
break
case 'experimental':
if (config.experimental) {
clientConfig.experimental = {}
if (config.experimental?.localizeStatus) {
clientConfig.experimental.localizeStatus = config.experimental.localizeStatus
}
}
break
case 'folders':
if (config.folders) {
clientConfig.folders = {

View File

@@ -47,7 +47,6 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
defaultDepth: 2,
defaultMaxTextLength: 40000,
endpoints: [],
experimental: {},
globals: [],
graphQL: {
disablePlaygroundInProduction: true,
@@ -122,7 +121,6 @@ export const addDefaultsToConfig = (config: Config): Config => {
config.defaultDepth = config.defaultDepth ?? 2
config.defaultMaxTextLength = config.defaultMaxTextLength ?? 40000
config.endpoints = config.endpoints ?? []
config.experimental = config.experimental ?? {}
config.globals = config.globals ?? []
config.graphQL = {
disableIntrospectionInProduction: true,

View File

@@ -721,14 +721,6 @@ export type ImportMapGenerators = Array<
}) => void
>
/**
* Experimental features.
* These may be unstable and may change or be removed in future releases.
*/
export type ExperimentalConfig = {
localizeStatus?: boolean
}
export type AfterErrorHook = (
args: AfterErrorHookArgs,
) => AfterErrorResult | Promise<AfterErrorResult>
@@ -1049,12 +1041,6 @@ export type Config = {
email?: EmailAdapter | Promise<EmailAdapter>
/** Custom REST endpoints */
endpoints?: Endpoint[]
/**
* Configure experimental features for Payload.
*
* These features may be unstable and may change or be removed in future releases.
*/
experimental?: ExperimentalConfig
/**
* Options for folder view within the admin panel
* @experimental this feature may change in minor versions until it is fully stable
@@ -1323,7 +1309,6 @@ export type SanitizedConfig = {
/** Default richtext editor to use for richText fields */
editor?: RichTextAdapter<any, any, any>
endpoints: Endpoint[]
experimental?: ExperimentalConfig
globals: SanitizedGlobalConfig[]
i18n: Required<I18nOptions>
jobs: SanitizedJobsConfig

View File

@@ -390,7 +390,6 @@ export type CreateVersionArgs<T = TypeWithID> = {
autosave: boolean
collectionSlug: CollectionSlug
createdAt: string
localeStatus?: Record<string, 'draft' | 'published'>
/** ID of the parent document for which the version should be created for */
parent: number | string
publishedLocale?: string
@@ -415,7 +414,6 @@ export type CreateGlobalVersionArgs<T = TypeWithID> = {
autosave: boolean
createdAt: string
globalSlug: GlobalSlug
localeStatus?: Record<string, 'draft' | 'published'>
/** ID of the parent document for which the version should be created for */
parent: number | string
publishedLocale?: string

View File

@@ -87,8 +87,8 @@ export type RootFoldersConfiguration = {
collectionOverrides?: (({
collection,
}: {
collection: Omit<CollectionConfig, 'trash'>
}) => Omit<CollectionConfig, 'trash'> | Promise<Omit<CollectionConfig, 'trash'>>)[]
collection: CollectionConfig
}) => CollectionConfig | Promise<CollectionConfig>)[]
/**
* If true, you can scope folders to specific collections.
*

View File

@@ -285,7 +285,6 @@ export const updateOperation = async <
docWithLocales: result,
draft: shouldSaveDraft,
global: globalConfig,
locale,
operation: 'update',
payload,
publishSpecificLocale,

View File

@@ -196,17 +196,10 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
type: 'group',
admin: {
hidden: true,
...(size.admin?.disableListColumn && { disableListColumn: true }),
...(size.admin?.disableListFilter && { disableListFilter: true }),
},
fields: [
{
...url,
admin: {
...url.admin,
...(size.admin?.disableListColumn && { disableListColumn: true }),
...(size.admin?.disableListFilter && { disableListFilter: true }),
},
hooks: {
afterRead: [
({ data, value }) => {
@@ -225,45 +218,12 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] =>
],
},
},
{
...width,
admin: {
...width.admin,
...(size.admin?.disableListColumn && { disableListColumn: true }),
...(size.admin?.disableListFilter && { disableListFilter: true }),
},
},
{
...height,
admin: {
...height.admin,
...(size.admin?.disableListColumn && { disableListColumn: true }),
...(size.admin?.disableListFilter && { disableListFilter: true }),
},
},
{
...mimeType,
admin: {
...mimeType.admin,
...(size.admin?.disableListColumn && { disableListColumn: true }),
...(size.admin?.disableListFilter && { disableListFilter: true }),
},
},
{
...filesize,
admin: {
...filesize.admin,
...(size.admin?.disableListColumn && { disableListColumn: true }),
...(size.admin?.disableListFilter && { disableListFilter: true }),
},
},
width,
height,
mimeType,
filesize,
{
...filename,
admin: {
...filename.admin,
...(size.admin?.disableListColumn && { disableListColumn: true }),
...(size.admin?.disableListFilter && { disableListFilter: true }),
},
unique: false,
},
],

View File

@@ -69,10 +69,6 @@ export type GenerateImageName = (args: {
}) => string
export type ImageSize = {
admin?: {
disableListColumn?: boolean
disableListFilter?: boolean
}
/**
* @deprecated prefer position
*/

View File

@@ -1,5 +1,4 @@
// @ts-strict-ignore
import type { SanitizedConfig } from '../config/types.js'
import type { CheckboxField, Field, Option } from '../fields/config/types.js'
export const statuses: Option[] = [
@@ -44,23 +43,3 @@ export const versionSnapshotField: CheckboxField = {
},
index: true,
}
export function buildLocaleStatusField(config: SanitizedConfig): Field[] {
if (!config.localization || !config.localization.locales) {
return []
}
return config.localization.locales.map((locale) => {
const code = typeof locale === 'string' ? locale : locale.code
return {
name: code,
type: 'select',
index: true,
options: [
{ label: ({ t }) => t('version:draft'), value: 'draft' },
{ label: ({ t }) => t('version:published'), value: 'published' },
],
}
})
}

View File

@@ -2,7 +2,7 @@ import type { SanitizedCollectionConfig } from '../collections/config/types.js'
import type { SanitizedConfig } from '../config/types.js'
import type { Field, FlattenedField } from '../fields/config/types.js'
import { buildLocaleStatusField, versionSnapshotField } from './baseFields.js'
import { versionSnapshotField } from './baseFields.js'
export const buildVersionCollectionFields = <T extends boolean = false>(
config: SanitizedConfig,
@@ -62,23 +62,6 @@ export const buildVersionCollectionFields = <T extends boolean = false>(
return locale.code
}),
})
if (config.experimental?.localizeStatus) {
const localeStatusFields = buildLocaleStatusField(config)
fields.push({
name: 'localeStatus',
type: 'group',
admin: {
disableBulkEdit: true,
disabled: true,
},
fields: localeStatusFields,
...(flatten && {
flattenedFields: localeStatusFields as FlattenedField[],
})!,
})
}
}
fields.push({

View File

@@ -2,7 +2,7 @@ import type { SanitizedConfig } from '../config/types.js'
import type { Field, FlattenedField } from '../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import { buildLocaleStatusField, versionSnapshotField } from './baseFields.js'
import { versionSnapshotField } from './baseFields.js'
export const buildVersionGlobalFields = <T extends boolean = false>(
config: SanitizedConfig,
@@ -56,23 +56,6 @@ export const buildVersionGlobalFields = <T extends boolean = false>(
return locale.code
}),
})
if (config.experimental.localizeStatus) {
const localeStatusFields = buildLocaleStatusField(config)
fields.push({
name: 'localeStatus',
type: 'group',
admin: {
disableBulkEdit: true,
disabled: true,
},
fields: localeStatusFields,
...(flatten && {
flattenedFields: localeStatusFields as FlattenedField[],
})!,
})
}
}
fields.push({

View File

@@ -111,12 +111,6 @@ export const replaceWithDraftIfAvailable = async <T extends TypeWithID>({
draft.version = {} as T
}
// Lift locale status from version data if available
const localeStatus = draft.localeStatus || {}
if (locale && localeStatus[locale]) {
;(draft.version as { _status?: string })['_status'] = localeStatus[locale]
}
// Disregard all other draft content at this point,
// Only interested in the version itself.
// Operations will handle firing hooks, etc.

View File

@@ -1,5 +1,3 @@
import { version } from 'os'
// @ts-strict-ignore
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
@@ -18,7 +16,6 @@ type Args = {
draft?: boolean
global?: SanitizedGlobalConfig
id?: number | string
locale?: null | string
operation?: 'create' | 'restoreVersion' | 'update'
payload: Payload
publishSpecificLocale?: string
@@ -34,7 +31,6 @@ export const saveVersion = async ({
docWithLocales: doc,
draft,
global,
locale,
operation,
payload,
publishSpecificLocale,
@@ -46,7 +42,6 @@ export const saveVersion = async ({
let createNewVersion = true
const now = new Date().toISOString()
const versionData = deepCopyObjectSimple(doc)
if (draft) {
versionData._status = 'draft'
}
@@ -60,39 +55,39 @@ export const saveVersion = async ({
}
try {
let docs
const findVersionArgs = {
limit: 1,
pagination: false,
req,
sort: '-updatedAt',
}
if (collection) {
;({ docs } = await payload.db.findVersions({
...findVersionArgs,
collection: collection.slug,
limit: 1,
pagination: false,
req,
where: {
parent: {
equals: id,
},
},
}))
} else {
;({ docs } = await payload.db.findGlobalVersions({
...findVersionArgs,
global: global!.slug,
limit: 1,
pagination: false,
req,
}))
}
const [latestVersion] = docs
if (autosave) {
let docs
const findVersionArgs = {
limit: 1,
pagination: false,
req,
sort: '-updatedAt',
}
if (collection) {
;({ docs } = await payload.db.findVersions({
...findVersionArgs,
collection: collection.slug,
limit: 1,
pagination: false,
req,
where: {
parent: {
equals: id,
},
},
}))
} else {
;({ docs } = await payload.db.findGlobalVersions({
...findVersionArgs,
global: global!.slug,
limit: 1,
pagination: false,
req,
}))
}
const [latestVersion] = docs
// overwrite the latest version if it's set to autosave
if (latestVersion && 'autosave' in latestVersion && latestVersion.autosave === true) {
createNewVersion = false
@@ -130,53 +125,11 @@ export const saveVersion = async ({
}
if (createNewVersion) {
let localeStatus = {}
const localizationEnabled =
payload.config.localization && payload.config.localization.locales.length > 0
if (
localizationEnabled &&
payload.config.localization !== false &&
payload.config.experimental?.localizeStatus
) {
const allLocales = (
(payload.config.localization && payload.config.localization?.locales) ||
[]
).map((locale) => (typeof locale === 'string' ? locale : locale.code))
// If `publish all`, set all locales to published
if (versionData._status === 'published' && !publishSpecificLocale) {
localeStatus = Object.fromEntries(allLocales.map((code) => [code, 'published']))
} else if (publishSpecificLocale || (locale && versionData._status === 'draft')) {
const status: 'draft' | 'published' = publishSpecificLocale ? 'published' : 'draft'
const incomingLocale = String(publishSpecificLocale || locale)
const existing = latestVersion?.localeStatus
// If no locale statuses are set, set it and set all others to draft
if (!existing) {
localeStatus = {
...Object.fromEntries(
allLocales.filter((code) => code !== incomingLocale).map((code) => [code, 'draft']),
),
[incomingLocale]: status,
}
} else {
// If locales already exist, update the status for the incoming locale
const { [incomingLocale]: _, ...rest } = existing
localeStatus = {
...rest,
[incomingLocale]: status,
}
}
}
}
const createVersionArgs = {
autosave: Boolean(autosave),
collectionSlug: undefined as string | undefined,
createdAt: operation === 'restoreVersion' ? versionData.createdAt : now,
globalSlug: undefined as string | undefined,
localeStatus,
parent: collection ? id : undefined,
publishedLocale: publishSpecificLocale || undefined,
req,

View File

@@ -122,7 +122,6 @@ export type SanitizedGlobalVersions = {
export type TypeWithVersion<T> = {
createdAt: string
id: string
localeStatus: Record<string, 'draft' | 'published'>
parent: number | string
publishedLocale?: string
snapshot?: boolean

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -183,11 +183,6 @@
"types": "./src/lexical-proxy/@lexical-react/LexicalContextMenuPlugin.ts",
"default": "./src/lexical-proxy/@lexical-react/LexicalContextMenuPlugin.ts"
},
"./lexical/react/LexicalNodeContextMenuPlugin": {
"import": "./src/lexical-proxy/@lexical-react/LexicalNodeContextMenuPlugin.ts",
"types": "./src/lexical-proxy/@lexical-react/LexicalNodeContextMenuPlugin.ts",
"default": "./src/lexical-proxy/@lexical-react/LexicalNodeContextMenuPlugin.ts"
},
"./lexical/react/LexicalDecoratorBlockNode": {
"import": "./src/lexical-proxy/@lexical-react/LexicalDecoratorBlockNode.ts",
"types": "./src/lexical-proxy/@lexical-react/LexicalDecoratorBlockNode.ts",
@@ -365,16 +360,16 @@
]
},
"dependencies": {
"@lexical/headless": "0.34.0",
"@lexical/html": "0.34.0",
"@lexical/link": "0.34.0",
"@lexical/list": "0.34.0",
"@lexical/mark": "0.34.0",
"@lexical/react": "0.34.0",
"@lexical/rich-text": "0.34.0",
"@lexical/selection": "0.34.0",
"@lexical/table": "0.34.0",
"@lexical/utils": "0.34.0",
"@lexical/headless": "0.28.0",
"@lexical/html": "0.28.0",
"@lexical/link": "0.28.0",
"@lexical/list": "0.28.0",
"@lexical/mark": "0.28.0",
"@lexical/react": "0.28.0",
"@lexical/rich-text": "0.28.0",
"@lexical/selection": "0.28.0",
"@lexical/table": "0.28.0",
"@lexical/utils": "0.28.0",
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"@types/uuid": "10.0.0",
@@ -384,7 +379,7 @@
"dequal": "2.0.3",
"escape-html": "1.0.3",
"jsox": "1.2.121",
"lexical": "0.34.0",
"lexical": "0.28.0",
"mdast-util-from-markdown": "2.0.2",
"mdast-util-mdx-jsx": "3.1.3",
"micromark-extension-mdx-jsx": "3.0.1",
@@ -399,14 +394,14 @@
"@babel/preset-env": "7.27.2",
"@babel/preset-react": "7.27.1",
"@babel/preset-typescript": "7.27.1",
"@lexical/eslint-plugin": "0.34.0",
"@lexical/eslint-plugin": "0.28.0",
"@payloadcms/eslint-config": "workspace:*",
"@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",
@@ -586,11 +581,6 @@
"types": "./dist/lexical-proxy/@lexical-react/LexicalContextMenuPlugin.d.ts",
"default": "./dist/lexical-proxy/@lexical-react/LexicalContextMenuPlugin.js"
},
"./lexical/react/LexicalNodeContextMenuPlugin": {
"import": "./dist/lexical-proxy/@lexical-react/LexicalNodeContextMenuPlugin.js",
"types": "./dist/lexical-proxy/@lexical-react/LexicalNodeContextMenuPlugin.d.ts",
"default": "./dist/lexical-proxy/@lexical-react/LexicalNodeContextMenuPlugin.js"
},
"./lexical/react/LexicalDecoratorBlockNode": {
"import": "./dist/lexical-proxy/@lexical-react/LexicalDecoratorBlockNode.js",
"types": "./dist/lexical-proxy/@lexical-react/LexicalDecoratorBlockNode.d.ts",

View File

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

View File

@@ -8,18 +8,17 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import {
$computeTableMapSkipCellCheck,
$deleteTableColumnAtSelection,
$deleteTableRowAtSelection,
$deleteTableColumn__EXPERIMENTAL,
$deleteTableRow__EXPERIMENTAL,
$getNodeTriplet,
$getTableCellNodeFromLexicalNode,
$getTableColumnIndexFromTableCellNode,
$getTableNodeFromLexicalNodeOrThrow,
$getTableRowIndexFromTableCellNode,
$insertTableColumnAtSelection,
$insertTableRowAtSelection,
$insertTableColumn__EXPERIMENTAL,
$insertTableRow__EXPERIMENTAL,
$isTableCellNode,
$isTableSelection,
$mergeCells,
$unmergeCell,
getTableElement,
getTableObserverFromTableElement,
@@ -29,8 +28,11 @@ import {
import { mergeRegister } from '@lexical/utils'
import { useScrollInfo } from '@payloadcms/ui'
import {
$createParagraphNode,
$getRoot,
$getSelection,
$isElementNode,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
$setSelection,
@@ -72,6 +74,17 @@ function $canUnmerge(): boolean {
return cell.__colSpan > 1 || cell.__rowSpan > 1
}
function $cellContainsEmptyParagraph(cell: TableCellNode): boolean {
if (cell.getChildrenSize() !== 1) {
return false
}
const firstChild = cell.getFirstChildOrThrow()
if (!$isParagraphNode(firstChild) || !firstChild.isEmpty()) {
return false
}
return true
}
function $selectLastDescendant(node: ElementNode): void {
const lastDescendant = node.getLastDescendant()
if ($isTextNode(lastDescendant)) {
@@ -214,14 +227,105 @@ function TableActionMenu({
const mergeTableCellsAtSelection = () => {
editor.update(() => {
const selection = $getSelection()
if (!$isTableSelection(selection)) {
return
}
const nodes = selection.getNodes()
const tableCells = nodes.filter($isTableCellNode)
const targetCell = $mergeCells(tableCells)
if ($isTableSelection(selection)) {
// Get all selected cells and compute the total area
const nodes = selection.getNodes()
const tableCells = nodes.filter($isTableCellNode)
if (tableCells.length === 0) {
return
}
// Find the table node
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCells[0] as TableCellNode)
const [gridMap] = $computeTableMapSkipCellCheck(tableNode, null, null)
// Find the boundaries of the selection including merged cells
let minRow = Infinity
let maxRow = -Infinity
let minCol = Infinity
let maxCol = -Infinity
// First pass: find the actual boundaries considering merged cells
const processedCells = new Set()
for (const row of gridMap) {
for (const mapCell of row) {
if (!mapCell || !mapCell.cell) {
continue
}
const cellKey = mapCell.cell.getKey()
if (processedCells.has(cellKey)) {
continue
}
if (tableCells.some((cell) => cell.is(mapCell.cell))) {
processedCells.add(cellKey)
// Get the actual position of this cell in the grid
const cellStartRow = mapCell.startRow
const cellStartCol = mapCell.startColumn
const cellRowSpan = mapCell.cell.__rowSpan || 1
const cellColSpan = mapCell.cell.__colSpan || 1
// Update boundaries considering the cell's actual position and span
minRow = Math.min(minRow, cellStartRow)
maxRow = Math.max(maxRow, cellStartRow + cellRowSpan - 1)
minCol = Math.min(minCol, cellStartCol)
maxCol = Math.max(maxCol, cellStartCol + cellColSpan - 1)
}
}
}
// Validate boundaries
if (minRow === Infinity || minCol === Infinity) {
return
}
// The total span of the merged cell
const totalRowSpan = maxRow - minRow + 1
const totalColSpan = maxCol - minCol + 1
// Use the top-left cell as the target cell
const targetCellMap = gridMap?.[minRow]?.[minCol]
if (!targetCellMap?.cell) {
return
}
const targetCell = targetCellMap.cell
// Set the spans for the target cell
targetCell.setColSpan(totalColSpan)
targetCell.setRowSpan(totalRowSpan)
// Move content from other cells to the target cell
const seenCells = new Set([targetCell.getKey()])
// Second pass: merge content and remove other cells
for (let row = minRow; row <= maxRow; row++) {
for (let col = minCol; col <= maxCol; col++) {
const mapCell = gridMap?.[row]?.[col]
if (!mapCell?.cell) {
continue
}
const currentCell = mapCell.cell
const key = currentCell.getKey()
if (!seenCells.has(key)) {
seenCells.add(key)
const isEmpty = $cellContainsEmptyParagraph(currentCell)
if (!isEmpty) {
targetCell.append(...currentCell.getChildren())
}
currentCell.remove()
}
}
}
// Ensure target cell has content
if (targetCell.getChildrenSize() === 0) {
targetCell.append($createParagraphNode())
}
if (targetCell) {
$selectLastDescendant(targetCell)
onClose()
}
@@ -238,7 +342,7 @@ function TableActionMenu({
(shouldInsertAfter: boolean) => {
editor.update(() => {
for (let i = 0; i < selectionCounts.rows; i++) {
$insertTableRowAtSelection(shouldInsertAfter)
$insertTableRow__EXPERIMENTAL(shouldInsertAfter)
}
onClose()
})
@@ -250,7 +354,7 @@ function TableActionMenu({
(shouldInsertAfter: boolean) => {
editor.update(() => {
for (let i = 0; i < selectionCounts.columns; i++) {
$insertTableColumnAtSelection(shouldInsertAfter)
$insertTableColumn__EXPERIMENTAL(shouldInsertAfter)
}
onClose()
})
@@ -260,7 +364,7 @@ function TableActionMenu({
const deleteTableRowAtSelection = useCallback(() => {
editor.update(() => {
$deleteTableRowAtSelection()
$deleteTableRow__EXPERIMENTAL()
onClose()
})
}, [editor, onClose])
@@ -277,7 +381,7 @@ function TableActionMenu({
const deleteTableColumnAtSelection = useCallback(() => {
editor.update(() => {
$deleteTableColumnAtSelection()
$deleteTableColumn__EXPERIMENTAL()
onClose()
})
}, [editor, onClose])

View File

@@ -17,7 +17,7 @@ import {
TableNode,
} from '@lexical/table'
import { calculateZoomLevel, mergeRegister } from '@lexical/utils'
import { $getNearestNodeFromDOMNode, isHTMLElement, SKIP_SCROLL_INTO_VIEW_TAG } from 'lexical'
import { $getNearestNodeFromDOMNode, isHTMLElement } from 'lexical'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
@@ -223,7 +223,7 @@ function TableCellResizer({ editor }: { editor: LexicalEditor }): JSX.Element {
const newHeight = Math.max(height + heightChange, MIN_ROW_HEIGHT)
tableRow.setHeight(newHeight)
},
{ tag: SKIP_SCROLL_INTO_VIEW_TAG },
{ tag: 'skip-scroll-into-view' },
)
},
[activeCell, editor],
@@ -281,7 +281,7 @@ function TableCellResizer({ editor }: { editor: LexicalEditor }): JSX.Element {
newColWidths[columnIndex] = newWidth
tableNode.setColWidths(newColWidths)
},
{ tag: SKIP_SCROLL_INTO_VIEW_TAG },
{ tag: 'skip-scroll-into-view' },
)
},
[activeCell, editor],

View File

@@ -9,8 +9,8 @@ import {
$getTableAndElementByKey,
$getTableColumnIndexFromTableCellNode,
$getTableRowIndexFromTableCellNode,
$insertTableColumnAtSelection,
$insertTableRowAtSelection,
$insertTableColumn__EXPERIMENTAL,
$insertTableRow__EXPERIMENTAL,
$isTableCellNode,
$isTableNode,
getTableElement,
@@ -223,10 +223,10 @@ function TableHoverActionsContainer({
const maybeTableNode = $getNearestNodeFromDOMNode(tableCellDOMNodeRef.current)
maybeTableNode?.selectEnd()
if (insertRow) {
$insertTableRowAtSelection()
$insertTableRow__EXPERIMENTAL()
setShownRow(false)
} else {
$insertTableColumnAtSelection()
$insertTableColumn__EXPERIMENTAL()
setShownColumn(false)
}
}

View File

@@ -0,0 +1,115 @@
'use client'
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.
*/
{
setValue?: FieldType<DefaultTypedEditorState | undefined>['setValue']
value?: FieldType<DefaultTypedEditorState | undefined>['value']
} & RenderFieldServerFnArgs
> = (args) => {
const { field, initialValue, 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,
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 <ShimmerEffect />
}
/**
* By default, the lexical will make form state requests (e.g. to get drawer fields), passing in the arguments
* of the current field. However, we need to override those arguments to get it to make requests based on the
* *target* field. The server only knows the schema map of the target field.
*/
const adjustedServerFunctionContext: ServerFunctionsContextType = {
...serverFunctionContext,
getFormState: async (getFormStateArgs) => {
return serverFunctionContext.getFormState({
...getFormStateArgs,
collectionSlug: entityType === 'collection' ? entitySlug : undefined,
globalSlug: entityType === 'global' ? entitySlug : undefined,
})
},
}
if (typeof value === 'undefined' && !setValue) {
return (
<ServerFunctionsContext value={{ ...adjustedServerFunctionContext }}>
<FieldPathContext key={fieldPath} value={fieldPath}>
{Component}
</FieldPathContext>
</ServerFunctionsContext>
)
}
const fieldValue: FieldType<DefaultTypedEditorState | undefined> = {
disabled: false,
formInitializing: false,
formProcessing: false,
formSubmitted: false,
initialValue: value,
path: fieldPath,
setValue: setValue ?? (() => undefined),
showError: false,
value,
}
return (
<ServerFunctionsContext value={{ ...adjustedServerFunctionContext }}>
<FieldPathContext key={fieldPath} value={fieldPath}>
<FieldContext value={fieldValue}>{Component}</FieldContext>
</FieldPathContext>
</ServerFunctionsContext>
)
}

View File

@@ -26,7 +26,7 @@ import { richTextValidateHOC } from './validate/index.js'
let checkedDependencies = false
export const lexicalTargetVersion = '0.34.0'
export const lexicalTargetVersion = '0.28.0'
export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapterProvider {
if (
@@ -1055,10 +1055,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 +1069,4 @@ export {
objectToFrontmatter,
propsToJSXString,
} from './utilities/jsx/jsx.js'
export { upgradeLexicalData } from './utilities/upgradeLexicalData/index.js'

View File

@@ -1 +0,0 @@
export * from '@lexical/react/LexicalNodeContextMenuPlugin'

View File

@@ -2,7 +2,6 @@
@layer payload-default {
.draggable-block-menu {
border: none;
border-radius: $style-radius-m;
padding: 0;
cursor: grab;
@@ -25,7 +24,6 @@
}
.icon {
background-color: transparent;
width: 18px;
height: 24px;
opacity: 0.3;

View File

@@ -70,7 +70,7 @@ function useDraggableBlockMenu(
): React.ReactElement {
const scrollerElem = anchorElem.parentElement
const menuRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const targetLineRef = useRef<HTMLDivElement>(null)
const debugHighlightRef = useRef<HTMLDivElement>(null)
const isDraggingBlockRef = useRef<boolean>(false)
@@ -396,7 +396,7 @@ function useDraggableBlockMenu(
editorConfig?.admin?.hideGutter,
])
function onDragStart(event: ReactDragEvent<HTMLButtonElement>): void {
function onDragStart(event: ReactDragEvent<HTMLDivElement>): void {
const dataTransfer = event.dataTransfer
if (!dataTransfer || !draggableBlockElem) {
return
@@ -422,17 +422,15 @@ function useDraggableBlockMenu(
return createPortal(
<React.Fragment>
<button
aria-label="Drag to move"
<div
className="icon draggable-block-menu"
draggable
onDragEnd={onDragEnd}
onDragStart={onDragStart}
ref={menuRef}
type="button"
>
<div className={isEditable ? 'icon' : ''} />
</button>
</div>
<div className="draggable-block-target-line" ref={targetLineRef} />
<div className="debug-highlight" ref={debugHighlightRef} />
</React.Fragment>,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -134,13 +134,13 @@ export const Autosave: React.FC<Props> = ({ id, collection, global: globalDoc })
if (collection && id) {
entitySlug = collection.slug
url = `${serverURL}${api}/${entitySlug}/${id}?depth=0&draft=true&autosave=true&locale=${localeRef.current}`
url = `${serverURL}${api}/${entitySlug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'PATCH'
}
if (globalDoc) {
entitySlug = globalDoc.slug
url = `${serverURL}${api}/globals/${entitySlug}?depth=0&draft=true&autosave=true&locale=${localeRef.current}`
url = `${serverURL}${api}/globals/${entitySlug}?draft=true&autosave=true&locale=${localeRef.current}`
method = 'POST'
}

View File

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

View File

@@ -24,7 +24,6 @@ export const Status: React.FC = () => {
hasPublishedDoc,
incrementVersionCount,
isTrashed,
savedDocumentData: doc,
setHasPublishedDoc,
setMostRecentVersionIsAutosaved,
setUnpublishedVersionCount,
@@ -38,7 +37,6 @@ export const Status: React.FC = () => {
routes: { api },
serverURL,
},
getEntityConfig,
} = useConfig()
const { reset: resetForm } = useForm()
@@ -48,22 +46,16 @@ export const Status: React.FC = () => {
const unPublishModalSlug = `confirm-un-publish-${id}`
const revertModalSlug = `confirm-revert-${id}`
let statusToRender: 'changed' | 'draft' | 'published' = 'draft'
let statusToRender: 'changed' | 'draft' | 'published'
const collectionConfig = getEntityConfig({ collectionSlug })
const globalConfig = getEntityConfig({ globalSlug })
const docConfig = collectionConfig || globalConfig
const autosaveEnabled =
typeof docConfig?.versions?.drafts === 'object' ? docConfig.versions.drafts.autosave : false
if (autosaveEnabled) {
if (hasPublishedDoc) {
statusToRender = unpublishedVersionCount > 0 ? 'changed' : 'published'
}
} else {
statusToRender = doc._status || 'draft'
if (unpublishedVersionCount > 0 && hasPublishedDoc) {
statusToRender = 'changed'
} else if (!hasPublishedDoc) {
statusToRender = 'draft'
} else if (hasPublishedDoc && unpublishedVersionCount <= 0) {
statusToRender = 'published'
}
const displayStatusKey = isTrashed
? hasPublishedDoc
? 'previouslyPublished'
@@ -198,7 +190,7 @@ export const Status: React.FC = () => {
/>
</React.Fragment>
)}
{!isTrashed && canUpdate && statusToRender === 'changed' || statusToRender === 'draft' && (
{!isTrashed && canUpdate && statusToRender === 'changed' && (
<React.Fragment>
&nbsp;&mdash;&nbsp;
<Button

View File

@@ -235,12 +235,14 @@ 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 { FieldPathContext, useFieldPath } from '../../forms/RenderFields/context.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'
@@ -287,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'
@@ -407,3 +411,7 @@ export { parseSearchParams } from '../../utilities/parseSearchParams.js'
export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'
export { FieldDiffContainer } from '../../elements/FieldDiffContainer/index.js'
export { formatTimeToNow } from '../../utilities/formatDocTitle/formatDateTitle.js'
export type {
RenderFieldServerFnArgs,
RenderFieldServerFnReturnType,
} from '../../forms/fieldSchemasToFormState/serverFunctions/renderFieldServerFn.js'

View File

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

View File

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

View File

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

View File

@@ -134,37 +134,35 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
case 'DUPLICATE_ROW': {
const { path, rowIndex } = action
const { remainingFields, rows } = separateRows(path, state)
const rowsWithDuplicate = [...(state[path].rows || [])]
const rowsMetadata = [...(state[path].rows || [])]
const newRow = deepCopyObjectSimpleWithoutReactComponents(rowsWithDuplicate[rowIndex])
const duplicateRowMetadata = deepCopyObjectSimpleWithoutReactComponents(
rowsMetadata[rowIndex],
)
const newRowID = new ObjectId().toHexString()
if (newRow.id) {
newRow.id = newRowID
if (duplicateRowMetadata.id) {
duplicateRowMetadata.id = new ObjectId().toHexString()
}
if (rowsWithDuplicate[rowIndex]?.customComponents?.RowLabel) {
newRow.customComponents = {
RowLabel: rowsWithDuplicate[rowIndex].customComponents.RowLabel,
if (rowsMetadata[rowIndex]?.customComponents?.RowLabel) {
duplicateRowMetadata.customComponents = {
RowLabel: rowsMetadata[rowIndex].customComponents.RowLabel,
}
}
const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex])
if (duplicateRowState.id) {
duplicateRowState.id.value = newRowID
duplicateRowState.id.initialValue = newRowID
duplicateRowState.id.value = new ObjectId().toHexString()
duplicateRowState.id.initialValue = new ObjectId().toHexString()
}
for (const key of Object.keys(duplicateRowState).filter((key) => key.endsWith('.id'))) {
const idState = duplicateRowState[key]
const newNestedFieldID = new ObjectId().toHexString()
if (idState && typeof idState.value === 'string' && ObjectId.isValid(idState.value)) {
duplicateRowState[key].value = newNestedFieldID
duplicateRowState[key].initialValue = newNestedFieldID
duplicateRowState[key].value = new ObjectId().toHexString()
duplicateRowState[key].initialValue = new ObjectId().toHexString()
}
}
@@ -172,7 +170,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
if (Object.keys(duplicateRowState).length > 0) {
// Add new object containing subfield names to unflattenedRows array
rows.splice(rowIndex + 1, 0, duplicateRowState)
rowsWithDuplicate.splice(rowIndex + 1, 0, newRow)
rowsMetadata.splice(rowIndex + 1, 0, duplicateRowMetadata)
}
const newState = {
@@ -181,7 +179,7 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
[path]: {
...state[path],
disableFormData: true,
rows: rowsWithDuplicate,
rows: rowsMetadata,
value: rows.length,
},
}

View File

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

View File

@@ -0,0 +1,116 @@
import { 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
/**
* chema path to the field to render. {global|collection}.entitySlug.fieldSchemaPath
*
* @example collection.posts.richText
*/
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,
}),
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 = {
...(targetField || {}),
...(fieldArg || {}),
} as Field
let data = {}
if (typeof initialValue !== 'undefined') {
if ('name' in field) {
data[field.name] = initialValue
} else {
data = initialValue
}
}
const fieldState: FieldState = {}
renderField({
clientFieldSchemaMap: clientSchemaMap,
collectionSlug: entityType === 'collection' && entitySlug ? entitySlug : '-',
data,
fieldConfig: field,
fieldSchemaMap: schemaMap,
fieldState, // TODO,
formState: {}, // TODO,
indexPath: '',
lastRenderedPath: '',
operation: 'create',
parentPath: '',
parentSchemaPath: '',
path: path ?? ('name' in field ? field.name : ''),
permissions: true,
preferences: {
fields: {},
},
previousFieldState: undefined,
renderAllFields: true,
req,
schemaPath: `${entitySlug}.${fieldPath.join('.')}`,
siblingData: data,
})
return fieldState.customComponents ?? {}
}

View File

@@ -1,7 +1,7 @@
'use client'
import type { PayloadRequest } from 'payload'
import { useCallback, useMemo, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import type { UPDATE } from '../Form/types.js'
import type { FieldType, Options } from './types.js'
@@ -24,12 +24,7 @@ import {
} from '../Form/context.js'
import { useFieldPath } from '../RenderFields/context.js'
/**
* Get and set the value of a form field.
*
* @see https://payloadcms.com/docs/admin/react-hooks#usefield
*/
export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
const useFieldInForm = <TValue,>(options?: Options): FieldType<TValue> => {
const {
disableFormData = false,
hasRows,
@@ -229,3 +224,47 @@ export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
return result
}
/**
* Context to allow providing useField value for fields directly, if managed outside the form
*/
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 ctx = 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 modeRef = React.useRef<'context' | 'impl' | null>(null)
if (modeRef.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 => 'impl'
const currentPath = options?.path || pathFromContext || options.potentiallyStalePath
modeRef.current = ctx && currentPath && ctx.path === currentPath ? 'context' : 'impl'
}
if (modeRef.current === 'context') {
if (!ctx) {
// Provider was removed after mount. That violates hook guarantees.
throw new Error('FieldContext was removed after mount. This breaks hook ordering.')
}
return ctx
}
// We intentionally guard this hook call with a mode that is fixed on first render.
// The order is consistent across renders. Silence the linters false positive.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/rules-of-hooks
return useFieldInForm<TValue>(options)
}

View File

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

View File

@@ -30,27 +30,7 @@ export const reduceFieldsToOptions = ({
}: ReduceFieldOptionsArgs): ReducedField[] => {
return fields.reduce((reduced, field) => {
// Do not filter out `field.admin.disableListFilter` fields here, as these should still render as disabled if they appear in the URL query
// Filter out `virtual: true` fields since they are regular virtuals and not backed by a DB field
if (
(fieldIsHiddenOrDisabled(field) && !fieldIsID(field)) ||
('virtual' in field && field.virtual === true)
) {
return reduced
}
// Handle virtual:string fields (virtual relationships, e.g. "post.title")
if ('virtual' in field && typeof field.virtual === 'string') {
const baseLabel = ('label' in field && field.label) || ('name' in field && field.name) || ''
const localizedLabel = getTranslation(baseLabel, i18n)
reduced.push({
label: localizedLabel,
plainTextLabel: localizedLabel,
value: field.virtual, // e.g. "post.title"
...fieldTypes[field.type],
field,
operators: fieldTypes[field.type].operators,
})
if (fieldIsHiddenOrDisabled(field) && !fieldIsID(field)) {
return reduced
}

1481
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,6 @@ import { getClientSideURL } from '@/utilities/getURL'
export const getMediaUrl = (url: string | null | undefined, cacheTag?: string | null): string => {
if (!url) return ''
if (cacheTag && cacheTag !== '') {
cacheTag = encodeURIComponent(cacheTag)
}
// Check if URL already has http/https protocol
if (url.startsWith('http://') || url.startsWith('https://')) {
return cacheTag ? `${url}?${cacheTag}` : url

View File

@@ -9,10 +9,6 @@ import { getClientSideURL } from '@/utilities/getURL'
export const getMediaUrl = (url: string | null | undefined, cacheTag?: string | null): string => {
if (!url) return ''
if (cacheTag && cacheTag !== '') {
cacheTag = encodeURIComponent(cacheTag)
}
// Check if URL already has http/https protocol
if (url.startsWith('http://') || url.startsWith('https://')) {
return cacheTag ? `${url}?${cacheTag}` : url

View File

@@ -1,5 +1,7 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
@@ -13,14 +15,11 @@ export const PostsCollection: CollectionConfig = {
type: 'text',
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'title',
type: 'text',
},
],
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
},
],
}

View File

@@ -126,12 +126,21 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title?: string | null;
array?:
| {
title?: string | null;
id?: string | null;
}[]
| null;
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
@@ -270,12 +279,7 @@ export interface PayloadMigration {
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
array?:
| T
| {
title?: T;
id?: T;
};
content?: T;
updatedAt?: T;
createdAt?: T;
}

View File

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

View File

@@ -1,27 +0,0 @@
import type { CollectionConfig } from 'payload'
import { virtualsSlug } from '../slugs.js'
export const Virtuals: CollectionConfig = {
slug: virtualsSlug,
admin: {
useAsTitle: 'virtualTitleFromPost',
},
fields: [
{
name: 'virtualTitleFromPost',
type: 'text',
virtual: 'post.title',
},
{
name: 'virtualText',
type: 'text',
virtual: true,
},
{
name: 'post',
type: 'relationship',
relationTo: 'posts',
},
],
}

View File

@@ -28,7 +28,6 @@ import { UploadCollection } from './collections/Upload.js'
import { UploadTwoCollection } from './collections/UploadTwo.js'
import { UseAsTitleGroupField } from './collections/UseAsTitleGroupField.js'
import { Users } from './collections/Users.js'
import { Virtuals } from './collections/Virtuals.js'
import { with300Documents } from './collections/With300Documents.js'
import { CustomGlobalViews1 } from './globals/CustomViews1.js'
import { CustomGlobalViews2 } from './globals/CustomViews2.js'
@@ -188,7 +187,6 @@ export default buildConfigWithDefaults({
UseAsTitleGroupField,
DisableBulkEdit,
CustomListDrawer,
Virtuals,
],
globals: [
GlobalHidden,

View File

@@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'
import { mapAsync } from 'payload'
import * as qs from 'qs-esm'
import type { Config, Geo, Post, Virtual } from '../../payload-types.js'
import type { Config, Geo, Post } from '../../payload-types.js'
import {
ensureCompilationIsDone,
@@ -23,7 +23,6 @@ import {
listDrawerSlug,
placeholderCollectionSlug,
postsCollectionSlug,
virtualsSlug,
with300DocumentsSlug,
} from '../../slugs.js'
@@ -71,7 +70,6 @@ describe('List View', () => {
let placeholderUrl: AdminUrlUtil
let disableBulkEditUrl: AdminUrlUtil
let user: any
let virtualsUrl: AdminUrlUtil
let serverURL: string
let adminRoutes: ReturnType<typeof getRoutes>
@@ -96,7 +94,6 @@ describe('List View', () => {
withListViewUrl = new AdminUrlUtil(serverURL, listDrawerSlug)
placeholderUrl = new AdminUrlUtil(serverURL, placeholderCollectionSlug)
disableBulkEditUrl = new AdminUrlUtil(serverURL, 'disable-bulk-edit')
virtualsUrl = new AdminUrlUtil(serverURL, virtualsSlug)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
@@ -419,44 +416,6 @@ describe('List View', () => {
).toBeVisible()
})
test('should not allow search by virtual: true field in field dropdown', async () => {
await page.goto(virtualsUrl.list)
await openListFilters(page, {})
const whereBuilder = page.locator('.where-builder')
await whereBuilder.locator('.where-builder__add-first-filter').click()
const conditionField = whereBuilder.locator('.condition__field')
await conditionField.click()
const menuList = conditionField.locator('.rs__menu-list')
// ensure the virtual field is not present
await expect(menuList.locator('div', { hasText: exactText('Virtual Text') })).toHaveCount(0)
})
test('should allow to filter by virtual relationship field', async () => {
const post1 = await createPost({ title: 'somePost' })
const post2 = await createPost({ title: 'otherPost' })
await createVirtualDoc({ post: post1.id })
await createVirtualDoc({ post: post2.id })
await page.goto(virtualsUrl.list)
await expect(page.locator(tableRowLocator)).toHaveCount(2)
await addListFilter({
page,
fieldLabel: 'Virtual Title From Post',
operatorLabel: 'equals',
value: 'somePost',
})
await expect(page.locator(tableRowLocator)).toHaveCount(1)
})
test('should allow to filter in array field', async () => {
await createArray()
@@ -1827,13 +1786,3 @@ async function createArray() {
},
})
}
async function createVirtualDoc(overrides?: Partial<Virtual>): Promise<Virtual> {
return payload.create({
collection: virtualsSlug,
data: {
post: overrides?.post,
...overrides,
},
}) as unknown as Promise<Virtual>
}

View File

@@ -94,7 +94,6 @@ export interface Config {
'use-as-title-group-field': UseAsTitleGroupField;
'disable-bulk-edit': DisableBulkEdit;
'custom-list-drawer': CustomListDrawer;
virtuals: Virtual;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -128,7 +127,6 @@ export interface Config {
'use-as-title-group-field': UseAsTitleGroupFieldSelect<false> | UseAsTitleGroupFieldSelect<true>;
'disable-bulk-edit': DisableBulkEditSelect<false> | DisableBulkEditSelect<true>;
'custom-list-drawer': CustomListDrawerSelect<false> | CustomListDrawerSelect<true>;
virtuals: VirtualsSelect<false> | VirtualsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -584,18 +582,6 @@ export interface CustomListDrawer {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "virtuals".
*/
export interface Virtual {
id: string;
virtualTitleFromPost?: string | null;
virtualText?: string | null;
post?: (string | null) | Post;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -710,10 +696,6 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'custom-list-drawer';
value: string | CustomListDrawer;
} | null)
| ({
relationTo: 'virtuals';
value: string | Virtual;
} | null);
globalSlug?: string | null;
user: {
@@ -1129,17 +1111,6 @@ export interface CustomListDrawerSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "virtuals_select".
*/
export interface VirtualsSelect<T extends boolean = true> {
virtualTitleFromPost?: T;
virtualText?: T;
post?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -23,7 +23,6 @@ export const uploadTwoCollectionSlug = 'uploads-two'
export const customFieldsSlug = 'custom-fields'
export const listDrawerSlug = 'with-list-drawer'
export const virtualsSlug = 'virtuals'
export const collectionSlugs = [
usersCollectionSlug,
customViews1CollectionSlug,
@@ -40,7 +39,6 @@ export const collectionSlugs = [
customFieldsSlug,
disableDuplicateSlug,
listDrawerSlug,
virtualsSlug,
]
export const customGlobalViews1GlobalSlug = 'custom-global-views-one'

View File

@@ -75,10 +75,6 @@ export default buildConfigWithDefaults({
label: 'Named Save To JWT',
saveToJWT: saveToJWTKey,
},
{
name: 'richText',
type: 'richText',
},
{
name: 'group',
type: 'group',

View File

@@ -124,33 +124,6 @@ describe('Auth', () => {
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.not.toContain('create-first-user')
})
test('richText field should should not be readOnly in create first user view', async () => {
const {
admin: {
routes: { createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getRoutes({})
// wait for create first user route
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
await expect(page.locator('.create-first-user')).toBeVisible()
await waitForVisibleAuthFields()
const richTextRoot = page
.locator('.rich-text-lexical .ContentEditable__root[data-lexical-editor="true"]')
.first()
// ensure editor is present
await expect(richTextRoot).toBeVisible()
// core read-only checks
await expect(richTextRoot).toHaveAttribute('contenteditable', 'true')
await expect(richTextRoot).not.toHaveAttribute('aria-readonly', 'true')
})
})
describe('non create first user', () => {

View File

@@ -243,21 +243,6 @@ export interface User {
adminOnlyField?: string | null;
roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[];
namedSaveToJWT?: string | null;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
group?: {
liftedSaveToJWT?: string | null;
};
@@ -518,7 +503,6 @@ export interface UsersSelect<T extends boolean = true> {
adminOnlyField?: T;
roles?: T;
namedSaveToJWT?: T;
richText?: T;
group?:
| T
| {

View File

@@ -2,14 +2,8 @@ import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { copyPasteField } from 'helpers/e2e/copyPasteField.js'
import { duplicateArrayRow } from 'helpers/e2e/fields/array/index.js'
import {
addBlock,
addBlockBelow,
duplicateBlock,
openBlocksDrawer,
reorderBlocks,
} from 'helpers/e2e/fields/blocks/index.js'
import { addArrayRowBelow, duplicateArrayRow } from 'helpers/e2e/fields/array/index.js'
import { addBlock, openBlocksDrawer, reorderBlocks } from 'helpers/e2e/fields/blocks/index.js'
import { scrollEntirePage } from 'helpers/e2e/scrollEntirePage.js'
import { toggleBlockOrArrayRow } from 'helpers/e2e/toggleCollapsible.js'
import path from 'path'
@@ -133,13 +127,22 @@ describe('Block fields', () => {
test('should open blocks drawer from block row and add below', async () => {
await page.goto(url.create)
await addBlockBelow(page, { fieldName: 'blocks', blockToSelect: 'Content' })
await addArrayRowBelow(page, { fieldName: 'blocks' })
const blocksDrawer = page.locator('[id^=drawer_1_blocks-drawer-]')
await expect(blocksDrawer).toBeVisible()
// select the first block in the drawer
const firstBlockSelector = blocksDrawer
.locator('.blocks-drawer__blocks .blocks-drawer__block')
.first()
await expect(firstBlockSelector).toContainText('Content')
await firstBlockSelector.click()
// ensure the block was inserted beneath the first in the rows
const addedRow = page.locator('#field-blocks #blocks-row-1')
await expect(addedRow).toBeVisible()
await expect(addedRow.locator('.blocks-field__block-header')).toHaveText(
'Custom Block Label: Content 02',
) // went from `Number` to `Content`
@@ -148,17 +151,19 @@ describe('Block fields', () => {
test('should duplicate block', async () => {
await page.goto(url.create)
const { rowCount } = await duplicateBlock(page, { fieldName: 'blocks' })
await duplicateArrayRow(page, { fieldName: 'blocks' })
expect(rowCount).toEqual(5)
const blocks = page.locator('#field-blocks > .blocks-field__rows > div')
expect(await blocks.count()).toEqual(5)
})
test('should save when duplicating subblocks', async () => {
await page.goto(url.create)
const { rowCount } = await duplicateBlock(page, { fieldName: 'blocks', rowIndex: 2 })
await duplicateArrayRow(page, { fieldName: 'blocks', rowIndex: 2 })
expect(rowCount).toEqual(5)
const blocks = page.locator('#field-blocks > .blocks-field__rows > div')
expect(await blocks.count()).toEqual(5)
await page.click('#action-save')
await expect(page.locator('.payload-toast-container')).toContainText('successfully')

View File

@@ -149,7 +149,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {};
globalsSelect: {};
@@ -215,7 +215,7 @@ export interface LocalizedTextReference2 {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
canViewConditionalField?: boolean | null;
updatedAt: string;
createdAt: string;
@@ -240,7 +240,7 @@ export interface User {
* via the `definition` "select-versions-fields".
*/
export interface SelectVersionsField {
id: string;
id: number;
hasMany?: ('a' | 'b' | 'c' | 'd')[] | null;
array?:
| {
@@ -265,7 +265,7 @@ export interface SelectVersionsField {
* via the `definition` "array-fields".
*/
export interface ArrayField {
id: string;
id: number;
title?: string | null;
items: {
text: string;
@@ -369,7 +369,7 @@ export interface ArrayField {
* via the `definition` "block-fields".
*/
export interface BlockField {
id: string;
id: number;
blocks: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[];
duplicate: (ContentBlock | NoBlockname | NumberBlock | SubBlocksBlock | TabsBlock)[];
collapsedByDefaultBlocks: (
@@ -500,7 +500,7 @@ export interface BlockField {
| null;
relationshipBlocks?:
| {
relationship?: (string | null) | TextField;
relationship?: (number | null) | TextField;
id?: string | null;
blockName?: string | null;
blockType: 'relationships';
@@ -697,7 +697,7 @@ export interface LocalizedTabsBlock {
* via the `definition` "text-fields".
*/
export interface TextField {
id: string;
id: number;
text: string;
hiddenTextField?: string | null;
/**
@@ -749,7 +749,7 @@ export interface TextField {
* via the `definition` "checkbox-fields".
*/
export interface CheckboxField {
id: string;
id: number;
checkbox: boolean;
checkboxNotRequired?: boolean | null;
updatedAt: string;
@@ -760,7 +760,7 @@ export interface CheckboxField {
* via the `definition` "code-fields".
*/
export interface CodeField {
id: string;
id: number;
javascript?: string | null;
typescript?: string | null;
json?: string | null;
@@ -775,7 +775,7 @@ export interface CodeField {
* via the `definition` "collapsible-fields".
*/
export interface CollapsibleField {
id: string;
id: number;
text: string;
group: {
textWithinGroup?: string | null;
@@ -808,7 +808,7 @@ export interface CollapsibleField {
* via the `definition` "conditional-logic".
*/
export interface ConditionalLogic {
id: string;
id: number;
text: string;
toggleField?: boolean | null;
fieldWithDocIDCondition?: string | null;
@@ -922,7 +922,7 @@ export interface CustomRowId {
* via the `definition` "date-fields".
*/
export interface DateField {
id: string;
id: number;
default: string;
timeOnly?: string | null;
timeOnlyWithMiliseconds?: string | null;
@@ -967,7 +967,7 @@ export interface DateField {
* via the `definition` "email-fields".
*/
export interface EmailField {
id: string;
id: number;
email: string;
localizedEmail?: string | null;
emailWithAutocomplete?: string | null;
@@ -992,7 +992,7 @@ export interface EmailField {
* via the `definition` "radio-fields".
*/
export interface RadioField {
id: string;
id: number;
radio?: ('one' | 'two' | 'three') | null;
radioWithJsxLabelOption?: ('one' | 'two' | 'three') | null;
updatedAt: string;
@@ -1003,7 +1003,7 @@ export interface RadioField {
* via the `definition` "group-fields".
*/
export interface GroupField {
id: string;
id: number;
/**
* This is a group.
*/
@@ -1085,22 +1085,22 @@ export interface GroupField {
select?: ('one' | 'two')[] | null;
};
localizedGroupRel?: {
email?: (string | null) | EmailField;
email?: (number | null) | EmailField;
};
localizedGroupManyRel?: {
email?: (string | EmailField)[] | null;
email?: (number | EmailField)[] | null;
};
localizedGroupPolyRel?: {
email?: {
relationTo: 'email-fields';
value: string | EmailField;
value: number | EmailField;
} | null;
};
localizedGroupPolyHasManyRel?: {
email?:
| {
relationTo: 'email-fields';
value: string | EmailField;
value: number | EmailField;
}[]
| null;
};
@@ -1154,30 +1154,30 @@ export interface RowField {
* via the `definition` "indexed-fields".
*/
export interface IndexedField {
id: string;
id: number;
text: string;
uniqueText?: string | null;
uniqueRelationship?: (string | null) | TextField;
uniqueHasManyRelationship?: (string | TextField)[] | null;
uniqueHasManyRelationship_2?: (string | TextField)[] | null;
uniqueRelationship?: (number | null) | TextField;
uniqueHasManyRelationship?: (number | TextField)[] | null;
uniqueHasManyRelationship_2?: (number | TextField)[] | null;
uniquePolymorphicRelationship?: {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
} | null;
uniquePolymorphicRelationship_2?: {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
} | null;
uniqueHasManyPolymorphicRelationship?:
| {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
}[]
| null;
uniqueHasManyPolymorphicRelationship_2?:
| {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
}[]
| null;
uniqueRequiredText: string;
@@ -1213,7 +1213,7 @@ export interface IndexedField {
* via the `definition` "json-fields".
*/
export interface JsonField {
id: string;
id: number;
json?: {
array?: {
object?: {
@@ -1254,7 +1254,7 @@ export interface JsonField {
* via the `definition` "number-fields".
*/
export interface NumberField {
id: string;
id: number;
number?: number | null;
min?: number | null;
max?: number | null;
@@ -1289,7 +1289,7 @@ export interface NumberField {
* via the `definition` "point-fields".
*/
export interface PointField {
id: string;
id: number;
/**
* @minItems 2
* @maxItems 2
@@ -1320,83 +1320,83 @@ export interface PointField {
* via the `definition` "relationship-fields".
*/
export interface RelationshipField {
id: string;
id: number;
text?: string | null;
relationship:
| {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
}
| {
relationTo: 'array-fields';
value: string | ArrayField;
value: number | ArrayField;
};
relationHasManyPolymorphic?:
| (
| {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
}
| {
relationTo: 'array-fields';
value: string | ArrayField;
value: number | ArrayField;
}
)[]
| null;
relationToSelf?: (string | null) | RelationshipField;
relationToSelfSelectOnly?: (string | null) | RelationshipField;
relationWithAllowCreateToFalse?: (string | null) | User;
relationWithAllowEditToFalse?: (string | null) | User;
relationWithDynamicDefault?: (string | null) | User;
relationToSelf?: (number | null) | RelationshipField;
relationToSelfSelectOnly?: (number | null) | RelationshipField;
relationWithAllowCreateToFalse?: (number | null) | User;
relationWithAllowEditToFalse?: (number | null) | User;
relationWithDynamicDefault?: (number | null) | User;
relationHasManyWithDynamicDefault?: {
relationTo: 'users';
value: string | User;
value: number | User;
} | null;
relationshipWithMin?: (string | TextField)[] | null;
relationshipWithMax?: (string | TextField)[] | null;
relationshipHasMany?: (string | TextField)[] | null;
relationshipWithMin?: (number | TextField)[] | null;
relationshipWithMax?: (number | TextField)[] | null;
relationshipHasMany?: (number | TextField)[] | null;
array?:
| {
relationship?: (string | null) | TextField;
relationship?: (number | null) | TextField;
id?: string | null;
}[]
| null;
relationshipWithMinRows?:
| {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
}[]
| null;
relationToRow?: (string | null) | RowField;
relationToRowMany?: (string | RowField)[] | null;
relationshipDrawer?: (string | null) | TextField;
relationshipDrawerReadOnly?: (string | null) | TextField;
relationshipDrawer?: (number | null) | TextField;
relationshipDrawerReadOnly?: (number | null) | TextField;
polymorphicRelationshipDrawer?:
| ({
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
} | null)
| ({
relationTo: 'array-fields';
value: string | ArrayField;
value: number | ArrayField;
} | null);
relationshipDrawerHasMany?: (string | TextField)[] | null;
relationshipDrawerHasMany?: (number | TextField)[] | null;
relationshipDrawerHasManyPolymorphic?:
| (
| {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
}
| {
relationTo: 'array-fields';
value: string | ArrayField;
value: number | ArrayField;
}
)[]
| null;
relationshipDrawerWithAllowCreateFalse?: (string | null) | TextField;
relationshipDrawerWithAllowCreateFalse?: (number | null) | TextField;
relationshipDrawerWithFilterOptions?: {
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
} | null;
updatedAt: string;
createdAt: string;
@@ -1406,7 +1406,7 @@ export interface RelationshipField {
* via the `definition` "select-fields".
*/
export interface SelectField {
id: string;
id: number;
select?: ('one' | 'two' | 'three') | null;
selectReadOnly?: ('one' | 'two' | 'three') | null;
selectHasMany?: ('one' | 'two' | 'three' | 'four' | 'five' | 'six')[] | null;
@@ -1436,7 +1436,7 @@ export interface SelectField {
* via the `definition` "tabs-fields-2".
*/
export interface TabsFields2 {
id: string;
id: number;
tabsInArray?:
| {
text?: string | null;
@@ -1454,7 +1454,7 @@ export interface TabsFields2 {
* via the `definition` "tabs-fields".
*/
export interface TabsField {
id: string;
id: number;
/**
* This should not collapse despite there being many tabs pushing the main fields open.
*/
@@ -1556,9 +1556,9 @@ export interface TabWithName {
* via the `definition` "uploads".
*/
export interface Upload {
id: string;
id: number;
text?: string | null;
media?: (string | null) | Upload;
media?: (number | null) | Upload;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -1576,9 +1576,9 @@ export interface Upload {
* via the `definition` "uploads2".
*/
export interface Uploads2 {
id: string;
id: number;
text?: string | null;
media?: (string | null) | Uploads2;
media?: (number | null) | Uploads2;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -1596,8 +1596,8 @@ export interface Uploads2 {
* via the `definition` "uploads3".
*/
export interface Uploads3 {
id: string;
media?: (string | null) | Uploads3;
id: number;
media?: (number | null) | Uploads3;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -1615,9 +1615,9 @@ export interface Uploads3 {
* via the `definition` "uploads-multi".
*/
export interface UploadsMulti {
id: string;
id: number;
text?: string | null;
media?: (string | Upload)[] | null;
media?: (number | Upload)[] | null;
updatedAt: string;
createdAt: string;
}
@@ -1626,16 +1626,16 @@ export interface UploadsMulti {
* via the `definition` "uploads-poly".
*/
export interface UploadsPoly {
id: string;
id: number;
text?: string | null;
media?:
| ({
relationTo: 'uploads';
value: string | Upload;
value: number | Upload;
} | null)
| ({
relationTo: 'uploads2';
value: string | Uploads2;
value: number | Uploads2;
} | null);
updatedAt: string;
createdAt: string;
@@ -1645,17 +1645,17 @@ export interface UploadsPoly {
* via the `definition` "uploads-multi-poly".
*/
export interface UploadsMultiPoly {
id: string;
id: number;
text?: string | null;
media?:
| (
| {
relationTo: 'uploads';
value: string | Upload;
value: number | Upload;
}
| {
relationTo: 'uploads2';
value: string | Uploads2;
value: number | Uploads2;
}
)[]
| null;
@@ -1667,11 +1667,11 @@ export interface UploadsMultiPoly {
* via the `definition` "uploads-restricted".
*/
export interface UploadsRestricted {
id: string;
id: number;
text?: string | null;
uploadWithoutRestriction?: (string | null) | Upload;
uploadWithAllowCreateFalse?: (string | null) | Upload;
uploadMultipleWithAllowCreateFalse?: (string | Upload)[] | null;
uploadWithoutRestriction?: (number | null) | Upload;
uploadWithAllowCreateFalse?: (number | null) | Upload;
uploadMultipleWithAllowCreateFalse?: (number | Upload)[] | null;
updatedAt: string;
createdAt: string;
}
@@ -1680,7 +1680,7 @@ export interface UploadsRestricted {
* via the `definition` "ui-fields".
*/
export interface UiField {
id: string;
id: number;
text: string;
updatedAt: string;
createdAt: string;
@@ -1690,39 +1690,39 @@ export interface UiField {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null)
| ({
relationTo: 'select-versions-fields';
value: string | SelectVersionsField;
value: number | SelectVersionsField;
} | null)
| ({
relationTo: 'array-fields';
value: string | ArrayField;
value: number | ArrayField;
} | null)
| ({
relationTo: 'block-fields';
value: string | BlockField;
value: number | BlockField;
} | null)
| ({
relationTo: 'checkbox-fields';
value: string | CheckboxField;
value: number | CheckboxField;
} | null)
| ({
relationTo: 'code-fields';
value: string | CodeField;
value: number | CodeField;
} | null)
| ({
relationTo: 'collapsible-fields';
value: string | CollapsibleField;
value: number | CollapsibleField;
} | null)
| ({
relationTo: 'conditional-logic';
value: string | ConditionalLogic;
value: number | ConditionalLogic;
} | null)
| ({
relationTo: 'custom-id';
@@ -1730,27 +1730,27 @@ export interface PayloadLockedDocument {
} | null)
| ({
relationTo: 'custom-tab-id';
value: string | CustomTabId;
value: number | CustomTabId;
} | null)
| ({
relationTo: 'custom-row-id';
value: string | CustomRowId;
value: number | CustomRowId;
} | null)
| ({
relationTo: 'date-fields';
value: string | DateField;
value: number | DateField;
} | null)
| ({
relationTo: 'email-fields';
value: string | EmailField;
value: number | EmailField;
} | null)
| ({
relationTo: 'radio-fields';
value: string | RadioField;
value: number | RadioField;
} | null)
| ({
relationTo: 'group-fields';
value: string | GroupField;
value: number | GroupField;
} | null)
| ({
relationTo: 'row-fields';
@@ -1758,76 +1758,76 @@ export interface PayloadLockedDocument {
} | null)
| ({
relationTo: 'indexed-fields';
value: string | IndexedField;
value: number | IndexedField;
} | null)
| ({
relationTo: 'json-fields';
value: string | JsonField;
value: number | JsonField;
} | null)
| ({
relationTo: 'number-fields';
value: string | NumberField;
value: number | NumberField;
} | null)
| ({
relationTo: 'point-fields';
value: string | PointField;
value: number | PointField;
} | null)
| ({
relationTo: 'relationship-fields';
value: string | RelationshipField;
value: number | RelationshipField;
} | null)
| ({
relationTo: 'select-fields';
value: string | SelectField;
value: number | SelectField;
} | null)
| ({
relationTo: 'tabs-fields-2';
value: string | TabsFields2;
value: number | TabsFields2;
} | null)
| ({
relationTo: 'tabs-fields';
value: string | TabsField;
value: number | TabsField;
} | null)
| ({
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
} | null)
| ({
relationTo: 'uploads';
value: string | Upload;
value: number | Upload;
} | null)
| ({
relationTo: 'uploads2';
value: string | Uploads2;
value: number | Uploads2;
} | null)
| ({
relationTo: 'uploads3';
value: string | Uploads3;
value: number | Uploads3;
} | null)
| ({
relationTo: 'uploads-multi';
value: string | UploadsMulti;
value: number | UploadsMulti;
} | null)
| ({
relationTo: 'uploads-poly';
value: string | UploadsPoly;
value: number | UploadsPoly;
} | null)
| ({
relationTo: 'uploads-multi-poly';
value: string | UploadsMultiPoly;
value: number | UploadsMultiPoly;
} | null)
| ({
relationTo: 'uploads-restricted';
value: string | UploadsRestricted;
value: number | UploadsRestricted;
} | null)
| ({
relationTo: 'ui-fields';
value: string | UiField;
value: number | UiField;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -1837,10 +1837,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -1860,7 +1860,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;

View File

@@ -1,9 +1,5 @@
import React from 'react'
export const ArrayRowLabel = (props) => {
return (
<p data-id={props.value[props?.rowNumber - 1]?.id} id="custom-array-row-label">
This is a custom component
</p>
)
export const ArrayRowLabel = () => {
return <p id="custom-array-row-label">This is a custom component</p>
}

View File

@@ -6,12 +6,7 @@ import { expect, test } from '@playwright/test'
import { assertElementStaysVisible } from 'helpers/e2e/assertElementStaysVisible.js'
import { assertNetworkRequests } from 'helpers/e2e/assertNetworkRequests.js'
import { assertRequestBody } from 'helpers/e2e/assertRequestBody.js'
import {
addArrayRow,
addArrayRowAsync,
duplicateArrayRow,
removeArrayRow,
} from 'helpers/e2e/fields/array/index.js'
import { addArrayRowAsync, removeArrayRow } from 'helpers/e2e/fields/array/index.js'
import { addBlock } from 'helpers/e2e/fields/blocks/index.js'
import { waitForAutoSaveToRunAndComplete } from 'helpers/e2e/waitForAutoSaveToRunAndComplete.js'
import * as path from 'path'
@@ -459,34 +454,6 @@ test.describe('Form State', () => {
await expect(computedTitleField).toHaveValue('Test Title 2')
})
test('array and block rows and maintain consistent row IDs across duplication', async () => {
await page.goto(postsUrl.create)
await addArrayRow(page, { fieldName: 'array' })
const row0 = page.locator('#field-array #array-row-0')
await expect(row0.locator('#custom-array-row-label')).toHaveAttribute('data-id')
await expect(row0.locator('#field-array__0__id')).toHaveValue(
(await row0.locator('#custom-array-row-label').getAttribute('data-id'))!,
)
await duplicateArrayRow(page, { fieldName: 'array' })
const row1 = page.locator('#field-array #array-row-1')
await expect(row1.locator('#custom-array-row-label')).toHaveAttribute('data-id')
await expect(row1.locator('#custom-array-row-label')).not.toHaveAttribute(
'data-id',
(await row0.locator('#custom-array-row-label').getAttribute('data-id'))!,
)
await expect(row1.locator('#field-array__1__id')).toHaveValue(
(await row1.locator('#custom-array-row-label').getAttribute('data-id'))!,
)
})
describe('Throttled tests', () => {
let cdpSession: CDPSession

View File

@@ -1,7 +1,6 @@
import type { Locator, Page } from 'playwright'
import { wait } from 'payload/shared'
import { expect } from 'playwright/test'
import { openArrayRowActions } from './openArrayRowActions.js'
@@ -19,12 +18,10 @@ export const addArrayRow = async (
page: Page,
{ fieldName }: Omit<Parameters<typeof openArrayRowActions>[1], 'rowIndex'>,
) => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
await addArrayRowAsync(page, fieldName)
expect(await rowLocator.count()).toBe(numberOfPrevRows + 1)
// TODO: test the array row has appeared
await wait(300)
}
/**
@@ -34,19 +31,16 @@ export const addArrayRowBelow = async (
page: Page,
{ fieldName, rowIndex = 0 }: Parameters<typeof openArrayRowActions>[1],
): Promise<{ popupContentLocator: Locator; rowActionsButtonLocator: Locator }> => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName,
rowIndex,
})
await popupContentLocator.locator('.array-actions__action.array-actions__add').click()
const addBelowButton = popupContentLocator.locator('.array-actions__action.array-actions__add')
await expect(rowLocator).toHaveCount(numberOfPrevRows + 1)
await addBelowButton.click()
// TODO: test the array row has appeared in the _correct position_ (immediately below the original row)
// TODO: test the array row has appeared
await wait(300)
return { popupContentLocator, rowActionsButtonLocator }

View File

@@ -1,7 +1,5 @@
import type { Locator, Page } from 'playwright'
import { expect } from 'playwright/test'
import { openArrayRowActions } from './openArrayRowActions.js'
/**
@@ -14,9 +12,6 @@ export const duplicateArrayRow = async (
popupContentLocator: Locator
rowActionsButtonLocator: Locator
}> => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName,
rowIndex,
@@ -24,9 +19,7 @@ export const duplicateArrayRow = async (
await popupContentLocator.locator('.array-actions__action.array-actions__duplicate').click()
expect(await rowLocator.count()).toBe(numberOfPrevRows + 1)
// TODO: test the array row's field input values have been duplicated as well
// TODO: test the array row has been duplicated
return { popupContentLocator, rowActionsButtonLocator }
}

View File

@@ -1,7 +1,5 @@
import type { Locator, Page } from 'playwright'
import { expect } from 'playwright/test'
import { openArrayRowActions } from './openArrayRowActions.js'
/**
@@ -14,9 +12,6 @@ export const removeArrayRow = async (
popupContentLocator: Locator
rowActionsButtonLocator: Locator
}> => {
const rowLocator = page.locator(`#field-${fieldName} .array-field__row`)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName,
rowIndex,
@@ -24,10 +19,8 @@ export const removeArrayRow = async (
await popupContentLocator.locator('.array-actions__action.array-actions__remove').click()
expect(await rowLocator.count()).toBe(numberOfPrevRows - 1)
// TODO: test the array row has been removed in the _correct position_ (original row index)
// another row may have been moved into its place, need to ensure the test accounts for this fact
// TODO: test the array row has been removed
// another row may have been moved into its place, though
return { popupContentLocator, rowActionsButtonLocator }
}

View File

@@ -1,30 +1,10 @@
import type { Locator, Page } from '@playwright/test'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText } from 'helpers.js'
import { openArrayRowActions } from '../array/openArrayRowActions.js'
import { openBlocksDrawer } from './openBlocksDrawer.js'
const selectBlockFromDrawer = async ({
blocksDrawer,
blockToSelect,
}: {
blocksDrawer: Locator
blockToSelect: string
}) => {
const blockCard = blocksDrawer.locator('.blocks-drawer__block .thumbnail-card__label', {
hasText: blockToSelect,
})
await expect(blockCard).toBeVisible()
await blocksDrawer.getByRole('button', { name: exactText(blockToSelect) }).click()
}
/**
* Adds a block to the end of the blocks array using the primary "Add Block" button.
*/
export const addBlock = async ({
page,
fieldName = 'blocks',
@@ -37,66 +17,15 @@ export const addBlock = async ({
fieldName: string
page: Page
}) => {
const rowLocator = page.locator(
`#field-${fieldName} > .blocks-field__rows > div > .blocks-field__row`,
)
const numberOfPrevRows = await rowLocator.count()
const blocksDrawer = await openBlocksDrawer({ page, fieldName })
await selectBlockFromDrawer({
blocksDrawer,
blockToSelect,
const blockCard = blocksDrawer.locator('.blocks-drawer__block .thumbnail-card__label', {
hasText: blockToSelect,
})
await expect(rowLocator).toHaveCount(numberOfPrevRows + 1)
await expect(blockCard).toBeVisible()
await blocksDrawer.getByRole('button', { name: exactText(blockToSelect) }).click()
// expect to see the block on the page
}
/**
* Like `addBlock`, but inserts the block at the specified index using the row actions menu.
*/
export const addBlockBelow = async (
page: Page,
{
fieldName = 'blocks',
blockToSelect = 'Block',
rowIndex = 0,
}: {
/**
* The name of the block to select from the blocks drawer.
*/
blockToSelect: string
fieldName: string
/**
* The index at which to insert the block.
*/
rowIndex?: number
},
) => {
const rowLocator = page.locator(
`#field-${fieldName} > .blocks-field__rows > div > .blocks-field__row`,
)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName,
rowIndex,
})
await popupContentLocator.locator('.array-actions__action.array-actions__add').click()
const blocksDrawer = page.locator('[id^=drawer_1_blocks-drawer-]')
await selectBlockFromDrawer({
blocksDrawer,
blockToSelect,
})
await expect(rowLocator).toHaveCount(numberOfPrevRows + 1)
return { popupContentLocator, rowActionsButtonLocator }
}

View File

@@ -1,37 +0,0 @@
import type { Locator, Page } from 'playwright'
import { expect } from 'playwright/test'
import { openArrayRowActions } from '../array/openArrayRowActions.js'
/**
* Duplicates the block row at the specified index.
*/
export const duplicateBlock = async (
page: Page,
{ fieldName, rowIndex = 0 }: Parameters<typeof openArrayRowActions>[1],
): Promise<{
popupContentLocator: Locator
rowActionsButtonLocator: Locator
rowCount: number
}> => {
const rowLocator = page.locator(
`#field-${fieldName} > .blocks-field__rows > div > .blocks-field__row`,
)
const numberOfPrevRows = await rowLocator.count()
const { popupContentLocator, rowActionsButtonLocator } = await openArrayRowActions(page, {
fieldName,
rowIndex,
})
await popupContentLocator.locator('.array-actions__action.array-actions__duplicate').click()
const numberOfCurrentRows = await rowLocator.count()
expect(numberOfCurrentRows).toBe(numberOfPrevRows + 1)
// TODO: test the array row's field input values have been duplicated as well
return { popupContentLocator, rowActionsButtonLocator, rowCount: numberOfCurrentRows }
}

View File

@@ -1,5 +1,4 @@
export { addBlock, addBlockBelow } from './addBlock.js'
export { duplicateBlock } from './duplicateBlock.js'
export { addBlock } from './addBlock.js'
export { openBlocksDrawer } from './openBlocksDrawer.js'
export { removeAllBlocks } from './removeAllBlocks.js'
export { reorderBlocks } from './reorderBlocks.js'

View File

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

Some files were not shown because too many files have changed in this diff Show More