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

View File

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