Skip to content

Commit 2163b0f

Browse files
feat(ui): improves field error toast messages (#11521)
### What? Adjusts how field errors are displayed within toasts so they are easier to read. ![Frame 36 (1)](https://github.com/user-attachments/assets/3debec4f-8d78-42ef-84bc-efd574a63ac6)
1 parent 9724067 commit 2163b0f

File tree

10 files changed

+194
-41
lines changed

10 files changed

+194
-41
lines changed

packages/payload/src/admin/RichText.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ export type BeforeChangeRichTextHookArgs<
8787
duplicate?: boolean
8888

8989
errors?: ValidationFieldError[]
90+
/**
91+
* Built up field label
92+
*
93+
* @example "Group Field > Tab Field > Rich Text Field"
94+
*/
95+
fieldLabelPath: string
9096
/** Only available in `beforeChange` field hooks */
9197
mergeLocaleActions?: (() => Promise<void> | void)[]
9298
/** A string relating to which operation the field type is currently executing within. */
@@ -95,11 +101,11 @@ export type BeforeChangeRichTextHookArgs<
95101
previousSiblingDoc?: TData
96102
/** The previous value of the field, before changes */
97103
previousValue?: TValue
104+
98105
/**
99106
* The original siblingData with locales (not modified by any hooks).
100107
*/
101108
siblingDocWithLocales?: JsonObject
102-
103109
skipValidation?: boolean
104110
}
105111

@@ -121,7 +127,6 @@ export type BaseRichTextHookArgs<
121127
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
122128
originalDoc?: TData
123129
parentIsLocalized: boolean
124-
125130
/**
126131
* The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas.
127132
*/

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const beforeChange = async <T extends JsonObject>({
5454
doc,
5555
docWithLocales,
5656
errors,
57+
fieldLabelPath: '',
5758
fields: collection?.fields || global?.fields,
5859
global,
5960
mergeLocaleActions,

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

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ import type { Block, Field, TabAsField, Validate } from '../../config/types.js'
99

1010
import { MissingEditorProp } from '../../../errors/index.js'
1111
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
12-
import { getLabelFromPath } from '../../../utilities/getLabelFromPath.js'
1312
import { getTranslatedLabel } from '../../../utilities/getTranslatedLabel.js'
1413
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js'
1514
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
1615
import { getExistingRowDoc } from './getExistingRowDoc.js'
1716
import { traverseFields } from './traverseFields.js'
1817

18+
function buildFieldLabel(parentLabel: string, label: string): string {
19+
const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1)
20+
return parentLabel ? `${parentLabel} > ${capitalizedLabel}` : capitalizedLabel
21+
}
22+
1923
type Args = {
2024
/**
2125
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
@@ -29,6 +33,12 @@ type Args = {
2933
errors: ValidationFieldError[]
3034
field: Field | TabAsField
3135
fieldIndex: number
36+
/**
37+
* Built up labels of parent fields
38+
*
39+
* @example "Group Field > Tab Field > Text Field"
40+
*/
41+
fieldLabelPath: string
3242
global: null | SanitizedGlobalConfig
3343
id?: number | string
3444
mergeLocaleActions: (() => Promise<void> | void)[]
@@ -64,6 +74,7 @@ export const promise = async ({
6474
errors,
6575
field,
6676
fieldIndex,
77+
fieldLabelPath,
6778
global,
6879
mergeLocaleActions,
6980
operation,
@@ -175,13 +186,10 @@ export const promise = async ({
175186
})
176187

177188
if (typeof validationResult === 'string') {
178-
const label = getTranslatedLabel(field?.label || field?.name, req.i18n)
179-
const parentPathSegments = parentPath ? parentPath.split('.') : []
180-
181-
const fieldLabel =
182-
Array.isArray(parentPathSegments) && parentPathSegments.length > 0
183-
? getLabelFromPath(parentPathSegments.concat(label))
184-
: label
189+
const fieldLabel = buildFieldLabel(
190+
fieldLabelPath,
191+
getTranslatedLabel(field?.label || field?.name, req.i18n),
192+
)
185193

186194
errors.push({
187195
label: fieldLabel,
@@ -234,6 +242,13 @@ export const promise = async ({
234242
doc,
235243
docWithLocales,
236244
errors,
245+
fieldLabelPath:
246+
field?.label === false
247+
? fieldLabelPath
248+
: buildFieldLabel(
249+
fieldLabelPath,
250+
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} ${rowIndex + 1}`,
251+
),
237252
fields: field.fields,
238253
global,
239254
mergeLocaleActions,
@@ -292,6 +307,13 @@ export const promise = async ({
292307
doc,
293308
docWithLocales,
294309
errors,
310+
fieldLabelPath:
311+
field?.label === false
312+
? fieldLabelPath
313+
: buildFieldLabel(
314+
fieldLabelPath,
315+
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} ${rowIndex + 1}`,
316+
),
295317
fields: block.fields,
296318
global,
297319
mergeLocaleActions,
@@ -327,6 +349,13 @@ export const promise = async ({
327349
doc,
328350
docWithLocales,
329351
errors,
352+
fieldLabelPath:
353+
field.type === 'row' || field?.label === false
354+
? fieldLabelPath
355+
: buildFieldLabel(
356+
fieldLabelPath,
357+
getTranslatedLabel(field?.label || field?.type, req.i18n),
358+
),
330359
fields: field.fields,
331360
global,
332361
mergeLocaleActions,
@@ -367,6 +396,13 @@ export const promise = async ({
367396
doc,
368397
docWithLocales,
369398
errors,
399+
fieldLabelPath:
400+
field?.label === false
401+
? fieldLabelPath
402+
: buildFieldLabel(
403+
fieldLabelPath,
404+
getTranslatedLabel(field?.label || field?.name, req.i18n),
405+
),
370406
fields: field.fields,
371407
global,
372408
mergeLocaleActions,
@@ -424,6 +460,13 @@ export const promise = async ({
424460
docWithLocales,
425461
errors,
426462
field,
463+
fieldLabelPath:
464+
field?.label === false
465+
? fieldLabelPath
466+
: buildFieldLabel(
467+
fieldLabelPath,
468+
getTranslatedLabel(field?.label || field?.name, req.i18n),
469+
),
427470
global,
428471
indexPath: indexPathSegments,
429472
mergeLocaleActions,
@@ -484,6 +527,13 @@ export const promise = async ({
484527
doc,
485528
docWithLocales,
486529
errors,
530+
fieldLabelPath:
531+
field?.label === false
532+
? fieldLabelPath
533+
: buildFieldLabel(
534+
fieldLabelPath,
535+
getTranslatedLabel(field?.label || field?.name, req.i18n),
536+
),
487537
fields: field.fields,
488538
global,
489539
mergeLocaleActions,
@@ -512,6 +562,10 @@ export const promise = async ({
512562
doc,
513563
docWithLocales,
514564
errors,
565+
fieldLabelPath:
566+
field?.label === false
567+
? fieldLabelPath
568+
: buildFieldLabel(fieldLabelPath, getTranslatedLabel(field?.label || '', req.i18n)),
515569
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
516570
global,
517571
mergeLocaleActions,

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ type Args = {
2525
*/
2626
docWithLocales: JsonObject
2727
errors: ValidationFieldError[]
28+
/**
29+
* Built up labels of parent fields
30+
*
31+
* @example "Group Field > Tab Field > Text Field"
32+
*/
33+
fieldLabelPath: string
2834
fields: (Field | TabAsField)[]
2935
global: null | SanitizedGlobalConfig
3036
id?: number | string
@@ -67,6 +73,7 @@ export const traverseFields = async ({
6773
doc,
6874
docWithLocales,
6975
errors,
76+
fieldLabelPath,
7077
fields,
7178
global,
7279
mergeLocaleActions,
@@ -96,6 +103,7 @@ export const traverseFields = async ({
96103
errors,
97104
field,
98105
fieldIndex,
106+
fieldLabelPath,
99107
global,
100108
mergeLocaleActions,
101109
operation,

packages/payload/src/utilities/getLabelFromPath.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

packages/richtext-lexical/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
421421
docWithLocales,
422422
errors,
423423
field,
424+
fieldLabelPath,
424425
global,
425426
indexPath,
426427
mergeLocaleActions,
@@ -554,6 +555,7 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
554555
doc: originalDoc ?? {},
555556
docWithLocales: docWithLocales ?? {},
556557
errors: errors!,
558+
fieldLabelPath,
557559
fields: subFields,
558560
global,
559561
mergeLocaleActions: mergeLocaleActions!,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client'
2+
3+
import React from 'react'
4+
5+
function groupSimilarErrors(items: string[]): string[] {
6+
const result: string[] = []
7+
8+
for (const item of items) {
9+
if (item) {
10+
const parts = item.split(' → ')
11+
let inserted = false
12+
13+
// Find a place where a similar path exists
14+
for (let i = 0; i < result.length; i++) {
15+
if (result[i].startsWith(parts[0])) {
16+
result.splice(i + 1, 0, item)
17+
inserted = true
18+
break
19+
}
20+
}
21+
22+
// If no similar path was found, add to the end
23+
if (!inserted) {
24+
result.push(item)
25+
}
26+
}
27+
}
28+
29+
return result
30+
}
31+
32+
function createErrorsFromMessage(message: string): {
33+
errors?: string[]
34+
message: string
35+
} {
36+
const [intro, errorsString] = message.split(':')
37+
const errors = (errorsString || '')
38+
.split(',')
39+
.map((error) => error.replaceAll(' > ', ' → ').trim())
40+
41+
if (errors.length === 0) {
42+
return {
43+
message: intro,
44+
}
45+
}
46+
47+
if (errors.length === 1) {
48+
return {
49+
message: `${intro}: ${errors[0]}`,
50+
}
51+
}
52+
53+
return {
54+
errors: groupSimilarErrors(errors),
55+
message: `${intro} (${errors.length}):`,
56+
}
57+
}
58+
59+
export function FieldErrorsToast({ errorMessage }) {
60+
const [{ errors, message }] = React.useState(() => createErrorsFromMessage(errorMessage))
61+
62+
return (
63+
<div>
64+
{message}
65+
{Array.isArray(errors) && errors.length > 0 ? (
66+
<ul data-testid="field-errors">
67+
{errors.map((error, index) => {
68+
return <li key={index}>{error}</li>
69+
})}
70+
</ul>
71+
) : null}
72+
</div>
73+
)
74+
}

packages/ui/src/forms/Form/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
SubmitOptions,
2222
} from './types.js'
2323

24+
import { FieldErrorsToast } from '../../elements/Toasts/fieldErrors.js'
2425
import { useDebouncedEffect } from '../../hooks/useDebouncedEffect.js'
2526
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
2627
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
@@ -392,7 +393,6 @@ export const Form: React.FC<FormProps> = (props) => {
392393
contextRef.current = { ...contextRef.current } // triggers rerender of all components that subscribe to form
393394
if (json.message) {
394395
errorToast(json.message)
395-
396396
return
397397
}
398398

@@ -432,7 +432,7 @@ export const Form: React.FC<FormProps> = (props) => {
432432
})
433433

434434
nonFieldErrors.forEach((err) => {
435-
errorToast(err.message || t('error:unknown'))
435+
errorToast(<FieldErrorsToast errorMessage={err.message || t('error:unknown')} />)
436436
})
437437

438438
return

0 commit comments

Comments
 (0)