feat(ui): improves field error toast messages (#11521)
### What? Adjusts how field errors are displayed within toasts so they are easier to read. 
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -54,6 +54,7 @@ export const beforeChange = async <T extends JsonObject>({
|
||||
doc,
|
||||
docWithLocales,
|
||||
errors,
|
||||
fieldLabelPath: '',
|
||||
fields: collection?.fields || global?.fields,
|
||||
global,
|
||||
mergeLocaleActions,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(' > ')
|
||||
}
|
||||
@@ -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!,
|
||||
|
||||
74
packages/ui/src/elements/Toasts/fieldErrors.tsx
Normal file
74
packages/ui/src/elements/Toasts/fieldErrors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user