From c451d561c0e0dae5cb20cd0d8637cd02f4d65d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Tue, 8 Oct 2024 10:19:42 +0200 Subject: [PATCH] feat(Forms): deprecate Ajv `errorMessages` keys (`pattern` => `Field.errorPattern` etc.) in favor of Eufemia translation keys This also means `validationRule` used in `FormError` is deprecated in favor of Eufemia translation keys. --- .github/workflows/verify.yml | 2 + .../releases/eufemia/v11-info.mdx | 22 ++ .../forms/Form/error-messages/Examples.tsx | 6 +- .../forms/Form/error-messages/info.mdx | 160 +++++++++----- .../extensions/forms/create-component.mdx | 22 +- .../useFieldProps/Examples.tsx | 26 +-- .../create-component/useFieldProps/info.mdx | 100 +++++---- .../NationalIdentityNumber/Examples.tsx | 16 +- .../extensions/forms/getting-started.mdx | 22 +- .../src/shared/tags/ComponentBox.tsx | 2 + .../extensions/forms/DataContext/Context.ts | 13 +- .../forms/DataContext/Provider/Provider.tsx | 31 +-- .../Provider/__tests__/Provider.test.tsx | 112 ++++++---- .../Provider/stories/Provider.stories.tsx | 6 +- .../Field/ArraySelection/ArraySelection.tsx | 2 +- .../BankAccountNumber/BankAccountNumber.tsx | 14 +- .../src/extensions/forms/Field/Date/Date.tsx | 12 +- .../extensions/forms/Field/Email/Email.tsx | 19 +- .../extensions/forms/Field/Expiry/Expiry.tsx | 13 +- .../src/extensions/forms/Field/Name/Name.tsx | 42 ++-- .../NationalIdentityNumber.tsx | 25 +-- .../__tests__/NationalIdentityNumber.test.tsx | 10 +- .../extensions/forms/Field/Number/Number.tsx | 26 +-- .../OrganizationNumber/OrganizationNumber.tsx | 15 +- .../forms/Field/PhoneNumber/PhoneNumber.tsx | 25 ++- .../__tests__/PhoneNumber.test.tsx | 6 +- .../stories/PhoneNumber.stories.tsx | 4 +- .../PostalCodeAndCity/PostalCodeAndCity.tsx | 9 +- .../Field/SelectCountry/SelectCountry.tsx | 25 ++- .../forms/Field/Selection/Selection.tsx | 2 +- .../extensions/forms/Field/String/String.tsx | 21 +- .../Field/String/__tests__/String.test.tsx | 86 +++++++- .../Field/String/stories/String.stories.tsx | 36 +++- .../extensions/forms/Field/Upload/Upload.tsx | 26 ++- .../forms/FieldBlock/FieldBlock.tsx | 2 +- .../Form/Handler/__tests__/Handler.test.tsx | 11 + .../Form/Section/__tests__/Section.test.tsx | 28 ++- .../Tools/__tests__/GenerateSchema.test.tsx | 30 --- .../Tools/__tests__/ListAllProps.test.tsx | 122 ----------- .../ChildrenWithAge/ChildrenWithAge.tsx | 22 +- .../ChildrenWithAge.test.tsx.snap | 24 +-- .../hooks/__tests__/useFieldProps.test.tsx | 75 +++++-- .../hooks/__tests__/useTranslation.test.tsx | 6 +- .../src/extensions/forms/hooks/index.ts | 4 +- .../extensions/forms/hooks/useErrorMessage.ts | 18 +- .../extensions/forms/hooks/useFieldProps.ts | 136 +++++++----- .../forms/hooks/useProcessManager.ts | 2 +- .../extensions/forms/hooks/useTranslation.tsx | 4 +- .../dnb-eufemia/src/extensions/forms/types.ts | 116 +++++++---- .../src/extensions/forms/utils/FormError.ts | 58 ++++++ .../forms/utils/__tests__/ajv.test.ts | 196 +++++++++++++++++- .../src/extensions/forms/utils/ajv.ts | 117 ++++++++++- .../src/extensions/forms/utils/errors.ts | 2 +- .../src/extensions/forms/utils/index.ts | 2 + .../dnb-eufemia/src/shared/useTranslation.tsx | 4 +- 55 files changed, 1230 insertions(+), 707 deletions(-) create mode 100644 packages/dnb-eufemia/src/extensions/forms/utils/FormError.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 26aef7ea466..5ad70b697e3 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -31,6 +31,8 @@ jobs: runs-on: ubuntu-latest + timeout-minutes: 20 + steps: - name: Git checkout uses: actions/checkout@v4 diff --git a/packages/dnb-design-system-portal/src/docs/uilib/about-the-lib/releases/eufemia/v11-info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/about-the-lib/releases/eufemia/v11-info.mdx index 5983a433358..d1ba2dfd345 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/about-the-lib/releases/eufemia/v11-info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/about-the-lib/releases/eufemia/v11-info.mdx @@ -153,4 +153,26 @@ The `InputPassword` component has been moved to `Field.Password`, and is now a p - Replace `omit_rounding` with `rounding="omit"`. +## Forms error handling + +**FormError** + +- Remove the `validationRule` parameter in favor of a translation key, like so: `new FormError('Field.errorRequired')`. + +**errorMessages** object + +- Replace `required` with `Field.errorRequired`. +- Replace `pattern` with `Field.errorPattern`. +- Replace `minLength` with `StringField.errorMinLength`. +- Replace `maxLength` with `StringField.errorMaxLength`. +- Replace `minimum` with `NumberField.errorMinimum`. +- Replace `maximum` with `NumberField.errorMaximum`. +- Replace `exclusiveMinimum` with `NumberField.errorExclusiveMinimum`. +- Replace `exclusiveMaximum` with `NumberField.errorExclusiveMaximum`. +- Replace `multipleOf` with `NumberField.errorMultipleOf`. + +**useErrorMessage** + +- Got removed. Simply provide your error message as a object in the `errorMessages` property. + _February, 6. 2024_ diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/error-messages/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/error-messages/Examples.tsx index 96bce5fdb60..7e9499a954e 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/error-messages/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/error-messages/Examples.tsx @@ -7,17 +7,17 @@ export const BasicErrorMessage = () => { ) Or in case of a validator: ```tsx -const validator = (value) => { +const myValidator = (value) => { // Your validation logic return new Error('Show this message') } -render() + +render() ``` ## Reuse existing error messages in a validator function You can reuse existing error messages in a validator function. The types of error messages available depend on the field type. -For example, you can reuse the `required` error message in a validator function: +For example, you can reuse the `Field.errorRequired` error message in a validator function: ```tsx -const validator = (value, { errorMessages }) => { +const myValidator = (value) => { // Your validation logic + return new FormError('Field.errorRequired') +} + +// Other options to reuse error messages, without using "FormError". +const myValidatorAlt = (value, { errorMessages }) => { + return new Error(errorMessages['Field.errorRequired']) + + // Deprecated return new Error(errorMessages.required) } -render() + +render() ``` ### FormError object @@ -44,56 +78,78 @@ You can use the JavaScript `Error` object to display a custom error message: ```tsx import { Field } from '@dnb/eufemia/extensions/forms' + render() ``` When it comes to re-using existing translations, you can also use the `FormError` object to display error messages. -The `validationRule` is used to identify the error message to display. +You can provide either an existing translation key, such as: -- `required` - Displayed when the field is required and the user has not provided a value. -- `pattern` - Displayed when the user has provided a value that does not match the pattern. +- `Field.errorRequired` - Displayed when the field is required and the user has not provided a value. +- `Field.errorPattern` - Displayed when the user has provided a value that does not match the pattern. ```tsx import { FormError, Field } from '@dnb/eufemia/extensions/forms' + +// - Error property +render() + +// - Validator function render( { + return new FormError('Field.errorRequired') + }} />, ) ``` -Here is how you can provide validation rules, or even overwrite existing ones: +#### Overwrite existing keys + +Per field, you can overwrite existing keys: ```tsx render( - - ... - , + />, ) ``` -For one field only: +#### Custom keys in a field + +You can also provide your own keys: + +```tsx + +``` + +#### Custom keys in Form.Handler + +Here is how you can provide your own keys or overwrite existing ones in a global `errorMessages` object inside the [Form.Handler](/uilib/extensions/forms/Form/Handler/): ```tsx render( - , + > + ... + , ) ``` -## Localization of error messages +#### Localization of error messages You can also provide localized error messages: @@ -102,10 +158,11 @@ render( @@ -114,15 +171,24 @@ render( ) ``` -In addition, you can customize the translations globally: +#### Use translations to localize error messages + +You can customize error messages via translations for the entire form: ```tsx import { Form } from '@dnb/eufemia/extensions/forms' + render( @@ -131,7 +197,9 @@ render( ) ``` -Or define an error message in a `schema` for one field: +#### Error message in a field `schema` + +You can define an error message in a `schema` for one field: ```tsx import { Provider } from '@dnb/eufemia/shared' @@ -149,29 +217,9 @@ render( ) ``` -Or in a field `schema` for one field with a JSON Pointer path: - -```tsx -const schema = { - type: 'object', - properties: { - myKey: { - type: 'string', - pattern: '^([a-z]+)$', - errorMessage: - 'You can provide a custom message in the schema itself', - }, - }, -} as const - -render( - - - , -) -``` +#### Error message in a global `schema` -Or in a Form.Handler `schema` for one field with a JSON Pointer path: +You can also define an error message in a `schema` for the entire form: ```tsx const schema = { @@ -228,7 +276,7 @@ You can provide custom error message different levels with the `errorMessages` p The levels are prioritized in the order above, so the field level error message will overwrite all other levels. -Here is an example of how to do expose a custom error message for the `pattern` validation rule on all levels: +Here is an example of how to do expose a custom error message for the `Field.errorRequired` validation rule on all levels: ```tsx import { Form, Field } from '@dnb/eufemia/extensions/forms' @@ -237,10 +285,10 @@ render( @@ -248,7 +296,7 @@ render( path="/myKey" errorMessages={{ // Level 3 - pattern: 'Or on a single Field itself', + 'Field.errorRequired': 'Or on a single Field itself', }} ... /> diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component.mdx index 4d390b630c7..87e419b8c0d 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component.mdx @@ -100,9 +100,11 @@ Here is a working example with code you can edit in the playground: You can customize the behavior of the field component. For example, you can add a custom error message: ```tsx +import { useFieldProps } from '@dnb/eufemia/extensions/forms' + useFieldProps({ errorMessages: { - required: 'Show this when "required" fails.', + 'Field.errorRequired': 'Show this when "required" fails.', }, }) ``` @@ -110,9 +112,11 @@ useFieldProps({ or a custom `required` property validation function: ```tsx +import { FormError } from '@dnb/eufemia/extensions/forms' + const validateRequired = (value, { emptyValue, required, isChanged }) => { if (required && value === emptyValue) { - return new Error('This value is required') + return new FormError('Field.errorRequired') } } @@ -174,10 +178,16 @@ import { const myFieldTranslations = { 'en-GB': { - MyField: { label: 'My field', required: 'Custom required message' }, + MyField: { + label: 'My field', + requiredMessage: 'Custom required message', + }, }, 'nb-NO': { - MyField: { label: 'Mitt felt', required: 'Obligatorisk felt melding' }, + MyField: { + label: 'Mitt felt', + requiredMessage: 'Obligatorisk felt melding', + }, }, } type Translation = @@ -187,12 +197,12 @@ const MyField = (props) => { const translations = Form.useTranslation( myFieldTranslations, ) - const { label, required } = translations.MyField + const { label, requiredMessage } = translations.MyField const preparedProps = { label, errorMessages: { - required, + 'Field.errorRequired': requiredMessage, }, ...props, } diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useFieldProps/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useFieldProps/Examples.tsx index 72beaa95c32..5f737d4fd20 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useFieldProps/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useFieldProps/Examples.tsx @@ -4,7 +4,6 @@ import { Field, FieldBlock, Form, - AllJSONSchemaVersions, useFieldProps, } from '@dnb/eufemia/src/extensions/forms' import { Flex, Slider } from '@dnb/eufemia/src' @@ -20,22 +19,17 @@ export const CustomComponentExample = () => { [], ) - const errorMessages = React.useMemo( - () => ({ - required: 'This field is required', + const errorMessages = React.useMemo(() => { + return { + 'Field.errorRequired': 'This field is required', ...props.errorMessages, - }), - [props.errorMessages], - ) - const schema = React.useMemo( - () => - props.schema ?? { - type: 'number', - minimum: props.minimum, - maximum: props.maximum, - }, - [props.schema, props.minimum, props.maximum], - ) + } + }, [props.errorMessages]) + const schema = props.schema ?? { + type: 'number', + minimum: props.minimum, + maximum: props.maximum, + } const preparedProps = { fromInput, diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useFieldProps/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useFieldProps/info.mdx index 5e6e967f4e7..30dce239d0e 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useFieldProps/info.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/create-component/useFieldProps/info.mdx @@ -22,51 +22,49 @@ render() Advanced usage: ```tsx -import { - useFieldProps, - useErrorMessage, -} from '@dnb/eufemia/extensions/forms' - -const sharedContext = useContext(SharedContext) -const tr = sharedContext?.translation.Forms - -const errorMessages = useErrorMessage(props.path, props.errorMessages, { - // Your default error messages - required: tr.Field.errorRequired, - pattern: tr.Field.errorPattern, -}) +import { Form, useFieldProps } from '@dnb/eufemia/extensions/forms' -const preparedProps = { - errorMessages, - // Your component props - ...props, -} +const MyFieldComponent = (props) => { + const translations = Form.useTranslation().MyField + + const errorMessages = React.useMemo(() => { + return { + // My default error messages + 'Field.errorRequired': translations.myErrorMessage, + 'MyCustom.message': translations.myCustomErrorMessage, + ...props.errorMessages, + } + }, [props.errorMessages]) -const { - // Return Parameters: - value, - onChange, - onFocus, - onBlur, - error, - hasError, - isChanged, - setHasFocus, - htmlAttributes, - - // Component Properties - ...rest -} = useFieldProps(preparedProps) + const preparedProps = { + errorMessages, + // Your component props + ...props, + } -render( - , -) + const { + // Return Parameters: + value, + handleChange, + handleFocus, + handleBlur, + htmlAttributes, + + // Component Properties + ...rest + } = useFieldProps(preparedProps) + + return ( + + ) +} ``` ### Internal Properties @@ -123,8 +121,8 @@ render() #### Error -- `error` object like `FormError` that includes the string to display or an object with the key `validationRule`. More info down below. -- `errorMessages` object with your custom messages, where each key represents a `validationRule`. More info down below. +- `error` like `new Error()` or `new FormError()` that includes a message to display. More info down below. +- `errorMessages` object with your custom messages or translation keys, such as `'Field.errorRequired'`. More info down below. ### Return Parameters @@ -147,9 +145,7 @@ It returns all of the given component properties, in addition to these: ```ts const validateRequired = (value, { emptyValue, required, isChanged }) => { if (required && value === emptyValue) { - return new FormError('This value is required', { - validationRule: 'required', - }) + return new FormError('Field.errorRequired') } } @@ -159,7 +155,7 @@ const { error, hasError } = useFieldProps({ validateInitially: true, validateRequired, errorMessages: { - required: 'Show this when "required" fails.', + 'Field.errorRequired': 'Show this when "required" fails.', }, }) ``` @@ -191,7 +187,7 @@ But when you handle errors via `useFieldProps`, you may rather provide an object const { error, hasError } = useFieldProps({ required: true, errorMessages: { - required: 'Show this when "required" fails!', + 'Field.errorRequired': 'Show this when "required" fails!', }, ...componentProps, }) @@ -203,9 +199,7 @@ To re-use existing `errorMessages`, you can use the `FormError` constructor as w import { FormError } from '@dnb/eufemia/extensions/forms' // Will show the message from the errorMessages -new FormError('Internal error message', { - validationRule: 'required', -}) +new FormError('Field.errorRequired') ``` In order to invoke an error without a change and blur event, you can use `validateInitially`: @@ -216,7 +210,7 @@ const { error, hasError } = useFieldProps({ required: true, validateInitially: true, errorMessages: { - required: 'Show this when "required" fails!', + 'Field.errorRequired': 'Show this when "required" fails!', }, }) ``` diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx index 939dfcaaa92..1a0f85047fb 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/feature-fields/NationalIdentityNumber/Examples.tsx @@ -1,6 +1,6 @@ import ComponentBox from '../../../../../../shared/tags/ComponentBox' import { createMinimumAgeValidator } from '@dnb/eufemia/src/extensions/forms/Field/NationalIdentityNumber' -import { Field } from '@dnb/eufemia/src/extensions/forms' +import { Field, FormError } from '@dnb/eufemia/src/extensions/forms' export const Empty = () => { return ( @@ -142,18 +142,16 @@ export const ValidationFunction = () => { const fnr = (value: string) => value.length >= 11 ? { status: 'valid' } : { status: 'invalid' } - const validator = (value, { errorMessages }) => { - const result = fnr(value) - return result.status === 'invalid' - ? new Error(errorMessages.pattern) - : undefined - } - return ( { + const result = fnr(value) + return result.status === 'invalid' + ? new FormError('Field.errorPattern') + : undefined + }} validateInitially /> ) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index 7803658e379..90e9e46f1bc 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -37,6 +37,7 @@ import AsyncChangeExample from './Form/Handler/parts/async-change-example.mdx' - [onChange and autosave](#onchange-and-autosave) - [Async field validation](#async-field-validation) - [Validation and error handling](#validation-and-error-handling) + - [Error messages](#error-messages) - [Summary for errors](#summary-for-errors) - [required](#required) - [pattern](#pattern) @@ -48,7 +49,7 @@ import AsyncChangeExample from './Form/Handler/parts/async-change-example.mdx' - [Localization and translation](#localization-and-translation) - [Customize translations](#customize-translations) - [How to customize translations in a form](#how-to-customize-translations-in-a-form) - - [Use the shared Provider to customize translations](#use-the-shared-provider-to-customize-translations) + - [Consume the translations](#consume-the-translations) - [Layout](#layout) - [Best practices](#best-practices) - [Create your own component](#create-your-own-component) @@ -401,6 +402,21 @@ Fields which have the `disabled` property or the `readOnly` property, will skip For monitoring and setting your form errors, you can use the [useValidation](/uilib/extensions/forms/Form/useValidation) hook. +#### Error messages + +Eufemia Forms comes with built-in error messages. But you can also customize and override these messages by using the `errorMessages` property both on [fields](/uilib/extensions/forms/all-fields/) (field level) and on the [Form.Handler](/uilib/extensions/forms/Form/Handler/) (global level). + +You may use the `errorMessages` property for two purposes: + +- Provide your onw error messages. +- Overwrite the default error messages. + +Both can be done on a global level or on a field level. + +How ever, for when overwriting the default error messages on a global level, you can also use [internationalization (i18n)](#localization-and-translation). + +Read more about [error messages](/uilib/extensions/forms/Form/error-messages/). + #### Summary for errors To improve user experience communication regarding errors and their locations, WCAG/UU suggests summarizing error messages when errors have occurred. @@ -485,7 +501,7 @@ const onChangeValidator = (value) => { render() ``` -You can find more info about error messages in the [Error messages](/uilib/extensions/forms/Form/error-messages/) docs. +You can find more info about error messages in the [error messages](/uilib/extensions/forms/Form/error-messages/) docs. ##### Connect with another field @@ -517,7 +533,7 @@ render( By default, the validator function will only run when the "/withValidator" field is changed. When the error message is shown, it will update the message with the new value of the "/myReference" field. -You can also change this behavior by using the following properties: +You can also change this behavior for testing purposes by using the following properties: - `validateInitially` will run the validation initially. - `continuousValidation` will run the validation on every change, including when the connected field changes. diff --git a/packages/dnb-design-system-portal/src/shared/tags/ComponentBox.tsx b/packages/dnb-design-system-portal/src/shared/tags/ComponentBox.tsx index 189503d543c..4d77f29e366 100644 --- a/packages/dnb-design-system-portal/src/shared/tags/ComponentBox.tsx +++ b/packages/dnb-design-system-portal/src/shared/tags/ComponentBox.tsx @@ -17,6 +17,7 @@ import { Wizard, FieldBlock, Iterate, + FormError, } from '@dnb/eufemia/src/extensions/forms' if (!globalThis.ComponentBoxMemo) { @@ -46,6 +47,7 @@ function ComponentBox(props: CodeSectionProps) { styled, React, Iterate, + FormError, ...scope, }} {...rest} diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts index b7f5b66e549..034451729b7 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Context.ts @@ -1,15 +1,14 @@ import React from 'react' -import { Ajv, makeAjvInstance } from '../utils/ajv' +import { Ajv, FormError, makeAjvInstance } from '../utils' import { AllJSONSchemaVersions, - CustomErrorMessagesWithPaths, + GlobalErrorMessagesWithPaths, SubmitState, Path, EventStateObject, EventReturnWithStateObject, Identifier, FieldProps, - FormError, ValueProps, OnChange, OnSubmitParams, @@ -118,8 +117,8 @@ export interface ContextState { hasErrors: () => boolean hasFieldState: (state: SubmitState) => boolean hasFieldError: (path: Path) => boolean - setFieldState: (path: Path, fieldState: SubmitState) => void - setFieldError: (path: Path, error: Error | FormError) => void + setFieldState?: (path: Path, fieldState: SubmitState) => void + setFieldError?: (path: Path, error: Error | FormError) => void setMountedFieldState: (path: Path, options: MountState) => void setFormState?: ( state: SubmitState, @@ -171,7 +170,7 @@ export interface ContextState { hasVisibleError: boolean formState: SubmitState ajvInstance: Ajv - contextErrorMessages: CustomErrorMessagesWithPaths + contextErrorMessages: GlobalErrorMessagesWithPaths schema: AllJSONSchemaVersions path?: Path disabled?: boolean @@ -205,8 +204,6 @@ export const defaultContextState: ContextState = { hasErrors: () => false, hasFieldState: () => false, hasFieldError: () => false, - setFieldState: () => null, - setFieldError: () => null, ajvInstance: makeAjvInstance(), contextErrorMessages: undefined, isInsideFormElement: false, diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx index 22c67f34d2c..4e691eb7fca 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/Provider.tsx @@ -12,10 +12,10 @@ import { Ajv, makeAjvInstance, ajvErrorsToFormErrors, -} from '../../utils/ajv' -import { FormError, - CustomErrorMessagesWithPaths, +} from '../../utils' +import { + GlobalErrorMessagesWithPaths, AllJSONSchemaVersions, FieldProps, SubmitState, @@ -34,9 +34,9 @@ import FieldPropsProvider from '../../Field/Provider' import useUpdateEffect from '../../../../shared/helpers/useUpdateEffect' import { isAsync } from '../../../../shared/helpers/isAsync' import { useSharedState } from '../../../../shared/helpers/useSharedState' -import { ContextProps } from '../../../../shared/Context' +import SharedContext, { ContextProps } from '../../../../shared/Context' import useTranslation from '../../hooks/useTranslation' -import Context, { +import DataContext, { ContextState, EventListenerCall, FilterData, @@ -102,7 +102,7 @@ export interface Props /** * Custom error messages for the whole data set */ - errorMessages?: CustomErrorMessagesWithPaths + errorMessages?: GlobalErrorMessagesWithPaths /** * @deprecated Use the `filterData` in the second event parameter in the `onSubmit` or `onChange` events. */ @@ -218,7 +218,7 @@ export default function Provider( locale, translations, required, - errorMessages: contextErrorMessages, + errorMessages, isolate, children, ...rest @@ -231,7 +231,7 @@ export default function Provider( ) } - const { hasContext } = useContext(Context) || {} + const { hasContext } = useContext(DataContext) || {} if (hasContext && !isolate) { throw new Error('DataContext (Form.Handler) can not be nested') @@ -241,6 +241,7 @@ export default function Provider( const formElementRef = useRef(null) // - Locale + const { locale: sharedLocale } = useContext(SharedContext) || {} const translation = useTranslation().Field // - Ajv @@ -388,8 +389,8 @@ export default function Provider( /** * Sets the error state for a specific path */ - const setFieldError = useCallback( - (path: Path, error: Error | FormError) => { + const setFieldError: ContextState['setFieldError'] = useCallback( + (path, error) => { fieldErrorRef.current[path] = error }, [] @@ -398,8 +399,8 @@ export default function Provider( /** * Sets the field state for a specific path */ - const setFieldState = useCallback( - (path: Path, fieldState: SubmitState) => { + const setFieldState: ContextState['setFieldState'] = useCallback( + (path, fieldState) => { if (fieldState !== fieldStateRef.current[path]) { // The state for the target value was changed fieldStateRef.current[path] = fieldState @@ -1285,6 +1286,8 @@ export default function Provider( : (formState === 'pending') === true ? true : undefined + const contextErrorMessages = + errorMessages?.[locale ?? sharedLocale] || errorMessages const contextValue: ContextState = { /** Method */ @@ -1353,7 +1356,7 @@ export default function Provider( } return ( - + ( > {children} - + ) } diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx index 76136b7ea69..6584a5fc6b5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx @@ -11,7 +11,9 @@ import userEvent from '@testing-library/user-event' import { spyOnEufemiaWarn, wait } from '../../../../../core/jest/jestSetup' import { simulateAnimationEnd } from '../../../../../components/height-animation/__tests__/HeightAnimationUtils' import { GlobalStatus } from '../../../../../components' +import SharedProvider from '../../../../../shared/Provider' import { makeUniqueId } from '../../../../../shared/component-helper' +import { debounceAsync } from '../../../../../shared/helpers/debounce' import { Form, DataContext, @@ -29,7 +31,6 @@ import { FilterData, FilterDataPathCondition, } from '../../Context' -import { debounceAsync } from '../../../../../shared/helpers/debounce' import nbNO from '../../../constants/locales/nb-NO' const nb = nbNO['nb-NO'] @@ -2274,7 +2275,7 @@ describe('DataContext.Provider', () => { label="Field 1" path="/foo" errorMessages={{ - required: 'Required string', + 'Field.errorRequired': 'Required string', }} required /> @@ -2283,13 +2284,13 @@ describe('DataContext.Provider', () => { value="abc" minLength={5} errorMessages={{ - minLength: 'Min 5 chars', + 'StringField.errorMinLength': 'Min 5 chars', }} /> @@ -2350,7 +2351,7 @@ describe('DataContext.Provider', () => { render( { ) expect(screen.getByRole('alert')).toHaveTextContent( - 'pattern provider error' + 'Pattern provider error' ) }) @@ -2371,9 +2372,9 @@ describe('DataContext.Provider', () => { render( @@ -2387,7 +2388,7 @@ describe('DataContext.Provider', () => { ) expect(screen.getByRole('alert')).toHaveTextContent( - 'pattern provider myKey error' + 'Pattern provider myKey error' ) }) @@ -2395,9 +2396,9 @@ describe('DataContext.Provider', () => { render( @@ -2407,14 +2408,14 @@ describe('DataContext.Provider', () => { pattern="^correct$" value="wrong" errorMessages={{ - pattern: 'pattern field error', + 'Field.errorPattern': 'Pattern field error', }} /> ) expect(screen.getByRole('alert')).toHaveTextContent( - 'pattern field error' + 'Pattern field error' ) }) @@ -2711,13 +2712,15 @@ describe('DataContext.Provider', () => { ) - expect(screen.getByText('Minimum 7 chars.')).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent( + 'Minimum 7 chars.' + ) }) describe('disabled and readOnly', () => { @@ -3068,7 +3071,7 @@ describe('DataContext.Provider', () => { myKey: { type: 'string', pattern: '[a-z]{1,}', - errorMessage: 'message in provider schema', + errorMessage: 'Message in provider schema', }, }, } as const @@ -3080,7 +3083,7 @@ describe('DataContext.Provider', () => { ) expect(screen.queryByRole('alert')).toHaveTextContent( - 'message in provider schema' + 'Message in provider schema' ) const providerSharedSchema = { @@ -3091,8 +3094,8 @@ describe('DataContext.Provider', () => { minLength: 2, maxLength: 3, errorMessage: { - minLength: 'minLength message in provider schema', - maxLength: 'maxLength message in provider schema', + minLength: 'minLength Message in provider schema', + maxLength: 'maxLength Message in provider schema', }, }, }, @@ -3110,14 +3113,14 @@ describe('DataContext.Provider', () => { fireEvent.blur(input) expect(screen.queryByRole('alert')).toHaveTextContent( - 'minLength message in provider schema' + 'minLength Message in provider schema' ) await userEvent.type(input, '1234') fireEvent.blur(input) expect(screen.queryByRole('alert')).toHaveTextContent( - 'maxLength message in provider schema' + 'maxLength Message in provider schema' ) }) @@ -3142,7 +3145,7 @@ describe('DataContext.Provider', () => { const { rerender } = render( { ) expect(screen.queryByRole('alert')).toHaveTextContent( - 'message in schema' + 'Message in schema' ) rerender( { ) expect(screen.queryByRole('alert')).toHaveTextContent( - 'message in schema' + 'Message in schema' ) rerender( { ) expect(screen.queryByRole('alert')).toHaveTextContent( - 'message in provider' + 'Message in provider' ) rerender( @@ -3214,16 +3217,16 @@ describe('DataContext.Provider', () => { ) expect(screen.queryByRole('alert')).toHaveTextContent( - 'message in provider for just one field' + 'Message in provider for just one field' ) rerender( @@ -3232,15 +3235,54 @@ describe('DataContext.Provider', () => { path="/myKey" value="" validateInitially - errorMessages={{ notEmpty: 'message for just this field' }} + errorMessages={{ notEmpty: 'Message for just this field' }} /> ) expect(screen.queryByRole('alert')).toHaveTextContent( - 'message for just this field' + 'Message for just this field' ) }) + + it('should support locale in errorMessages', () => { + const errorRequired = 'Display me, instead of the default message' + + render( + + + + ) + + expect(screen.queryByRole('alert')).toHaveTextContent(errorRequired) + }) + + it('should support locale in errorMessages when locale is given by the shared Provider', () => { + const errorRequired = 'Display me, instead of the default message' + + render( + + + + + + ) + + expect(screen.queryByRole('alert')).toHaveTextContent(errorRequired) + }) }) it('should run filterData with correct data in onSubmit', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/stories/Provider.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/stories/Provider.stories.tsx index d65ce32a8d5..095d6104403 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/stories/Provider.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/stories/Provider.stories.tsx @@ -65,7 +65,7 @@ export function Validation() { label="Field 1" path="/foo" errorMessages={{ - required: 'Required string', + 'Field.errorRequired': 'Required string', }} required /> @@ -74,13 +74,13 @@ export function Validation() { value="abc" minLength={5} errorMessages={{ - minLength: 'Min 5 chars', + 'StringField.errorMinLength': 'Min 5 chars', }} /> diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelection.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelection.tsx index 01c578d1ace..e714e5d56c3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelection.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/ArraySelection/ArraySelection.tsx @@ -8,7 +8,6 @@ import { DefaultErrorMessages, FieldHelpProps, FieldProps, - FormError, Path, } from '../../types' import { pickSpacingProps } from '../../../../components/flex/utils' @@ -17,6 +16,7 @@ import { HelpButtonProps } from '../../../../components/HelpButton' import ToggleButtonGroupContext from '../../../../components/toggle-button/ToggleButtonGroupContext' import DataContext from '../../DataContext/Context' import useDataValue from '../../hooks/useDataValue' +import { FormError } from '../../utils' type OptionProps = React.ComponentProps< React.FC<{ diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/BankAccountNumber/BankAccountNumber.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/BankAccountNumber/BankAccountNumber.tsx index 3f5e80131f2..89abcdae5e5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/BankAccountNumber/BankAccountNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/BankAccountNumber/BankAccountNumber.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useMemo } from 'react' import StringField, { Props as StringFieldProps } from '../String' -import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' import { Validator } from '../../types' @@ -19,12 +18,13 @@ function BankAccountNumber(props: Props) { label, } = useTranslation().BankAccountNumber - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: errorRequired, - pattern: errorBankAccountNumber, - errorBankAccountNumber, - errorBankAccountNumberLength, - }) + const errorMessages = useMemo(() => { + return { + 'Field.errorRequired': errorRequired, + 'Field.errorPattern': errorBankAccountNumber, + ...props.errorMessages, + } + }, [errorBankAccountNumber, errorRequired, props.errorMessages]) const bankAccountNumberValidator = useCallback( (value: string) => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx index b2baa4c6672..67d9c47e164 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Date/Date.tsx @@ -10,7 +10,6 @@ import { pickSpacingProps } from '../../../../components/flex/utils' import classnames from 'classnames' import FieldBlock from '../../FieldBlock' import { parseISO, isValid } from 'date-fns' -import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' import { DatePickerEvent } from '../../../../components/DatePicker' @@ -29,10 +28,13 @@ export type Props = FieldHelpProps & function DateComponent(props: Props) { const translations = useTranslation() - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: translations.Date.errorRequired, - pattern: translations.Field.errorRequired, - }) + const errorMessages = useMemo(() => { + return { + 'Field.errorRequired': translations.Date.errorRequired, + 'Field.errorPattern': translations.Date.errorRequired, + ...props.errorMessages, + } + }, [props.errorMessages, translations.Date.errorRequired]) const schema = useMemo( () => diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Email/Email.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Email/Email.tsx index c189c0b69ea..47427fb3880 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Email/Email.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Email/Email.tsx @@ -1,20 +1,23 @@ -import React from 'react' +import React, { useMemo } from 'react' import StringField, { Props as StringFieldProps } from '../String' -import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' export type Props = StringFieldProps function Email(props: Props) { - const translations = useTranslation().Email + const { label, errorRequired, errorPattern } = useTranslation().Email - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: translations.errorRequired, - pattern: translations.errorPattern, - }) + const errorMessages = useMemo( + () => ({ + 'Field.errorRequired': errorRequired, + 'Field.errorPattern': errorPattern, + ...props.errorMessages, + }), + [errorPattern, errorRequired, props.errorMessages] + ) const StringFieldProps: Props = { - label: translations.label, + label, autoComplete: 'email', inputMode: 'email', pattern: diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Expiry/Expiry.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Expiry/Expiry.tsx index 31d2b4098d2..91d174c87da 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Expiry/Expiry.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Expiry/Expiry.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import { FieldHelpProps, FieldProps } from '../../types' import { pickSpacingProps } from '../../../../components/flex/utils' import { useFieldProps } from '../../hooks' @@ -7,7 +7,6 @@ import FieldBlock from '../../FieldBlock' import { MultiInputMask } from '../../../../components/input-masked' import type { MultiInputMaskValue } from '../../../../components/input-masked' import { HelpButton } from '../../../../components' -import useErrorMessage from '../../hooks/useErrorMessage' import { useTranslation as useSharedTranslation } from '../../../../shared' import useTranslation from '../../hooks/useTranslation' @@ -30,9 +29,13 @@ function Expiry(props: ExpiryProps) { }, } = useSharedTranslation() - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: errorRequired, - }) + const errorMessages = useMemo( + () => ({ + 'Field.errorRequired': errorRequired, + ...props.errorMessages, + }), + [errorRequired, props.errorMessages] + ) const validateRequired = useCallback( (value: string, { required, error }) => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Name/Name.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Name/Name.tsx index 8feb0230f66..2a361b16771 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Name/Name.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Name/Name.tsx @@ -1,6 +1,5 @@ -import React from 'react' +import React, { useMemo } from 'react' import StringField, { Props as StringFieldProps } from '../String' -import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' export type Props = StringFieldProps @@ -19,10 +18,17 @@ Name._supportsSpacingProps = true Name.First = function FirstName(props: Props) { const translations = useTranslation().FirstName - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: translations.errorRequired, - pattern: translations.errorPattern, - }) + const errorMessages = useMemo(() => { + return { + 'Field.errorRequired': translations.errorRequired, + 'Field.errorPattern': translations.errorPattern, + ...props.errorMessages, + } + }, [ + props.errorMessages, + translations.errorPattern, + translations.errorRequired, + ]) const nameProps: Props = { label: translations.label, @@ -37,10 +43,17 @@ Name.First['_supportsSpacingProps'] = true Name.Last = function LastName(props: Props) { const translations = useTranslation().LastName - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: translations.errorRequired, - pattern: translations.errorPattern, - }) + const errorMessages = useMemo(() => { + return { + 'Field.errorRequired': translations.errorRequired, + 'Field.errorPattern': translations.errorPattern, + ...props.errorMessages, + } + }, [ + props.errorMessages, + translations.errorPattern, + translations.errorRequired, + ]) const nameProps: Props = { label: translations.label, @@ -55,9 +68,12 @@ Name.First['_supportsSpacingProps'] = true Name.Company = function CompanyName(props: Props) { const translations = useTranslation().CompanyName - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: translations.errorRequired, - }) + const errorMessages = useMemo(() => { + return { + 'Field.errorRequired': translations.errorRequired, + ...props.errorMessages, + } + }, [props.errorMessages, translations.errorRequired]) const StringFieldProps: Props = { label: translations.label, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx index ee9f7c9c527..650876cfdd7 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/NationalIdentityNumber.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useMemo } from 'react' import StringField, { Props as StringFieldProps } from '../String' import { dnr, fnr } from '@navikt/fnrvalidator' -import { FormError, Validator } from '../../types' - -import useErrorMessage from '../../hooks/useErrorMessage' +import { Validator } from '../../types' +import { FormError } from '../../utils' import useTranslation from '../../hooks/useTranslation' export type Props = Omit & { @@ -21,19 +20,15 @@ function NationalIdentityNumber(props: Props) { errorFnrLength, errorDnr, errorDnrLength, - errorMinimumAgeValidator, - errorMinimumAgeValidatorLength, } = translations - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: errorRequired, - pattern: errorFnr, - errorFnr, - errorFnrLength, - errorDnr, - errorDnrLength, - errorMinimumAgeValidator, - errorMinimumAgeValidatorLength, - }) + const errorMessages = useMemo( + () => ({ + 'Field.errorRequired': errorRequired, + 'Field.errorPattern': errorFnr, + ...props.errorMessages, + }), + [errorRequired, errorFnr, props.errorMessages] + ) const identificationNumberIsOfLength = ( identificationNumber: string, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx index e220fd4695a..72b3e73d4e8 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/NationalIdentityNumber/__tests__/NationalIdentityNumber.test.tsx @@ -176,12 +176,18 @@ describe('Field.NationalIdentityNumber', () => { '123', expect.objectContaining({ errorMessages: expect.objectContaining({ + 'Field.errorRequired': expect.stringContaining('fødselsnummer'), + 'Field.errorPattern': expect.stringContaining('fødselsnummer'), + 'StringField.errorMinLength': + expect.stringContaining('{minLength}'), + 'StringField.errorMaxLength': + expect.stringContaining('{maxLength}'), + + // For backward compatibility – can be removed in v11 maxLength: expect.stringContaining('{maxLength}'), minLength: expect.stringContaining('{minLength}'), pattern: expect.stringContaining('fødselsnummer'), required: expect.stringContaining('fødselsnummer'), - errorDnr: expect.stringContaining('d-nummer'), - errorFnr: expect.stringContaining('fødselsnummer'), }), }) ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx index 97e03e542e1..734de41c454 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Number/Number.tsx @@ -11,28 +11,15 @@ import { FieldProps, FieldHelpProps, AllJSONSchemaVersions, - CustomErrorMessages, FieldBlockWidth, } from '../../types' import { pickSpacingProps } from '../../../../components/flex/utils' import { ButtonProps, ButtonSize } from '../../../../components/Button' import { clamp } from '../../../../components/slider/SliderHelpers' -import useErrorMessage from '../../hooks/useErrorMessage' -import useTranslation from '../../hooks/useTranslation' import DataContext from '../../DataContext/Context' -interface ErrorMessages extends CustomErrorMessages { - required?: string - schema?: string - minimum?: string - maximum?: string - exclusiveMinimum?: string - exclusiveMaximum?: string - multipleOf?: string -} - export type Props = FieldHelpProps & - FieldProps & { + FieldProps & { inputClassName?: string currency?: InputMaskedProps['as_currency'] currencyDisplay?: 'code' | 'symbol' | 'narrowSymbol' | 'name' @@ -66,7 +53,6 @@ function NumberComponent(props: Props) { const dataContext = useContext(DataContext) const fieldBlockContext = useContext(FieldBlockContext) const sharedContext = useContext(SharedContext) - const translations = useTranslation() const { currency, @@ -82,15 +68,6 @@ function NumberComponent(props: Props) { showStepControls, } = props - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: translations.Field.errorRequired, - minimum: translations.NumberField.errorMinimum, - maximum: translations.NumberField.errorMaximum, - exclusiveMinimum: translations.NumberField.errorExclusiveMinimum, - exclusiveMaximum: translations.NumberField.errorExclusiveMaximum, - multipleOf: translations.NumberField.errorMultipleOf, - }) - const schema = useMemo( () => props.schema ?? { @@ -130,7 +107,6 @@ function NumberComponent(props: Props) { const preparedProps: Props = { valueType: 'number', ...props, - errorMessages, schema, toInput, fromInput, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumber.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumber.tsx index bc34a90f4a2..79dabac77ee 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/OrganizationNumber/OrganizationNumber.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useMemo } from 'react' import StringField, { Props as StringFieldProps } from '../String' -import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' import { Validator } from '../../types' @@ -15,12 +14,14 @@ function OrganizationNumber(props: Props) { const { errorOrgNo, errorOrgNoLength, errorRequired, label } = translations - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: errorRequired, - pattern: errorOrgNo, - errorOrgNo, - errorOrgNoLength, - }) + const errorMessages = useMemo( + () => ({ + 'Field.errorRequired': errorRequired, + 'Field.errorPattern': errorOrgNo, + ...props.errorMessages, + }), + [errorRequired, errorOrgNo, props.errorMessages] + ) const organizationNumberValidator = useCallback( (value: string) => { diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/PhoneNumber.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/PhoneNumber.tsx index 3451cfe2da0..3988f42e9e0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/PhoneNumber.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/PhoneNumber.tsx @@ -21,7 +21,6 @@ import { CountryFilterSet, getCountryData, } from '../SelectCountry' -import useErrorMessage from '../../hooks/useErrorMessage' import useTranslation from '../../hooks/useTranslation' import { DrawerListDataObject } from '../../../../fragments/DrawerList' @@ -79,7 +78,11 @@ const defaultMask = [ function PhoneNumber(props: Props) { const sharedContext = useContext(SharedContext) - const translations = useTranslation() + const { + label: defaultLabel, + countryCodeLabel: defaultCountryCodeLabel, + errorRequired, + } = useTranslation().PhoneNumber const lang = sharedContext.locale?.split('-')[0] as CountryLang const countryCodeRef = React.useRef(props?.emptyValue) @@ -88,10 +91,14 @@ function PhoneNumber(props: Props) { const langRef = React.useRef(lang) const wasFilled = React.useRef(false) - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: translations.PhoneNumber.errorRequired, - pattern: translations.PhoneNumber.errorRequired, - }) + const errorMessages = useMemo( + () => ({ + 'Field.errorRequired': errorRequired, + 'Field.errorPattern': errorRequired, + ...props.errorMessages, + }), + [errorRequired, props.errorMessages] + ) const validateRequired = useCallback( (value: string, { required, isChanged, error }) => { @@ -143,7 +150,7 @@ function PhoneNumber(props: Props) { countryCodePlaceholder, placeholder, countryCodeLabel, - label = translations.PhoneNumber.label, + label = defaultLabel, numberMask, countries: ccFilter = 'Prioritized', emptyValue, @@ -350,9 +357,7 @@ function PhoneNumber(props: Props) { mode="async" placeholder={countryCodePlaceholder} label_direction="vertical" - label={ - countryCodeLabel ?? translations.PhoneNumber.countryCodeLabel - } + label={countryCodeLabel ?? defaultCountryCodeLabel} data={dataRef.current} value={countryCodeRef.current} status={hasError ? 'error' : undefined} diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx index 180e252c5e3..3d5969c85ac 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx @@ -682,8 +682,12 @@ describe('Field.PhoneNumber', () => { '+41 9999', expect.objectContaining({ errorMessages: expect.objectContaining({ - pattern: enGB.PhoneNumber.errorRequired, + 'Field.errorRequired': enGB.PhoneNumber.errorRequired, + 'Field.errorPattern': enGB.PhoneNumber.errorRequired, + + /** @deprecated – can be removed in v11 */ required: enGB.PhoneNumber.errorRequired, + pattern: enGB.PhoneNumber.errorRequired, }), }) ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/stories/PhoneNumber.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/stories/PhoneNumber.stories.tsx index 0779d38055f..202ab3d8e48 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/stories/PhoneNumber.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/stories/PhoneNumber.stories.tsx @@ -20,9 +20,7 @@ const validator = async (value) => { // Delay the response const isValid = await makeRequest(value) if (!isValid) { - return new FormError('Invalid value', { - validationRule: 'required', - }) + return new FormError('Field.errorRequired') } } diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/PostalCodeAndCity/PostalCodeAndCity.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/PostalCodeAndCity/PostalCodeAndCity.tsx index 4e1a79010e5..6d3d41dd2db 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/PostalCodeAndCity/PostalCodeAndCity.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/PostalCodeAndCity/PostalCodeAndCity.tsx @@ -100,8 +100,8 @@ function PostalCodeAndCity(props: Props) { )} label={postalCodeLabel ?? translations.PostalCode.label} errorMessages={{ - required: translations.PostalCode.errorRequired, - pattern: translations.PostalCode.errorPattern, + 'Field.errorRequired': translations.PostalCode.errorRequired, + 'Field.errorPattern': translations.PostalCode.errorPattern, ...postalCodeErrorMessages, }} width={postalCodeWidth ?? false} @@ -109,6 +109,7 @@ function PostalCodeAndCity(props: Props) { inputMode="numeric" autoComplete="postal-code" /> + ({ + required: errorRequired, + }), + [errorRequired] + ) - const defaultProps: Partial = { - errorMessages, - } const preparedProps: Props = { - ...defaultProps, + errorMessages, ...props, provideAdditionalArgs, } const { className, - placeholder = translations.placeholder, - label = translations.label, + placeholder = defaultPlaceholder, + label = defaultLabel, countries: ccFilter = 'Prioritized', info, warning, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Selection/Selection.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Selection/Selection.tsx index 8c20c95308e..01b7a17646a 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Selection/Selection.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Selection/Selection.tsx @@ -14,7 +14,6 @@ import { ReturnAdditional } from '../../hooks/useFieldProps' import { pickSpacingProps } from '../../../../components/flex/utils' import FieldBlock from '../../FieldBlock' import { - FormError, FieldProps, FieldHelpProps, FieldBlockWidth, @@ -30,6 +29,7 @@ import { ToCamelCase, } from '../../../../shared/helpers/withCamelCaseProps' import useDataValue from '../../hooks/useDataValue' +import { FormError } from '../../utils' type IOption = { title: string | React.ReactNode diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/String.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/String.tsx index ed01fea08f6..07944af772c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/String.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/String.tsx @@ -16,22 +16,12 @@ import type { TextCounterProps } from '../../../../fragments/TextCounter' import type { FieldProps, FieldHelpProps, - CustomErrorMessages, AllJSONSchemaVersions, FieldBlockWidth, } from '../../types' -import useErrorMessage from '../../hooks/useErrorMessage' -import useTranslation from '../../hooks/useTranslation' -interface ErrorMessages extends CustomErrorMessages { - required?: string - schema?: string - minLength?: string - maxLength?: string - pattern?: string -} export type Props = FieldHelpProps & - FieldProps & { + FieldProps & { // - Shared props multiline?: boolean inputClassName?: string @@ -78,14 +68,6 @@ export type Props = FieldHelpProps & function StringComponent(props: Props) { const dataContext = useContext(DataContext) const fieldBlockContext = useContext(FieldBlockContext) - const translations = useTranslation() - - const errorMessages = useErrorMessage(props.path, props.errorMessages, { - required: translations.Field.errorRequired, - minLength: translations.StringField.errorMinLength, - maxLength: translations.StringField.errorMaxLength, - pattern: translations.Field.errorPattern, - }) const schema = useMemo( () => @@ -140,7 +122,6 @@ function StringComponent(props: Props) { const preparedProps: Props = { ...props, - errorMessages, schema, fromInput, toEvent, diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx index 5e24d3809fb..ed5cd1810ad 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx @@ -10,10 +10,11 @@ import { import userEvent from '@testing-library/user-event' import { Provider } from '../../../../../shared' import * as DataContext from '../../../DataContext' -import { Field, FieldBlock, Form, Value } from '../../..' -import enGB from '../../../../../shared/locales/en-GB' +import { Field, FieldBlock, Form, FormError, Value } from '../../..' +import sharedGB from '../../../../../shared/locales/en-GB' +import nbNO from '../../../constants/locales/nb-NO' -const gb = enGB['en-GB'] +const nb = nbNO['nb-NO'] const syncValidatorReturningUndefined = () => undefined @@ -1117,8 +1118,38 @@ describe('Field.String', () => { it('should show provided errorMessages based on validation rule', () => { render( + ) + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe('You need this') + }) + + it('should support custom error messages', () => { + render( + + ) + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe('Your custom error message') + }) + + /** + * @deprecated – can be removed in v11 + */ + it('should support deprecated "required" errorMessage', () => { + render( + { validateInitially /> ) - expect(screen.getByText('You need this')).toBeInTheDocument() + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe('You need this') }) it('should show provided errorMessages based on validation rule with injected value', () => { @@ -1135,13 +1168,48 @@ describe('Field.String', () => { emptyValue="" value="" errorMessages={{ - minLength: 'At least {minLength}..', + 'StringField.errorMinLength': 'At least {minLength}.', + + /** @deprecated – can be removed in v11 */ + minLength: 'At least {minLength}.', }} minLength={4} validateInitially /> ) - expect(screen.getByText('At least 4..')).toBeInTheDocument() + + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe('At least 4.') + }) + + it('should provide error message to the validator', async () => { + let collectDeprecatedMessage = null + let collectCustomMessage = null + const customMessage = 'Your custom error message' + + render( + { + collectDeprecatedMessage = errorMessages.required + collectCustomMessage = errorMessages['MyCustom.message'] + return new FormError('MyCustom.message') + }} + validateInitially + /> + ) + + await waitFor(() => { + expect( + document.querySelector('.dnb-form-status').textContent + ).toBe(customMessage) + }) + + expect(collectCustomMessage).toBe(customMessage) + expect(collectDeprecatedMessage).toBe(nb.Field.errorRequired) }) }) }) @@ -1263,7 +1331,7 @@ describe('Field.String', () => { ) expect(counter).toHaveTextContent( - gb.TextCounter.characterExceeded + sharedGB['en-GB'].TextCounter.characterExceeded .replace('%count', '1') .replace('%max', '8') ) diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx index 5363f40070c..c7bc883aa69 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/stories/String.stories.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Field, Form, Tools } from '../../..' +import { Field, Form, FormError, Tools } from '../../..' import { Flex } from '../../../../../components' export default { @@ -88,3 +88,37 @@ export function TransformObject() { ) } + +export function ErrorMessages() { + return ( + + + { + return new FormError('OrganizationNumber.errorRequired') + }} + errorMessages={{ + 'OrganizationNumber.errorRequired': + 'Display me, instead of the default message', + }} + /> + { + // return new FormError('OrganizationNumber.errorRequired') + // }} + // errorMessages={{ + // 'OrganizationNumber.errorRequired': + // 'Display me, instead of the default message', + // }} + /> + + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx index 1f5c84f93bc..b9db8609a5e 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Upload/Upload.tsx @@ -1,15 +1,10 @@ -import React, { useCallback, useEffect } from 'react' +import React, { useCallback, useEffect, useMemo } from 'react' import FieldBlock, { Props as FieldBlockProps } from '../../FieldBlock' import { useFieldProps, useTranslation as useFormsTranslation, } from '../../hooks' -import { - FieldBlockWidth, - FieldHelpProps, - FieldProps, - FormError, -} from '../../types' +import { FieldBlockWidth, FieldHelpProps, FieldProps } from '../../types' import Upload, { UploadFile, UploadProps, @@ -19,6 +14,7 @@ import { pickSpacingProps } from '../../../../components/flex/utils' import { HelpButton } from '../../../../components' import { useTranslation as useSharedTranslation } from '../../../../shared' import { SpacingProps } from '../../../../shared/types' +import { FormError } from '../../utils' export type UploadValue = Array export type Props = FieldHelpProps & @@ -41,9 +37,7 @@ function UploadComponent(props: Props) { (value: UploadValue, { required, isChanged, error }) => { const hasError = value?.some((file) => file.errorMessage) if (hasError) { - return new FormError(error.message, { - validationRule: 'invalid', - }) + return new FormError('Upload.errorInvalidFiles') } if (required && (!isChanged || !(value.length > 0))) { @@ -58,11 +52,15 @@ function UploadComponent(props: Props) { const sharedTr = useSharedTranslation().Upload const formsTr = useFormsTranslation().Upload + const errorMessages = useMemo( + () => ({ + 'Field.errorRequired': formsTr.errorRequired, + }), + [formsTr.errorRequired] + ) + const preparedProps = { - errorMessages: { - required: formsTr.errorRequired, - invalid: formsTr.errorInvalidFiles, - }, + errorMessages, validateRequired, ...props, } diff --git a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx index a0349e61077..842ca60b841 100644 --- a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx @@ -32,7 +32,6 @@ import useId from '../../../shared/helpers/useId' import { ComponentProps, FieldProps, - FormError, SubmitState, Identifier, FieldBlockWidth, @@ -41,6 +40,7 @@ import type { FormLabelAllProps } from '../../../components/FormLabel' import SubmitIndicator from '../Form/SubmitIndicator/SubmitIndicator' import { createSharedState } from '../../../shared/helpers/useSharedState' import useTranslation from '../hooks/useTranslation' +import { FormError } from '../utils' export const states: Array = ['error', 'info', 'warning'] diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx index 021ec9bd206..81bcb59307c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.test.tsx @@ -827,6 +827,17 @@ describe('Form.Handler', () => { 'bar', expect.objectContaining({ errorMessages: expect.objectContaining({ + 'Field.errorRequired': expect.any(String), + 'Field.errorPattern': expect.any(String), + 'StringField.errorMinLength': expect.any(String), + 'StringField.errorMaxLength': expect.any(String), + 'NumberField.errorMinimum': expect.any(String), + 'NumberField.errorMaximum': expect.any(String), + 'NumberField.errorExclusiveMinimum': expect.any(String), + 'NumberField.errorExclusiveMaximum': expect.any(String), + 'NumberField.errorMultipleOf': expect.any(String), + + /** @deprecated – can be removed in v11 */ maxLength: expect.any(String), minLength: expect.any(String), pattern: expect.any(String), diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Section/__tests__/Section.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Section/__tests__/Section.test.tsx index e33495ce929..344f97e20e4 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/Section/__tests__/Section.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Section/__tests__/Section.test.tsx @@ -19,10 +19,19 @@ describe('Form.Section', () => { }: SectionProps<{ lastName: FieldNameProps }> & { children?: React.ReactNode }) => { + const errorMessages = {} return ( - - + + {children} ) @@ -36,10 +45,11 @@ describe('Form.Section', () => { }> & { children?: React.ReactNode }) => { + const errorMessages = {} return ( - + {children} ) @@ -112,10 +122,8 @@ describe('Form.Section', () => { "firstName": { "autoComplete": "given-name", "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Kun bokstaver og tegn som bindestrek og mellomrom er tillatt.", - "required": "Du må fylle inn fornavn.", + "Field.errorPattern": "Kun bokstaver og tegn som bindestrek og mellomrom er tillatt.", + "Field.errorRequired": "Du må fylle inn fornavn.", }, "label": "Fornavn", "path": "/firstName", @@ -132,10 +140,8 @@ describe('Form.Section', () => { "lastName": { "autoComplete": "family-name", "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Kun bokstaver og tegn som bindestrek og mellomrom er tillatt.", - "required": "Du må fylle inn etternavn.", + "Field.errorPattern": "Kun bokstaver og tegn som bindestrek og mellomrom er tillatt.", + "Field.errorRequired": "Du må fylle inn etternavn.", }, "label": "Etternavn", "minLength": 2, diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/GenerateSchema.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/GenerateSchema.test.tsx index 0ce249ce2d1..279846df000 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/GenerateSchema.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/GenerateSchema.test.tsx @@ -102,12 +102,6 @@ describe('Tools.GenerateSchema', () => { expect(generateRef.current().propsOfFields).toMatchInlineSnapshot(` { "myField": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "label": "My field", "path": "/myField", "schema": { @@ -120,12 +114,6 @@ describe('Tools.GenerateSchema', () => { }, "nested": { "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "minLength": 2, "path": "/nested/myString", "required": true, @@ -157,12 +145,6 @@ describe('Tools.GenerateSchema', () => { expect(generateRef.current().propsOfFields).toMatchInlineSnapshot(` { "myField": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "label": "My field", "path": "/myField", "schema": { @@ -174,12 +156,6 @@ describe('Tools.GenerateSchema', () => { "width": "large", }, "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "minLength": 2, "path": "/myString", "required": true, @@ -194,12 +170,6 @@ describe('Tools.GenerateSchema', () => { }, "nested": { "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "minLength": 2, "path": "/nested/myString", "required": true, diff --git a/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/ListAllProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/ListAllProps.test.tsx index 17f751aa888..31f8b766fa5 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/ListAllProps.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Tools/__tests__/ListAllProps.test.tsx @@ -23,14 +23,6 @@ describe('Tools.ListAllProps', () => { expect(log).toHaveBeenCalledWith({ propsOfFields: { myString: { - errorMessages: { - maxLength: - 'Verdien kan ikke være lengre enn {maxLength} tegn.', - minLength: - 'Verdien kan ikke være kortere enn {minLength} tegn.', - pattern: 'Verdien er ugyldig.', - required: 'Dette feltet må fylles ut.', - }, path: '/myString', pattern: '^[a-z]{2}[0-9]+$', required: true, @@ -64,12 +56,6 @@ describe('Tools.ListAllProps', () => { expect(generateRef.current().propsOfFields).toMatchInlineSnapshot(` { "myField": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "label": "My field", "path": "/myField", "schema": { @@ -82,12 +68,6 @@ describe('Tools.ListAllProps', () => { }, "nested": { "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "minLength": 2, "path": "/nested/myString", "required": true, @@ -119,12 +99,6 @@ describe('Tools.ListAllProps', () => { expect(generateRef.current().propsOfFields).toMatchInlineSnapshot(` { "myField": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "label": "My field", "path": "/myField", "schema": { @@ -136,12 +110,6 @@ describe('Tools.ListAllProps', () => { "width": "large", }, "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "minLength": 2, "path": "/myString", "required": true, @@ -156,12 +124,6 @@ describe('Tools.ListAllProps', () => { }, "nested": { "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "minLength": 2, "path": "/nested/myString", "required": true, @@ -263,14 +225,6 @@ describe('Tools.ListAllProps', () => { "valueType": "boolean", }, "myNumber": { - "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Verdien må være minst {minimum}.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Dette feltet må fylles ut.", - }, "path": "/myNumber", "schema": { "exclusiveMaximum": undefined, @@ -284,12 +238,6 @@ describe('Tools.ListAllProps', () => { "width": "medium", }, "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "path": "/myString", "schema": { "maxLength": undefined, @@ -348,14 +296,6 @@ describe('Tools.ListAllProps', () => { "valueType": "boolean", }, "myNumber": { - "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Verdien må være minst {minimum}.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Dette feltet må fylles ut.", - }, "exclusiveMaximum": 25, "exclusiveMinimum": 15, "maximum": 20, @@ -375,12 +315,6 @@ describe('Tools.ListAllProps', () => { }, "myObject": { "withString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "maxLength": 10, "minLength": 10, "path": "/myObject/withString", @@ -395,12 +329,6 @@ describe('Tools.ListAllProps', () => { }, }, "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "maxLength": 5, "minLength": 5, "path": "/myString", @@ -447,14 +375,6 @@ describe('Tools.ListAllProps', () => { "myObject": { "nested": { "withNumber": { - "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Verdien må være minst {minimum}.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Dette feltet må fylles ut.", - }, "exclusiveMaximum": 25, "exclusiveMinimum": 15, "maximum": 20, @@ -474,12 +394,6 @@ describe('Tools.ListAllProps', () => { }, }, "withString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "maxLength": 10, "minLength": 10, "path": "/myObject/withString", @@ -536,14 +450,6 @@ describe('Tools.ListAllProps', () => { }, "myObject": { "withNumber": { - "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Verdien må være minst {minimum}.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Dette feltet må fylles ut.", - }, "maximum": 20, "minimum": 10, "path": "/myObject/withNumber", @@ -560,12 +466,6 @@ describe('Tools.ListAllProps', () => { "width": "medium", }, "withString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "maxLength": 10, "minLength": 10, "path": "/myObject/withString", @@ -580,12 +480,6 @@ describe('Tools.ListAllProps', () => { }, }, "myString": { - "errorMessages": { - "maxLength": "Verdien kan ikke være lengre enn {maxLength} tegn.", - "minLength": "Verdien kan ikke være kortere enn {minLength} tegn.", - "pattern": "Verdien er ugyldig.", - "required": "Dette feltet må fylles ut.", - }, "path": "/myString", "required": true, "schema": { @@ -634,14 +528,6 @@ describe('Tools.ListAllProps', () => { "items": { "0": { "item": { - "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Verdien må være minst {minimum}.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Dette feltet må fylles ut.", - }, "itemPath": "/item", "label": "My field", "schema": { @@ -660,14 +546,6 @@ describe('Tools.ListAllProps', () => { }, "1": { "item": { - "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Verdien må være minst {minimum}.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Dette feltet må fylles ut.", - }, "itemPath": "/item", "label": "My field", "schema": { diff --git a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx index 44a35b6c93a..134c964f690 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/ChildrenWithAge.tsx @@ -65,7 +65,7 @@ function EditContainer({ label={tr.ChildrenWithAge.hasChildren.fieldLabel} variant="buttons" errorMessages={{ - required: tr.ChildrenWithAge.hasChildren.required, + 'Field.errorRequired': tr.ChildrenWithAge.hasChildren.required, }} /> @@ -74,8 +74,10 @@ function EditContainer({ path="/countChildren" label={tr.ChildrenWithAge.countChildren.fieldLabel} errorMessages={{ - minimum: tr.ChildrenWithAge.countChildren.required, - required: tr.ChildrenWithAge.countChildren.required, + 'NumberField.errorMinimum': + tr.ChildrenWithAge.countChildren.required, + 'Field.errorRequired': + tr.ChildrenWithAge.countChildren.required, }} defaultValue={1} width="small" @@ -96,7 +98,8 @@ function EditContainer({ itemPath="/age" label={tr.ChildrenWithAge.childrenAge.fieldLabel} errorMessages={{ - required: tr.ChildrenWithAge.childrenAge.required, + 'Field.errorRequired': + tr.ChildrenWithAge.childrenAge.required, }} placeholder="0" width="small" @@ -115,7 +118,8 @@ function EditContainer({ label={tr.ChildrenWithAge.usesDaycare.fieldLabel} variant="buttons" errorMessages={{ - required: tr.ChildrenWithAge.usesDaycare.required, + 'Field.errorRequired': + tr.ChildrenWithAge.usesDaycare.required, }} help={{ title: tr.ChildrenWithAge.usesDaycare.fieldLabel, @@ -133,7 +137,8 @@ function EditContainer({ path="/daycareExpenses" label={tr.ChildrenWithAge.dayCareExpenses.fieldLabel} errorMessages={{ - required: tr.ChildrenWithAge.dayCareExpenses.required, + 'Field.errorRequired': + tr.ChildrenWithAge.dayCareExpenses.required, }} minimum={1} decimalLimit={0} @@ -149,7 +154,8 @@ function EditContainer({ label={tr.ChildrenWithAge.hasJointResponsibility.fieldLabel} variant="buttons" errorMessages={{ - required: tr.ChildrenWithAge.hasJointResponsibility.required, + 'Field.errorRequired': + tr.ChildrenWithAge.hasJointResponsibility.required, }} /> @@ -164,7 +170,7 @@ function EditContainer({ tr.ChildrenWithAge.jointResponsibilityExpenses.fieldLabel } errorMessages={{ - required: + 'Field.errorRequired': tr.ChildrenWithAge.jointResponsibilityExpenses.required, }} minimum={1} diff --git a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap index dd5baeb7133..e6c678cce9f 100644 --- a/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap +++ b/packages/dnb-eufemia/src/extensions/forms/blocks/ChildrenWithAge/__tests__/__snapshots__/ChildrenWithAge.test.tsx.snap @@ -8,12 +8,7 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "allowNegative": false, "decimalLimit": 0, "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Verdien må være minst {minimum}.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Du må skrive inn alder på barnet.", + "Field.errorRequired": "Du må skrive inn alder på barnet.", }, "itemPath": "/age", "label": "Alder på barn nr. {itemNo}", @@ -112,12 +107,7 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "allowNegative": false, "decimalLimit": 0, "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Verdien må være minst {minimum}.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Du må skrive inn alder på barnet.", + "Field.errorRequired": "Du må skrive inn alder på barnet.", }, "itemPath": "/age", "label": "Alder på barn nr. {itemNo}", @@ -308,12 +298,8 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` "decimalLimit": 0, "defaultValue": 1, "errorMessages": { - "exclusiveMaximum": "Verdien må være mindre enn {exclusiveMaximum}.", - "exclusiveMinimum": "Verdien må være større enn {exclusiveMinimum}.", - "maximum": "Verdien må være maksimalt {maximum}.", - "minimum": "Du må skrive inn antall barn.", - "multipleOf": "Verdien må være et multiplum av {multipleOf}.", - "required": "Du må skrive inn antall barn.", + "Field.errorRequired": "Du må skrive inn antall barn.", + "NumberField.errorMinimum": "Du må skrive inn antall barn.", }, "label": "Antall barn under 18 år", "maximum": 20, @@ -412,7 +398,7 @@ exports[`ChildrenWithAge should match snapshot 1`] = ` }, "hasChildren": { "errorMessages": { - "required": "Du må angi om du har barn under 18 år eller ikke.", + "Field.errorRequired": "Du må angi om du har barn under 18 år eller ikke.", }, "label": "Har du/dere barn under 18 år?", "path": "/hasChildren", diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx index 3e07ac65fa9..13253d00740 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx @@ -24,6 +24,9 @@ import Field, { import { spyOnEufemiaWarn, wait } from '../../../../core/jest/jestSetup' import { useSharedState } from '../../../../shared/helpers/useSharedState' +import nbNO from '../../constants/locales/nb-NO' +const nb = nbNO['nb-NO'] + describe('useFieldProps', () => { it('should call external onChange based change callbacks', () => { const onChange = jest.fn() @@ -926,9 +929,7 @@ describe('useFieldProps', () => { }) await waitFor(() => { - expect(result.current.error.message).toBe( - 'must match pattern "^(throw-on-validator)$"' - ) + expect(result.current.error.message).toBe(nb.Field.errorPattern) }) await validateBlur() @@ -944,19 +945,66 @@ describe('useFieldProps', () => { await validateBlur() }) - it('should show given error from errorMessages', () => { - const { result } = renderHook(() => - useFieldProps({ + describe('errorMessages', () => { + it('should show given error from errorMessages', () => { + const { result } = renderHook(() => + useFieldProps({ + value: undefined, + required: true, + validateInitially: true, + errorMessages: { + 'Field.errorRequired': 'Show this message', + }, + }) + ) + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error.message).toBe('Show this message') + }) + + it('should update error message given via errorMessages', () => { + const props = { value: undefined, required: true, validateInitially: true, + } + const { result, rerender } = renderHook(useFieldProps, { + initialProps: { + ...props, + errorMessages: { + 'Field.errorRequired': 'Show this message', + }, + }, + }) + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error.message).toBe('Show this message') + + rerender({ + ...props, errorMessages: { - required: 'Show this message', + 'Field.errorRequired': 'Update the message', }, }) - ) - expect(result.current.error).toBeInstanceOf(Error) - expect(result.current.error.message).toBe('Show this message') + + expect(result.current.error.message).toBe('Update the message') + }) + + /** + * @deprecated – can be removed in v11 + */ + it('with backwards compatibility', () => { + const { result } = renderHook(() => + useFieldProps({ + value: undefined, + required: true, + validateInitially: true, + errorMessages: { + required: 'Show this message', + }, + }) + ) + expect(result.current.error).toBeInstanceOf(Error) + expect(result.current.error.message).toBe('Show this message') + }) }) it('should validate required when value is empty string', () => { @@ -973,9 +1021,7 @@ describe('useFieldProps', () => { it('should validate "validateRequired"', async () => { const validateRequired = jest.fn((v, { emptyValue, required }) => { return required && emptyValue === 'empty' && v > 1 - ? new FormError('The value is required', { - validationRule: 'required', - }) + ? new FormError('Field.errorRequired') : undefined }) const onChange = jest.fn() @@ -989,6 +1035,9 @@ describe('useFieldProps', () => { validateInitially: true, validateRequired, errorMessages: { + 'Field.errorRequired': 'Show this message', + + /** @deprecated – can be removed in v11 */ required: 'Show this message', }, onChange, diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useTranslation.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useTranslation.test.tsx index 5f244349017..1ef12203ac6 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useTranslation.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useTranslation.test.tsx @@ -17,7 +17,7 @@ import { extendDeep } from '../../../../shared/component-helper' describe('Form.useTranslation', () => { it('should default to nb-NO if no locale is specified in context', () => { - const { result } = renderHook(() => useTranslation(), { + const { result } = renderHook(useTranslation, { wrapper: ({ children }) => {children}, }) @@ -30,7 +30,7 @@ describe('Form.useTranslation', () => { }) it('should inherit locale from shared context', () => { - const { result: resultGB } = renderHook(() => useTranslation(), { + const { result: resultGB } = renderHook(useTranslation, { wrapper: ({ children }) => ( {children} ), @@ -43,7 +43,7 @@ describe('Form.useTranslation', () => { expect(resultGB.current).toEqual(gb) - const { result: resultNO } = renderHook(() => useTranslation(), { + const { result: resultNO } = renderHook(useTranslation, { wrapper: ({ children }) => ( {children} ), diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/index.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/index.ts index 70ce6aed041..564cc93ccaa 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/index.ts @@ -2,6 +2,8 @@ export { default as usePath } from './usePath' export { default as useFieldProps } from './useFieldProps' export { default as useValueProps } from './useValueProps' export { default as useExternalValue } from './useExternalValue' -export { default as useErrorMessage } from './useErrorMessage' export { default as useProcessManager } from './useProcessManager' export { default as useTranslation } from './useTranslation' + +/** @deprecated – can be removed in v11 */ +export { default as useErrorMessage } from './useErrorMessage' diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useErrorMessage.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useErrorMessage.ts index 6beeb216303..af423da1347 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useErrorMessage.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useErrorMessage.ts @@ -1,22 +1,16 @@ import { useContext, useMemo } from 'react' import { DefaultErrorMessages, - CustomErrorMessagesWithPaths, + GlobalErrorMessagesWithPaths, + Path, } from '../types' import Context from '../DataContext/Context' -/** - * Custom hook that retrieves error messages based on the provided path and error configurations. - * - * @param path - The path to the error messages in the configuration. - * @param errorMessages - Custom error messages with paths. - * @param defaultErrorMessages - Default error messages. - * @returns An object containing the merged error messages. - */ +/** @deprecated – can be removed in v11 */ export default function useErrorMessage( - path: string, - errorMessages: DefaultErrorMessages | CustomErrorMessagesWithPaths, - defaultErrorMessages: Record + path: Path, + errorMessages: DefaultErrorMessages | GlobalErrorMessagesWithPaths, + defaultErrorMessages: Record<`${string}.${string}`, string> ) { const context = useContext(Context) diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index 3db3ff7ccda..ad61bf072b0 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -9,10 +9,14 @@ import React, { } from 'react' import pointer from '../utils/json-pointer' import { ValidateFunction } from 'ajv/dist/2020' -import { errorChanged } from '../utils' -import { ajvErrorsToOneFormError } from '../utils/ajv' import { + ajvErrorsToOneFormError, + errorChanged, + overwriteErrorMessagesWithGivenAjvKeys, + extendErrorMessagesWithTranslationMessages, FormError, +} from '../utils' +import { FieldPropsGeneric, AdditionalEventArgs, SubmitState, @@ -161,6 +165,9 @@ export default function useFieldProps( const { isVisible } = useContext(VisibilityContext) || {} const translation = useTranslation() + const { formatMessage } = translation + const translationRef = useRef(translation) + translationRef.current = translation const transformers = useRef({ transformIn, @@ -313,15 +320,6 @@ export default function useFieldProps( schema ? dataContext.ajvInstance?.compile(schema) : undefined ) - // Needs to be placed before "prepareError" - const errorMessagesRef = useRef(null) - errorMessagesRef.current = useMemo(() => { - return { - required: translation.Field.errorRequired, - ...errorMessages, - } - }, [errorMessages, translation.Field.errorRequired]) - // - Async behavior const asyncBehaviorIsEnabled = useMemo(() => { return isAsync(onChange) || isAsync(onChangeContext) @@ -394,7 +392,7 @@ export default function useFieldProps( const setFieldState = useCallback( (state: SubmitStateWithValidating) => { fieldStateRef.current = state - setFieldStateDataContext(identifier, resolveValidatingState(state)) + setFieldStateDataContext?.(identifier, resolveValidatingState(state)) if (!validateInitially) { forceUpdate() } @@ -437,6 +435,25 @@ export default function useFieldProps( showFieldErrorFieldBlock, ]) + const getErrorMessages = useCallback(() => { + const messages = { + ...contextErrorMessages, + ...contextErrorMessages?.[identifier], + ...errorMessages, + } + + return extendErrorMessagesWithTranslationMessages( + overwriteErrorMessagesWithGivenAjvKeys(messages), + translationRef.current + ) + }, [contextErrorMessages, errorMessages, identifier]) + + const loopCounterRef = useRef(0) + loopCounterRef.current += 1 + if (loopCounterRef.current > 100) { + throw new Error('Infinity loop detected and stopped.') + } + /** * Prepare error from validation logic with correct error messages based on props */ @@ -444,29 +461,50 @@ export default function useFieldProps( (error: Error | FormError | undefined): FormError | undefined => { if (error instanceof FormError) { let message = error.message + const errorMessages = getErrorMessages() + const { ajvKeyword } = error + if (typeof ajvKeyword === 'string') { + const ajvMessage = errorMessages?.[ajvKeyword] + if (ajvMessage) { + message = ajvMessage + } + } + + /** @deprecated – can be removed in v11 */ const { validationRule } = error if (typeof validationRule === 'string') { - const fieldMessage = errorMessagesRef.current?.[validationRule] - if (fieldMessage) { - message = fieldMessage + const ajvMessage = errorMessages?.[validationRule] + if (ajvMessage) { + message = ajvMessage } } - const messageHasValues = Object.entries( - error.messageValues || {} - ).reduce((message, [key, value]) => { - return message.replace(`{${key}}`, value) - }, message) + if (errorMessages[message]) { + // - For when the message is e.g. Field.errorRequired or Custom.key, but delivered in the `errorMessages` object + message = errorMessages[message] + + if (error.messageValues) { + message = Object.entries(error.messageValues || {}).reduce( + (msg, [key, value]) => { + return msg.replace(`{${key}}`, value) + }, + message + ) + } + } else if (message.includes('.')) { + // - For when the message is e.g. Field.errorRequired + message = formatMessage(message, error.messageValues) + } - error.message = messageHasValues + error.message = message return error } return error }, - [] + [getErrorMessages, formatMessage] ) contextErrorRef.current = useMemo(() => { @@ -479,12 +517,16 @@ export default function useFieldProps( } }, [dataContextError, prepareError]) - const error = - revealErrorRef.current || - // If the error is a type error, we want to show it even if the field as not been used - localErrorRef.current?.['validationRule'] === 'type' - ? errorProp ?? localErrorRef.current ?? contextErrorRef.current - : undefined + // If the error is a type error, we want to show it even if the field as not been used + if (localErrorRef.current?.['ajvKeyword'] === 'type') { + revealErrorRef.current = true + } + + const error = revealErrorRef.current + ? prepareError(errorProp) ?? + localErrorRef.current ?? + contextErrorRef.current + : undefined const hasVisibleError = Boolean(error) || (inFieldBlock && fieldBlockContext.hasErrorProp) @@ -514,10 +556,8 @@ export default function useFieldProps( const exportValidatorsRef = useRef(exportValidators) exportValidatorsRef.current = exportValidators const additionalArgs = useMemo(() => { - const errorMessages = { - ...contextErrorMessages, - ...errorMessagesRef.current, - } + const errorMessages = getErrorMessages() + const args: ValidatorAdditionalArgs = { /** @deprecated – can be removed in v11 */ ...errorMessages, @@ -538,7 +578,7 @@ export default function useFieldProps( } return args - }, [contextErrorMessages, getValueByPath, setFieldEventListener]) + }, [getErrorMessages, getValueByPath, setFieldEventListener]) const callStackRef = useRef>>([]) const hasBeenCalledRef = useCallback((validator: Validator) => { @@ -657,7 +697,6 @@ export default function useFieldProps( setFieldErrorBoundary?.(identifier, error) // Set the visual states - setFieldStateDataContext?.(identifier, error ? 'error' : undefined) setFieldStateFieldBlock?.({ stateId, identifier, @@ -665,18 +704,19 @@ export default function useFieldProps( content: error, showInitially: Boolean(inFieldBlock && validateInitially), }) + setFieldStateDataContext?.(identifier, error ? 'error' : undefined) forceUpdate() }, [ - prepareError, - setFieldErrorDataContext, identifier, + inFieldBlock, + prepareError, setFieldErrorBoundary, + setFieldErrorDataContext, setFieldStateDataContext, setFieldStateFieldBlock, stateId, - inFieldBlock, validateInitially, ] ) @@ -970,9 +1010,7 @@ export default function useFieldProps( emptyValue, required: requiredProp ?? required, isChanged: changedRef.current, - error: new FormError('The value is required', { - validationRule: 'required', - }), + error: new FormError('Field.errorRequired'), }) if (requiredError instanceof Error) { throw requiredError @@ -1029,20 +1067,20 @@ export default function useFieldProps( } } }, [ - startProcess, - disabled, - hideError, - setFieldState, + callOnBlurValidator, clearErrorState, + disabled, emptyValue, - requiredProp, - required, + hideError, + persistErrorState, prioritizeContextSchema, + required, + requiredProp, + setFieldState, + startOnChangeValidatorValidation, + startProcess, validateInitially, validateUnchanged, - startOnChangeValidatorValidation, - callOnBlurValidator, - persistErrorState, ]) const handleError = useCallback(() => { diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useProcessManager.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useProcessManager.ts index 434bc5a7a5e..c7933f990da 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useProcessManager.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useProcessManager.ts @@ -7,7 +7,7 @@ export default function useProcessManager() { const tokenRef = useRef() const startProcess = useCallback(() => { - const processToken = Math.round(Math.random() * 1000000000) + const processToken = Math.floor(Math.random() * 100000) tokenRef.current = processToken // If another process was started after this one code can skip further steps to avoid race conditions diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useTranslation.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/useTranslation.tsx index 46e9b200f9b..49912386456 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useTranslation.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useTranslation.tsx @@ -4,7 +4,7 @@ import SharedContext, { } from '../../../shared/Context' import { combineWithExternalTranslations, - FormatMessage, + AdditionalReturnUtils, useAdditionalUtils, } from '../../../shared/useTranslation' import { extendDeep } from '../../../shared/component-helper' @@ -51,6 +51,6 @@ export default function useTranslation( messages, locale, }) - ) as T & FormatMessage + ) as T & AdditionalReturnUtils }, [assignUtils, globalTranslation, locale, messages]) } diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index fa8dc3a1700..9704a0f1e1c 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -1,9 +1,13 @@ +import type { AriaAttributes } from 'react' import type { SpacingProps } from '../../components/space/types' +import type { FilterData, VisibleDataOptions } from './DataContext' import type { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema' import type { JSONSchemaType } from 'ajv/dist/2020' -import { JsonObject } from './utils/json-pointer' -import { AriaAttributes } from 'react' -import { FilterData, VisibleDataOptions } from './DataContext' +import { JsonObject, FormError } from './utils' +import { + FormsTranslationFlat, + FormsTranslationLocale, +} from './hooks/useTranslation' export type * from 'json-schema' export type JSONSchema = JSONSchema7 @@ -21,8 +25,6 @@ export type AllJSONSchemaVersions = }) export { JSONSchemaType } -type ValidationRule = 'type' | 'pattern' | 'required' | string -type MessageValues = Record export type ValidatorReturnSync = | Error | undefined @@ -57,61 +59,83 @@ export type ValidatorAdditionalArgs< validators: Record> } & { /** @deprecated use the error messages from the { errorMessages } object instead. */ - pattern: string + pattern?: string /** @deprecated use the error messages from the { errorMessages } object instead. */ - required: string -} - -interface IFormErrorOptions { - validationRule?: ValidationRule - messageValues?: MessageValues + required?: string } /** - * Standard error object for Eufemia Forms, extending the built-in error with additional information for data handling + * Accept any key, so custom message keys can be used + * including the path to the field the message is for */ -export class FormError extends Error { - /** - * What validation rule did the error occur based on? (i.e: minLength, required or maximum) - */ - validationRule?: ValidationRule - - /** - * Replacement values relevant for this error. - * @example { minLength: 3 } to be able to replace values in a message like "Minimum {minLength} characters" - */ - messageValues?: MessageValues - - constructor(message: string, options?: IFormErrorOptions) { - super(message) - - if (options) { - for (const key in options) { - this[key] = options[key] - } +export type GlobalErrorMessagesWithPaths = + | VariousErrorMessages + | { + // eslint-disable-next-line no-unused-vars + [K in `/${string}`]?: VariousErrorMessages } - } -} /** - * Accept any key, so custom message keys can be used + * 'MyCustom.message': 'Your custom message' */ -export type CustomErrorMessages = Record - +export type DotNotationErrorMessages = Record< + `${string}` | `${string}.${string}`, + string +> /** - * Accept any key, so custom message keys can be used - * including the path to the field the message is for + * { 'nb-NO': { 'Field.errorRequired': 'Dette feltet er påkrevd' } } */ -export type CustomErrorMessagesWithPaths = - | CustomErrorMessages - | { - // eslint-disable-next-line no-unused-vars - [K in `/${string}`]?: CustomErrorMessages - } +export type ErrorMessagesWithLocaleSupport = Record< + FormsTranslationLocale, + DefaultErrorMessages +> + +export type InternalErrorMessages = Record +export type DefaultErrorMessages = Partial & + Partial & + Partial -export interface DefaultErrorMessages { +export type VariousErrorMessages = + | DefaultErrorMessages + | ErrorMessagesWithLocaleSupport + +export type DeprecatedErrorMessages = { + /** + * @deprecated Use translation keys as the message instead of this parameter (e.g. Field.errorRequired) + */ required?: string + /** + * @deprecated Use translation keys as the message instead of this parameter (e.g. Field.errorPattern) + */ pattern?: string + /** + * @deprecated use StringField.errorMinLength instead + */ + minLength?: string + /** + * @deprecated use StringField.errorMaxLength instead + */ + maxLength?: string + /** + * @deprecated use NumberField.errorMinimum instead + */ + minimum?: string + /** + * @deprecated use NumberField.errorMaximum instead + */ + maximum?: string + /** + * @deprecated use NumberField.errorExclusiveMinimum instead + */ + exclusiveMinimum?: string + /** + * @deprecated use NumberField.errorExclusiveMaximum instead + */ + exclusiveMaximum?: string + /** + * @deprecated use NumberField.errorMultipleOf instead + */ + multipleOf?: string } export interface DataValueReadProps { diff --git a/packages/dnb-eufemia/src/extensions/forms/utils/FormError.ts b/packages/dnb-eufemia/src/extensions/forms/utils/FormError.ts new file mode 100644 index 00000000000..79184916378 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/utils/FormError.ts @@ -0,0 +1,58 @@ +import { ErrorObject } from 'ajv/dist/2020' +import { FormsTranslationFlat } from '../hooks/useTranslation' + +type ValidationRule = + | string + | 'type' + /** + * @deprecated Use translation keys as the message instead of this parameter (e.g. Field.errorRequired) + */ + | 'pattern' + /** + * @deprecated Use translation keys as the message instead of this parameter (e.g. Field.errorRequired) + */ + | 'required' + +type FormErrorOptions = { + /** + * @deprecated Use translation keys as the message instead of this parameter (e.g. Field.errorRequired) + */ + validationRule?: ValidationRule + messageValues?: Record + ajvKeyword?: ErrorObject['keyword'] +} + +/** + * Standard error object for Eufemia Forms, extending the built-in error with additional information for data handling + */ +export class FormError extends Error { + /** + * What validation rule did the error occur based on? (i.e: minLength, required or maximum) + * @deprecated – can be removed in v11 + */ + validationRule?: FormErrorOptions['validationRule'] + + /** + * Replacement values relevant for this error. + * @example { minLength: 3 } to be able to replace values in a message like "Minimum {minLength} characters" + */ + messageValues?: FormErrorOptions['messageValues'] + + /** + * The AJV keyword that caused the error. + */ + ajvKeyword?: FormErrorOptions['ajvKeyword'] + + constructor( + message: FormsTranslationFlat | string, + options?: FormErrorOptions + ) { + super(message) + + if (options) { + for (const key in options) { + this[key] = options[key] + } + } + } +} diff --git a/packages/dnb-eufemia/src/extensions/forms/utils/__tests__/ajv.test.ts b/packages/dnb-eufemia/src/extensions/forms/utils/__tests__/ajv.test.ts index 71278826af0..36799fee7db 100644 --- a/packages/dnb-eufemia/src/extensions/forms/utils/__tests__/ajv.test.ts +++ b/packages/dnb-eufemia/src/extensions/forms/utils/__tests__/ajv.test.ts @@ -1,4 +1,8 @@ import Ajv, { ErrorObject } from 'ajv/dist/2020' +import { FormError } from '../FormError' +import { DefaultErrorMessages } from '../../types' +import { FormsTranslation } from '../../hooks/useTranslation' +import { AdditionalReturnUtils } from '../../../../shared/useTranslation' import { makeAjvInstance, getInstancePath, @@ -7,8 +11,10 @@ import { ajvErrorToFormError, ajvErrorsToOneFormError, ajvErrorsToFormErrors, + getTranslationKeyFromValidationRule, + extendErrorMessagesWithTranslationMessages, + overwriteErrorMessagesWithGivenAjvKeys, } from '../ajv' -import { FormError } from '../../types' describe('makeAjvInstance', () => { it('should return a new Ajv instance', () => { @@ -261,16 +267,14 @@ describe('ajvErrorToFormError', () => { message: 'Should not be shorter than 5 characters', } const formError = ajvErrorToFormError(ajvError) - expect(formError.message).toBe( - 'Should not be shorter than 5 characters' - ) + expect(formError.message).toBe('StringField.errorMinLength') }) - it('should return a FormError with "Unknown error" message if no message is provided', () => { + it('should return a FormError with "Unknown error" message if no message and undefined keyword is provided', () => { const ajvError: ErrorObject = { schemaPath: '#', instancePath: '/path', - keyword: 'minLength', + keyword: undefined, params: { limit: 5 }, } const formError = ajvErrorToFormError(ajvError) @@ -299,9 +303,7 @@ describe('ajvErrorsToOneFormError', () => { } const formError = ajvErrorsToOneFormError([ajvError]) expect(formError).toBeInstanceOf(FormError) - expect(formError.message).toBe( - 'Should not be shorter than 5 characters' - ) + expect(formError.message).toBe('StringField.errorMinLength') }) it('should return a FormError with multiple messages for multiple errors', () => { @@ -359,8 +361,180 @@ describe('ajvErrorsToFormErrors', () => { ] const formErrors = ajvErrorsToFormErrors(ajvErrors) expect(formErrors).toEqual({ - '/path1': new Error('Should not be shorter than 5 characters'), - '/path2': new Error('Should not be longer than 10 characters'), + '/path1': new Error('StringField.errorMinLength'), + '/path2': new Error('StringField.errorMaxLength'), + }) + }) +}) + +describe('getTranslationKeyFromValidationRule', () => { + it('should return the correct translation key for pattern', () => { + const key = getTranslationKeyFromValidationRule('pattern') + expect(key).toBe('Field.errorPattern') + }) + + it('should return the correct translation key for required', () => { + const key = getTranslationKeyFromValidationRule('required') + expect(key).toBe('Field.errorRequired') + }) + + it('should return the correct translation key for minLength', () => { + const key = getTranslationKeyFromValidationRule('minLength') + expect(key).toBe('StringField.errorMinLength') + }) + + it('should return the correct translation key for maxLength', () => { + const key = getTranslationKeyFromValidationRule('maxLength') + expect(key).toBe('StringField.errorMaxLength') + }) + + it('should return the correct translation key for minimum', () => { + const key = getTranslationKeyFromValidationRule('minimum') + expect(key).toBe('NumberField.errorMinimum') + }) + + it('should return the correct translation key for maximum', () => { + const key = getTranslationKeyFromValidationRule('maximum') + expect(key).toBe('NumberField.errorMaximum') + }) + + it('should return the correct translation key for exclusiveMinimum', () => { + const key = getTranslationKeyFromValidationRule('exclusiveMinimum') + expect(key).toBe('NumberField.errorExclusiveMinimum') + }) + + it('should return the correct translation key for exclusiveMaximum', () => { + const key = getTranslationKeyFromValidationRule('exclusiveMaximum') + expect(key).toBe('NumberField.errorExclusiveMaximum') + }) + + it('should return the correct translation key for multipleOf', () => { + const key = getTranslationKeyFromValidationRule('multipleOf') + expect(key).toBe('NumberField.errorMultipleOf') + }) + + it('should return undefined for unsupported validation rules', () => { + const key = getTranslationKeyFromValidationRule('unsupported') + expect(key).toBeUndefined() + }) +}) + +describe('extendErrorMessagesWithTranslationMessages', () => { + const mockTranslation = { + Field: { + errorRequired: 'Field is required', + errorPattern: 'Pattern is incorrect', + }, + StringField: { + errorMinLength: 'Too short', + errorMaxLength: 'Too long', + }, + NumberField: { + errorMinimum: 'Below minimum', + errorMaximum: 'Above maximum', + errorExclusiveMinimum: 'Below exclusive minimum', + errorExclusiveMaximum: 'Above exclusive maximum', + errorMultipleOf: 'Not a multiple of', + }, + } as FormsTranslation & AdditionalReturnUtils + + it('should extend messages with default translations if messages are undefined', () => { + const mockMessages = {} + + const messages = extendErrorMessagesWithTranslationMessages( + mockMessages, + mockTranslation + ) + + expect(messages).toEqual({ + 'Field.errorRequired': 'Field is required', + 'Field.errorPattern': 'Pattern is incorrect', + 'StringField.errorMinLength': 'Too short', + 'StringField.errorMaxLength': 'Too long', + 'NumberField.errorMinimum': 'Below minimum', + 'NumberField.errorMaximum': 'Above maximum', + 'NumberField.errorExclusiveMinimum': 'Below exclusive minimum', + 'NumberField.errorExclusiveMaximum': 'Above exclusive maximum', + 'NumberField.errorMultipleOf': 'Not a multiple of', + + /** @deprecated – can be removed in v11 */ + required: 'Field is required', + pattern: 'Pattern is incorrect', + minLength: 'Too short', + maxLength: 'Too long', + minimum: 'Below minimum', + maximum: 'Above maximum', + exclusiveMinimum: 'Below exclusive minimum', + exclusiveMaximum: 'Above exclusive maximum', + multipleOf: 'Not a multiple of', + }) + + expect(mockMessages).toEqual({}) + }) + + it('should not overwrite existing messages', () => { + const customMessages = { + required: 'Custom required message', + } + const messages = extendErrorMessagesWithTranslationMessages( + customMessages, + mockTranslation + ) + + expect(messages.required).toBe('Custom required message') // should not be overwritten + expect(messages.pattern).toBe('Pattern is incorrect') // default translation + }) + + it('should add translation messages for missing fields', () => { + const messages = { + 'Field.errorRequired': 'Already exists', + } + const result = extendErrorMessagesWithTranslationMessages( + messages, + mockTranslation + ) + + expect(result['Field.errorRequired']).toBe('Already exists') + expect(result.required).toBe('Already exists') + }) +}) + +describe('overwriteErrorMessagesWithGivenAjvKeys', () => { + it('should map Ajv error messages to the corresponding translation keys when provided', () => { + const messages: DefaultErrorMessages = { + required: 'This field is required', + pattern: 'Invalid pattern', + } + + const result = overwriteErrorMessagesWithGivenAjvKeys(messages) + + expect(result).toEqual({ + required: 'This field is required', + pattern: 'Invalid pattern', + 'Field.errorRequired': 'This field is required', + 'Field.errorPattern': 'Invalid pattern', + }) + }) + + it('should not alter the message object if no Ajv keys are provided', () => { + const messages: DefaultErrorMessages = {} + + const result = overwriteErrorMessagesWithGivenAjvKeys(messages) + + expect(result).toEqual({}) + }) + + it('should overwrite existing translations with Ajv keys', () => { + const messages: DefaultErrorMessages = { + required: 'This field is required', + 'Field.errorRequired': 'Should not overwrite', + } + + const result = overwriteErrorMessagesWithGivenAjvKeys(messages) + + expect(result).toEqual({ + required: 'This field is required', + 'Field.errorRequired': 'This field is required', }) }) }) diff --git a/packages/dnb-eufemia/src/extensions/forms/utils/ajv.ts b/packages/dnb-eufemia/src/extensions/forms/utils/ajv.ts index ebeb1c6d5bd..1e1a553e657 100644 --- a/packages/dnb-eufemia/src/extensions/forms/utils/ajv.ts +++ b/packages/dnb-eufemia/src/extensions/forms/utils/ajv.ts @@ -1,12 +1,45 @@ import ajvInstance, { ErrorObject } from 'ajv/dist/2020' import ajvErrors from 'ajv-errors' import pointer, { JsonObject } from './json-pointer' -import { FormError, Path } from '../types' +import { DefaultErrorMessages, Path } from '../types' +import { FormError } from './FormError' import type Ajv from 'ajv/dist/2020' +import type { FormsTranslation } from '../hooks/useTranslation' export type AjvInstance = typeof ajvInstance export { ajvInstance, Ajv } +/** + * Translation table for Ajv error keywords. + * It represents the mapping between Ajv error keywords and their corresponding translation keys. + */ +const ajvErrorKeywordsTranslationTable = [ + { ajvKey: 'required', translationKey: 'Field.errorRequired' }, + { ajvKey: 'pattern', translationKey: 'Field.errorPattern' }, + { + ajvKey: 'minLength', + translationKey: 'StringField.errorMinLength', + }, + { + ajvKey: 'maxLength', + translationKey: 'StringField.errorMaxLength', + }, + { ajvKey: 'minimum', translationKey: 'NumberField.errorMinimum' }, + { ajvKey: 'maximum', translationKey: 'NumberField.errorMaximum' }, + { + ajvKey: 'exclusiveMinimum', + translationKey: 'NumberField.errorExclusiveMinimum', + }, + { + ajvKey: 'exclusiveMaximum', + translationKey: 'NumberField.errorExclusiveMaximum', + }, + { + ajvKey: 'multipleOf', + translationKey: 'NumberField.errorMultipleOf', + }, +] + /** * Creates an instance of Ajv (Another JSON Schema Validator) with optional custom instance. * If no instance is provided, a new instance of Ajv is created with the specified options. @@ -103,6 +136,71 @@ export function getMessageValues( } } +/** + * Overwrite the internal translation messages with given messaged that uses the Ajv keywords. + * + * @deprecated – can be removed in v11 + */ +export function overwriteErrorMessagesWithGivenAjvKeys( + messages: DefaultErrorMessages +) { + messages = { ...messages } + + ajvErrorKeywordsTranslationTable.forEach( + ({ ajvKey, translationKey }) => { + if (messages[ajvKey]) { + messages[translationKey] = messages[ajvKey] + } + } + ) + + return messages +} + +/** + * Extend the error messages with relevant translation messages. + */ +export function extendErrorMessagesWithTranslationMessages( + messages: DefaultErrorMessages, + translation: FormsTranslation +) { + messages = { ...messages } + + ajvErrorKeywordsTranslationTable.forEach( + ({ ajvKey, translationKey }) => { + if (!messages[ajvKey]) { + const keys = translationKey.split('.') + + /** + * For backward compatibility. + * Because we removed ajv keys in the fields, we now always set all the messages here instead. + * + * @deprecated – can be removed in v11 + */ + messages[ajvKey] = + messages[translationKey] ?? translation[keys[0]][keys[1]] + + messages[translationKey] = + messages[translationKey] ?? translation[keys[0]][keys[1]] + } + } + ) + + return messages +} + +/** + * Get the translation key from the Ajv validation rule + */ +export function getTranslationKeyFromValidationRule( + validationRule: string +) { + const item = ajvErrorKeywordsTranslationTable.find( + ({ ajvKey }) => ajvKey === validationRule + ) + return item?.translationKey +} + /** * Converts an AJV error object to a FormError object. * @@ -114,12 +212,17 @@ export function ajvErrorToFormError(ajvError: ErrorObject): FormError { return new Error(ajvError.message ?? 'Unknown error') } - return new FormError(ajvError.message ?? 'Unknown error', { - validationRule: getValidationRule(ajvError), - // Keep the message values in the error object instead of injecting them into the message - // at once, since an error might be validated one place, and then get a new message before it is displayed. - messageValues: getMessageValues(ajvError), - }) + return new FormError( + getTranslationKeyFromValidationRule(getValidationRule(ajvError)) ?? + ajvError.message ?? + 'Unknown error', + { + // Keep the message values in the error object instead of injecting them into the message + // at once, since an error might be validated one place, and then get a new message before it is displayed. + messageValues: getMessageValues(ajvError), + ajvKeyword: ajvError.keyword, + } + ) } /** diff --git a/packages/dnb-eufemia/src/extensions/forms/utils/errors.ts b/packages/dnb-eufemia/src/extensions/forms/utils/errors.ts index 0993995c7b6..002092c86b2 100644 --- a/packages/dnb-eufemia/src/extensions/forms/utils/errors.ts +++ b/packages/dnb-eufemia/src/extensions/forms/utils/errors.ts @@ -1,4 +1,4 @@ -import { FormError } from '../types' +import { FormError } from './FormError' export function errorChanged( error1?: FormError, diff --git a/packages/dnb-eufemia/src/extensions/forms/utils/index.ts b/packages/dnb-eufemia/src/extensions/forms/utils/index.ts index 0a28ce45e0b..c29de1a3d12 100644 --- a/packages/dnb-eufemia/src/extensions/forms/utils/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/utils/index.ts @@ -1,3 +1,5 @@ +export * from './ajv' export * from './errors' export * from './json-pointer' +export * from './FormError' export { default as TestElement } from './TestElement/TestElement' diff --git a/packages/dnb-eufemia/src/shared/useTranslation.tsx b/packages/dnb-eufemia/src/shared/useTranslation.tsx index 8d458e006ad..a1b182ef212 100644 --- a/packages/dnb-eufemia/src/shared/useTranslation.tsx +++ b/packages/dnb-eufemia/src/shared/useTranslation.tsx @@ -52,13 +52,13 @@ export type CombineWithExternalTranslationsArgs = { messages?: TranslationCustomLocales locale?: InternalLocale } -export type FormatMessage = { +export type AdditionalReturnUtils = { formatMessage: typeof formatMessage renderMessage: typeof renderMessage } export type CombineWithExternalTranslationsReturn = Translation & TranslationCustomLocales & - FormatMessage + AdditionalReturnUtils export function useAdditionalUtils() { const translationsRef = useRef()