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)