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:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user