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:
@@ -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>
|
||||||
|
|||||||
38
test/fields/collections/JSON/AfterField.tsx
Normal file
38
test/fields/collections/JSON/AfterField.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user