Skip to content

Commit ad553e9

Browse files
authored
fix: updates field validation error messages to use labels if applicable (#10601)
### What? Previously, field error messages displayed in toast notifications used the field path to reference fields that failed validation. This path-based approach was necessary to distinguish between fields that might share the same name when nested inside arrays, groups, rows, or collapsible fields. However, the human readability of these paths was lacking, especially for unnamed fields like rows and collapsible fields. For example: - A text field inside a row could display as: `_index-0.text` - A text field nested within multiple arrays could display as: `items.0.subArray.0.text` These outputs are technically correct but not user-friendly. ### Why? While the previous format was helpful for pinpointing the specific field that caused the validation error, it could be more user-friendly and clearer to read. The goal is to maintain the same level of accuracy while improving the readability for both developers and content editors. ### How? To improve readability, the following changes were made: 1. Use Field Labels Instead of Field Paths: - The ValidationError component now uses the label prop from the field config (if available) instead of the field’s name. - If a label is provided, it will be used in the error message. - If no label exists, it will fall back to the field’s name. 2. Remove _index from Paths for Unnamed Fields (In the validationError component only): - For unnamed fields like rows and collapsibles, the _index prefix is now stripped from the output to make it cleaner. - Instead of `_index-0.text`, it now outputs just `Text`. 3. Reformat the Error Path for Readability: - The error message format has been improved to be more human-readable, showing the field hierarchy in a structured way with array indices converted to 1-based numbers. #### Example transformation: ##### Before: The following fields are invalid: `items.0.subArray.0.text` ##### After: The following fields are invalid: `Items 1 > SubArray 1 > Text`
1 parent 38a06e7 commit ad553e9

File tree

11 files changed

+180
-16
lines changed

11 files changed

+180
-16
lines changed

packages/payload/src/errors/ValidationError.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import type { TFunction } from '@payloadcms/translations'
33
import { en } from '@payloadcms/translations/languages/en'
44
import httpStatus from 'http-status'
55

6+
import type { LabelFunction, StaticLabel } from '../config/types.js'
7+
68
import { APIError } from './APIError.js'
79

810
// This gets dynamically reassigned during compilation
911
export let ValidationErrorName = 'ValidationError'
1012

1113
export type ValidationFieldError = {
14+
label?: LabelFunction | StaticLabel
1215
// The error message to display for this field
1316
message: string
1417
path: string
@@ -35,7 +38,7 @@ export class ValidationError extends APIError<{
3538
: en.translations.error.followingFieldsInvalid_other
3639

3740
super(
38-
`${message} ${results.errors.map((f) => f.path).join(', ')}`,
41+
`${message} ${results.errors.map((f) => f.label || f.path).join(', ')}`,
3942
httpStatus.BAD_REQUEST,
4043
results,
4144
)

packages/payload/src/fields/hooks/beforeChange/promise.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type { Field, TabAsField } from '../../config/types.js'
88

99
import { MissingEditorProp } from '../../../errors/index.js'
1010
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
11+
import { getFormattedLabel } from '../../../utilities/getFormattedLabel.js'
12+
import { getTranslatedLabel } from '../../../utilities/getTranslatedLabel.js'
1113
import { fieldAffectsData, tabHasName } from '../../config/types.js'
1214
import { getFieldPaths } from '../../getFieldPaths.js'
1315
import { getExistingRowDoc } from './getExistingRowDoc.js'
@@ -159,7 +161,15 @@ export const promise = async ({
159161
)
160162

161163
if (typeof validationResult === 'string') {
164+
const label = getTranslatedLabel(field?.label || field?.name, req.i18n)
165+
166+
const fieldLabel =
167+
Array.isArray(parentPath) && parentPath.length > 0
168+
? getFormattedLabel([...parentPath, label])
169+
: label
170+
162171
errors.push({
172+
label: fieldLabel,
163173
message: validationResult,
164174
path: fieldPath.join('.'),
165175
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const getFormattedLabel = (path: (number | string)[]): string => {
2+
return path
3+
.filter((pathSegment) => !(typeof pathSegment === 'string' && pathSegment.includes('_index')))
4+
.reduce<string[]>((acc, part) => {
5+
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
6+
// Convert index to 1-based and format as "Array 01", "Array 02", etc.
7+
const fieldName = acc.pop()
8+
acc.push(`${fieldName} ${Number(part) + 1}`)
9+
} else {
10+
// Capitalize field names
11+
acc.push(part.charAt(0).toUpperCase() + part.slice(1))
12+
}
13+
return acc
14+
}, [])
15+
.join(' > ')
16+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { getTranslation, type I18n } from '@payloadcms/translations'
2+
3+
import type { LabelFunction, StaticLabel } from '../config/types.js'
4+
5+
export const getTranslatedLabel = (label: LabelFunction | StaticLabel, i18n?: I18n): string => {
6+
if (typeof label === 'function') {
7+
return label({ t: i18n.t })
8+
}
9+
10+
if (typeof label === 'object') {
11+
return getTranslation(label, i18n)
12+
}
13+
14+
return label
15+
}

test/collections-graphql/int.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1159,7 +1159,7 @@ describe('collections-graphql', () => {
11591159
})
11601160
.then((res) => res.json())
11611161
expect(Array.isArray(errors)).toBe(true)
1162-
expect(errors[0].message).toEqual('The following field is invalid: min')
1162+
expect(errors[0].message).toEqual('The following field is invalid: Min')
11631163
expect(typeof errors[0].locations).toBeDefined()
11641164
})
11651165

@@ -1207,7 +1207,7 @@ describe('collections-graphql', () => {
12071207
expect(errors[1].extensions.data.errors[0].path).toEqual('email')
12081208

12091209
expect(Array.isArray(errors[2].locations)).toEqual(true)
1210-
expect(errors[2].message).toEqual('The following field is invalid: email')
1210+
expect(errors[2].message).toEqual('The following field is invalid: Email')
12111211
expect(errors[2].path[0]).toEqual('test4')
12121212
expect(errors[2].extensions.name).toEqual('ValidationError')
12131213
expect(errors[2].extensions.data.errors[0].message).toEqual(

test/database/int.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,7 @@ describe('database', () => {
892892
errorMessage = e.message
893893
}
894894

895-
await expect(errorMessage).toBe('The following field is invalid: title')
895+
await expect(errorMessage).toBe('The following field is invalid: Title')
896896
})
897897

898898
it('should return proper deeply nested field validation errors', async () => {

test/fields/collections/Array/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,24 @@ const ArrayFields: CollectionConfig = {
5454
name: 'text',
5555
type: 'text',
5656
},
57+
{
58+
name: 'textTwo',
59+
label: 'Second text field',
60+
type: 'text',
61+
required: true,
62+
defaultValue: 'default',
63+
},
64+
{
65+
type: 'row',
66+
fields: [
67+
{
68+
name: 'textInRow',
69+
type: 'text',
70+
required: true,
71+
defaultValue: 'default',
72+
},
73+
],
74+
},
5775
],
5876
type: 'array',
5977
},

test/fields/collections/Collapsible/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ const CollapsibleFields: CollectionConfig = {
3636
name: 'textWithinSubGroup',
3737
type: 'text',
3838
},
39+
{
40+
name: 'requiredTextWithinSubGroup',
41+
type: 'text',
42+
required: true,
43+
defaultValue: 'required text',
44+
},
3945
],
4046
},
4147
],

test/fields/int.spec.ts

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,20 @@ describe('Fields', () => {
486486
})
487487
})
488488

489+
describe('rows', () => {
490+
it('show proper validation error message on text field within row field', async () => {
491+
await expect(async () =>
492+
payload.create({
493+
collection: 'row-fields',
494+
data: {
495+
id: 'some-id',
496+
title: '',
497+
},
498+
}),
499+
).rejects.toThrow('The following field is invalid: Title within a row')
500+
})
501+
})
502+
489503
describe('timestamps', () => {
490504
const tenMinutesAgo = new Date(Date.now() - 1000 * 60 * 10)
491505
let doc
@@ -693,7 +707,7 @@ describe('Fields', () => {
693707
min: 5,
694708
},
695709
}),
696-
).rejects.toThrow('The following field is invalid: min')
710+
).rejects.toThrow('The following field is invalid: Min')
697711
})
698712
it('should not create number above max', async () => {
699713
await expect(async () =>
@@ -703,7 +717,7 @@ describe('Fields', () => {
703717
max: 15,
704718
},
705719
}),
706-
).rejects.toThrow('The following field is invalid: max')
720+
).rejects.toThrow('The following field is invalid: Max')
707721
})
708722

709723
it('should not create number below 0', async () => {
@@ -714,7 +728,7 @@ describe('Fields', () => {
714728
positiveNumber: -5,
715729
},
716730
}),
717-
).rejects.toThrow('The following field is invalid: positiveNumber')
731+
).rejects.toThrow('The following field is invalid: Positive Number')
718732
})
719733

720734
it('should not create number above 0', async () => {
@@ -725,7 +739,7 @@ describe('Fields', () => {
725739
negativeNumber: 5,
726740
},
727741
}),
728-
).rejects.toThrow('The following field is invalid: negativeNumber')
742+
).rejects.toThrow('The following field is invalid: Negative Number')
729743
})
730744
it('should not create a decimal number below min', async () => {
731745
await expect(async () =>
@@ -735,7 +749,7 @@ describe('Fields', () => {
735749
decimalMin: -0.25,
736750
},
737751
}),
738-
).rejects.toThrow('The following field is invalid: decimalMin')
752+
).rejects.toThrow('The following field is invalid: Decimal Min')
739753
})
740754

741755
it('should not create a decimal number above max', async () => {
@@ -746,7 +760,7 @@ describe('Fields', () => {
746760
decimalMax: 1.5,
747761
},
748762
}),
749-
).rejects.toThrow('The following field is invalid: decimalMax')
763+
).rejects.toThrow('The following field is invalid: Decimal Max')
750764
})
751765
it('should localize an array of numbers using hasMany', async () => {
752766
const localizedHasMany = [5, 10]
@@ -1128,7 +1142,7 @@ describe('Fields', () => {
11281142
min: 5,
11291143
},
11301144
}),
1131-
).rejects.toThrow('The following field is invalid: min')
1145+
).rejects.toThrow('The following field is invalid: Min')
11321146

11331147
expect(doc.point).toEqual(point)
11341148
expect(doc.localized).toEqual(localized)
@@ -1662,6 +1676,46 @@ describe('Fields', () => {
16621676

16631677
expect(res.id).toBe(doc.id)
16641678
})
1679+
1680+
it('show proper validation error on text field in nested array', async () => {
1681+
await expect(async () =>
1682+
payload.create({
1683+
collection,
1684+
data: {
1685+
items: [
1686+
{
1687+
text: 'required',
1688+
subArray: [
1689+
{
1690+
textTwo: '',
1691+
},
1692+
],
1693+
},
1694+
],
1695+
},
1696+
}),
1697+
).rejects.toThrow('The following field is invalid: Items 1 > SubArray 1 > Second text field')
1698+
})
1699+
1700+
it('show proper validation error on text field in row field in nested array', async () => {
1701+
await expect(async () =>
1702+
payload.create({
1703+
collection,
1704+
data: {
1705+
items: [
1706+
{
1707+
text: 'required',
1708+
subArray: [
1709+
{
1710+
textInRow: '',
1711+
},
1712+
],
1713+
},
1714+
],
1715+
},
1716+
}),
1717+
).rejects.toThrow('The following field is invalid: Items 1 > SubArray 1 > Text In Row')
1718+
})
16651719
})
16661720

16671721
describe('group', () => {
@@ -2168,6 +2222,28 @@ describe('Fields', () => {
21682222
expect(res.camelCaseTab.array[0].text).toBe('text')
21692223
expect(res.camelCaseTab.array[0].array[0].text).toBe('nested')
21702224
})
2225+
2226+
it('should show proper validation error message on text field within array within tab', async () => {
2227+
await expect(async () =>
2228+
payload.update({
2229+
id: document.id,
2230+
collection: tabsFieldsSlug,
2231+
data: {
2232+
array: [
2233+
{
2234+
text: 'one',
2235+
},
2236+
{
2237+
text: 'two',
2238+
},
2239+
{
2240+
text: '',
2241+
},
2242+
],
2243+
},
2244+
}),
2245+
).rejects.toThrow('The following field is invalid: Array 3 > Text')
2246+
})
21712247
})
21722248

21732249
describe('blocks', () => {
@@ -2429,6 +2505,26 @@ describe('Fields', () => {
24292505
})
24302506
})
24312507

2508+
describe('collapsible', () => {
2509+
it('show proper validation error message for fields nested in collapsible', async () => {
2510+
await expect(async () =>
2511+
payload.create({
2512+
collection: 'collapsible-fields',
2513+
data: {
2514+
text: 'required',
2515+
group: {
2516+
subGroup: {
2517+
requiredTextWithinSubGroup: '',
2518+
},
2519+
},
2520+
},
2521+
}),
2522+
).rejects.toThrow(
2523+
'The following field is invalid: Group > SubGroup > Required Text Within Sub Group',
2524+
)
2525+
})
2526+
})
2527+
24322528
describe('json', () => {
24332529
it('should save json data', async () => {
24342530
const json = { foo: 'bar' }
@@ -2450,7 +2546,7 @@ describe('Fields', () => {
24502546
json: '{ bad input: true }',
24512547
},
24522548
}),
2453-
).rejects.toThrow('The following field is invalid: json')
2549+
).rejects.toThrow('The following field is invalid: Json')
24542550
})
24552551

24562552
it('should validate json schema', async () => {
@@ -2461,7 +2557,7 @@ describe('Fields', () => {
24612557
json: { foo: 'bad' },
24622558
},
24632559
}),
2464-
).rejects.toThrow('The following field is invalid: json')
2560+
).rejects.toThrow('The following field is invalid: Json')
24652561
})
24662562

24672563
it('should save empty json objects', async () => {

test/relationships/int.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ describe('Relationships', () => {
562562
// @ts-expect-error Sending bad data to test error handling
563563
customIdRelation: 1234,
564564
}),
565-
).rejects.toThrow('The following field is invalid: customIdRelation')
565+
).rejects.toThrow('The following field is invalid: Custom Id Relation')
566566
})
567567

568568
it('should validate the format of number id relationships', async () => {
@@ -571,7 +571,7 @@ describe('Relationships', () => {
571571
// @ts-expect-error Sending bad data to test error handling
572572
customIdNumberRelation: 'bad-input',
573573
}),
574-
).rejects.toThrow('The following field is invalid: customIdNumberRelation')
574+
).rejects.toThrow('The following field is invalid: Custom Id Number Relation')
575575
})
576576

577577
it('should allow update removing a relationship', async () => {

0 commit comments

Comments
 (0)