fix(ui): allow json fields to be updated externally (#11371)

### What?
Unable to update json fields externally. For example, calling `setValue`
on a json field would not be reflected in the admin panel UI.

### Why?
JSON fields use the monaco editor to manage state internally, so
programmatically updating the value in state does not change the
internal value.

### How?
Set a ref when the user updates the value and then unset the ref after
the change is complete.

Inside the hook that watches `value`, if the value changed and the
change came from the system (i.e. a programmatic change) refresh the
editor by adjusting its key prop. If the change was made by the user,
there is no need to refresh the editor.

Fixes https://github.com/payloadcms/payload/issues/10819
This commit is contained in:
Jarrod Flesch
2025-02-25 09:44:06 -05:00
committed by GitHub
parent 1e698c2bdf
commit 36e152d69d
5 changed files with 103 additions and 15 deletions

View File

@@ -10,10 +10,10 @@ import { useField } from '../../forms/useField/index.js'
import { withCondition } from '../../forms/withCondition/index.js' import { withCondition } from '../../forms/withCondition/index.js'
import { FieldDescription } from '../FieldDescription/index.js' import { FieldDescription } from '../FieldDescription/index.js'
import { FieldError } from '../FieldError/index.js' import { FieldError } from '../FieldError/index.js'
import './index.scss'
import { FieldLabel } from '../FieldLabel/index.js' import { FieldLabel } from '../FieldLabel/index.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js' import { mergeFieldStyles } from '../mergeFieldStyles.js'
import { fieldBaseClass } from '../shared/index.js' import { fieldBaseClass } from '../shared/index.js'
import './index.scss'
const baseClass = 'json-field' const baseClass = 'json-field'
@@ -31,10 +31,9 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
readOnly, readOnly,
validate, validate,
} = props } = props
const [stringValue, setStringValue] = useState<string>()
const [jsonError, setJsonError] = useState<string>() const [jsonError, setJsonError] = useState<string>()
const [hasLoadedValue, setHasLoadedValue] = useState(false) const inputChangeFromRef = React.useRef<'system' | 'user'>('system')
const [editorKey, setEditorKey] = useState<string>('')
const memoizedValidate = useCallback( const memoizedValidate = useCallback(
(value, options) => { (value, options) => {
@@ -56,6 +55,12 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
validate: memoizedValidate, validate: memoizedValidate,
}) })
const [initialStringValue, setInitialStringValue] = useState<string | undefined>(() =>
(value || initialValue) !== undefined
? JSON.stringify(value ?? initialValue, null, 2)
: undefined,
)
const handleMount = useCallback<OnMount>( const handleMount = useCallback<OnMount>(
(editor, monaco) => { (editor, monaco) => {
if (!jsonSchema) { if (!jsonSchema) {
@@ -88,7 +93,7 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
if (readOnly) { if (readOnly) {
return return
} }
setStringValue(val) inputChangeFromRef.current = 'user'
try { try {
setValue(val ? JSON.parse(val) : null) setValue(val ? JSON.parse(val) : null)
@@ -98,20 +103,21 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
setJsonError(e) setJsonError(e)
} }
}, },
[readOnly, setValue, setStringValue], [readOnly, setValue],
) )
useEffect(() => { useEffect(() => {
if (hasLoadedValue || value === undefined) { if (inputChangeFromRef.current === 'system') {
return setInitialStringValue(
(value || initialValue) !== undefined
? JSON.stringify(value ?? initialValue, null, 2)
: undefined,
)
setEditorKey(new Date().toString())
} }
setStringValue( inputChangeFromRef.current = 'system'
value || initialValue ? JSON.stringify(value ? value : initialValue, null, 2) : '', }, [initialValue, value])
)
setHasLoadedValue(true)
}, [initialValue, value, hasLoadedValue])
const styles = useMemo(() => mergeFieldStyles(field), [field]) const styles = useMemo(() => mergeFieldStyles(field), [field])
@@ -142,12 +148,16 @@ const JSONFieldComponent: JSONFieldClientComponent = (props) => {
{BeforeInput} {BeforeInput}
<CodeEditor <CodeEditor
defaultLanguage="json" defaultLanguage="json"
key={editorKey}
maxHeight={maxHeight} maxHeight={maxHeight}
onChange={handleChange} onChange={handleChange}
onMount={handleMount} onMount={handleMount}
options={editorOptions} options={editorOptions}
readOnly={readOnly} readOnly={readOnly}
value={stringValue} value={initialStringValue}
wrapperProps={{
id: `field-${path?.replace(/\./g, '__')}`,
}}
/> />
{AfterInput} {AfterInput}
</div> </div>

View File

@@ -0,0 +1,38 @@
'use client'
import { useField } from '@payloadcms/ui'
export function AfterField() {
const { setValue } = useField({ path: 'customJSON' })
return (
<button
id="set-custom-json"
onClick={(e) => {
e.preventDefault()
setValue({
users: [
{
id: 1,
name: 'John Doe',
email: 'john.doe@example.com',
isActive: true,
roles: ['admin', 'editor'],
},
{
id: 2,
name: 'Jane Smith',
email: 'jane.smith@example.com',
isActive: false,
roles: ['viewer'],
},
],
})
}}
style={{ marginTop: '5px', padding: '5px 10px' }}
type="button"
>
Set Custom JSON
</button>
)
}

View File

@@ -103,4 +103,24 @@ describe('JSON', () => {
'"foo.with.periods": "bar"', '"foo.with.periods": "bar"',
) )
}) })
test('should update', async () => {
const createdDoc = await payload.create({
collection: 'json-fields',
data: {
customJSON: {
default: 'value',
},
},
})
await page.goto(url.edit(createdDoc.id))
const jsonField = page.locator('.json-field #field-customJSON')
await expect(jsonField).toContainText('"default": "value"')
const originalHeight = (await page.locator('#field-customJSON').boundingBox())?.height || 0
await page.locator('#set-custom-json').click()
const newHeight = (await page.locator('#field-customJSON').boundingBox())?.height || 0
expect(newHeight).toBeGreaterThan(originalHeight)
})
}) })

View File

@@ -67,6 +67,16 @@ const JSON: CollectionConfig = {
}, },
], ],
}, },
{
name: 'customJSON',
type: 'json',
admin: {
components: {
afterInput: ['./collections/JSON/AfterField#AfterField'],
},
},
label: 'Custom Json',
},
], ],
versions: { versions: {
maxPerDoc: 1, maxPerDoc: 1,

View File

@@ -1474,6 +1474,15 @@ export interface JsonField {
| boolean | boolean
| null; | null;
}; };
customJSON?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -3165,6 +3174,7 @@ export interface JsonFieldsSelect<T extends boolean = true> {
| { | {
jsonWithinGroup?: T; jsonWithinGroup?: T;
}; };
customJSON?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }