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)
This commit is contained in:
Jarrod Flesch
2025-03-05 14:28:26 -05:00
committed by GitHub
parent 9724067242
commit 2163b0fdb5
10 changed files with 194 additions and 41 deletions

View File

@@ -87,6 +87,12 @@ export type BeforeChangeRichTextHookArgs<
duplicate?: boolean
errors?: ValidationFieldError[]
/**
* Built up field label
*
* @example "Group Field > Tab Field > Rich Text Field"
*/
fieldLabelPath: string
/** Only available in `beforeChange` field hooks */
mergeLocaleActions?: (() => Promise<void> | void)[]
/** A string relating to which operation the field type is currently executing within. */
@@ -95,11 +101,11 @@ export type BeforeChangeRichTextHookArgs<
previousSiblingDoc?: TData
/** The previous value of the field, before changes */
previousValue?: TValue
/**
* The original siblingData with locales (not modified by any hooks).
*/
siblingDocWithLocales?: JsonObject
skipValidation?: boolean
}
@@ -121,7 +127,6 @@ export type BaseRichTextHookArgs<
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
originalDoc?: TData
parentIsLocalized: boolean
/**
* 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.
*/

View File

@@ -54,6 +54,7 @@ export const beforeChange = async <T extends JsonObject>({
doc,
docWithLocales,
errors,
fieldLabelPath: '',
fields: collection?.fields || global?.fields,
global,
mergeLocaleActions,

View File

@@ -9,13 +9,17 @@ import type { Block, Field, TabAsField, Validate } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
import { getLabelFromPath } from '../../../utilities/getLabelFromPath.js'
import { getTranslatedLabel } from '../../../utilities/getTranslatedLabel.js'
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js'
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
import { getExistingRowDoc } from './getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
function buildFieldLabel(parentLabel: string, label: string): string {
const capitalizedLabel = label.charAt(0).toUpperCase() + label.slice(1)
return parentLabel ? `${parentLabel} > ${capitalizedLabel}` : capitalizedLabel
}
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
@@ -29,6 +33,12 @@ type Args = {
errors: ValidationFieldError[]
field: Field | TabAsField
fieldIndex: number
/**
* Built up labels of parent fields
*
* @example "Group Field > Tab Field > Text Field"
*/
fieldLabelPath: string
global: null | SanitizedGlobalConfig
id?: number | string
mergeLocaleActions: (() => Promise<void> | void)[]
@@ -64,6 +74,7 @@ export const promise = async ({
errors,
field,
fieldIndex,
fieldLabelPath,
global,
mergeLocaleActions,
operation,
@@ -175,13 +186,10 @@ export const promise = async ({
})
if (typeof validationResult === 'string') {
const label = getTranslatedLabel(field?.label || field?.name, req.i18n)
const parentPathSegments = parentPath ? parentPath.split('.') : []
const fieldLabel =
Array.isArray(parentPathSegments) && parentPathSegments.length > 0
? getLabelFromPath(parentPathSegments.concat(label))
: label
const fieldLabel = buildFieldLabel(
fieldLabelPath,
getTranslatedLabel(field?.label || field?.name, req.i18n),
)
errors.push({
label: fieldLabel,
@@ -234,6 +242,13 @@ export const promise = async ({
doc,
docWithLocales,
errors,
fieldLabelPath:
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} ${rowIndex + 1}`,
),
fields: field.fields,
global,
mergeLocaleActions,
@@ -292,6 +307,13 @@ export const promise = async ({
doc,
docWithLocales,
errors,
fieldLabelPath:
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
`${getTranslatedLabel(field?.label || field?.name, req.i18n)} ${rowIndex + 1}`,
),
fields: block.fields,
global,
mergeLocaleActions,
@@ -327,6 +349,13 @@ export const promise = async ({
doc,
docWithLocales,
errors,
fieldLabelPath:
field.type === 'row' || field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
getTranslatedLabel(field?.label || field?.type, req.i18n),
),
fields: field.fields,
global,
mergeLocaleActions,
@@ -367,6 +396,13 @@ export const promise = async ({
doc,
docWithLocales,
errors,
fieldLabelPath:
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
getTranslatedLabel(field?.label || field?.name, req.i18n),
),
fields: field.fields,
global,
mergeLocaleActions,
@@ -424,6 +460,13 @@ export const promise = async ({
docWithLocales,
errors,
field,
fieldLabelPath:
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
getTranslatedLabel(field?.label || field?.name, req.i18n),
),
global,
indexPath: indexPathSegments,
mergeLocaleActions,
@@ -484,6 +527,13 @@ export const promise = async ({
doc,
docWithLocales,
errors,
fieldLabelPath:
field?.label === false
? fieldLabelPath
: buildFieldLabel(
fieldLabelPath,
getTranslatedLabel(field?.label || field?.name, req.i18n),
),
fields: field.fields,
global,
mergeLocaleActions,
@@ -512,6 +562,10 @@ export const promise = async ({
doc,
docWithLocales,
errors,
fieldLabelPath:
field?.label === false
? fieldLabelPath
: buildFieldLabel(fieldLabelPath, getTranslatedLabel(field?.label || '', req.i18n)),
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
mergeLocaleActions,

View File

@@ -25,6 +25,12 @@ type Args = {
*/
docWithLocales: JsonObject
errors: ValidationFieldError[]
/**
* Built up labels of parent fields
*
* @example "Group Field > Tab Field > Text Field"
*/
fieldLabelPath: string
fields: (Field | TabAsField)[]
global: null | SanitizedGlobalConfig
id?: number | string
@@ -67,6 +73,7 @@ export const traverseFields = async ({
doc,
docWithLocales,
errors,
fieldLabelPath,
fields,
global,
mergeLocaleActions,
@@ -96,6 +103,7 @@ export const traverseFields = async ({
errors,
field,
fieldIndex,
fieldLabelPath,
global,
mergeLocaleActions,
operation,

View File

@@ -1,16 +0,0 @@
export const getLabelFromPath = (path: (number | string)[]): string => {
return path
.filter((pathSegment) => !(typeof pathSegment === 'string' && pathSegment.includes('_index')))
.reduce<string[]>((acc, part) => {
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
// Convert index to 1-based and format as "Array 01", "Array 02", etc.
const fieldName = acc.pop()
acc.push(`${fieldName} ${Number(part) + 1}`)
} else {
// Capitalize field names
acc.push(part.charAt(0).toUpperCase() + part.slice(1))
}
return acc
}, [])
.join(' > ')
}

View File

@@ -421,6 +421,7 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
docWithLocales,
errors,
field,
fieldLabelPath,
global,
indexPath,
mergeLocaleActions,
@@ -554,6 +555,7 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
doc: originalDoc ?? {},
docWithLocales: docWithLocales ?? {},
errors: errors!,
fieldLabelPath,
fields: subFields,
global,
mergeLocaleActions: mergeLocaleActions!,

View File

@@ -0,0 +1,74 @@
'use client'
import React from 'react'
function groupSimilarErrors(items: string[]): string[] {
const result: string[] = []
for (const item of items) {
if (item) {
const parts = item.split(' → ')
let inserted = false
// Find a place where a similar path exists
for (let i = 0; i < result.length; i++) {
if (result[i].startsWith(parts[0])) {
result.splice(i + 1, 0, item)
inserted = true
break
}
}
// If no similar path was found, add to the end
if (!inserted) {
result.push(item)
}
}
}
return result
}
function createErrorsFromMessage(message: string): {
errors?: string[]
message: string
} {
const [intro, errorsString] = message.split(':')
const errors = (errorsString || '')
.split(',')
.map((error) => error.replaceAll(' > ', ' → ').trim())
if (errors.length === 0) {
return {
message: intro,
}
}
if (errors.length === 1) {
return {
message: `${intro}: ${errors[0]}`,
}
}
return {
errors: groupSimilarErrors(errors),
message: `${intro} (${errors.length}):`,
}
}
export function FieldErrorsToast({ errorMessage }) {
const [{ errors, message }] = React.useState(() => createErrorsFromMessage(errorMessage))
return (
<div>
{message}
{Array.isArray(errors) && errors.length > 0 ? (
<ul data-testid="field-errors">
{errors.map((error, index) => {
return <li key={index}>{error}</li>
})}
</ul>
) : null}
</div>
)
}

View File

@@ -21,6 +21,7 @@ import type {
SubmitOptions,
} from './types.js'
import { FieldErrorsToast } from '../../elements/Toasts/fieldErrors.js'
import { useDebouncedEffect } from '../../hooks/useDebouncedEffect.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
@@ -392,7 +393,6 @@ export const Form: React.FC<FormProps> = (props) => {
contextRef.current = { ...contextRef.current } // triggers rerender of all components that subscribe to form
if (json.message) {
errorToast(json.message)
return
}
@@ -432,7 +432,7 @@ export const Form: React.FC<FormProps> = (props) => {
})
nonFieldErrors.forEach((err) => {
errorToast(err.message || t('error:unknown'))
errorToast(<FieldErrorsToast errorMessage={err.message || t('error:unknown')} />)
})
return

View File

@@ -570,9 +570,17 @@ describe('lexicalBlocks', () => {
await topLevelDocTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Doc Data',
)
await expect(
page
.locator('.payload-toast-container li')
.filter({ hasText: 'The following fields are invalid (2):' }),
).toBeVisible()
await expect(
page.locator('.payload-toast-container [data-testid="field-errors"] li').nth(0),
).toHaveText('Lexical With Blocks')
await expect(
page.locator('.payload-toast-container [data-testid="field-errors"] li').nth(1),
).toHaveText('Lexical With Blocks → Group → Text Depends On Doc Data')
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
@@ -593,9 +601,18 @@ describe('lexicalBlocks', () => {
await blockGroupTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Sibling Data',
)
await expect(
page
.locator('.payload-toast-container li')
.filter({ hasText: 'The following fields are invalid (2):' }),
).toBeVisible()
await expect(
page.locator('.payload-toast-container [data-testid="field-errors"] li').nth(0),
).toHaveText('Lexical With Blocks')
await expect(
page.locator('.payload-toast-container [data-testid="field-errors"] li').nth(1),
).toHaveText('Lexical With Blocks → Group → Text Depends On Sibling Data')
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()
await trackNetworkRequests(
@@ -616,9 +633,17 @@ describe('lexicalBlocks', () => {
await blockTextField.fill('invalid')
await saveDocAndAssert(page, '#action-save', 'error')
await expect(page.locator('.payload-toast-container')).toHaveText(
'The following fields are invalid: Lexical With Blocks, LexicalWithBlocks > Group > Text Depends On Block Data',
)
await expect(
page
.locator('.payload-toast-container li')
.filter({ hasText: 'The following fields are invalid (2):' }),
).toBeVisible()
await expect(
page.locator('.payload-toast-container [data-testid="field-errors"] li').nth(0),
).toHaveText('Lexical With Blocks')
await expect(
page.locator('.payload-toast-container [data-testid="field-errors"] li').nth(1),
).toHaveText('Lexical With Blocks → Group → Text Depends On Block Data')
await expect(page.locator('.payload-toast-container .payload-toast-item')).toBeHidden()

View File

@@ -1815,7 +1815,7 @@ describe('Fields', () => {
],
},
}),
).rejects.toThrow('The following field is invalid: Items 1 > SubArray 1 > Second text field')
).rejects.toThrow('The following field is invalid: Items 1 > Sub Array 1 > Second text field')
})
it('should show proper validation error on text field in row field in nested array', async () => {
@@ -1835,7 +1835,7 @@ describe('Fields', () => {
],
},
}),
).rejects.toThrow('The following field is invalid: Items 1 > SubArray 1 > Text In Row')
).rejects.toThrow('The following field is invalid: Items 1 > Sub Array 1 > Text In Row')
})
})
@@ -2363,7 +2363,7 @@ describe('Fields', () => {
],
},
}),
).rejects.toThrow('The following field is invalid: Array 3 > Text')
).rejects.toThrow('The following field is invalid: Tab with Array > Array 3 > Text')
})
})
@@ -2667,7 +2667,7 @@ describe('Fields', () => {
},
}),
).rejects.toThrow(
'The following field is invalid: Group > SubGroup > Required Text Within Sub Group',
'The following field is invalid: Collapsible Field > Group > Sub Group > Required Text Within Sub Group',
)
})
})