Skip to content

Commit 72396f6

Browse files
authored
fix: default virtual fields to readOnly in admin UI (#16016)
### What? Virtual fields (`virtual: true` or `virtual: "path"`) now default to `admin.readOnly: true` during field sanitization. ### Why? Virtual fields are populated server-side and not persisted from user input. Without `readOnly`, users can type into them but edits are silently discarded on save — confusing UX with no indication the field is virtual. ### How? In `sanitize.ts`, after initializing `field.admin`, set `readOnly: true` for virtual fields unless the user has explicitly set `readOnly: false`. Fixes #16013 Related #16012
1 parent 067e14f commit 72396f6

2 files changed

Lines changed: 92 additions & 1 deletion

File tree

packages/payload/src/fields/config/sanitize.spec.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,8 +695,95 @@ describe('sanitizeFields', () => {
695695
expect(sanitizedField.validate).toBeDefined()
696696
// Non-virtual text field should use the text validator which checks required/minLength/etc.
697697
// Passing undefined with required should fail
698-
const result = sanitizedField.validate!(undefined as any, { required: true, req: { payload: { config: {} }, t: ((v: string) => v) as any } } as any)
698+
const result = sanitizedField.validate!(
699+
undefined as any,
700+
{ required: true, req: { payload: { config: {} }, t: ((v: string) => v) as any } } as any,
701+
)
699702
expect(result).not.toBe(true)
700703
})
704+
705+
it('should default readOnly to true for virtual: true fields', async () => {
706+
const fields: Field[] = [
707+
{
708+
name: 'virtualText',
709+
type: 'text',
710+
virtual: true,
711+
},
712+
]
713+
714+
const sanitizedField = (
715+
await sanitizeFields({
716+
config,
717+
collectionConfig,
718+
fields,
719+
validRelationships: [],
720+
})
721+
)[0] as TextField
722+
723+
expect(sanitizedField.admin?.readOnly).toBe(true)
724+
})
725+
726+
it('should default readOnly to true for virtual: "string" fields', async () => {
727+
const fields: Field[] = [
728+
{
729+
name: 'virtualRef',
730+
type: 'text',
731+
virtual: 'post.title',
732+
},
733+
]
734+
735+
const sanitizedField = (
736+
await sanitizeFields({
737+
config,
738+
collectionConfig,
739+
fields,
740+
validRelationships: [],
741+
})
742+
)[0] as TextField
743+
744+
expect(sanitizedField.admin?.readOnly).toBe(true)
745+
})
746+
747+
it('should not override readOnly: false on virtual fields', async () => {
748+
const fields: Field[] = [
749+
{
750+
name: 'virtualText',
751+
type: 'text',
752+
virtual: true,
753+
admin: { readOnly: false },
754+
},
755+
]
756+
757+
const sanitizedField = (
758+
await sanitizeFields({
759+
config,
760+
collectionConfig,
761+
fields,
762+
validRelationships: [],
763+
})
764+
)[0] as TextField
765+
766+
expect(sanitizedField.admin?.readOnly).toBe(false)
767+
})
768+
769+
it('should not set readOnly on non-virtual fields', async () => {
770+
const fields: Field[] = [
771+
{
772+
name: 'normalText',
773+
type: 'text',
774+
},
775+
]
776+
777+
const sanitizedField = (
778+
await sanitizeFields({
779+
config,
780+
collectionConfig,
781+
fields,
782+
validRelationships: [],
783+
})
784+
)[0] as TextField
785+
786+
expect(sanitizedField.admin?.readOnly).toBeUndefined()
787+
})
701788
})
702789
})

packages/payload/src/fields/config/sanitize.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,10 @@ export const sanitizeField = async ({
356356
field.admin = {}
357357
}
358358

359+
if ('virtual' in field && field.virtual && field.admin.readOnly !== false && fieldAffectsData) {
360+
field.admin.readOnly = true
361+
}
362+
359363
// Make sure that the richText field has an editor
360364
if (field.type === 'richText') {
361365
const sanitizeRichText = async (_config: SanitizedConfig) => {

0 commit comments

Comments
 (0)