From 7765351baa2c8ea912b895731c47e34ea0508a4d Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 7 Sep 2026 15:53:50 +0100 Subject: [PATCH] Share customValidationMessage(s) support lists between tests and docs The generated component docs listed customValidationMessage and customValidationMessages on every component whose forms-model type declares them, even where the engine ignores the option - 11 of 28 component types documented an option with no effect. The lists proving which components actually honour each option already existed as private constants in the two test files, so extracting them into a shared module lets the doc generator use the same source of truth: it now omits the customValidationMessages row for components that only inherit it, and appends a no-effect note to customValidationMessage where a component declares it but ignores it. --- scripts/generate-component-docs.js | 37 +++++- scripts/generate-component-docs.test.js | 34 ++++++ .../customValidationMessage.test.ts | 64 ++-------- .../customValidationMessageSupport.ts | 115 ++++++++++++++++++ .../customValidationMessages.test.ts | 66 ++-------- 5 files changed, 203 insertions(+), 113 deletions(-) create mode 100644 src/server/plugins/engine/components/customValidationMessageSupport.ts diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index bd0ad1f1d..80b22de88 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -10,6 +10,11 @@ import { writePreviewPartial } from './generate-component-previews.js' import { writePagePreviewPartial } from './generate-page-previews.js' import { pageFixtures } from './page-preview-fixtures.js' +import { + IGNORES_PLURAL_MESSAGES, + IGNORES_SINGULAR_MESSAGE +} from '~/src/server/plugins/engine/components/customValidationMessageSupport.js' + const __dirname = path.dirname(fileURLToPath(import.meta.url)) const formsModelTypesDir = path.resolve( @@ -93,6 +98,14 @@ export function toLabel(name) { const UNIVERSAL = new Set(['type', 'name', 'title', 'id', 'options', 'schema']) +/** @type {Set} */ +const IGNORES_SINGULAR_MESSAGE_SET = new Set(IGNORES_SINGULAR_MESSAGE) +/** @type {Set} */ +const IGNORES_PLURAL_MESSAGES_SET = new Set(IGNORES_PLURAL_MESSAGES) + +const SINGULAR_MESSAGE_NO_EFFECT_NOTE = + 'Declared on this component but not applied — setting it has no effect.' + /** * Type strings from `.d.ts` files are verbose and use internal model names * that mean nothing in user-facing docs. This function cleans them up: @@ -796,12 +809,30 @@ export function generateComponentMd( lines.push(``) } - if (options.length > 0) { + // customValidationMessages is declared on the shared FormFieldBase options + // type, so components that never read it still inherit the property. + // Omitting the row here keeps the table limited to options this component + // actually honours. + const visibleOptions = options.filter( + (prop) => + prop.name !== 'customValidationMessages' || + !IGNORES_PLURAL_MESSAGES_SET.has(componentName) + ) + + if (visibleOptions.length > 0) { lines.push(`## Options`, ``) lines.push(`| Property | Type | Required | Description |`) lines.push(`|----------|------|----------|-------------|`) - for (const prop of options) { - const desc = metadata.properties[prop.name] ?? '' + for (const prop of visibleOptions) { + let desc = metadata.properties[prop.name] ?? '' + if ( + prop.name === 'customValidationMessage' && + IGNORES_SINGULAR_MESSAGE_SET.has(componentName) + ) { + desc = desc + ? `${desc} ${SINGULAR_MESSAGE_NO_EFFECT_NOTE}` + : SINGULAR_MESSAGE_NO_EFFECT_NOTE + } lines.push( `| \`${prop.name}\` | \`${prop.type.replace(/\|/g, '\\|')}\` | ${prop.optional ? 'No' : 'Yes'} | ${desc} |` ) diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index 59a6802ae..73519d81b 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -454,6 +454,40 @@ describe('Component Documentation Generator', () => { }) }) + describe('generateComponentMd with customValidationMessage support', () => { + const interfaceData = { + options: [ + { name: 'customValidationMessage', type: 'string', optional: true }, + { name: 'customValidationMessages', type: 'object', optional: true } + ], + schema: [], + props: [] + } + + it('leaves the customValidationMessage row plain for a component that honours it', () => { + const result = generateComponentMd('TextField', interfaceData, 1) + expect(result).toContain('| `customValidationMessage` | `string` | No |') + expect(result).not.toContain('has no effect') + }) + + it('appends a no-effect caveat to customValidationMessage for a component that declares but ignores it', () => { + const result = generateComponentMd('LatLongField', interfaceData, 1) + expect(result).toMatch( + /\| `customValidationMessage` \|.*has no effect\. \|/ + ) + }) + + it('keeps the customValidationMessages row for a component that honours it', () => { + const result = generateComponentMd('TextField', interfaceData, 1) + expect(result).toContain('`customValidationMessages`') + }) + + it('omits the customValidationMessages row for a component that declares but ignores it', () => { + const result = generateComponentMd('CheckboxesField', interfaceData, 1) + expect(result).not.toContain('customValidationMessages') + }) + }) + describe('buildJsNotice', () => { it('Level 1: renders a GOV.UK notification banner with banner structure', () => { const result = buildJsNotice(1, 'Notice text.') diff --git a/src/server/plugins/engine/components/customValidationMessage.test.ts b/src/server/plugins/engine/components/customValidationMessage.test.ts index 61abc18b6..58db1f56e 100644 --- a/src/server/plugins/engine/components/customValidationMessage.test.ts +++ b/src/server/plugins/engine/components/customValidationMessage.test.ts @@ -14,6 +14,11 @@ import { } from '@defra/forms-model' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { + IGNORES_SINGULAR_MESSAGE, + NO_SINGULAR_MESSAGE_OPTION, + SUPPORTS_SINGULAR_MESSAGE +} from '~/src/server/plugins/engine/components/customValidationMessageSupport.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import definition from '~/test/form/definitions/blank.js' import { @@ -34,9 +39,9 @@ import { * same as support for this singular option, so that option has its own test * file: customValidationMessages.test.ts. * - * The lists below record which components declare and read this option. - * They come from reading each component's source code and its - * `@defra/forms-model` type, not from guessing. Only 11 of the 28 + * Which components declare and read this option is recorded in + * customValidationMessageSupport.js, shared with the doc generator so the + * published Options tables stay in step with this test. 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; @@ -56,63 +61,12 @@ import { const CUSTOM = 'This is a custom validation message' -/** - * Component types that declare customValidationMessage on their options - * type and read it. Tested below under 'Supported'. - */ -const SUPPORTS_SINGULAR_MESSAGE = [ - ComponentType.TextField, - ComponentType.EmailAddressField, - ComponentType.MultilineTextField, - ComponentType.TelephoneNumberField, - ComponentType.NumberField, - ComponentType.OsGridRefField, - ComponentType.NationalGridFieldNumberField, - ComponentType.YesNoField -] - -/** - * Component types that declare customValidationMessage on their options - * type but never read it. Tested below under 'Not supported'. - */ -const NOT_SUPPORTED_COMPONENT_TYPES = [ - ComponentType.EastingNorthingField, - ComponentType.LatLongField, - ComponentType.MonthYearField -] - -/** - * Component types with no customValidationMessage field on their options - * type. TypeScript itself proves the option cannot be set, so there is - * nothing to test at runtime. Listed here only so the coverage guard test - * below accounts for every ComponentType. - */ -const NO_SINGULAR_MESSAGE_OPTION = [ - ComponentType.CheckboxesField, - ComponentType.DatePartsField, - ComponentType.UkAddressField, - ComponentType.FileUploadField, - ComponentType.DeclarationField, - ComponentType.HiddenField, - ComponentType.GeospatialField, - ComponentType.PaymentField, - ComponentType.RadiosField, - ComponentType.SelectField, - ComponentType.AutocompleteField, - ComponentType.Details, - ComponentType.Html, - ComponentType.Markdown, - ComponentType.InsetText, - ComponentType.List, - ComponentType.NotificationBanner -] - describe('Component-level validation message override (customValidationMessage)', () => { // Fails when a new ComponentType is not in one of the three lists above. it('accounts for every ComponentType', () => { const accountedFor = [ ...SUPPORTS_SINGULAR_MESSAGE, - ...NOT_SUPPORTED_COMPONENT_TYPES, + ...IGNORES_SINGULAR_MESSAGE, ...NO_SINGULAR_MESSAGE_OPTION ] diff --git a/src/server/plugins/engine/components/customValidationMessageSupport.ts b/src/server/plugins/engine/components/customValidationMessageSupport.ts new file mode 100644 index 000000000..84ac706f2 --- /dev/null +++ b/src/server/plugins/engine/components/customValidationMessageSupport.ts @@ -0,0 +1,115 @@ +import { ComponentType } from '@defra/forms-model' + +/** + * Single source of truth for which component types apply the singular + * `options.customValidationMessage` string and the plural + * `options.customValidationMessages` map. Both + * `customValidationMessage.test.ts` and `customValidationMessages.test.ts` + * import these lists to verify engine behaviour, and + * `scripts/generate-component-docs.js` imports them to keep the published + * Options tables in step with that behaviour - a component listed here as + * "ignores" gets its docs annotated or its row omitted instead of silently + * documenting an option that does nothing. + * + * Each test file's 'accounts for every ComponentType' guard keeps its three + * lists exhaustive, so an engine change that adds or removes support is + * caught at the point these lists would otherwise go stale. + */ + +/** + * Component types that read `options.customValidationMessage` and apply it + * in place of every translated validation message. + */ +export const SUPPORTS_SINGULAR_MESSAGE = [ + ComponentType.TextField, + ComponentType.EmailAddressField, + ComponentType.MultilineTextField, + ComponentType.TelephoneNumberField, + ComponentType.NumberField, + ComponentType.OsGridRefField, + ComponentType.NationalGridFieldNumberField, + ComponentType.YesNoField +] + +/** + * Component types whose options type declares `customValidationMessage` but + * whose engine code never reads it, so setting it has no effect. + */ +export const IGNORES_SINGULAR_MESSAGE = [ + ComponentType.EastingNorthingField, + ComponentType.LatLongField, + ComponentType.MonthYearField +] + +/** + * Component types with no `customValidationMessage` on their options type. + * TypeScript itself proves the option cannot be set on these. + */ +export const NO_SINGULAR_MESSAGE_OPTION = [ + ComponentType.CheckboxesField, + ComponentType.DatePartsField, + ComponentType.UkAddressField, + ComponentType.FileUploadField, + ComponentType.DeclarationField, + ComponentType.HiddenField, + ComponentType.GeospatialField, + ComponentType.PaymentField, + ComponentType.RadiosField, + ComponentType.SelectField, + ComponentType.AutocompleteField, + ComponentType.Details, + ComponentType.Html, + ComponentType.Markdown, + ComponentType.InsetText, + ComponentType.List, + ComponentType.NotificationBanner +] + +/** + * Component types that read `options.customValidationMessages` and use it + * in their Joi schema. + */ +export const SUPPORTS_PLURAL_MESSAGES = [ + 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. + */ +export const IGNORES_PLURAL_MESSAGES = [ + 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. + */ +export const NO_PLURAL_MESSAGES_OPTION = [ + ComponentType.Details, + ComponentType.Html, + ComponentType.Markdown, + ComponentType.InsetText, + ComponentType.List, + ComponentType.NotificationBanner +] diff --git a/src/server/plugins/engine/components/customValidationMessages.test.ts b/src/server/plugins/engine/components/customValidationMessages.test.ts index 3fa78cf69..7f71c59fd 100644 --- a/src/server/plugins/engine/components/customValidationMessages.test.ts +++ b/src/server/plugins/engine/components/customValidationMessages.test.ts @@ -25,6 +25,11 @@ import { } from '@defra/forms-model' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { + IGNORES_PLURAL_MESSAGES, + NO_PLURAL_MESSAGES_OPTION, + SUPPORTS_PLURAL_MESSAGES +} from '~/src/server/plugins/engine/components/customValidationMessageSupport.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { type FormValue } from '~/src/server/plugins/engine/types.js' import { listString } from '~/test/fixtures/list.js' @@ -42,8 +47,9 @@ import { * - `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. + * Which components use the option is recorded in + * customValidationMessageSupport.js, shared with the doc generator so the + * published Options tables stay in step with this test. * * Some of these components also support a singular `customValidationMessage` * string. Support for the two options is not identical, so the singular @@ -62,63 +68,13 @@ import { 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 + ...SUPPORTS_PLURAL_MESSAGES, + ...IGNORES_PLURAL_MESSAGES, + ...NO_PLURAL_MESSAGES_OPTION ] expect(new Set(accountedFor).size).toBe(accountedFor.length)