fix: admin.hidden not respected for RSCs, render-field server function not respecting field-overrides client-side (#13869)

This PR fixes 2 issues:

- the `fieldConfig.admin.hidden` property had no effect for react server
components, because the RSC was returned before we're checking for
`admin.hidden` in RenderFields
- the `render-field` server function did not propagate fieldConfig
overrides to the clientProps. This means overriding `admin.Label` had no
effect

Adds e2e tests for both issues
This commit is contained in:
Alessio Gravili
2025-09-22 11:36:41 -07:00
committed by GitHub
parent c33c037e55
commit 228e8f281a
8 changed files with 114 additions and 63 deletions

View File

@@ -53,10 +53,6 @@ export function RenderField({
}: RenderFieldProps) {
const CustomField = useFormFields(([fields]) => fields && fields?.[path]?.customComponents?.Field)
if (CustomField !== undefined) {
return CustomField || null
}
const baseFieldProps: Pick<
ClientComponentProps,
'forceRender' | 'permissions' | 'readOnly' | 'schemaPath'
@@ -67,6 +63,14 @@ export function RenderField({
schemaPath,
}
if (clientFieldConfig.admin?.hidden) {
return <HiddenField {...baseFieldProps} path={path} />
}
if (CustomField !== undefined) {
return CustomField || null
}
const iterableFieldProps = {
...baseFieldProps,
indexPath,
@@ -74,10 +78,6 @@ export function RenderField({
parentSchemaPath,
}
if (clientFieldConfig.admin?.hidden) {
return <HiddenField {...baseFieldProps} path={path} />
}
switch (clientFieldConfig.type) {
case 'array':
return <ArrayField {...iterableFieldProps} field={clientFieldConfig} path={path} />

View File

@@ -32,6 +32,7 @@ export const renderField: RenderFieldMethod = ({
fieldConfig,
fieldSchemaMap,
fieldState,
forceCreateClientField,
formState,
indexPath,
lastRenderedPath,
@@ -54,14 +55,15 @@ export const renderField: RenderFieldMethod = ({
return
}
const clientField = clientFieldSchemaMap
? (clientFieldSchemaMap.get(schemaPath) as ClientField)
: createClientField({
defaultIDType: req.payload.config.db.defaultIDType,
field: fieldConfig,
i18n: req.i18n,
importMap: req.payload.importMap,
})
const clientField =
clientFieldSchemaMap && !forceCreateClientField
? (clientFieldSchemaMap.get(schemaPath) as ClientField)
: createClientField({
defaultIDType: req.payload.config.db.defaultIDType,
field: fieldConfig,
i18n: req.i18n,
importMap: req.payload.importMap,
})
const clientProps: ClientComponentProps & Partial<FieldPaths> = {
field: clientField,

View File

@@ -108,6 +108,8 @@ export const _internal_renderFieldHandler: ServerFunction<
preferences: {
fields: {},
},
// If we are passed a field override, we want to ensure we create a new client field based on that override
forceCreateClientField: fieldArg ? true : false,
previousFieldState: undefined,
renderAllFields: true,
req,

View File

@@ -18,6 +18,12 @@ export type RenderFieldArgs = {
fieldConfig: Field
fieldSchemaMap: FieldSchemaMap
fieldState: FieldState
/**
* If set to true, it will force creating a clientField based on the passed fieldConfig instead of pulling
* the client field from the clientFieldSchemaMap. This is useful if the passed fieldConfig differs from the one in the schema map,
* e.g. when calling the render-field server function and passing a field config override.
*/
forceCreateClientField?: boolean
formState: FormState
id?: number | string
indexPath: string

View File

@@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import { reInitializeDB } from 'helpers/reInitializeDB.js'
import path from 'path'
import { wait } from 'payload/shared'
import { fileURLToPath } from 'url'
import { ensureCompilationIsDone, saveDocAndAssert } from '../../../helpers.js'
@@ -102,5 +103,19 @@ describe('Lexical On Demand', () => {
await expect(lexical.drawer.locator('#field-rows')).toHaveValue('5')
await expect(lexical.drawer.locator('#field-columns')).toHaveValue('5')
})
test('on-demand editor renders label', async ({ page }) => {
await expect(page.locator('.field-label[for="field-myField"]')).toHaveText('My Label')
})
test('ensure anchor richText field is hidden', async ({ page }) => {
// Important: Wait for all fields to render
await wait(1000)
await expect(page.locator('.shimmer')).toHaveCount(0)
await expect(page.locator('.field-label[for="field-hiddenAnchor"]')).toHaveCount(0)
await expect(page.locator('.rich-text-lexical')).toHaveCount(1)
})
})
})

View File

@@ -6,8 +6,6 @@ import type { JSONFieldClientComponent } from 'payload'
import { buildEditorState, RenderLexical } from '@payloadcms/richtext-lexical/client'
import React, { useState } from 'react'
import { lexicalFullyFeaturedSlug } from '../../slugs.js'
export const Component: JSONFieldClientComponent = () => {
const [value, setValue] = useState<DefaultTypedEditorState | undefined>(() =>
buildEditorState({ text: 'state default' }),
@@ -21,9 +19,9 @@ export const Component: JSONFieldClientComponent = () => {
<div>
Default Component:
<RenderLexical
field={{ name: 'myField' }}
field={{ name: 'myField', label: 'My Label' }}
initialValue={buildEditorState({ text: 'defaultValue' })}
schemaPath={`collection.${lexicalFullyFeaturedSlug}.richText`}
schemaPath={`collection.OnDemandOutsideForm.hiddenAnchor`}
setValue={setValue as any}
value={value}
/>

View File

@@ -1,5 +1,7 @@
import type { CollectionConfig } from 'payload'
import { EXPERIMENTAL_TableFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
export const OnDemandOutsideForm: CollectionConfig = {
slug: 'OnDemandOutsideForm',
fields: [
@@ -12,5 +14,15 @@ export const OnDemandOutsideForm: CollectionConfig = {
},
},
},
{
name: 'hiddenAnchor',
type: 'richText',
editor: lexicalEditor({
features: ({ rootFeatures }) => [...rootFeatures, EXPERIMENTAL_TableFeature()],
}),
admin: {
hidden: true,
},
},
],
}

View File

@@ -130,7 +130,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {
tabsWithRichText: TabsWithRichText;
@@ -170,7 +170,7 @@ export interface UserAuthOperations {
* via the `definition` "lexical-fully-featured".
*/
export interface LexicalFullyFeatured {
id: string;
id: number;
richText?: {
root: {
type: string;
@@ -194,7 +194,7 @@ export interface LexicalFullyFeatured {
* via the `definition` "lexical-link-feature".
*/
export interface LexicalLinkFeature {
id: string;
id: number;
richText?: {
root: {
type: string;
@@ -242,7 +242,7 @@ export interface LexicalHeadingFeature {
* via the `definition` "lexical-jsx-converter".
*/
export interface LexicalJsxConverter {
id: string;
id: number;
richText?: {
root: {
type: string;
@@ -266,7 +266,7 @@ export interface LexicalJsxConverter {
* via the `definition` "lexical-fields".
*/
export interface LexicalField {
id: string;
id: number;
title: string;
lexicalRootEditor?: {
root: {
@@ -322,7 +322,7 @@ export interface LexicalField {
* via the `definition` "lexical-migrate-fields".
*/
export interface LexicalMigrateField {
id: string;
id: number;
title: string;
lexicalWithLexicalPluginData?: {
root: {
@@ -417,7 +417,7 @@ export interface LexicalMigrateField {
* via the `definition` "lexical-localized-fields".
*/
export interface LexicalLocalizedField {
id: string;
id: number;
title: string;
/**
* Non-localized field with localized block subfields
@@ -463,7 +463,7 @@ export interface LexicalLocalizedField {
* via the `definition` "lexicalObjectReferenceBug".
*/
export interface LexicalObjectReferenceBug {
id: string;
id: number;
lexicalDefault?: {
root: {
type: string;
@@ -502,7 +502,7 @@ export interface LexicalObjectReferenceBug {
* via the `definition` "LexicalInBlock".
*/
export interface LexicalInBlock {
id: string;
id: number;
content?: {
root: {
type: string;
@@ -548,7 +548,7 @@ export interface LexicalInBlock {
* via the `definition` "lexical-access-control".
*/
export interface LexicalAccessControl {
id: string;
id: number;
title?: string | null;
richText?: {
root: {
@@ -573,7 +573,7 @@ export interface LexicalAccessControl {
* via the `definition` "lexical-relationship-fields".
*/
export interface LexicalRelationshipField {
id: string;
id: number;
richText?: {
root: {
type: string;
@@ -612,7 +612,7 @@ export interface LexicalRelationshipField {
* via the `definition` "rich-text-fields".
*/
export interface RichTextField {
id: string;
id: number;
title: string;
lexicalCustomFields: {
root: {
@@ -693,7 +693,7 @@ export interface RichTextField {
* via the `definition` "text-fields".
*/
export interface TextField {
id: string;
id: number;
text: string;
hiddenTextField?: string | null;
/**
@@ -745,9 +745,9 @@ export interface TextField {
* 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;
@@ -765,7 +765,7 @@ export interface Upload {
* via the `definition` "array-fields".
*/
export interface ArrayField {
id: string;
id: number;
title?: string | null;
items: {
text: string;
@@ -863,7 +863,7 @@ export interface ArrayField {
* via the `definition` "OnDemandForm".
*/
export interface OnDemandForm {
id: string;
id: number;
json?:
| {
[k: string]: unknown;
@@ -881,7 +881,7 @@ export interface OnDemandForm {
* via the `definition` "OnDemandOutsideForm".
*/
export interface OnDemandOutsideForm {
id: string;
id: number;
json?:
| {
[k: string]: unknown;
@@ -891,6 +891,21 @@ export interface OnDemandOutsideForm {
| number
| boolean
| null;
hiddenAnchor?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
@@ -899,7 +914,7 @@ export interface OnDemandOutsideForm {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -923,15 +938,15 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'lexical-fully-featured';
value: string | LexicalFullyFeatured;
value: number | LexicalFullyFeatured;
} | null)
| ({
relationTo: 'lexical-link-feature';
value: string | LexicalLinkFeature;
value: number | LexicalLinkFeature;
} | null)
| ({
relationTo: 'lexical-heading-feature';
@@ -939,68 +954,68 @@ export interface PayloadLockedDocument {
} | null)
| ({
relationTo: 'lexical-jsx-converter';
value: string | LexicalJsxConverter;
value: number | LexicalJsxConverter;
} | null)
| ({
relationTo: 'lexical-fields';
value: string | LexicalField;
value: number | LexicalField;
} | null)
| ({
relationTo: 'lexical-migrate-fields';
value: string | LexicalMigrateField;
value: number | LexicalMigrateField;
} | null)
| ({
relationTo: 'lexical-localized-fields';
value: string | LexicalLocalizedField;
value: number | LexicalLocalizedField;
} | null)
| ({
relationTo: 'lexicalObjectReferenceBug';
value: string | LexicalObjectReferenceBug;
value: number | LexicalObjectReferenceBug;
} | null)
| ({
relationTo: 'LexicalInBlock';
value: string | LexicalInBlock;
value: number | LexicalInBlock;
} | null)
| ({
relationTo: 'lexical-access-control';
value: string | LexicalAccessControl;
value: number | LexicalAccessControl;
} | null)
| ({
relationTo: 'lexical-relationship-fields';
value: string | LexicalRelationshipField;
value: number | LexicalRelationshipField;
} | null)
| ({
relationTo: 'rich-text-fields';
value: string | RichTextField;
value: number | RichTextField;
} | null)
| ({
relationTo: 'text-fields';
value: string | TextField;
value: number | TextField;
} | null)
| ({
relationTo: 'uploads';
value: string | Upload;
value: number | Upload;
} | null)
| ({
relationTo: 'array-fields';
value: string | ArrayField;
value: number | ArrayField;
} | null)
| ({
relationTo: 'OnDemandForm';
value: string | OnDemandForm;
value: number | OnDemandForm;
} | null)
| ({
relationTo: 'OnDemandOutsideForm';
value: string | OnDemandOutsideForm;
value: number | OnDemandOutsideForm;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -1010,10 +1025,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?:
@@ -1033,7 +1048,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -1388,6 +1403,7 @@ export interface OnDemandFormSelect<T extends boolean = true> {
*/
export interface OnDemandOutsideFormSelect<T extends boolean = true> {
json?: T;
hiddenAnchor?: T;
updatedAt?: T;
createdAt?: T;
}
@@ -1450,7 +1466,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
* via the `definition` "tabsWithRichText".
*/
export interface TabsWithRichText {
id: string;
id: number;
tab1?: {
rt1?: {
root: {
@@ -1524,7 +1540,7 @@ export interface LexicalBlocksRadioButtonsBlock {
export interface AvatarGroupBlock {
avatars?:
| {
image?: (string | null) | Upload;
image?: (number | null) | Upload;
id?: string | null;
}[]
| null;