From 7e6d990bb710d2866a83c0f2192691db9294443e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Fri, 4 Sep 2026 12:53:36 +0100 Subject: [PATCH 1/7] Add test coverage for component-level custom validation message overrides Checks every component type against options.customValidationMessage(s), with and without a translator. 5 components lose the override at request time because getValidationMessagesOverride() always replaces the message. --- .../customValidationMessage.test.ts | 865 ++++++++++++++++++ 1 file changed, 865 insertions(+) create mode 100644 src/server/plugins/engine/components/customValidationMessage.test.ts 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..cd3860851 --- /dev/null +++ b/src/server/plugins/engine/components/customValidationMessage.test.ts @@ -0,0 +1,865 @@ +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 FormPayload, + 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 { getFormData } from '~/test/helpers/component-helpers.js' + +/** + * Composite fields (DatePartsField, UkAddressField, ...) use flat payload + * keys, e.g. `myComponent__day`. They do not nest an object under + * `myComponent`. Each component's own test file uses the same format. + */ +function getCompositeFormData(fields: Record): FormPayload { + const data: FormPayload = {} + + for (const [key, value] of Object.entries(fields)) { + data[`myComponent__${key}`] = value + } + + return data +} + +/** + * Each component has its own test file for schema, state and view model + * checks. This file has one job: check `options.customValidationMessage(s)` + * 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. + * + * 7 tests fail on `main` right now. This is expected. On NumberField, + * OsGridRefField, NationalGridFieldNumberField, AutocompleteField and + * YesNoField, `getValidationMessagesOverride()` always replaces the message + * with a translated default. It ignores the custom message. This method only + * runs when `validate()` gets a translator, so the bug shows only in the + * "with a translator" tests. + * + * v4.23.0 (the last v4 release) had no `getValidationMessagesOverride()` and + * no translator argument on `validate()`. So: + * - NumberField, OsGridRefField, NationalGridFieldNumberField and + * AutocompleteField worked in v4. This is a regression. + * - YesNoField never supported the option in v4. It is not a regression. + * These tests stay red until the code is fixed. + */ + +const CUSTOM = 'This is a custom validation message' + +/** + * Component types that read options.customValidationMessage(s) 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.customValidationMessage(s). + * 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.customValidationMessage(s) 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', () => { + // 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 }) + ]) + }) + + // Regression. This worked in v4.23.0. + // NumberField.getValidationMessagesOverride() always replaces + // 'any.required' with a translated default message. + 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('singular customValidationMessage', () => { + const singularDef = { + ...def, + options: { customValidationMessage: CUSTOM } + } satisfies NumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([singularDef], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + // Regression. This worked in v4.23.0 too. The single message + // applies to several Joi keys, and getValidationMessagesOverride() + // replaces all of them at request time. + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([singularDef], { 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 }) + ]) + }) + + // Regression. This worked in v4.23.0. + // LocationFieldBase#getValidationMessagesOverride() always replaces + // 'any.required' with a translated default message. + 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('singular customValidationMessage', () => { + const singularDef = { + ...def, + options: { customValidationMessage: CUSTOM } + } satisfies OsGridRefFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([singularDef], { model }) + const result = collection.validate(getFormData()) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + + // Regression. This worked in v4.23.0 too. The single message + // applies to several Joi keys, and getValidationMessagesOverride() + // replaces all of them at request time. + it('honours the override at request time (with a translator)', () => { + const collection = new ComponentCollection([singularDef], { 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 }) + ]) + }) + + // Regression, same as OsGridRefField above. Both extend + // LocationFieldBase. + 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 }) + ]) + }) + + // Not a regression. YesNoField did not support this option in v4.23.0. + // getValidationMessagesOverride() always replaces 'any.required' with + // the default "select yes or no" message, so the option has never + // worked at request time. + 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 }) + ]) + }) + + // Regression. This worked in v4.23.0. + // AutocompleteField.getValidationMessagesOverride() always replaces + // 'any.required' and 'any.only' with a translated default message. + 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.customValidationMessage(s). 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) + }) + }) +}) From 69ce7ac6c7e2cfd5db7385f52ad91e0aad8ce408 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 7 Sep 2026 10:57:23 +0100 Subject: [PATCH 2/7] Preserve component custom validation messages when a translator is present getValidationMessagesOverride() always replaced Joi's messages with the engine's translated defaults, so a form author's customValidationMessage(s) was silently dropped on real requests, where a translator is always passed. The author's message now wins for any key they set. --- .../engine/components/AutocompleteField.ts | 18 ++++++++--- .../engine/components/LocationFieldBase.ts | 30 ++++++++++++----- .../plugins/engine/components/NumberField.ts | 32 +++++++++++++------ .../plugins/engine/components/YesNoField.ts | 23 ++++++++++--- 4 files changed, 77 insertions(+), 26 deletions(-) diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 064c5346f..31504111c 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -50,12 +50,20 @@ export class AutocompleteField extends SelectField { } getValidationMessagesOverride(translator: Translator) { + const { customValidationMessages } = this.options const { t } = translator - return { - [this.name]: { - 'any.required': buildValidationMessages(t).objectRequired, - 'any.only': buildValidationMessages(t).objectRequired - } as LanguageMessages + const defaults = { + 'any.required': buildValidationMessages(t).objectRequired, + 'any.only': buildValidationMessages(t).objectRequired } + + // Keep the author's message for any key they set. + const messages = Object.fromEntries( + Object.entries(defaults).filter( + ([key]) => !(key in (customValidationMessages ?? {})) + ) + ) as LanguageMessages + + return { [this.name]: messages } } } diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 09d56b452..5220af378 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -141,17 +141,31 @@ export abstract class LocationFieldBase extends FormComponent { } getValidationMessagesOverride(translator: Translator) { + const { customValidationMessage, customValidationMessages } = this.options + + // A single custom message already covers every key in the schema. + if (customValidationMessage) { + return null + } + const { t } = translator const config = this.getValidationConfig(translator) - return { - [this.name]: { - 'any.required': - config.requiredMessage ?? buildValidationMessages(t).objectRequired, - 'string.empty': - config.requiredMessage ?? buildValidationMessages(t).objectRequired, - 'string.pattern.base': config.patternErrorMessage - } as LanguageMessages + const defaults = { + 'any.required': + config.requiredMessage ?? buildValidationMessages(t).objectRequired, + 'string.empty': + config.requiredMessage ?? buildValidationMessages(t).objectRequired, + 'string.pattern.base': config.patternErrorMessage } + + // Keep the author's message for any key they set. + const messages = Object.fromEntries( + Object.entries(defaults).filter( + ([key]) => !(key in (customValidationMessages ?? {})) + ) + ) as LanguageMessages + + return { [this.name]: messages } } getAllPossibleErrors(): ErrorMessageTemplateList { diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index 42e366531..c142a8796 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -137,17 +137,31 @@ export class NumberField extends FormComponent { } getValidationMessagesOverride(translator: Translator) { + const { customValidationMessage, customValidationMessages } = this.options + + // A single custom message already covers every key in the schema. + if (customValidationMessage) { + return null + } + const { t } = translator - 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 + 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 } + + // Keep the author's message for any key they set. + const messages = Object.fromEntries( + Object.entries(defaults).filter( + ([key]) => !(key in (customValidationMessages ?? {})) + ) + ) as LanguageMessages + + return { [this.name]: messages } } /** diff --git a/src/server/plugins/engine/components/YesNoField.ts b/src/server/plugins/engine/components/YesNoField.ts index 7461b2cb6..7ccebae0f 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -4,6 +4,7 @@ import { yesNoListName, type YesNoFieldComponent } from '@defra/forms-model' +import { type LanguageMessages } from 'joi' import { SelectionControlField } from '~/src/server/plugins/engine/components/SelectionControlField.js' import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' @@ -62,12 +63,26 @@ export class YesNoField extends SelectionControlField { } getValidationMessagesOverride(translator: Translator) { + const { customValidationMessage, customValidationMessages } = this.options + + // A single custom message already covers the only key in the schema. + if (customValidationMessage) { + return null + } + const { selectYesNoRequired } = buildValidationMessages(translator.t) - return { - [this.name]: { - 'any.required': selectYesNoRequired - } + const defaults = { + 'any.required': selectYesNoRequired } + + // Keep the author's message for any key they set. + const messages: LanguageMessages = Object.fromEntries( + Object.entries(defaults).filter( + ([key]) => !(key in (customValidationMessages ?? {})) + ) + ) + + return { [this.name]: messages } } /** From 61bc359586283b15cb746ca3c146370f460aea4e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 7 Sep 2026 11:13:33 +0100 Subject: [PATCH 3/7] Extract shared helper for custom validation message overrides The keep-the-author's-message filter was duplicated across four components. Moved it into withCustomValidationOverrides(), which also holds the one type workaround for Joi's LanguageMessages type not covering JoiExpression values, instead of repeating the cast per file. --- .../engine/components/AutocompleteField.ts | 14 ++++------ .../engine/components/LocationFieldBase.ts | 17 +++++------ .../plugins/engine/components/NumberField.ts | 19 +++++-------- .../plugins/engine/components/YesNoField.ts | 18 ++++++------ .../engine/components/helpers/index.ts | 28 ++++++++++++++++++- 5 files changed, 58 insertions(+), 38 deletions(-) diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 31504111c..0e799a5a7 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' @@ -57,13 +57,11 @@ export class AutocompleteField extends SelectField { 'any.only': buildValidationMessages(t).objectRequired } - // Keep the author's message for any key they set. - const messages = Object.fromEntries( - Object.entries(defaults).filter( - ([key]) => !(key in (customValidationMessages ?? {})) + return { + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessages ) - ) as LanguageMessages - - return { [this.name]: messages } + } } } diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 5220af378..534946aca 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' @@ -158,14 +161,12 @@ export abstract class LocationFieldBase extends FormComponent { 'string.pattern.base': config.patternErrorMessage } - // Keep the author's message for any key they set. - const messages = Object.fromEntries( - Object.entries(defaults).filter( - ([key]) => !(key in (customValidationMessages ?? {})) + return { + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessages ) - ) as LanguageMessages - - return { [this.name]: messages } + } } getAllPossibleErrors(): ErrorMessageTemplateList { diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index c142a8796..f220cad39 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' @@ -154,14 +151,12 @@ export class NumberField extends FormComponent { 'number.max': buildValidationMessages(t).numberMax } - // Keep the author's message for any key they set. - const messages = Object.fromEntries( - Object.entries(defaults).filter( - ([key]) => !(key in (customValidationMessages ?? {})) + return { + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessages ) - ) as LanguageMessages - - return { [this.name]: messages } + } } /** diff --git a/src/server/plugins/engine/components/YesNoField.ts b/src/server/plugins/engine/components/YesNoField.ts index 7ccebae0f..a79636a8a 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -4,10 +4,12 @@ import { yesNoListName, type YesNoFieldComponent } from '@defra/forms-model' -import { type LanguageMessages } from 'joi' 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' @@ -75,14 +77,12 @@ export class YesNoField extends SelectionControlField { 'any.required': selectYesNoRequired } - // Keep the author's message for any key they set. - const messages: LanguageMessages = Object.fromEntries( - Object.entries(defaults).filter( - ([key]) => !(key in (customValidationMessages ?? {})) + return { + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessages ) - ) - - return { [this.name]: messages } + } } /** diff --git a/src/server/plugins/engine/components/helpers/index.ts b/src/server/plugins/engine/components/helpers/index.ts index 6450118b1..ee102f866 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,28 @@ 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. + */ +export function withCustomValidationOverrides( + defaults: TranslatedValidationMessages, + customValidationMessages?: LanguageMessages +): LanguageMessages { + 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 From 5c6bdb6a7bc004ff10cf4ceea0d1be48b41c91d0 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 7 Sep 2026 11:21:53 +0100 Subject: [PATCH 4/7] Centralise the singular customValidationMessage guard, add missing test coverage withCustomValidationOverrides() now also skips translated defaults when a single customValidationMessage is set, instead of each component repeating that check. Added singular-message tests for the six components that support it but had only plural coverage, confirming the fix against a v4.23.0 baseline for both forms of the option. --- .../engine/components/AutocompleteField.ts | 13 +- .../engine/components/LocationFieldBase.ts | 19 +- .../plugins/engine/components/NumberField.ts | 19 +- .../plugins/engine/components/YesNoField.ts | 19 +- .../customValidationMessage.test.ts | 196 +++++++++++++++--- .../engine/components/helpers/index.ts | 9 +- 6 files changed, 199 insertions(+), 76 deletions(-) diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index 0e799a5a7..eef785988 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -57,11 +57,12 @@ export class AutocompleteField extends SelectField { 'any.only': buildValidationMessages(t).objectRequired } - return { - [this.name]: withCustomValidationOverrides( - defaults, - customValidationMessages - ) - } + const messages = withCustomValidationOverrides( + defaults, + undefined, + customValidationMessages + ) + + return messages && { [this.name]: messages } } } diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 534946aca..3356ba879 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -145,12 +145,6 @@ export abstract class LocationFieldBase extends FormComponent { getValidationMessagesOverride(translator: Translator) { const { customValidationMessage, customValidationMessages } = this.options - - // A single custom message already covers every key in the schema. - if (customValidationMessage) { - return null - } - const { t } = translator const config = this.getValidationConfig(translator) const defaults = { @@ -161,12 +155,13 @@ export abstract class LocationFieldBase extends FormComponent { 'string.pattern.base': config.patternErrorMessage } - return { - [this.name]: withCustomValidationOverrides( - defaults, - customValidationMessages - ) - } + const messages = withCustomValidationOverrides( + defaults, + customValidationMessage, + customValidationMessages + ) + + return messages && { [this.name]: messages } } getAllPossibleErrors(): ErrorMessageTemplateList { diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index f220cad39..06fad8dd3 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -135,12 +135,6 @@ export class NumberField extends FormComponent { getValidationMessagesOverride(translator: Translator) { const { customValidationMessage, customValidationMessages } = this.options - - // A single custom message already covers every key in the schema. - if (customValidationMessage) { - return null - } - const { t } = translator const defaults = { 'any.required': buildValidationMessages(t).objectRequired, @@ -151,12 +145,13 @@ export class NumberField extends FormComponent { 'number.max': buildValidationMessages(t).numberMax } - return { - [this.name]: withCustomValidationOverrides( - defaults, - customValidationMessages - ) - } + const messages = withCustomValidationOverrides( + defaults, + customValidationMessage, + customValidationMessages + ) + + return messages && { [this.name]: messages } } /** diff --git a/src/server/plugins/engine/components/YesNoField.ts b/src/server/plugins/engine/components/YesNoField.ts index a79636a8a..d427d90f7 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -66,23 +66,18 @@ export class YesNoField extends SelectionControlField { getValidationMessagesOverride(translator: Translator) { const { customValidationMessage, customValidationMessages } = this.options - - // A single custom message already covers the only key in the schema. - if (customValidationMessage) { - return null - } - const { selectYesNoRequired } = buildValidationMessages(translator.t) const defaults = { 'any.required': selectYesNoRequired } - return { - [this.name]: withCustomValidationOverrides( - defaults, - customValidationMessages - ) - } + const messages = withCustomValidationOverrides( + defaults, + customValidationMessage, + customValidationMessages + ) + + return messages && { [this.name]: messages } } /** diff --git a/src/server/plugins/engine/components/customValidationMessage.test.ts b/src/server/plugins/engine/components/customValidationMessage.test.ts index cd3860851..6ee986ade 100644 --- a/src/server/plugins/engine/components/customValidationMessage.test.ts +++ b/src/server/plugins/engine/components/customValidationMessage.test.ts @@ -60,19 +60,20 @@ function getCompositeFormData(fields: Record): FormPayload { * The lists below record which components use the option. They come from * reading each component's source code, not from guessing. * - * 7 tests fail on `main` right now. This is expected. On NumberField, - * OsGridRefField, NationalGridFieldNumberField, AutocompleteField and - * YesNoField, `getValidationMessagesOverride()` always replaces the message - * with a translated default. It ignores the custom message. This method only - * runs when `validate()` gets a translator, so the bug shows only in the - * "with a translator" tests. + * Some components also support a singular `customValidationMessage` string, + * which applies to every key in the component's schema. Where a component + * supports it, its own nested 'singular customValidationMessage' block + * covers it too. * - * v4.23.0 (the last v4 release) had no `getValidationMessagesOverride()` and - * no translator argument on `validate()`. So: - * - NumberField, OsGridRefField, NationalGridFieldNumberField and - * AutocompleteField worked in v4. This is a regression. - * - YesNoField never supported the option in v4. It is not a regression. - * These tests stay red until the code is fixed. + * 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' @@ -177,6 +178,31 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) + + describe('singular customValidationMessage', () => { + const singularDef = { + ...def, + options: { customValidationMessage: CUSTOM } + } satisfies TextFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) }) describe('EmailAddressField', () => { @@ -204,6 +230,31 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) + + describe('singular customValidationMessage', () => { + const singularDef = { + ...def, + options: { customValidationMessage: CUSTOM } + } satisfies EmailAddressFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) }) describe('MultilineTextField', () => { @@ -232,6 +283,31 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) + + describe('singular customValidationMessage', () => { + const singularDef = { + ...def, + options: { customValidationMessage: CUSTOM } + } satisfies MultilineTextFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) }) describe('TelephoneNumberField', () => { @@ -259,6 +335,31 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) + + describe('singular customValidationMessage', () => { + const singularDef = { + ...def, + options: { customValidationMessage: CUSTOM } + } satisfies TelephoneNumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) }) describe('NumberField', () => { @@ -279,9 +380,6 @@ describe('Component-level validation message override', () => { ]) }) - // Regression. This worked in v4.23.0. - // NumberField.getValidationMessagesOverride() always replaces - // 'any.required' with a translated default message. it('honours the override at request time (with a translator)', () => { const collection = new ComponentCollection([def], { model }) const result = collection.validate(getFormData(), translator) @@ -306,9 +404,6 @@ describe('Component-level validation message override', () => { ]) }) - // Regression. This worked in v4.23.0 too. The single message - // applies to several Joi keys, and getValidationMessagesOverride() - // replaces all of them at request time. it('honours the override at request time (with a translator)', () => { const collection = new ComponentCollection([singularDef], { model }) const result = collection.validate(getFormData(), translator) @@ -337,9 +432,6 @@ describe('Component-level validation message override', () => { ]) }) - // Regression. This worked in v4.23.0. - // LocationFieldBase#getValidationMessagesOverride() always replaces - // 'any.required' with a translated default message. it('honours the override at request time (with a translator)', () => { const collection = new ComponentCollection([def], { model }) const result = collection.validate(getFormData(), translator) @@ -364,9 +456,6 @@ describe('Component-level validation message override', () => { ]) }) - // Regression. This worked in v4.23.0 too. The single message - // applies to several Joi keys, and getValidationMessagesOverride() - // replaces all of them at request time. it('honours the override at request time (with a translator)', () => { const collection = new ComponentCollection([singularDef], { model }) const result = collection.validate(getFormData(), translator) @@ -395,8 +484,6 @@ describe('Component-level validation message override', () => { ]) }) - // Regression, same as OsGridRefField above. Both extend - // LocationFieldBase. it('honours the override at request time (with a translator)', () => { const collection = new ComponentCollection([def], { model }) const result = collection.validate(getFormData(), translator) @@ -405,6 +492,31 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) + + describe('singular customValidationMessage', () => { + const singularDef = { + ...def, + options: { customValidationMessage: CUSTOM } + } satisfies NationalGridFieldNumberFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) }) describe('YesNoField', () => { @@ -424,10 +536,6 @@ describe('Component-level validation message override', () => { ]) }) - // Not a regression. YesNoField did not support this option in v4.23.0. - // getValidationMessagesOverride() always replaces 'any.required' with - // the default "select yes or no" message, so the option has never - // worked at request time. it('honours the override at request time (with a translator)', () => { const collection = new ComponentCollection([def], { model }) const result = collection.validate(getFormData(), translator) @@ -436,6 +544,31 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) + + describe('singular customValidationMessage', () => { + const singularDef = { + ...def, + options: { customValidationMessage: CUSTOM } + } satisfies YesNoFieldComponent + + it('honours the override', () => { + const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) + const result = collection.validate(getFormData(), translator) + + expect(result.errors).toEqual([ + expect.objectContaining({ text: CUSTOM }) + ]) + }) + }) }) describe('RadiosField', () => { @@ -544,9 +677,6 @@ describe('Component-level validation message override', () => { ]) }) - // Regression. This worked in v4.23.0. - // AutocompleteField.getValidationMessagesOverride() always replaces - // 'any.required' and 'any.only' with a translated default message. it('honours the override at request time (with a translator)', () => { const listModel = buildModel() const collection = new ComponentCollection([def], { model: listModel }) diff --git a/src/server/plugins/engine/components/helpers/index.ts b/src/server/plugins/engine/components/helpers/index.ts index ee102f866..a4d52ae9b 100644 --- a/src/server/plugins/engine/components/helpers/index.ts +++ b/src/server/plugins/engine/components/helpers/index.ts @@ -83,11 +83,18 @@ 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 already covers every key, so it skips + * the translated defaults entirely (return null). */ export function withCustomValidationOverrides( defaults: TranslatedValidationMessages, + customValidationMessage?: string, customValidationMessages?: LanguageMessages -): LanguageMessages { +): LanguageMessages | null { + if (customValidationMessage) { + return null + } + return Object.fromEntries( Object.entries(defaults).filter( ([key]) => !(key in (customValidationMessages ?? {})) From 299648b88b578b74506389141ca2e9229a500074 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 7 Sep 2026 11:34:47 +0100 Subject: [PATCH 5/7] Build the singular custom message override explicitly withCustomValidationOverrides() now builds the override for every key in the schema instead of returning null and relying on the constructor having already set the same keys. This removes the implicit assumption that the constructor's key list matches the translated defaults, and callers no longer need to handle a null result. --- .../plugins/engine/components/AutocompleteField.ts | 14 +++++++------- .../plugins/engine/components/LocationFieldBase.ts | 14 +++++++------- .../plugins/engine/components/NumberField.ts | 14 +++++++------- src/server/plugins/engine/components/YesNoField.ts | 14 +++++++------- .../plugins/engine/components/helpers/index.ts | 11 +++++++---- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/server/plugins/engine/components/AutocompleteField.ts b/src/server/plugins/engine/components/AutocompleteField.ts index eef785988..dd4115bb9 100644 --- a/src/server/plugins/engine/components/AutocompleteField.ts +++ b/src/server/plugins/engine/components/AutocompleteField.ts @@ -57,12 +57,12 @@ export class AutocompleteField extends SelectField { 'any.only': buildValidationMessages(t).objectRequired } - const messages = withCustomValidationOverrides( - defaults, - undefined, - customValidationMessages - ) - - return messages && { [this.name]: messages } + return { + [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 3356ba879..09615559f 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -155,13 +155,13 @@ export abstract class LocationFieldBase extends FormComponent { 'string.pattern.base': config.patternErrorMessage } - const messages = withCustomValidationOverrides( - defaults, - customValidationMessage, - customValidationMessages - ) - - return messages && { [this.name]: messages } + return { + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessage, + customValidationMessages + ) + } } getAllPossibleErrors(): ErrorMessageTemplateList { diff --git a/src/server/plugins/engine/components/NumberField.ts b/src/server/plugins/engine/components/NumberField.ts index 06fad8dd3..aae93c4c9 100644 --- a/src/server/plugins/engine/components/NumberField.ts +++ b/src/server/plugins/engine/components/NumberField.ts @@ -145,13 +145,13 @@ export class NumberField extends FormComponent { 'number.max': buildValidationMessages(t).numberMax } - const messages = withCustomValidationOverrides( - defaults, - customValidationMessage, - customValidationMessages - ) - - return messages && { [this.name]: messages } + return { + [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 d427d90f7..1197359f4 100644 --- a/src/server/plugins/engine/components/YesNoField.ts +++ b/src/server/plugins/engine/components/YesNoField.ts @@ -71,13 +71,13 @@ export class YesNoField extends SelectionControlField { 'any.required': selectYesNoRequired } - const messages = withCustomValidationOverrides( - defaults, - customValidationMessage, - customValidationMessages - ) - - return messages && { [this.name]: messages } + return { + [this.name]: withCustomValidationOverrides( + defaults, + customValidationMessage, + customValidationMessages + ) + } } /** diff --git a/src/server/plugins/engine/components/helpers/index.ts b/src/server/plugins/engine/components/helpers/index.ts index a4d52ae9b..ef9c233ba 100644 --- a/src/server/plugins/engine/components/helpers/index.ts +++ b/src/server/plugins/engine/components/helpers/index.ts @@ -83,18 +83,21 @@ 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 already covers every key, so it skips - * the translated defaults entirely (return null). + * A single customValidationMessage covers every key in the schema. */ export function withCustomValidationOverrides( defaults: TranslatedValidationMessages, customValidationMessage?: string, customValidationMessages?: LanguageMessages -): LanguageMessages | null { +): LanguageMessages { + // If component has a global override for all messages, set that as the value for all scenarios if (customValidationMessage) { - return null + 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 ?? {})) From d00daca6dae3c933c32ece5ab51c7c41da28bc5e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 7 Sep 2026 15:24:03 +0100 Subject: [PATCH 6/7] Split singular and plural custom validation message tests Support for options.customValidationMessage (singular) is not the same as support for options.customValidationMessages (plural): 3 components support only the plural form, and 17 don't even declare the singular field on their options type, so the two were being conflated in one matrix. Each option now has its own file, its own supported/not-supported lists, and its own coverage guard test. --- .../customValidationMessage.test.ts | 680 ++------------- .../customValidationMessages.test.ts | 794 ++++++++++++++++++ 2 files changed, 860 insertions(+), 614 deletions(-) create mode 100644 src/server/plugins/engine/components/customValidationMessages.test.ts diff --git a/src/server/plugins/engine/components/customValidationMessage.test.ts b/src/server/plugins/engine/components/customValidationMessage.test.ts index 6ee986ade..66f9e4d8d 100644 --- a/src/server/plugins/engine/components/customValidationMessage.test.ts +++ b/src/server/plugins/engine/components/customValidationMessage.test.ts @@ -1,43 +1,28 @@ 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 FormPayload, - type FormValue -} from '~/src/server/plugins/engine/types.js' -import { listString } from '~/test/fixtures/list.js' +import { type FormPayload } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' import { getFormData } from '~/test/helpers/component-helpers.js' /** - * Composite fields (DatePartsField, UkAddressField, ...) use flat payload - * keys, e.g. `myComponent__day`. They do not nest an object under - * `myComponent`. Each component's own test file uses the same format. + * Composite fields (EastingNorthingField, ...) use flat payload keys, e.g. + * `myComponent__easting`. They do not nest an object under `myComponent`. + * Each component's own test file uses the same format. */ function getCompositeFormData(fields: Record): FormPayload { const data: FormPayload = {} @@ -51,27 +36,33 @@ function getCompositeFormData(fields: Record): FormPayload { /** * Each component has its own test file for schema, state and view model - * checks. This file has one job: check `options.customValidationMessage(s)` - * for every component type, in two ways: + * 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. * - * The lists below record which components use the option. They come from - * reading each component's source code, not from guessing. + * 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. * - * Some components also support a singular `customValidationMessage` string, - * which applies to every key in the component's schema. Where a component - * supports it, its own nested 'singular customValidationMessage' block - * covers it too. + * 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, NationalGridFieldNumberField - * and AutocompleteField worked in v4.23.0 (the last v4 release, which had no + * 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 - * keeps the author's message for any key they set. + * builds the override from the author's message when one is set. * * Not a regression: YesNoField never supported the option in v4.23.0. */ @@ -79,10 +70,10 @@ function getCompositeFormData(fields: Record): FormPayload { const CUSTOM = 'This is a custom validation message' /** - * Component types that read options.customValidationMessage(s) and use it - * in their Joi schema. Tested below under 'Supported'. + * Component types that declare customValidationMessage on their options + * type and read it. Tested below under 'Supported'. */ -const SUPPORTED_COMPONENT_TYPES = [ +const SUPPORTS_SINGULAR_MESSAGE = [ ComponentType.TextField, ComponentType.EmailAddressField, ComponentType.MultilineTextField, @@ -90,36 +81,37 @@ const SUPPORTED_COMPONENT_TYPES = [ ComponentType.NumberField, ComponentType.OsGridRefField, ComponentType.NationalGridFieldNumberField, - ComponentType.YesNoField, - ComponentType.RadiosField, - ComponentType.SelectField, - ComponentType.AutocompleteField + ComponentType.YesNoField ] /** - * Component types that never read options.customValidationMessage(s). - * CheckboxesField reads it but then throws away that schema. Tested below - * under 'Not supported'. + * Component types that declare customValidationMessage on their options + * type but never read it. Tested below under 'Not supported'. */ const NOT_SUPPORTED_COMPONENT_TYPES = [ - ComponentType.CheckboxesField, - ComponentType.DatePartsField, - ComponentType.MonthYearField, 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 -] - -/** - * Content-only component types. They have no user input and no validation, - * so options.customValidationMessage(s) does not apply to them. - */ -const CONTENT_ONLY_COMPONENT_TYPES = [ + ComponentType.PaymentField, + ComponentType.RadiosField, + ComponentType.SelectField, + ComponentType.AutocompleteField, ComponentType.Details, ComponentType.Html, ComponentType.Markdown, @@ -128,13 +120,13 @@ const CONTENT_ONLY_COMPONENT_TYPES = [ ComponentType.NotificationBanner ] -describe('Component-level validation message override', () => { +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 = [ - ...SUPPORTED_COMPONENT_TYPES, + ...SUPPORTS_SINGULAR_MESSAGE, ...NOT_SUPPORTED_COMPONENT_TYPES, - ...CONTENT_ONLY_COMPONENT_TYPES + ...NO_SINGULAR_MESSAGE_OPTION ] expect(new Set(accountedFor).size).toBe(accountedFor.length) @@ -157,7 +149,7 @@ describe('Component-level validation message override', () => { title: 'Example text field', name: 'myComponent', type: ComponentType.TextField, - options: { customValidationMessages: { 'any.required': CUSTOM } }, + options: { customValidationMessage: CUSTOM }, schema: {} } satisfies TextFieldComponent @@ -178,31 +170,6 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) - - describe('singular customValidationMessage', () => { - const singularDef = { - ...def, - options: { customValidationMessage: CUSTOM } - } satisfies TextFieldComponent - - it('honours the override', () => { - const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) - const result = collection.validate(getFormData(), translator) - - expect(result.errors).toEqual([ - expect.objectContaining({ text: CUSTOM }) - ]) - }) - }) }) describe('EmailAddressField', () => { @@ -210,7 +177,7 @@ describe('Component-level validation message override', () => { title: 'Example email address field', name: 'myComponent', type: ComponentType.EmailAddressField, - options: { customValidationMessages: { 'any.required': CUSTOM } } + options: { customValidationMessage: CUSTOM } } satisfies EmailAddressFieldComponent it('honours the override', () => { @@ -230,31 +197,6 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) - - describe('singular customValidationMessage', () => { - const singularDef = { - ...def, - options: { customValidationMessage: CUSTOM } - } satisfies EmailAddressFieldComponent - - it('honours the override', () => { - const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) - const result = collection.validate(getFormData(), translator) - - expect(result.errors).toEqual([ - expect.objectContaining({ text: CUSTOM }) - ]) - }) - }) }) describe('MultilineTextField', () => { @@ -262,7 +204,7 @@ describe('Component-level validation message override', () => { title: 'Example multiline text field', name: 'myComponent', type: ComponentType.MultilineTextField, - options: { customValidationMessages: { 'any.required': CUSTOM } }, + options: { customValidationMessage: CUSTOM }, schema: {} } satisfies MultilineTextFieldComponent @@ -283,31 +225,6 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) - - describe('singular customValidationMessage', () => { - const singularDef = { - ...def, - options: { customValidationMessage: CUSTOM } - } satisfies MultilineTextFieldComponent - - it('honours the override', () => { - const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) - const result = collection.validate(getFormData(), translator) - - expect(result.errors).toEqual([ - expect.objectContaining({ text: CUSTOM }) - ]) - }) - }) }) describe('TelephoneNumberField', () => { @@ -315,7 +232,7 @@ describe('Component-level validation message override', () => { title: 'Example telephone number field', name: 'myComponent', type: ComponentType.TelephoneNumberField, - options: { customValidationMessages: { 'any.required': CUSTOM } } + options: { customValidationMessage: CUSTOM } } satisfies TelephoneNumberFieldComponent it('honours the override', () => { @@ -335,31 +252,6 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) - - describe('singular customValidationMessage', () => { - const singularDef = { - ...def, - options: { customValidationMessage: CUSTOM } - } satisfies TelephoneNumberFieldComponent - - it('honours the override', () => { - const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) - const result = collection.validate(getFormData(), translator) - - expect(result.errors).toEqual([ - expect.objectContaining({ text: CUSTOM }) - ]) - }) - }) }) describe('NumberField', () => { @@ -367,7 +259,7 @@ describe('Component-level validation message override', () => { title: 'Example number field', name: 'myComponent', type: ComponentType.NumberField, - options: { customValidationMessages: { 'any.required': CUSTOM } }, + options: { customValidationMessage: CUSTOM }, schema: {} } satisfies NumberFieldComponent @@ -388,31 +280,6 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) - - describe('singular customValidationMessage', () => { - const singularDef = { - ...def, - options: { customValidationMessage: CUSTOM } - } satisfies NumberFieldComponent - - it('honours the override', () => { - const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) - const result = collection.validate(getFormData(), translator) - - expect(result.errors).toEqual([ - expect.objectContaining({ text: CUSTOM }) - ]) - }) - }) }) describe('OsGridRefField', () => { @@ -420,7 +287,7 @@ describe('Component-level validation message override', () => { title: 'Example OS grid reference', name: 'myComponent', type: ComponentType.OsGridRefField, - options: { customValidationMessages: { 'any.required': CUSTOM } } + options: { customValidationMessage: CUSTOM } } satisfies OsGridRefFieldComponent it('honours the override', () => { @@ -440,31 +307,6 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) - - describe('singular customValidationMessage', () => { - const singularDef = { - ...def, - options: { customValidationMessage: CUSTOM } - } satisfies OsGridRefFieldComponent - - it('honours the override', () => { - const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) - const result = collection.validate(getFormData(), translator) - - expect(result.errors).toEqual([ - expect.objectContaining({ text: CUSTOM }) - ]) - }) - }) }) describe('NationalGridFieldNumberField', () => { @@ -472,7 +314,7 @@ describe('Component-level validation message override', () => { title: 'Example national grid field number', name: 'myComponent', type: ComponentType.NationalGridFieldNumberField, - options: { customValidationMessages: { 'any.required': CUSTOM } } + options: { customValidationMessage: CUSTOM } } satisfies NationalGridFieldNumberFieldComponent it('honours the override', () => { @@ -492,31 +334,6 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) - - describe('singular customValidationMessage', () => { - const singularDef = { - ...def, - options: { customValidationMessage: CUSTOM } - } satisfies NationalGridFieldNumberFieldComponent - - it('honours the override', () => { - const collection = new ComponentCollection([singularDef], { 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([singularDef], { model }) - const result = collection.validate(getFormData(), translator) - - expect(result.errors).toEqual([ - expect.objectContaining({ text: CUSTOM }) - ]) - }) - }) }) describe('YesNoField', () => { @@ -524,7 +341,7 @@ describe('Component-level validation message override', () => { title: 'Example yes/no field', name: 'myComponent', type: ComponentType.YesNoField, - options: { customValidationMessages: { 'any.required': CUSTOM } } + options: { customValidationMessage: CUSTOM } } satisfies YesNoFieldComponent it('honours the override', () => { @@ -544,236 +361,15 @@ describe('Component-level validation message override', () => { expect.objectContaining({ text: CUSTOM }) ]) }) - - describe('singular customValidationMessage', () => { - const singularDef = { - ...def, - options: { customValidationMessage: CUSTOM } - } satisfies YesNoFieldComponent - - it('honours the override', () => { - const collection = new ComponentCollection([singularDef], { 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([singularDef], { 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.customValidationMessage(s). Setting the - * option has no effect, so the same input must give the same errors with - * and without it. + * 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('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', @@ -785,7 +381,7 @@ describe('Component-level validation message override', () => { const withOverride = { ...base, - options: { customValidationMessages: { 'any.required': CUSTOM } } + options: { customValidationMessage: CUSTOM } } satisfies EastingNorthingFieldComponent const input = getCompositeFormData({ easting: '', northing: '' }) @@ -810,7 +406,7 @@ describe('Component-level validation message override', () => { const withOverride = { ...base, - options: { customValidationMessages: { 'any.required': CUSTOM } } + options: { customValidationMessage: CUSTOM } } satisfies LatLongFieldComponent const input = getCompositeFormData({ latitude: '', longitude: '' }) @@ -824,164 +420,20 @@ describe('Component-level validation message override', () => { 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', () => { + it('MonthYearField', () => { const base = { - title: 'Example hidden field', + title: 'Example month/year field', name: 'myComponent', - type: ComponentType.HiddenField, + type: ComponentType.MonthYearField, 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 + } satisfies MonthYearFieldComponent 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 + options: { customValidationMessage: CUSTOM } + } satisfies MonthYearFieldComponent - 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 input = getCompositeFormData({ month: '', year: '' }) const baseline = new ComponentCollection([base], { model }).validate( input ) 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..4e0cd6cb1 --- /dev/null +++ b/src/server/plugins/engine/components/customValidationMessages.test.ts @@ -0,0 +1,794 @@ +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 FormPayload, + 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 { getFormData } from '~/test/helpers/component-helpers.js' + +/** + * Composite fields (DatePartsField, UkAddressField, ...) use flat payload + * keys, e.g. `myComponent__day`. They do not nest an object under + * `myComponent`. Each component's own test file uses the same format. + */ +function getCompositeFormData(fields: Record): FormPayload { + const data: FormPayload = {} + + for (const [key, value] of Object.entries(fields)) { + data[`myComponent__${key}`] = value + } + + return data +} + +/** + * 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) + }) + }) +}) From ee00a06da704811d5a60b929edfb3c8f073f6d2d Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 7 Sep 2026 15:31:06 +0100 Subject: [PATCH 7/7] Deduplicate composite form data helper across the two test files Splitting the singular and plural test files left getCompositeFormData() copy-pasted in both. Moved it to test/helpers/component-helpers.js, next to getFormData(), so it exists once. --- .../customValidationMessage.test.ts | 21 +++------------- .../customValidationMessages.test.ts | 25 ++++--------------- test/helpers/component-helpers.js | 19 ++++++++++++++ 3 files changed, 28 insertions(+), 37 deletions(-) diff --git a/src/server/plugins/engine/components/customValidationMessage.test.ts b/src/server/plugins/engine/components/customValidationMessage.test.ts index 66f9e4d8d..61abc18b6 100644 --- a/src/server/plugins/engine/components/customValidationMessage.test.ts +++ b/src/server/plugins/engine/components/customValidationMessage.test.ts @@ -15,24 +15,11 @@ import { import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' -import { type FormPayload } from '~/src/server/plugins/engine/types.js' import definition from '~/test/form/definitions/blank.js' -import { getFormData } from '~/test/helpers/component-helpers.js' - -/** - * Composite fields (EastingNorthingField, ...) use flat payload keys, e.g. - * `myComponent__easting`. They do not nest an object under `myComponent`. - * Each component's own test file uses the same format. - */ -function getCompositeFormData(fields: Record): FormPayload { - const data: FormPayload = {} - - for (const [key, value] of Object.entries(fields)) { - data[`myComponent__${key}`] = value - } - - return data -} +import { + getCompositeFormData, + getFormData +} from '~/test/helpers/component-helpers.js' /** * Each component has its own test file for schema, state and view model diff --git a/src/server/plugins/engine/components/customValidationMessages.test.ts b/src/server/plugins/engine/components/customValidationMessages.test.ts index 4e0cd6cb1..3fa78cf69 100644 --- a/src/server/plugins/engine/components/customValidationMessages.test.ts +++ b/src/server/plugins/engine/components/customValidationMessages.test.ts @@ -26,28 +26,13 @@ import { import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' -import { - type FormPayload, - type FormValue -} from '~/src/server/plugins/engine/types.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 { getFormData } from '~/test/helpers/component-helpers.js' - -/** - * Composite fields (DatePartsField, UkAddressField, ...) use flat payload - * keys, e.g. `myComponent__day`. They do not nest an object under - * `myComponent`. Each component's own test file uses the same format. - */ -function getCompositeFormData(fields: Record): FormPayload { - const data: FormPayload = {} - - for (const [key, value] of Object.entries(fields)) { - data[`myComponent__${key}`] = value - } - - return data -} +import { + getCompositeFormData, + getFormData +} from '~/test/helpers/component-helpers.js' /** * Each component has its own test file for schema, state and view model 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