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 CodeEditor: React.FC<Props> = (props) => {
|
||||||
const { className, maxHeight, minHeight, options, readOnly, ...rest } = props
|
const { className, maxHeight, minHeight, options, readOnly, ...rest } = props
|
||||||
const MIN_HEIGHT = minHeight ?? 56 // equivalent to 3 lines
|
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
|
const paddingFromProps = options?.padding
|
||||||
? (options.padding.top || 0) + (options.padding?.bottom || 0)
|
? (options.padding.top || 0) + (options.padding?.bottom || 0)
|
||||||
: 0
|
: 0
|
||||||
@@ -36,8 +39,9 @@ const CodeEditor: React.FC<Props> = (props) => {
|
|||||||
className={classes}
|
className={classes}
|
||||||
loading={<ShimmerEffect height={dynamicHeight} />}
|
loading={<ShimmerEffect height={dynamicHeight} />}
|
||||||
options={{
|
options={{
|
||||||
detectIndentation: true,
|
detectIndentation: false, // use the tabSize on the model, set onMount
|
||||||
hideCursorInOverviewRuler: true,
|
hideCursorInOverviewRuler: true,
|
||||||
|
insertSpaces: false,
|
||||||
minimap: {
|
minimap: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
@@ -47,9 +51,9 @@ const CodeEditor: React.FC<Props> = (props) => {
|
|||||||
alwaysConsumeMouseWheel: false,
|
alwaysConsumeMouseWheel: false,
|
||||||
},
|
},
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
tabSize: 2,
|
trimAutoWhitespace: false,
|
||||||
wordWrap: 'on',
|
wordWrap: 'on',
|
||||||
...options,
|
...editorOptions,
|
||||||
}}
|
}}
|
||||||
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
|
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -64,6 +68,17 @@ const CodeEditor: React.FC<Props> = (props) => {
|
|||||||
}}
|
}}
|
||||||
onMount={(editor, monaco) => {
|
onMount={(editor, monaco) => {
|
||||||
rest.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(
|
setDynamicHeight(
|
||||||
Math.max(MIN_HEIGHT, editor.getValue().split('\n').length * 18 + 2 + paddingFromProps),
|
Math.max(MIN_HEIGHT, editor.getValue().split('\n').length * 18 + 2 + paddingFromProps),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import type { CodeFieldClientComponent } from 'payload'
|
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 { CodeEditor } from '../../elements/CodeEditor/index.js'
|
||||||
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
|
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
|
||||||
@@ -36,6 +36,9 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
|
|||||||
validate,
|
validate,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
|
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
|
||||||
|
const [editorKey, setEditorKey] = useState<string>('')
|
||||||
|
|
||||||
const memoizedValidate = useCallback(
|
const memoizedValidate = useCallback(
|
||||||
(value, options) => {
|
(value, options) => {
|
||||||
if (typeof validate === 'function') {
|
if (typeof validate === 'function') {
|
||||||
@@ -48,15 +51,47 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
|
|||||||
const {
|
const {
|
||||||
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
|
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
|
||||||
disabled,
|
disabled,
|
||||||
|
initialValue,
|
||||||
path,
|
path,
|
||||||
setValue,
|
setValue,
|
||||||
showError,
|
showError,
|
||||||
value,
|
value,
|
||||||
} = useField({
|
} = useField<string>({
|
||||||
potentiallyStalePath: pathFromProps,
|
potentiallyStalePath: pathFromProps,
|
||||||
validate: memoizedValidate,
|
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])
|
const styles = useMemo(() => mergeFieldStyles(field), [field])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,11 +121,15 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => {
|
|||||||
{BeforeInput}
|
{BeforeInput}
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
defaultLanguage={prismToMonacoLanguageMap[language] || language}
|
defaultLanguage={prismToMonacoLanguageMap[language] || language}
|
||||||
onChange={readOnly || disabled ? () => null : (val) => setValue(val)}
|
key={editorKey}
|
||||||
|
onChange={handleChange}
|
||||||
onMount={onMount}
|
onMount={onMount}
|
||||||
options={editorOptions}
|
options={editorOptions}
|
||||||
readOnly={readOnly || disabled}
|
readOnly={readOnly || disabled}
|
||||||
value={(value as string) || ''}
|
value={initialStringValue}
|
||||||
|
wrapperProps={{
|
||||||
|
id: `field-${path?.replace(/\./g, '__')}`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{AfterInput}
|
{AfterInput}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
|||||||
validate,
|
validate,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
|
const { tabSize = 2 } = editorOptions || {}
|
||||||
|
|
||||||
const [jsonError, setJsonError] = useState<string>()
|
const [jsonError, setJsonError] = useState<string>()
|
||||||
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
|
const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
|
||||||
const [editorKey, setEditorKey] = useState<string>('')
|
const [editorKey, setEditorKey] = useState<string>('')
|
||||||
@@ -61,7 +63,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
|||||||
|
|
||||||
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
|
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
|
||||||
(value || initialValue) !== undefined
|
(value || initialValue) !== undefined
|
||||||
? JSON.stringify(value ?? initialValue, null, 2)
|
? JSON.stringify(value ?? initialValue, null, tabSize)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,10 +88,14 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
|||||||
: `${uri}?${crypto.randomUUID ? crypto.randomUUID() : uuidv4()}`
|
: `${uri}?${crypto.randomUUID ? crypto.randomUUID() : uuidv4()}`
|
||||||
|
|
||||||
editor.setModel(
|
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(
|
const handleChange = useCallback(
|
||||||
@@ -114,14 +120,14 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
|
|||||||
if (inputChangeFromRef.current === 'system') {
|
if (inputChangeFromRef.current === 'system') {
|
||||||
setInitialStringValue(
|
setInitialStringValue(
|
||||||
(value || initialValue) !== undefined
|
(value || initialValue) !== undefined
|
||||||
? JSON.stringify(value ?? initialValue, null, 2)
|
? JSON.stringify(value ?? initialValue, null, tabSize)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
setEditorKey(new Date().toString())
|
setEditorKey(`${path}-${new Date().toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
inputChangeFromRef.current = 'system'
|
inputChangeFromRef.current = 'system'
|
||||||
}, [initialValue, value])
|
}, [initialValue, path, tabSize, value])
|
||||||
|
|
||||||
const styles = useMemo(() => mergeFieldStyles(field), [field])
|
const styles = useMemo(() => mergeFieldStyles(field), [field])
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export interface Config {
|
|||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
};
|
};
|
||||||
db: {
|
db: {
|
||||||
defaultIDType: number;
|
defaultIDType: string;
|
||||||
};
|
};
|
||||||
globals: {
|
globals: {
|
||||||
menu: Menu;
|
menu: Menu;
|
||||||
@@ -124,7 +124,7 @@ export interface UserAuthOperations {
|
|||||||
* via the `definition` "posts".
|
* via the `definition` "posts".
|
||||||
*/
|
*/
|
||||||
export interface Post {
|
export interface Post {
|
||||||
id: number;
|
id: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
content?: {
|
content?: {
|
||||||
root: {
|
root: {
|
||||||
@@ -149,7 +149,7 @@ export interface Post {
|
|||||||
* via the `definition` "media".
|
* via the `definition` "media".
|
||||||
*/
|
*/
|
||||||
export interface Media {
|
export interface Media {
|
||||||
id: number;
|
id: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
@@ -193,7 +193,7 @@ export interface Media {
|
|||||||
* via the `definition` "users".
|
* via the `definition` "users".
|
||||||
*/
|
*/
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -217,24 +217,24 @@ export interface User {
|
|||||||
* via the `definition` "payload-locked-documents".
|
* via the `definition` "payload-locked-documents".
|
||||||
*/
|
*/
|
||||||
export interface PayloadLockedDocument {
|
export interface PayloadLockedDocument {
|
||||||
id: number;
|
id: string;
|
||||||
document?:
|
document?:
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'posts';
|
relationTo: 'posts';
|
||||||
value: number | Post;
|
value: string | Post;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'media';
|
relationTo: 'media';
|
||||||
value: number | Media;
|
value: string | Media;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: number | User;
|
value: string | User;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user: {
|
user: {
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: number | User;
|
value: string | User;
|
||||||
};
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -244,10 +244,10 @@ export interface PayloadLockedDocument {
|
|||||||
* via the `definition` "payload-preferences".
|
* via the `definition` "payload-preferences".
|
||||||
*/
|
*/
|
||||||
export interface PayloadPreference {
|
export interface PayloadPreference {
|
||||||
id: number;
|
id: string;
|
||||||
user: {
|
user: {
|
||||||
relationTo: 'users';
|
relationTo: 'users';
|
||||||
value: number | User;
|
value: string | User;
|
||||||
};
|
};
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
value?:
|
value?:
|
||||||
@@ -267,7 +267,7 @@ export interface PayloadPreference {
|
|||||||
* via the `definition` "payload-migrations".
|
* via the `definition` "payload-migrations".
|
||||||
*/
|
*/
|
||||||
export interface PayloadMigration {
|
export interface PayloadMigration {
|
||||||
id: number;
|
id: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
batch?: number | null;
|
batch?: number | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -393,7 +393,7 @@ export interface PayloadMigrationsSelect<T extends boolean = true> {
|
|||||||
* via the `definition` "menu".
|
* via the `definition` "menu".
|
||||||
*/
|
*/
|
||||||
export interface Menu {
|
export interface Menu {
|
||||||
id: number;
|
id: string;
|
||||||
globalText?: string | null;
|
globalText?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user