diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 064c5346f..dd4115bb9 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -1,7 +1,7 @@ import { type AutocompleteFieldComponent } from '@defra/forms-model' -import { type LanguageMessages } from 'joi' import { SelectField } from '~/src/server/plugins/engine/components/SelectField.js' +import { withCustomValidationOverrides } from '~/src/server/plugins/engine/components/helpers/index.js' import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -50,12 +50,19 @@ export class AutocompleteField extends SelectField { } getValidationMessagesOverride(translator: Translator) { + const { customValidationMessages } = this.options const { t } = translator + const defaults = { + 'any.required': buildValidationMessages(t).objectRequired, + 'any.only': buildValidationMessages(t).objectRequired + } + return { - [this.name]: { - 'any.required': buildValidationMessages(t).objectRequired, - 'any.only': buildValidationMessages(t).objectRequired - } as LanguageMessages + [this.name]: withCustomValidationOverrides( + defaults, + undefined, + customValidationMessages + ) } } } diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 09d56b452..09615559f 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -9,7 +9,10 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' -import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' +import { + addClassOptionIfNone, + withCustomValidationOverrides +} from '~/src/server/plugins/engine/components/helpers/index.js' import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -141,16 +144,23 @@ export abstract class LocationFieldBase extends FormComponent { } getValidationMessagesOverride(translator: Translator) { + const { customValidationMessage, customValidationMessages } = this.options const { t } = translator const config = this.getValidationConfig(translator) + const defaults = { + 'any.required': + config.requiredMessage ?? buildValidationMessages(t).objectRequired, + 'string.empty': + config.requiredMessage ?? buildValidationMessages(t).objectRequired, + 'string.pattern.base': config.patternErrorMessage + } + return { - [this.name]: { - 'any.required': - config.requiredMessage ?? buildValidationMessages(t).objectRequired, - 'string.empty': - config.requiredMessage ?? buildValidationMessages(t).objectRequired, - 'string.pattern.base': config.patternErrorMessage - } as LanguageMessages + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessage, + customValidationMessages + ) } } diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index 42e366531..aae93c4c9 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -1,14 +1,11 @@ import { type NumberFieldComponent } from '@defra/forms-model' -import joi, { - type CustomValidator, - type LanguageMessages, - type NumberSchema -} from 'joi' +import joi, { type CustomValidator, type NumberSchema } from 'joi' import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { withCustomValidationOverrides } from '~/src/server/plugins/engine/components/helpers/index.js' import { type RenderContext } from '~/src/server/plugins/engine/components/types.js' import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -137,16 +134,23 @@ export class NumberField extends FormComponent { } getValidationMessagesOverride(translator: Translator) { + const { customValidationMessage, customValidationMessages } = this.options const { t } = translator + const defaults = { + 'any.required': buildValidationMessages(t).objectRequired, + 'number.base': buildValidationMessages(t).objectMissing, + 'number.precision': buildValidationMessages(t).numberPrecision, + 'number.integer': buildValidationMessages(t).numberInteger, + 'number.min': buildValidationMessages(t).numberMin, + 'number.max': buildValidationMessages(t).numberMax + } + return { - [this.name]: { - 'any.required': buildValidationMessages(t).objectRequired, - 'number.base': buildValidationMessages(t).objectMissing, - 'number.precision': buildValidationMessages(t).numberPrecision, - 'number.integer': buildValidationMessages(t).numberInteger, - 'number.min': buildValidationMessages(t).numberMin, - 'number.max': buildValidationMessages(t).numberMax - } as LanguageMessages + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessage, + customValidationMessages + ) } } diff --git a/src/server/plugins/engine/components/YesNoField.ts b/src/server/plugins/engine/components/YesNoField.ts index 7461b2cb6..1197359f4 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -6,7 +6,10 @@ import { } from '@defra/forms-model' import { SelectionControlField } from '~/src/server/plugins/engine/components/SelectionControlField.js' -import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' +import { + addClassOptionIfNone, + withCustomValidationOverrides +} from '~/src/server/plugins/engine/components/helpers/index.js' import { buildValidationMessages } from '~/src/server/plugins/engine/i18n/buildValidationMessages.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' @@ -62,11 +65,18 @@ export class YesNoField extends SelectionControlField { } getValidationMessagesOverride(translator: Translator) { + const { customValidationMessage, customValidationMessages } = this.options const { selectYesNoRequired } = buildValidationMessages(translator.t) + const defaults = { + 'any.required': selectYesNoRequired + } + return { - [this.name]: { - 'any.required': selectYesNoRequired - } + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessage, + customValidationMessages + ) } } diff --git a/src/server/plugins/engine/components/customValidationMessage.test.ts b/src/server/plugins/engine/components/customValidationMessage.test.ts new file mode 100644 index 000000000..61abc18b6 --- /dev/null +++ b/src/server/plugins/engine/components/customValidationMessage.test.ts @@ -0,0 +1,434 @@ +import { + ComponentType, + type EastingNorthingFieldComponent, + type EmailAddressFieldComponent, + type LatLongFieldComponent, + type MonthYearFieldComponent, + type MultilineTextFieldComponent, + type NationalGridFieldNumberFieldComponent, + type NumberFieldComponent, + type OsGridRefFieldComponent, + type TelephoneNumberFieldComponent, + type TextFieldComponent, + type YesNoFieldComponent +} from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' +import { + getCompositeFormData, + getFormData +} from '~/test/helpers/component-helpers.js' + +/** + * Each component has its own test file for schema, state and view model + * checks. This file has one job: check the singular `options.customValidationMessage` + * string (it applies to every key in a component's schema) for every + * component type, in two ways: + * - `validate(input)` - a plain unit test, no translator. + * - `validate(input, translator)` - matches a real request. `FormModel.ts` + * always passes a translator when it validates form input. + * + * Support for the plural `options.customValidationMessages` map is not the + * same as support for this singular option, so that option has its own test + * file: customValidationMessages.test.ts. + * + * The lists below record which components declare and read this option. + * They come from reading each component's source code and its + * `@defra/forms-model` type, not from guessing. Only 11 of the 28 + * ComponentType values declare customValidationMessage on their options + * type at all - the other 17 cannot express it, so TypeScript itself proves + * the option does not apply to them. No runtime test is needed for those; + * they are listed below only so the coverage guard test accounts for every + * ComponentType. + * + * Fixed regression: NumberField, OsGridRefField and NationalGridFieldNumberField + * worked in v4.23.0 (the last v4 release, which had no + * `getValidationMessagesOverride()` and no translator argument on + * `validate()`). In v5, `getValidationMessagesOverride()` always replaced the + * author's message with a translated default at request time, breaking the + * option. `withCustomValidationOverrides()` (components/helpers/index.ts) now + * builds the override from the author's message when one is set. + * + * Not a regression: YesNoField never supported the option in v4.23.0. + */ + +const CUSTOM = 'This is a custom validation message' + +/** + * Component types that declare customValidationMessage on their options + * type and read it. Tested below under 'Supported'. + */ +const SUPPORTS_SINGULAR_MESSAGE = [ + ComponentType.TextField, + ComponentType.EmailAddressField, + ComponentType.MultilineTextField, + ComponentType.TelephoneNumberField, + ComponentType.NumberField, + ComponentType.OsGridRefField, + ComponentType.NationalGridFieldNumberField, + ComponentType.YesNoField +] + +/** + * Component types that declare customValidationMessage on their options + * type but never read it. Tested below under 'Not supported'. + */ +const NOT_SUPPORTED_COMPONENT_TYPES = [ + ComponentType.EastingNorthingField, + ComponentType.LatLongField, + ComponentType.MonthYearField +] + +/** + * Component types with no customValidationMessage field on their options + * type. TypeScript itself proves the option cannot be set, so there is + * nothing to test at runtime. Listed here only so the coverage guard test + * below accounts for every ComponentType. + */ +const NO_SINGULAR_MESSAGE_OPTION = [ + ComponentType.CheckboxesField, + ComponentType.DatePartsField, + ComponentType.UkAddressField, + ComponentType.FileUploadField, + ComponentType.DeclarationField, + ComponentType.HiddenField, + ComponentType.GeospatialField, + ComponentType.PaymentField, + ComponentType.RadiosField, + ComponentType.SelectField, + ComponentType.AutocompleteField, + ComponentType.Details, + ComponentType.Html, + ComponentType.Markdown, + ComponentType.InsetText, + ComponentType.List, + ComponentType.NotificationBanner +] + +describe('Component-level validation message override (customValidationMessage)', () => { + // Fails when a new ComponentType is not in one of the three lists above. + it('accounts for every ComponentType', () => { + const accountedFor = [ + ...SUPPORTS_SINGULAR_MESSAGE, + ...NOT_SUPPORTED_COMPONENT_TYPES, + ...NO_SINGULAR_MESSAGE_OPTION + ] + + expect(new Set(accountedFor).size).toBe(accountedFor.length) + expect([...accountedFor].sort()).toEqual( + [...Object.values(ComponentType)].sort() + ) + }) + + let model: FormModel + let translator: ReturnType + + beforeEach(() => { + model = new FormModel(definition, { basePath: 'test' }) + translator = model.createTranslator() + }) + + describe('Supported', () => { + describe('TextField', () => { + const def = { + title: 'Example text field', + name: 'myComponent', + type: ComponentType.TextField, + options: { customValidationMessage: CUSTOM }, + schema: {} + } satisfies TextFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('EmailAddressField', () => { + const def = { + title: 'Example email address field', + name: 'myComponent', + type: ComponentType.EmailAddressField, + options: { customValidationMessage: CUSTOM } + } satisfies EmailAddressFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('MultilineTextField', () => { + const def = { + title: 'Example multiline text field', + name: 'myComponent', + type: ComponentType.MultilineTextField, + options: { customValidationMessage: CUSTOM }, + schema: {} + } satisfies MultilineTextFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('TelephoneNumberField', () => { + const def = { + title: 'Example telephone number field', + name: 'myComponent', + type: ComponentType.TelephoneNumberField, + options: { customValidationMessage: CUSTOM } + } satisfies TelephoneNumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('NumberField', () => { + const def = { + title: 'Example number field', + name: 'myComponent', + type: ComponentType.NumberField, + options: { customValidationMessage: CUSTOM }, + schema: {} + } satisfies NumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('OsGridRefField', () => { + const def = { + title: 'Example OS grid reference', + name: 'myComponent', + type: ComponentType.OsGridRefField, + options: { customValidationMessage: CUSTOM } + } satisfies OsGridRefFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('NationalGridFieldNumberField', () => { + const def = { + title: 'Example national grid field number', + name: 'myComponent', + type: ComponentType.NationalGridFieldNumberField, + options: { customValidationMessage: CUSTOM } + } satisfies NationalGridFieldNumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('YesNoField', () => { + const def = { + title: 'Example yes/no field', + name: 'myComponent', + type: ComponentType.YesNoField, + options: { customValidationMessage: CUSTOM } + } satisfies YesNoFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + }) + + /** + * These components declare customValidationMessage on their options type + * but ignore it. Setting the option has no effect, so the same input must + * give the same errors with and without it. + */ + describe('Not supported', () => { + it('EastingNorthingField', () => { + const base = { + title: 'Example easting northing', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: {}, + schema: {} + } satisfies EastingNorthingFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessage: CUSTOM } + } satisfies EastingNorthingFieldComponent + + const input = getCompositeFormData({ easting: '', northing: '' }) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('LatLongField', () => { + const base = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } satisfies LatLongFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessage: CUSTOM } + } satisfies LatLongFieldComponent + + const input = getCompositeFormData({ latitude: '', longitude: '' }) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('MonthYearField', () => { + const base = { + title: 'Example month/year field', + name: 'myComponent', + type: ComponentType.MonthYearField, + options: {} + } satisfies MonthYearFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessage: CUSTOM } + } satisfies MonthYearFieldComponent + + const input = getCompositeFormData({ month: '', year: '' }) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + }) +}) diff --git a/src/server/plugins/engine/components/customValidationMessages.test.ts b/src/server/plugins/engine/components/customValidationMessages.test.ts new file mode 100644 index 000000000..3fa78cf69 --- /dev/null +++ b/src/server/plugins/engine/components/customValidationMessages.test.ts @@ -0,0 +1,779 @@ +import { + ComponentType, + type AutocompleteFieldComponent, + type CheckboxesFieldComponent, + type DatePartsFieldComponent, + type DeclarationFieldComponent, + type EastingNorthingFieldComponent, + type EmailAddressFieldComponent, + type FileUploadFieldComponent, + type GeospatialFieldComponent, + type HiddenFieldComponent, + type LatLongFieldComponent, + type MonthYearFieldComponent, + type MultilineTextFieldComponent, + type NationalGridFieldNumberFieldComponent, + type NumberFieldComponent, + type OsGridRefFieldComponent, + type PaymentFieldComponent, + type RadiosFieldComponent, + type SelectFieldComponent, + type TelephoneNumberFieldComponent, + type TextFieldComponent, + type UkAddressFieldComponent, + type YesNoFieldComponent +} from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import { type FormValue } from '~/src/server/plugins/engine/types.js' +import { listString } from '~/test/fixtures/list.js' +import definition from '~/test/form/definitions/blank.js' +import { + getCompositeFormData, + getFormData +} from '~/test/helpers/component-helpers.js' + +/** + * Each component has its own test file for schema, state and view model + * checks. This file has one job: check `options.customValidationMessages` + * (a map keyed by Joi error type) for every component type, in two ways: + * - `validate(input)` - a plain unit test, no translator. + * - `validate(input, translator)` - matches a real request. `FormModel.ts` + * always passes a translator when it validates form input. + * + * The lists below record which components use the option. They come from + * reading each component's source code, not from guessing. + * + * Some of these components also support a singular `customValidationMessage` + * string. Support for the two options is not identical, so the singular + * option has its own test file: customValidationMessage.test.ts. + * + * Fixed regression: NumberField, OsGridRefField, NationalGridFieldNumberField + * and AutocompleteField worked in v4.23.0 (the last v4 release, which had no + * `getValidationMessagesOverride()` and no translator argument on + * `validate()`). In v5, `getValidationMessagesOverride()` always replaced the + * author's message with a translated default at request time, breaking the + * option. `withCustomValidationOverrides()` (components/helpers/index.ts) now + * keeps the author's message for any key they set. + * + * Not a regression: YesNoField never supported the option in v4.23.0. + */ + +const CUSTOM = 'This is a custom validation message' + +/** + * Component types that read options.customValidationMessages and use it + * in their Joi schema. Tested below under 'Supported'. + */ +const SUPPORTED_COMPONENT_TYPES = [ + ComponentType.TextField, + ComponentType.EmailAddressField, + ComponentType.MultilineTextField, + ComponentType.TelephoneNumberField, + ComponentType.NumberField, + ComponentType.OsGridRefField, + ComponentType.NationalGridFieldNumberField, + ComponentType.YesNoField, + ComponentType.RadiosField, + ComponentType.SelectField, + ComponentType.AutocompleteField +] + +/** + * Component types that never read options.customValidationMessages. + * CheckboxesField reads it but then throws away that schema. Tested below + * under 'Not supported'. + */ +const NOT_SUPPORTED_COMPONENT_TYPES = [ + ComponentType.CheckboxesField, + ComponentType.DatePartsField, + ComponentType.MonthYearField, + ComponentType.EastingNorthingField, + ComponentType.LatLongField, + ComponentType.UkAddressField, + ComponentType.FileUploadField, + ComponentType.DeclarationField, + ComponentType.HiddenField, + ComponentType.GeospatialField, + ComponentType.PaymentField +] + +/** + * Content-only component types. They have no user input and no validation, + * so options.customValidationMessages does not apply to them. + */ +const CONTENT_ONLY_COMPONENT_TYPES = [ + ComponentType.Details, + ComponentType.Html, + ComponentType.Markdown, + ComponentType.InsetText, + ComponentType.List, + ComponentType.NotificationBanner +] + +describe('Component-level validation message override (customValidationMessages)', () => { + // Fails when a new ComponentType is not in one of the three lists above. + it('accounts for every ComponentType', () => { + const accountedFor = [ + ...SUPPORTED_COMPONENT_TYPES, + ...NOT_SUPPORTED_COMPONENT_TYPES, + ...CONTENT_ONLY_COMPONENT_TYPES + ] + + expect(new Set(accountedFor).size).toBe(accountedFor.length) + expect([...accountedFor].sort()).toEqual( + [...Object.values(ComponentType)].sort() + ) + }) + + let model: FormModel + let translator: ReturnType + + beforeEach(() => { + model = new FormModel(definition, { basePath: 'test' }) + translator = model.createTranslator() + }) + + describe('Supported', () => { + describe('TextField', () => { + const def = { + title: 'Example text field', + name: 'myComponent', + type: ComponentType.TextField, + options: { customValidationMessages: { 'any.required': CUSTOM } }, + schema: {} + } satisfies TextFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('EmailAddressField', () => { + const def = { + title: 'Example email address field', + name: 'myComponent', + type: ComponentType.EmailAddressField, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies EmailAddressFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('MultilineTextField', () => { + const def = { + title: 'Example multiline text field', + name: 'myComponent', + type: ComponentType.MultilineTextField, + options: { customValidationMessages: { 'any.required': CUSTOM } }, + schema: {} + } satisfies MultilineTextFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('TelephoneNumberField', () => { + const def = { + title: 'Example telephone number field', + name: 'myComponent', + type: ComponentType.TelephoneNumberField, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies TelephoneNumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('NumberField', () => { + const def = { + title: 'Example number field', + name: 'myComponent', + type: ComponentType.NumberField, + options: { customValidationMessages: { 'any.required': CUSTOM } }, + schema: {} + } satisfies NumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('OsGridRefField', () => { + const def = { + title: 'Example OS grid reference', + name: 'myComponent', + type: ComponentType.OsGridRefField, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies OsGridRefFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('NationalGridFieldNumberField', () => { + const def = { + title: 'Example national grid field number', + name: 'myComponent', + type: ComponentType.NationalGridFieldNumberField, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies NationalGridFieldNumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('YesNoField', () => { + const def = { + title: 'Example yes/no field', + name: 'myComponent', + type: ComponentType.YesNoField, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies YesNoFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([def], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('RadiosField', () => { + const def = { + title: 'Example radios field', + name: 'myComponent', + type: ComponentType.RadiosField, + list: 'listString', + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies RadiosFieldComponent + + function buildModel() { + const updated = structuredClone(definition) + updated.lists = [listString] + return new FormModel(updated, { basePath: 'test' }) + } + + it('honours the override', () => { + const collection = new ComponentCollection([def], { + model: buildModel() + }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const listModel = buildModel() + const collection = new ComponentCollection([def], { model: listModel }) + const result = collection.validate( + getFormData(), + listModel.createTranslator() + ) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('SelectField', () => { + const def = { + title: 'Example select field', + name: 'myComponent', + type: ComponentType.SelectField, + list: 'listString', + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies SelectFieldComponent + + function buildModel() { + const updated = structuredClone(definition) + updated.lists = [listString] + return new FormModel(updated, { basePath: 'test' }) + } + + it('honours the override', () => { + const collection = new ComponentCollection([def], { + model: buildModel() + }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const listModel = buildModel() + const collection = new ComponentCollection([def], { model: listModel }) + const result = collection.validate( + getFormData(), + listModel.createTranslator() + ) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + + describe('AutocompleteField', () => { + const def = { + title: 'Example autocomplete field', + name: 'myComponent', + type: ComponentType.AutocompleteField, + list: 'listString', + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies AutocompleteFieldComponent + + function buildModel() { + const updated = structuredClone(definition) + updated.lists = [listString] + return new FormModel(updated, { basePath: 'test' }) + } + + it('honours the override', () => { + const collection = new ComponentCollection([def], { + model: buildModel() + }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + it('honours the override at request time (with a translator)', () => { + const listModel = buildModel() + const collection = new ComponentCollection([def], { model: listModel }) + const result = collection.validate( + getFormData(), + listModel.createTranslator() + ) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) + }) + + /** + * These components ignore options.customValidationMessages. Setting the + * option has no effect, so the same input must give the same errors with + * and without it. + */ + describe('Not supported', () => { + it('CheckboxesField', () => { + const updated = structuredClone(definition) + updated.lists = [listString] + const listModel = new FormModel(updated, { basePath: 'test' }) + + const base = { + title: 'Example checkboxes field', + name: 'myComponent', + type: ComponentType.CheckboxesField, + list: 'listString', + options: {} + } satisfies CheckboxesFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies CheckboxesFieldComponent + + const baseline = new ComponentCollection([base], { + model: listModel + }).validate(getFormData()) + const overridden = new ComponentCollection([withOverride], { + model: listModel + }).validate(getFormData()) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('DatePartsField', () => { + const base = { + title: 'Example date parts field', + name: 'myComponent', + type: ComponentType.DatePartsField, + options: {} + } satisfies DatePartsFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies DatePartsFieldComponent + + const input = getCompositeFormData({ day: '', month: '', year: '' }) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('MonthYearField', () => { + const base = { + title: 'Example month/year field', + name: 'myComponent', + type: ComponentType.MonthYearField, + options: {} + } satisfies MonthYearFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies MonthYearFieldComponent + + const input = getCompositeFormData({ month: '', year: '' }) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('EastingNorthingField', () => { + const base = { + title: 'Example easting northing', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: {}, + schema: {} + } satisfies EastingNorthingFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies EastingNorthingFieldComponent + + const input = getCompositeFormData({ easting: '', northing: '' }) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('LatLongField', () => { + const base = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } satisfies LatLongFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies LatLongFieldComponent + + const input = getCompositeFormData({ latitude: '', longitude: '' }) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('UkAddressField', () => { + const base = { + title: 'Example UK address', + name: 'myComponent', + type: ComponentType.UkAddressField, + options: {} + } satisfies UkAddressFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies UkAddressFieldComponent + + const input = getCompositeFormData({ + addressLine1: '', + addressLine2: '', + town: '', + county: '', + postcode: '', + uprn: '' + }) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('FileUploadField', () => { + const base = { + title: 'Example file upload field', + name: 'myComponent', + type: ComponentType.FileUploadField, + options: {}, + schema: {} + } satisfies FileUploadFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies FileUploadFieldComponent + + const input = getFormData() + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('DeclarationField', () => { + const base = { + title: 'Example declaration field', + name: 'myComponent', + content: 'Lorem ipsum dolar sit amet', + type: ComponentType.DeclarationField, + options: {} + } satisfies DeclarationFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies DeclarationFieldComponent + + const input = getFormData(['unchecked']) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('HiddenField', () => { + const base = { + title: 'Example hidden field', + name: 'myComponent', + type: ComponentType.HiddenField, + options: {} + } satisfies HiddenFieldComponent + + const withOverride = { + ...base, + options: { customValidationMessages: { 'any.required': CUSTOM } } + } satisfies HiddenFieldComponent + + const input = getFormData() + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('GeospatialField', () => { + const base = { + title: 'Example geospatial field', + name: 'myComponent', + type: ComponentType.GeospatialField, + options: { required: true } + } satisfies GeospatialFieldComponent + + const withOverride = { + ...base, + options: { + required: true, + customValidationMessages: { 'any.required': CUSTOM } + } + } satisfies GeospatialFieldComponent + + const input = getFormData('') + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + + it('PaymentField', () => { + const base = { + title: 'Example payment field', + name: 'myComponent', + type: ComponentType.PaymentField, + options: { amount: 100, description: 'Test payment description' } + } satisfies PaymentFieldComponent + + const withOverride = { + ...base, + options: { + ...base.options, + customValidationMessages: { 'any.required': CUSTOM } + } + } satisfies PaymentFieldComponent + + const payment = { + paymentId: '', + reference: '', + amount: 0, + description: '', + uuid: '', + formId: '', + isLivePayment: false + } + const input = getFormData(payment as unknown as FormValue) + const baseline = new ComponentCollection([base], { model }).validate( + input + ) + const overridden = new ComponentCollection([withOverride], { + model + }).validate(input) + + expect(overridden.errors).toEqual(baseline.errors) + }) + }) +}) diff --git a/src/server/plugins/engine/components/helpers/index.ts b/src/server/plugins/engine/components/helpers/index.ts index 6450118b1..ef9c233ba 100644 --- a/src/server/plugins/engine/components/helpers/index.ts +++ b/src/server/plugins/engine/components/helpers/index.ts @@ -1,5 +1,9 @@ import { type ComponentDef } from '@defra/forms-model' -import joi, { type JoiExpression, type ReferenceOptions } from 'joi' +import joi, { + type JoiExpression, + type LanguageMessages, + type ReferenceOptions +} from 'joi' import lowerFirst from 'lodash/lowerFirst.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' @@ -69,6 +73,38 @@ export const lowerFirstExpressionOptions = { export const createLowerFirstExpression = (template: string): JoiExpression => joi.expression(template, lowerFirstExpressionOptions) as JoiExpression +/** + * Joi's own LanguageMessages type only allows string values, but + * buildValidationMessages() returns JoiExpression for templated messages, + * which Joi accepts at runtime. This type covers both. + */ +type TranslatedValidationMessages = Record + +/** + * Keep the form author's custom validation message for any key they set, + * so translated defaults only fill in the keys the author left blank. + * A single customValidationMessage covers every key in the schema. + */ +export function withCustomValidationOverrides( + defaults: TranslatedValidationMessages, + customValidationMessage?: string, + customValidationMessages?: LanguageMessages +): LanguageMessages { + // If component has a global override for all messages, set that as the value for all scenarios + if (customValidationMessage) { + return Object.fromEntries( + Object.keys(defaults).map((key) => [key, customValidationMessage]) + ) as LanguageMessages + } + + // When populating the defaults, don't override anything the user has explicitly configured + return Object.fromEntries( + Object.entries(defaults).filter( + ([key]) => !(key in (customValidationMessages ?? {})) + ) + ) as LanguageMessages +} + /** * Translate a component label, in precedence order of errorDescription, then shortDesciption, then title * @param {ComponentDef} component diff --git a/test/helpers/component-helpers.js b/test/helpers/component-helpers.js index b9d37e4f7..08c0835db 100644 --- a/test/helpers/component-helpers.js +++ b/test/helpers/component-helpers.js @@ -22,6 +22,25 @@ export function getFormState(value, name = 'myComponent') { return { [name]: value ?? null } } +/** + * Get form data for a composite field (DatePartsField, UkAddressField, ...). + * Composite fields use flat payload keys, e.g. `myComponent__day`, rather + * than a nested object under `myComponent`. + * @param {Record} fields + * @param {string} [name] + * @returns {FormPayload} + */ +export function getCompositeFormData(fields, name = 'myComponent') { + /** @type {FormPayload} */ + const data = {} + + for (const [key, value] of Object.entries(fields)) { + data[`${name}__${key}`] = value + } + + return data +} + /** * Render Nunjucks macro into DOM * @param {Parameters} args