Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions scripts/generate-component-docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -93,6 +98,14 @@ export function toLabel(name) {

const UNIVERSAL = new Set(['type', 'name', 'title', 'id', 'options', 'schema'])

/** @type {Set<string>} */
const IGNORES_SINGULAR_MESSAGE_SET = new Set(IGNORES_SINGULAR_MESSAGE)
/** @type {Set<string>} */
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:
Expand Down Expand 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} |`
)
Expand Down
34 changes: 34 additions & 0 deletions scripts/generate-component-docs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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
]

Expand Down
115 changes: 115 additions & 0 deletions src/server/plugins/engine/components/customValidationMessageSupport.ts
Original file line number Diff line number Diff line change
@@ -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
]
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading