fix(ui): json field type ignoring editorOptions (#13630)

### What?

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

### Why?

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

### How?

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

Fixes #13583 


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

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
This commit is contained in:
Patrik
2025-08-28 15:57:16 -04:00
committed by GitHub
parent 4600c94cac
commit 426f99ca99
4 changed files with 86 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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