From e6d0c989a3134e541ebfaa91df3c8a91bdd48962 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 21 Oct 2025 09:48:31 +0100 Subject: [PATCH 01/21] feat: location components --- .../engine/components/EastingNorthingField.ts | 122 ++++++++++++++ .../plugins/engine/components/LatLongField.ts | 158 ++++++++++++++++++ .../NationalGridFieldNumberField.ts | 121 ++++++++++++++ .../engine/components/OsGridRefField.ts | 121 ++++++++++++++ .../engine/components/helpers/components.ts | 16 ++ src/server/plugins/engine/components/index.ts | 4 + .../components/eastingnorthingfield.html | 13 ++ .../engine/views/components/latlongfield.html | 13 ++ .../nationalgridfieldnumberfield.html | 13 ++ .../views/components/osgridreffield.html | 13 ++ 10 files changed, 594 insertions(+) create mode 100644 src/server/plugins/engine/components/EastingNorthingField.ts create mode 100644 src/server/plugins/engine/components/LatLongField.ts create mode 100644 src/server/plugins/engine/components/NationalGridFieldNumberField.ts create mode 100644 src/server/plugins/engine/components/OsGridRefField.ts create mode 100644 src/server/plugins/engine/views/components/eastingnorthingfield.html create mode 100644 src/server/plugins/engine/views/components/latlongfield.html create mode 100644 src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html create mode 100644 src/server/plugins/engine/views/components/osgridreffield.html diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts new file mode 100644 index 000000000..6e6638cc1 --- /dev/null +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -0,0 +1,122 @@ +import { type EastingNorthingFieldComponent } from '@defra/forms-model' +import joi, { type StringSchema } from 'joi' + +import { + FormComponent, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormState, + type FormStateValue, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +export class EastingNorthingField extends FormComponent { + declare options: EastingNorthingFieldComponent['options'] + declare formSchema: StringSchema + declare stateSchema: StringSchema + instructionText?: string + + constructor( + def: EastingNorthingFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { options } = def + this.instructionText = options.instructionText + + let formSchema = joi + .string() + .trim() + .label(this.label) + .required() + // Pattern for Easting and Northing coordinates + // Accepts formats like: "Easting: 248741, Northing: 63688" or "248741, 63688" + .pattern(/^(?:Easting:\s*)?(\d{6}),?\s*(?:Northing:\s*)?(\d{6})$/i) + .messages({ + 'string.pattern.base': + 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 63688' + }) + + if (options.required === false) { + formSchema = formSchema.allow('') + } + + if (options.customValidationMessage) { + const message = options.customValidationMessage + + formSchema = formSchema.messages({ + 'any.required': message, + 'string.empty': message, + 'string.pattern.base': message + }) + } else if (options.customValidationMessages) { + formSchema = formSchema.messages(options.customValidationMessages) + } + + this.formSchema = formSchema.default('') + this.stateSchema = formSchema.default(null).allow(null) + this.options = options + } + + getFormValueFromState(state: FormSubmissionState) { + const { name } = this + return this.getFormValue(state[name]) + } + + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value : undefined + } + + isValue(value?: FormStateValue | FormState): value is string { + return EastingNorthingField.isText(value) + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const viewModel = super.getViewModel(payload, errors) + + // Add instruction text to the component for rendering + if (this.instructionText) { + return { + ...viewModel, + instructionText: markdown.parse(this.instructionText, { async: false }) + } + } + + return viewModel + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return EastingNorthingField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { + type: 'pattern', + template: + 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 63688' + } + ], + advancedSettingsErrors: [] + } + } + + static isText(value?: FormStateValue | FormState): value is string { + return isFormValue(value) && typeof value === 'string' + } +} diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts new file mode 100644 index 000000000..d93cf48e3 --- /dev/null +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -0,0 +1,158 @@ +import { type LatLongFieldComponent } from '@defra/forms-model' +import joi, { type StringSchema } from 'joi' + +import { + FormComponent, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormState, + type FormStateValue, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +export class LatLongField extends FormComponent { + declare options: LatLongFieldComponent['options'] + declare formSchema: StringSchema + declare stateSchema: StringSchema + instructionText?: string + + constructor( + def: LatLongFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { options } = def + this.instructionText = options.instructionText + + let formSchema = joi + .string() + .trim() + .label(this.label) + .required() + // Pattern for latitude and longitude - flexible format + // Accepts: "51.5074, -0.1278" or "51.5074,-0.1278" or "Lat: 51.5074, Long: -0.1278" + .pattern( + /^(?:Lat(?:itude)?:\s*)?(-?\d+\.?\d*),?\s*(?:Long?(?:itude)?:\s*)?(-?\d+\.?\d*)$/i + ) + .custom((value, helpers) => { + const match = value.match( + /^(?:Lat(?:itude)?:\s*)?(-?\d+\.?\d*),?\s*(?:Long?(?:itude)?:\s*)?(-?\d+\.?\d*)$/i + ) + if (match) { + const latitude = parseFloat(match[1]) + const longitude = parseFloat(match[2]) + + // Validate Great Britain ranges + if (latitude < 49.85 || latitude > 60.859) { + return helpers.error('custom.latitude') + } + if (longitude < -13.687 || longitude > 1.767) { + return helpers.error('custom.longitude') + } + } + return value + }) + .messages({ + 'string.pattern.base': + 'Enter latitude and longitude in the correct format, for example, 51.5074, -0.1278', + 'custom.latitude': + 'Latitude must be between 49.850 and 60.859 for Great Britain', + 'custom.longitude': + 'Longitude must be between -13.687 and 1.767 for Great Britain' + }) + + if (options.required === false) { + formSchema = formSchema.allow('') + } + + if (options.customValidationMessage) { + const message = options.customValidationMessage + + formSchema = formSchema.messages({ + 'any.required': message, + 'string.empty': message, + 'string.pattern.base': message, + 'custom.latitude': message, + 'custom.longitude': message + }) + } else if (options.customValidationMessages) { + formSchema = formSchema.messages(options.customValidationMessages) + } + + this.formSchema = formSchema.default('') + this.stateSchema = formSchema.default(null).allow(null) + this.options = options + } + + getFormValueFromState(state: FormSubmissionState) { + const { name } = this + return this.getFormValue(state[name]) + } + + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value : undefined + } + + isValue(value?: FormStateValue | FormState): value is string { + return LatLongField.isText(value) + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const viewModel = super.getViewModel(payload, errors) + + // Add instruction text to the component for rendering + if (this.instructionText) { + return { + ...viewModel, + instructionText: markdown.parse(this.instructionText, { async: false }) + } + } + + return viewModel + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return LatLongField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { + type: 'pattern', + template: + 'Enter latitude and longitude in the correct format, for example, 51.5074, -0.1278' + }, + { + type: 'latitude', + template: + 'Latitude must be between 49.850 and 60.859 for Great Britain' + }, + { + type: 'longitude', + template: + 'Longitude must be between -13.687 and 1.767 for Great Britain' + } + ], + advancedSettingsErrors: [] + } + } + + static isText(value?: FormStateValue | FormState): value is string { + return isFormValue(value) && typeof value === 'string' + } +} diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts new file mode 100644 index 000000000..8489b0db3 --- /dev/null +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -0,0 +1,121 @@ +import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' +import joi, { type StringSchema } from 'joi' + +import { + FormComponent, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormState, + type FormStateValue, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +export class NationalGridFieldNumberField extends FormComponent { + declare options: NationalGridFieldNumberFieldComponent['options'] + declare formSchema: StringSchema + declare stateSchema: StringSchema + instructionText?: string + + constructor( + def: NationalGridFieldNumberFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { options } = def + this.instructionText = options.instructionText + + let formSchema = joi + .string() + .trim() + .label(this.label) + .required() + // Pattern for National Grid Field Number: 2 letters followed by 8 numbers + .pattern(/^[A-Z]{2}\d{8}$/i) + .messages({ + 'string.pattern.base': + 'Enter a National Grid field number in the correct format, for example, SO04188589' + }) + + if (options.required === false) { + formSchema = formSchema.allow('') + } + + if (options.customValidationMessage) { + const message = options.customValidationMessage + + formSchema = formSchema.messages({ + 'any.required': message, + 'string.empty': message, + 'string.pattern.base': message + }) + } else if (options.customValidationMessages) { + formSchema = formSchema.messages(options.customValidationMessages) + } + + this.formSchema = formSchema.default('') + this.stateSchema = formSchema.default(null).allow(null) + this.options = options + } + + getFormValueFromState(state: FormSubmissionState) { + const { name } = this + return this.getFormValue(state[name]) + } + + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value : undefined + } + + isValue(value?: FormStateValue | FormState): value is string { + return NationalGridFieldNumberField.isText(value) + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const viewModel = super.getViewModel(payload, errors) + + // Add instruction text to the component for rendering + if (this.instructionText) { + return { + ...viewModel, + instructionText: markdown.parse(this.instructionText, { async: false }) + } + } + + return viewModel + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return NationalGridFieldNumberField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { + type: 'pattern', + template: + 'Enter a National Grid field number in the correct format, for example, SO04188589' + } + ], + advancedSettingsErrors: [] + } + } + + static isText(value?: FormStateValue | FormState): value is string { + return isFormValue(value) && typeof value === 'string' + } +} diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts new file mode 100644 index 000000000..cc6c0e640 --- /dev/null +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -0,0 +1,121 @@ +import { type OsGridRefFieldComponent } from '@defra/forms-model' +import joi, { type StringSchema } from 'joi' + +import { + FormComponent, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormState, + type FormStateValue, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +export class OsGridRefField extends FormComponent { + declare options: OsGridRefFieldComponent['options'] + declare formSchema: StringSchema + declare stateSchema: StringSchema + instructionText?: string + + constructor( + def: OsGridRefFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { options } = def + this.instructionText = options.instructionText + + let formSchema = joi + .string() + .trim() + .label(this.label) + .required() + // Pattern for OS Grid Reference: 2 letters followed by 10 numbers + .pattern(/^[A-Z]{2}\d{10}$/i) + .messages({ + 'string.pattern.base': + 'Enter an OS grid reference in the correct format, for example, SO7394301364' + }) + + if (options.required === false) { + formSchema = formSchema.allow('') + } + + if (options.customValidationMessage) { + const message = options.customValidationMessage + + formSchema = formSchema.messages({ + 'any.required': message, + 'string.empty': message, + 'string.pattern.base': message + }) + } else if (options.customValidationMessages) { + formSchema = formSchema.messages(options.customValidationMessages) + } + + this.formSchema = formSchema.default('') + this.stateSchema = formSchema.default(null).allow(null) + this.options = options + } + + getFormValueFromState(state: FormSubmissionState) { + const { name } = this + return this.getFormValue(state[name]) + } + + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value : undefined + } + + isValue(value?: FormStateValue | FormState): value is string { + return OsGridRefField.isText(value) + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const viewModel = super.getViewModel(payload, errors) + + // Add instruction text to the component for rendering + if (this.instructionText) { + return { + ...viewModel, + instructionText: markdown.parse(this.instructionText, { async: false }) + } + } + + return viewModel + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return OsGridRefField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { + type: 'pattern', + template: + 'Enter an OS grid reference in the correct format, for example, SO7394301364' + } + ], + advancedSettingsErrors: [] + } + } + + static isText(value?: FormStateValue | FormState): value is string { + return isFormValue(value) && typeof value === 'string' + } +} diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 8fa55556d..fe5deafc1 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -197,6 +197,22 @@ export function createComponent( case ComponentType.FileUploadField: component = new Components.FileUploadField(def, options) break + + case ComponentType.EastingNorthingField: + component = new Components.EastingNorthingField(def, options) + break + + case ComponentType.OsGridRefField: + component = new Components.OsGridRefField(def, options) + break + + case ComponentType.NationalGridFieldNumberField: + component = new Components.NationalGridFieldNumberField(def, options) + break + + case ComponentType.LatLongField: + component = new Components.LatLongField(def, options) + break } if (typeof component === 'undefined') { diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 8b6d001b7..b17a786ea 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -23,3 +23,7 @@ export { TelephoneNumberField } from '~/src/server/plugins/engine/components/Tel export { TextField } from '~/src/server/plugins/engine/components/TextField.js' export { UkAddressField } from '~/src/server/plugins/engine/components/UkAddressField.js' export { YesNoField } from '~/src/server/plugins/engine/components/YesNoField.js' +export { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js' +export { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js' +export { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js' +export { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' diff --git a/src/server/plugins/engine/views/components/eastingnorthingfield.html b/src/server/plugins/engine/views/components/eastingnorthingfield.html new file mode 100644 index 000000000..e0d36f9ed --- /dev/null +++ b/src/server/plugins/engine/views/components/eastingnorthingfield.html @@ -0,0 +1,13 @@ +{% from "components/textfield.html" import TextField %} +{% from "govuk/components/details/macro.njk" import govukDetails %} + +{% macro EastingNorthingField(component) %} + {{ TextField(component) }} + + {% if component.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.instructionText | safe + }) }} + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/src/server/plugins/engine/views/components/latlongfield.html b/src/server/plugins/engine/views/components/latlongfield.html new file mode 100644 index 000000000..f7477522e --- /dev/null +++ b/src/server/plugins/engine/views/components/latlongfield.html @@ -0,0 +1,13 @@ +{% from "components/textfield.html" import TextField %} +{% from "govuk/components/details/macro.njk" import govukDetails %} + +{% macro LatLongField(component) %} + {{ TextField(component) }} + + {% if component.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.instructionText | safe + }) }} + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html new file mode 100644 index 000000000..f2aff5b37 --- /dev/null +++ b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html @@ -0,0 +1,13 @@ +{% from "components/textfield.html" import TextField %} +{% from "govuk/components/details/macro.njk" import govukDetails %} + +{% macro NationalGridFieldNumberField(component) %} + {{ TextField(component) }} + + {% if component.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.instructionText | safe + }) }} + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/src/server/plugins/engine/views/components/osgridreffield.html b/src/server/plugins/engine/views/components/osgridreffield.html new file mode 100644 index 000000000..b84a3b6d5 --- /dev/null +++ b/src/server/plugins/engine/views/components/osgridreffield.html @@ -0,0 +1,13 @@ +{% from "components/textfield.html" import TextField %} +{% from "govuk/components/details/macro.njk" import govukDetails %} + +{% macro OsGridRefField(component) %} + {{ TextField(component) }} + + {% if component.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.instructionText | safe + }) }} + {% endif %} +{% endmacro %} \ No newline at end of file From 28e8ddd3f52bbbd95c3181f5c09e51f84d9b4e15 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 22 Oct 2025 09:59:36 +0100 Subject: [PATCH 02/21] fix: update latitude and longitude regex patterns for improved validation --- src/server/plugins/engine/components/LatLongField.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index d93cf48e3..6fadf2218 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -39,15 +39,15 @@ export class LatLongField extends FormComponent { // Pattern for latitude and longitude - flexible format // Accepts: "51.5074, -0.1278" or "51.5074,-0.1278" or "Lat: 51.5074, Long: -0.1278" .pattern( - /^(?:Lat(?:itude)?:\s*)?(-?\d+\.?\d*),?\s*(?:Long?(?:itude)?:\s*)?(-?\d+\.?\d*)$/i + /^(?:Lat:?\s*)?(-?\d+(?:\.\d+)?),?\s*(?:Lon(?:g)?:?\s*)?(-?\d+(?:\.\d+)?)$/i ) .custom((value, helpers) => { const match = value.match( - /^(?:Lat(?:itude)?:\s*)?(-?\d+\.?\d*),?\s*(?:Long?(?:itude)?:\s*)?(-?\d+\.?\d*)$/i + /^(?:Lat:?\s*)?(-?\d+(?:\.\d+)?),?\s*(?:Lon(?:g)?:?\s*)?(-?\d+(?:\.\d+)?)$/i ) if (match) { - const latitude = parseFloat(match[1]) - const longitude = parseFloat(match[2]) + const latitude = Number.parseFloat(match[1]) + const longitude = Number.parseFloat(match[2]) // Validate Great Britain ranges if (latitude < 49.85 || latitude > 60.859) { From bab339d775dc8309b8436eaae8b69d7799257224 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 22 Oct 2025 10:05:44 +0100 Subject: [PATCH 03/21] fix: refine latitude and longitude regex for better validation accuracy --- src/server/plugins/engine/components/LatLongField.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 6fadf2218..c87ad6355 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -38,12 +38,10 @@ export class LatLongField extends FormComponent { .required() // Pattern for latitude and longitude - flexible format // Accepts: "51.5074, -0.1278" or "51.5074,-0.1278" or "Lat: 51.5074, Long: -0.1278" - .pattern( - /^(?:Lat:?\s*)?(-?\d+(?:\.\d+)?),?\s*(?:Lon(?:g)?:?\s*)?(-?\d+(?:\.\d+)?)$/i - ) + .pattern(/^(?:Lat:\s*)?(-?\d+\.?\d*),?\s*(?:Long:\s*)?(-?\d+\.?\d*)$/i) .custom((value, helpers) => { const match = value.match( - /^(?:Lat:?\s*)?(-?\d+(?:\.\d+)?),?\s*(?:Lon(?:g)?:?\s*)?(-?\d+(?:\.\d+)?)$/i + /^(?:Lat:\s*)?(-?\d+\.?\d*),?\s*(?:Long:\s*)?(-?\d+\.?\d*)$/i ) if (match) { const latitude = Number.parseFloat(match[1]) From 55c438edb031ebc3101594e57736f1887b1b3507 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 23 Oct 2025 17:37:06 +0100 Subject: [PATCH 04/21] refactor: introduce LocationFieldBase to reduce code duplication in location components --- .../engine/components/EastingNorthingField.ts | 125 +++----------- .../plugins/engine/components/LatLongField.ts | 159 ++++-------------- .../engine/components/LocationFieldBase.ts | 147 ++++++++++++++++ .../NationalGridFieldNumberField.ts | 124 +++----------- .../engine/components/OsGridRefField.ts | 122 ++------------ 5 files changed, 239 insertions(+), 438 deletions(-) create mode 100644 src/server/plugins/engine/components/LocationFieldBase.ts diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 6e6638cc1..b43c014ac 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -1,122 +1,35 @@ import { type EastingNorthingFieldComponent } from '@defra/forms-model' -import joi, { type StringSchema } from 'joi' -import { - FormComponent, - isFormValue -} from '~/src/server/plugins/engine/components/FormComponent.js' -import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' -import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' -import { - type ErrorMessageTemplateList, - type FormPayload, - type FormState, - type FormStateValue, - type FormSubmissionError, - type FormSubmissionState -} from '~/src/server/plugins/engine/types.js' +import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' -export class EastingNorthingField extends FormComponent { +export class EastingNorthingField extends LocationFieldBase { declare options: EastingNorthingFieldComponent['options'] - declare formSchema: StringSchema - declare stateSchema: StringSchema - instructionText?: string - constructor( - def: EastingNorthingFieldComponent, - props: ConstructorParameters[1] - ) { - super(def, props) - - const { options } = def - this.instructionText = options.instructionText - - let formSchema = joi - .string() - .trim() - .label(this.label) - .required() - // Pattern for Easting and Northing coordinates - // Accepts formats like: "Easting: 248741, Northing: 63688" or "248741, 63688" - .pattern(/^(?:Easting:\s*)?(\d{6}),?\s*(?:Northing:\s*)?(\d{6})$/i) - .messages({ - 'string.pattern.base': - 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 63688' - }) - - if (options.required === false) { - formSchema = formSchema.allow('') - } - - if (options.customValidationMessage) { - const message = options.customValidationMessage - - formSchema = formSchema.messages({ - 'any.required': message, - 'string.empty': message, - 'string.pattern.base': message - }) - } else if (options.customValidationMessages) { - formSchema = formSchema.messages(options.customValidationMessages) + protected getValidationConfig() { + return { + pattern: /^(?:Easting:\s*)?(\d{6})\s*,?\s*(?:Northing:\s*)?(\d{6})$/i, + patternErrorMessage: + 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 63688' } - - this.formSchema = formSchema.default('') - this.stateSchema = formSchema.default(null).allow(null) - this.options = options - } - - getFormValueFromState(state: FormSubmissionState) { - const { name } = this - return this.getFormValue(state[name]) } - getFormValue(value?: FormStateValue | FormState) { - return this.isValue(value) ? value : undefined - } - - isValue(value?: FormStateValue | FormState): value is string { - return EastingNorthingField.isText(value) - } - - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) - - // Add instruction text to the component for rendering - if (this.instructionText) { - return { - ...viewModel, - instructionText: markdown.parse(this.instructionText, { async: false }) + protected getErrorTemplates() { + return [ + { + type: 'pattern', + template: + 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 63688' } - } - - return viewModel - } - - /** - * For error preview page that shows all possible errors on a component - */ - getAllPossibleErrors(): ErrorMessageTemplateList { - return EastingNorthingField.getAllPossibleErrors() + ] } /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ - static getAllPossibleErrors(): ErrorMessageTemplateList { - return { - baseErrors: [ - { type: 'required', template: messageTemplate.required }, - { - type: 'pattern', - template: - 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 63688' - } - ], - advancedSettingsErrors: [] - } - } - - static isText(value?: FormStateValue | FormState): value is string { - return isFormValue(value) && typeof value === 'string' + static getAllPossibleErrors() { + const instance = Object.create( + EastingNorthingField.prototype + ) as EastingNorthingField + return instance.getAllPossibleErrors() } } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index c87ad6355..674dd6f31 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -1,48 +1,24 @@ import { type LatLongFieldComponent } from '@defra/forms-model' -import joi, { type StringSchema } from 'joi' +import { type CustomHelpers, type ErrorReport } from 'joi' -import { - FormComponent, - isFormValue -} from '~/src/server/plugins/engine/components/FormComponent.js' -import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' -import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' -import { - type ErrorMessageTemplateList, - type FormPayload, - type FormState, - type FormStateValue, - type FormSubmissionError, - type FormSubmissionState -} from '~/src/server/plugins/engine/types.js' +import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' -export class LatLongField extends FormComponent { +export class LatLongField extends LocationFieldBase { declare options: LatLongFieldComponent['options'] - declare formSchema: StringSchema - declare stateSchema: StringSchema - instructionText?: string - constructor( - def: LatLongFieldComponent, - props: ConstructorParameters[1] - ) { - super(def, props) + protected getValidationConfig() { + const pattern = + /^(?:Lat:\s*)?(-?\d+(?:\.\d+)?)\s*,?\s*(?:Long:\s*)?(-?\d+(?:\.\d+)?)$/i - const { options } = def - this.instructionText = options.instructionText - - let formSchema = joi - .string() - .trim() - .label(this.label) - .required() - // Pattern for latitude and longitude - flexible format - // Accepts: "51.5074, -0.1278" or "51.5074,-0.1278" or "Lat: 51.5074, Long: -0.1278" - .pattern(/^(?:Lat:\s*)?(-?\d+\.?\d*),?\s*(?:Long:\s*)?(-?\d+\.?\d*)$/i) - .custom((value, helpers) => { - const match = value.match( - /^(?:Lat:\s*)?(-?\d+\.?\d*),?\s*(?:Long:\s*)?(-?\d+\.?\d*)$/i - ) + return { + pattern, + patternErrorMessage: + 'Enter latitude and longitude in the correct format, for example, 51.5074, -0.1278', + customValidation: ( + value: string, + helpers: CustomHelpers + ): string | ErrorReport => { + const match = pattern.exec(value) if (match) { const latitude = Number.parseFloat(match[1]) const longitude = Number.parseFloat(match[2]) @@ -56,101 +32,40 @@ export class LatLongField extends FormComponent { } } return value - }) - .messages({ - 'string.pattern.base': - 'Enter latitude and longitude in the correct format, for example, 51.5074, -0.1278', + }, + additionalMessages: { 'custom.latitude': 'Latitude must be between 49.850 and 60.859 for Great Britain', 'custom.longitude': 'Longitude must be between -13.687 and 1.767 for Great Britain' - }) - - if (options.required === false) { - formSchema = formSchema.allow('') - } - - if (options.customValidationMessage) { - const message = options.customValidationMessage - - formSchema = formSchema.messages({ - 'any.required': message, - 'string.empty': message, - 'string.pattern.base': message, - 'custom.latitude': message, - 'custom.longitude': message - }) - } else if (options.customValidationMessages) { - formSchema = formSchema.messages(options.customValidationMessages) - } - - this.formSchema = formSchema.default('') - this.stateSchema = formSchema.default(null).allow(null) - this.options = options - } - - getFormValueFromState(state: FormSubmissionState) { - const { name } = this - return this.getFormValue(state[name]) - } - - getFormValue(value?: FormStateValue | FormState) { - return this.isValue(value) ? value : undefined - } - - isValue(value?: FormStateValue | FormState): value is string { - return LatLongField.isText(value) - } - - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) - - // Add instruction text to the component for rendering - if (this.instructionText) { - return { - ...viewModel, - instructionText: markdown.parse(this.instructionText, { async: false }) } } - - return viewModel } - /** - * For error preview page that shows all possible errors on a component - */ - getAllPossibleErrors(): ErrorMessageTemplateList { - return LatLongField.getAllPossibleErrors() + protected getErrorTemplates() { + return [ + { + type: 'pattern', + template: + 'Enter latitude and longitude in the correct format, for example, 51.5074, -0.1278' + }, + { + type: 'latitude', + template: 'Latitude must be between 49.850 and 60.859 for Great Britain' + }, + { + type: 'longitude', + template: + 'Longitude must be between -13.687 and 1.767 for Great Britain' + } + ] } /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ - static getAllPossibleErrors(): ErrorMessageTemplateList { - return { - baseErrors: [ - { type: 'required', template: messageTemplate.required }, - { - type: 'pattern', - template: - 'Enter latitude and longitude in the correct format, for example, 51.5074, -0.1278' - }, - { - type: 'latitude', - template: - 'Latitude must be between 49.850 and 60.859 for Great Britain' - }, - { - type: 'longitude', - template: - 'Longitude must be between -13.687 and 1.767 for Great Britain' - } - ], - advancedSettingsErrors: [] - } - } - - static isText(value?: FormStateValue | FormState): value is string { - return isFormValue(value) && typeof value === 'string' + static getAllPossibleErrors() { + const instance = Object.create(LatLongField.prototype) as LatLongField + return instance.getAllPossibleErrors() } } diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts new file mode 100644 index 000000000..9c1ee59a7 --- /dev/null +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -0,0 +1,147 @@ +import joi, { type StringSchema } from 'joi' + +import { + FormComponent, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormState, + type FormStateValue, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +interface LocationFieldOptions { + instructionText?: string + required?: boolean + customValidationMessage?: string + customValidationMessages?: Record +} + +interface ValidationConfig { + pattern: RegExp + patternErrorMessage: string + customValidation?: ( + value: string, + helpers: joi.CustomHelpers + ) => string | joi.ErrorReport + additionalMessages?: Record +} + +/** + * Abstract base class for location-based field components + * Reduces code duplication across similar location field types + */ +export abstract class LocationFieldBase extends FormComponent { + declare options: LocationFieldOptions + declare formSchema: StringSchema + declare stateSchema: StringSchema + instructionText?: string + + protected abstract getValidationConfig(): ValidationConfig + protected abstract getErrorTemplates(): Array<{ + type: string + template: string + }> + + constructor(def: any, props: ConstructorParameters[1]) { + super(def, props) + + const { options } = def + this.instructionText = options.instructionText + + const config = this.getValidationConfig() + + let formSchema = joi + .string() + .trim() + .label(this.label) + .required() + .pattern(config.pattern) + .messages({ + 'string.pattern.base': config.patternErrorMessage, + ...config.additionalMessages + }) + + if (config.customValidation) { + formSchema = formSchema.custom(config.customValidation) + } + + if (options.required === false) { + formSchema = formSchema.allow('') + } + + if (options.customValidationMessage) { + const message = options.customValidationMessage + const messageKeys = [ + 'any.required', + 'string.empty', + 'string.pattern.base' + ] + + if (config.additionalMessages) { + messageKeys.push(...Object.keys(config.additionalMessages)) + } + + const messages = messageKeys.reduce( + (acc, key) => { + acc[key] = message + return acc + }, + {} as Record + ) + + formSchema = formSchema.messages(messages) + } else if (options.customValidationMessages) { + formSchema = formSchema.messages(options.customValidationMessages) + } + + this.formSchema = formSchema.default('') + this.stateSchema = formSchema.default(null).allow(null) + this.options = options + } + + getFormValueFromState(state: FormSubmissionState) { + const { name } = this + return this.getFormValue(state[name]) + } + + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value : undefined + } + + isValue(value?: FormStateValue | FormState): value is string { + return LocationFieldBase.isText(value) + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const viewModel = super.getViewModel(payload, errors) + + if (this.instructionText) { + return { + ...viewModel, + instructionText: markdown.parse(this.instructionText, { async: false }) + } + } + + return viewModel + } + + getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + ...this.getErrorTemplates() + ], + advancedSettingsErrors: [] + } + } + + static isText(value?: FormStateValue | FormState): value is string { + return isFormValue(value) && typeof value === 'string' + } +} diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index 8489b0db3..fc7d380da 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -1,121 +1,35 @@ import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' -import joi, { type StringSchema } from 'joi' -import { - FormComponent, - isFormValue -} from '~/src/server/plugins/engine/components/FormComponent.js' -import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' -import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' -import { - type ErrorMessageTemplateList, - type FormPayload, - type FormState, - type FormStateValue, - type FormSubmissionError, - type FormSubmissionState -} from '~/src/server/plugins/engine/types.js' +import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' -export class NationalGridFieldNumberField extends FormComponent { +export class NationalGridFieldNumberField extends LocationFieldBase { declare options: NationalGridFieldNumberFieldComponent['options'] - declare formSchema: StringSchema - declare stateSchema: StringSchema - instructionText?: string - constructor( - def: NationalGridFieldNumberFieldComponent, - props: ConstructorParameters[1] - ) { - super(def, props) - - const { options } = def - this.instructionText = options.instructionText - - let formSchema = joi - .string() - .trim() - .label(this.label) - .required() - // Pattern for National Grid Field Number: 2 letters followed by 8 numbers - .pattern(/^[A-Z]{2}\d{8}$/i) - .messages({ - 'string.pattern.base': - 'Enter a National Grid field number in the correct format, for example, SO04188589' - }) - - if (options.required === false) { - formSchema = formSchema.allow('') - } - - if (options.customValidationMessage) { - const message = options.customValidationMessage - - formSchema = formSchema.messages({ - 'any.required': message, - 'string.empty': message, - 'string.pattern.base': message - }) - } else if (options.customValidationMessages) { - formSchema = formSchema.messages(options.customValidationMessages) + protected getValidationConfig() { + return { + pattern: /^[A-Z]{2}\d{8}$/i, + patternErrorMessage: + 'Enter a National Grid field number in the correct format, for example, SO04188589' } - - this.formSchema = formSchema.default('') - this.stateSchema = formSchema.default(null).allow(null) - this.options = options - } - - getFormValueFromState(state: FormSubmissionState) { - const { name } = this - return this.getFormValue(state[name]) } - getFormValue(value?: FormStateValue | FormState) { - return this.isValue(value) ? value : undefined - } - - isValue(value?: FormStateValue | FormState): value is string { - return NationalGridFieldNumberField.isText(value) - } - - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) - - // Add instruction text to the component for rendering - if (this.instructionText) { - return { - ...viewModel, - instructionText: markdown.parse(this.instructionText, { async: false }) + protected getErrorTemplates() { + return [ + { + type: 'pattern', + template: + 'Enter a National Grid field number in the correct format, for example, SO04188589' } - } - - return viewModel - } - - /** - * For error preview page that shows all possible errors on a component - */ - getAllPossibleErrors(): ErrorMessageTemplateList { - return NationalGridFieldNumberField.getAllPossibleErrors() + ] } /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ - static getAllPossibleErrors(): ErrorMessageTemplateList { - return { - baseErrors: [ - { type: 'required', template: messageTemplate.required }, - { - type: 'pattern', - template: - 'Enter a National Grid field number in the correct format, for example, SO04188589' - } - ], - advancedSettingsErrors: [] - } - } - - static isText(value?: FormStateValue | FormState): value is string { - return isFormValue(value) && typeof value === 'string' + static getAllPossibleErrors() { + const instance = Object.create( + NationalGridFieldNumberField.prototype + ) as NationalGridFieldNumberField + return instance.getAllPossibleErrors() } } diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index cc6c0e640..5db377329 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -1,121 +1,33 @@ import { type OsGridRefFieldComponent } from '@defra/forms-model' -import joi, { type StringSchema } from 'joi' -import { - FormComponent, - isFormValue -} from '~/src/server/plugins/engine/components/FormComponent.js' -import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' -import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' -import { - type ErrorMessageTemplateList, - type FormPayload, - type FormState, - type FormStateValue, - type FormSubmissionError, - type FormSubmissionState -} from '~/src/server/plugins/engine/types.js' +import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' -export class OsGridRefField extends FormComponent { +export class OsGridRefField extends LocationFieldBase { declare options: OsGridRefFieldComponent['options'] - declare formSchema: StringSchema - declare stateSchema: StringSchema - instructionText?: string - constructor( - def: OsGridRefFieldComponent, - props: ConstructorParameters[1] - ) { - super(def, props) - - const { options } = def - this.instructionText = options.instructionText - - let formSchema = joi - .string() - .trim() - .label(this.label) - .required() - // Pattern for OS Grid Reference: 2 letters followed by 10 numbers - .pattern(/^[A-Z]{2}\d{10}$/i) - .messages({ - 'string.pattern.base': - 'Enter an OS grid reference in the correct format, for example, SO7394301364' - }) - - if (options.required === false) { - formSchema = formSchema.allow('') - } - - if (options.customValidationMessage) { - const message = options.customValidationMessage - - formSchema = formSchema.messages({ - 'any.required': message, - 'string.empty': message, - 'string.pattern.base': message - }) - } else if (options.customValidationMessages) { - formSchema = formSchema.messages(options.customValidationMessages) + protected getValidationConfig() { + return { + pattern: /^[A-Z]{2}\d{10}$/i, + patternErrorMessage: + 'Enter an OS grid reference in the correct format, for example, SO7394301364' } - - this.formSchema = formSchema.default('') - this.stateSchema = formSchema.default(null).allow(null) - this.options = options - } - - getFormValueFromState(state: FormSubmissionState) { - const { name } = this - return this.getFormValue(state[name]) } - getFormValue(value?: FormStateValue | FormState) { - return this.isValue(value) ? value : undefined - } - - isValue(value?: FormStateValue | FormState): value is string { - return OsGridRefField.isText(value) - } - - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) - - // Add instruction text to the component for rendering - if (this.instructionText) { - return { - ...viewModel, - instructionText: markdown.parse(this.instructionText, { async: false }) + protected getErrorTemplates() { + return [ + { + type: 'pattern', + template: + 'Enter an OS grid reference in the correct format, for example, SO7394301364' } - } - - return viewModel - } - - /** - * For error preview page that shows all possible errors on a component - */ - getAllPossibleErrors(): ErrorMessageTemplateList { - return OsGridRefField.getAllPossibleErrors() + ] } /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ - static getAllPossibleErrors(): ErrorMessageTemplateList { - return { - baseErrors: [ - { type: 'required', template: messageTemplate.required }, - { - type: 'pattern', - template: - 'Enter an OS grid reference in the correct format, for example, SO7394301364' - } - ], - advancedSettingsErrors: [] - } - } - - static isText(value?: FormStateValue | FormState): value is string { - return isFormValue(value) && typeof value === 'string' + static getAllPossibleErrors() { + const instance = Object.create(OsGridRefField.prototype) as OsGridRefField + return instance.getAllPossibleErrors() } } From e7a6177ed364c0e8d0ea969f8cc337a2cf68028c Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 23 Oct 2025 17:48:01 +0100 Subject: [PATCH 05/21] fix: update regex patterns for Sonar --- .../plugins/engine/components/EastingNorthingField.ts | 6 +++--- src/server/plugins/engine/components/LatLongField.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index b43c014ac..0c4bbfdb2 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -7,9 +7,9 @@ export class EastingNorthingField extends LocationFieldBase { protected getValidationConfig() { return { - pattern: /^(?:Easting:\s*)?(\d{6})\s*,?\s*(?:Northing:\s*)?(\d{6})$/i, + pattern: /^(?:Easting:\s+)?(\d{6})\s*,\s*(?:Northing:\s+)?(\d{6})$/i, patternErrorMessage: - 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 63688' + 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 636880' } } @@ -18,7 +18,7 @@ export class EastingNorthingField extends LocationFieldBase { { type: 'pattern', template: - 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 63688' + 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 636880' } ] } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 674dd6f31..36e0249c8 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -8,7 +8,7 @@ export class LatLongField extends LocationFieldBase { protected getValidationConfig() { const pattern = - /^(?:Lat:\s*)?(-?\d+(?:\.\d+)?)\s*,?\s*(?:Long:\s*)?(-?\d+(?:\.\d+)?)$/i + /^(?:Lat:\s+)?(-?\d+(?:\.\d+)?)\s*,\s*(?:Long:\s+)?(-?\d+(?:\.\d+)?)$/i return { pattern, From 4160ca71e333deb07c1e185653dfc41fe243859a Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 24 Oct 2025 17:23:50 +0100 Subject: [PATCH 06/21] refactor: enhance location components by extending FormComponent and improving validation logic --- .../engine/components/ComponentBase.ts | 2 +- .../engine/components/EastingNorthingField.ts | 308 +++++++++++++++-- .../plugins/engine/components/LatLongField.ts | 322 +++++++++++++++--- .../engine/components/LocationFieldBase.ts | 42 +-- .../NationalGridFieldNumberField.ts | 17 +- .../engine/components/OsGridRefField.ts | 19 +- .../engine/components/helpers/components.ts | 1 + src/server/plugins/engine/components/types.ts | 10 + .../components/eastingnorthingfield.html | 47 ++- .../engine/views/components/latlongfield.html | 47 ++- .../nationalgridfieldnumberfield.html | 4 +- .../views/components/osgridreffield.html | 4 +- 12 files changed, 704 insertions(+), 119 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentBase.ts b/src/server/plugins/engine/components/ComponentBase.ts index 307583354..180241b05 100644 --- a/src/server/plugins/engine/components/ComponentBase.ts +++ b/src/server/plugins/engine/components/ComponentBase.ts @@ -22,7 +22,7 @@ export class ComponentBase { type: ComponentDef['type'] name: ComponentDef['name'] title: ComponentDef['title'] - schema?: Extract['schema'] + schema?: Extract['schema'] options?: Extract['options'] isFormComponent = false diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 0c4bbfdb2..bbac558a7 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -1,35 +1,303 @@ -import { type EastingNorthingFieldComponent } from '@defra/forms-model' +import { + ComponentType, + type EastingNorthingFieldComponent +} from '@defra/forms-model' +import { + type Context, + type CustomValidator, + type LanguageMessages, + type ObjectSchema +} from 'joi' -import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { + FormComponent, + isFormState, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' +import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { type EastingNorthingState } from '~/src/server/plugins/engine/components/types.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormState, + type FormStateValue, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' -export class EastingNorthingField extends LocationFieldBase { +export class EastingNorthingField extends FormComponent { declare options: EastingNorthingFieldComponent['options'] + declare formSchema: ObjectSchema + declare stateSchema: ObjectSchema + declare collection: ComponentCollection + instructionText?: string - protected getValidationConfig() { - return { - pattern: /^(?:Easting:\s+)?(\d{6})\s*,\s*(?:Northing:\s+)?(\d{6})$/i, - patternErrorMessage: - 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 636880' + constructor( + def: EastingNorthingFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { name, options, schema } = def + + const isRequired = options.required !== false + this.instructionText = options.instructionText + + const eastingMin = schema?.easting?.min ?? 0 + const eastingMax = schema?.easting?.max ?? 70000 + const northingMin = schema?.northing?.min ?? 0 + const northingMax = schema?.northing?.max ?? 1300000 + + const customValidationMessages: LanguageMessages = + convertToLanguageMessages({ + 'any.required': messageTemplate.objectMissing, + 'number.base': messageTemplate.objectMissing, + 'number.min': `{{#label}} for ${this.title} must be between {{#limit}} and ${eastingMax}`, + 'number.max': `{{#label}} for ${this.title} must be between ${eastingMin} and {{#limit}}`, + 'number.precision': `{{#label}} for ${this.title} must be between 1 and 5 digits`, + 'number.integer': `{{#label}} for ${this.title} must be between 1 and 5 digits`, + 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 5 digits` + }) + + const northingValidationMessages: LanguageMessages = + convertToLanguageMessages({ + 'any.required': messageTemplate.objectMissing, + 'number.base': messageTemplate.objectMissing, + 'number.min': `{{#label}} for ${this.title} must be between {{#limit}} and ${northingMax}`, + 'number.max': `{{#label}} for ${this.title} must be between ${northingMin} and {{#limit}}`, + 'number.precision': `{{#label}} for ${this.title} must be between 1 and 7 digits`, + 'number.integer': `{{#label}} for ${this.title} must be between 1 and 7 digits`, + 'number.unsafe': `{{#label}} for ${this.title} must be between 1 and 7 digits` + }) + + this.collection = new ComponentCollection( + [ + { + type: ComponentType.NumberField, + name: `${name}__easting`, + title: 'Easting', + schema: { min: eastingMin, max: eastingMax, precision: 0 }, + options: { + required: isRequired, + optionalText: true, + classes: 'govuk-input--width-10', + customValidationMessages + } + }, + { + type: ComponentType.NumberField, + name: `${name}__northing`, + title: 'Northing', + schema: { min: northingMin, max: northingMax, precision: 0 }, + options: { + required: isRequired, + optionalText: true, + classes: 'govuk-input--width-10', + customValidationMessages: northingValidationMessages + } + } + ], + { ...props, parent: this }, + { + custom: getValidatorEastingNorthing(this), + peers: [`${name}__easting`, `${name}__northing`] + } + ) + + this.options = options + this.formSchema = this.collection.formSchema + this.stateSchema = this.collection.stateSchema + } + + getFormValueFromState(state: FormSubmissionState) { + const value = super.getFormValueFromState(state) + return EastingNorthingField.isEastingNorthing(value) ? value : undefined + } + + getDisplayStringFromFormValue( + value: EastingNorthingState | undefined + ): string { + if (!value) { + return '' } + + // CYA page format: <> + return `${value.northing}, ${value.easting}` } - protected getErrorTemplates() { - return [ - { - type: 'pattern', - template: - 'Enter easting and northing in the correct format, for example, Easting: 248741, Northing: 636880' + getDisplayStringFromState(state: FormSubmissionState) { + const value = this.getFormValueFromState(state) + + return this.getDisplayStringFromFormValue(value) + } + + getContextValueFromFormValue( + value: EastingNorthingState | undefined + ): string | null { + if (!value) { + return null + } + + // Output format: Northing: <>\nEasting: <> + return `Northing: ${value.northing}\nEasting: ${value.easting}` + } + + getContextValueFromState(state: FormSubmissionState) { + const value = this.getFormValueFromState(state) + + return this.getContextValueFromFormValue(value) + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const { collection, name } = this + + const viewModel = super.getViewModel(payload, errors) + let { fieldset, label } = viewModel + + // Check for component errors only + const hasError = errors?.some((error) => error.name === name) + + // Use the component collection to generate the subitems + const items = collection.getViewModel(payload, errors).map(({ model }) => { + let { label, type, value, classes, errorMessage } = model + + if (label) { + label.toString = () => label.text // Use string labels + } + + if (hasError || errorMessage) { + classes = `${classes} govuk-input--error`.trim() } - ] + + // Allow any `toString()`-able value so non-numeric + // values are shown alongside their error messages + if (!isFormValue(value)) { + value = undefined + } + + return { + label, + id: model.id, + name: model.name, + type, + value, + classes + } + }) + + fieldset ??= { + legend: { + text: label.text, + classes: 'govuk-fieldset__legend--m' + } + } + + const result = { + ...viewModel, + fieldset, + items + } + + if (this.instructionText) { + return { + ...result, + instructionText: markdown.parse(this.instructionText, { async: false }) + } + } + + return result + } + + isState(value?: FormStateValue | FormState) { + return EastingNorthingField.isEastingNorthing(value) + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return EastingNorthingField.getAllPossibleErrors() } /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ - static getAllPossibleErrors() { - const instance = Object.create( - EastingNorthingField.prototype - ) as EastingNorthingField - return instance.getAllPossibleErrors() + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { + type: 'eastingFormat', + template: + 'Easting for [short description] must be between 1 and 5 digits' + }, + { + type: 'northingFormat', + template: + 'Northing for [short description] must be between 1 and 7 digits' + } + ], + advancedSettingsErrors: [ + { + type: 'eastingMin', + template: + 'Easting for [short description] must be between 0 and 70000' + }, + { + type: 'eastingMax', + template: + 'Easting for [short description] must be between 0 and 70000' + }, + { + type: 'northingMin', + template: + 'Northing for [short description] must be between 0 and 1300000' + }, + { + type: 'northingMax', + template: + 'Northing for [short description] must be between 0 and 1300000' + } + ] + } + } + + static isEastingNorthing( + value?: FormStateValue | FormState + ): value is EastingNorthingState { + return ( + isFormState(value) && + NumberField.isNumber(value.easting) && + NumberField.isNumber(value.northing) + ) + } +} + +export function getValidatorEastingNorthing(component: EastingNorthingField) { + const validator: CustomValidator = (payload: FormPayload, helpers) => { + const { collection, name, options } = component + + const values = component.getFormValueFromState( + component.getStateFromValidForm(payload) + ) + + const context: Context = { + missing: collection.keys, + key: name + } + + if (!component.isState(values)) { + return options.required !== false + ? helpers.error('object.required', context) + : payload + } + + return payload } + + return validator } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 36e0249c8..4b7a286ad 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -1,71 +1,291 @@ -import { type LatLongFieldComponent } from '@defra/forms-model' -import { type CustomHelpers, type ErrorReport } from 'joi' +import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' +import { + type Context, + type CustomValidator, + type LanguageMessages, + type ObjectSchema +} from 'joi' -import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { + FormComponent, + isFormState, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' +import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { type LatLongState } from '~/src/server/plugins/engine/components/types.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormPayload, + type FormState, + type FormStateValue, + type FormSubmissionError, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' +import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' -export class LatLongField extends LocationFieldBase { +export class LatLongField extends FormComponent { declare options: LatLongFieldComponent['options'] + declare formSchema: ObjectSchema + declare stateSchema: ObjectSchema + declare collection: ComponentCollection + instructionText?: string - protected getValidationConfig() { - const pattern = - /^(?:Lat:\s+)?(-?\d+(?:\.\d+)?)\s*,\s*(?:Long:\s+)?(-?\d+(?:\.\d+)?)$/i + constructor( + def: LatLongFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) - return { - pattern, - patternErrorMessage: - 'Enter latitude and longitude in the correct format, for example, 51.5074, -0.1278', - customValidation: ( - value: string, - helpers: CustomHelpers - ): string | ErrorReport => { - const match = pattern.exec(value) - if (match) { - const latitude = Number.parseFloat(match[1]) - const longitude = Number.parseFloat(match[2]) - - // Validate Great Britain ranges - if (latitude < 49.85 || latitude > 60.859) { - return helpers.error('custom.latitude') + const { name, options, schema } = def + + const isRequired = options.required !== false + this.instructionText = options.instructionText + + // Read schema values from def.schema with fallback defaults + const latitudeMin = schema?.latitude?.min ?? 49 + const latitudeMax = schema?.latitude?.max ?? 60 + const longitudeMin = schema?.longitude?.min ?? -9 + const longitudeMax = schema?.longitude?.max ?? 2 + + const customValidationMessages: LanguageMessages = + convertToLanguageMessages({ + 'any.required': messageTemplate.objectMissing, + 'number.base': messageTemplate.objectMissing, + 'number.precision': '{{#label}} must be a decimal number', + 'number.unsafe': '{{#label}} must be a valid number' + }) + + const latitudeMessages: LanguageMessages = convertToLanguageMessages({ + ...customValidationMessages, + 'number.base': `Enter a valid latitude for ${this.title} like 51.519450`, + 'number.min': `Latitude for ${this.title} must be between ${latitudeMin} and ${latitudeMax}`, + 'number.max': `Latitude for ${this.title} must be between ${latitudeMin} and ${latitudeMax}` + }) + + const longitudeMessages: LanguageMessages = convertToLanguageMessages({ + ...customValidationMessages, + 'number.base': `Enter a valid longitude for ${this.title} like -0.127758`, + 'number.min': `Longitude for ${this.title} must be between ${longitudeMin} and ${longitudeMax}`, + 'number.max': `Longitude for ${this.title} must be between ${longitudeMin} and ${longitudeMax}` + }) + + this.collection = new ComponentCollection( + [ + { + type: ComponentType.NumberField, + name: `${name}__latitude`, + title: 'Latitude', + schema: { min: latitudeMin, max: latitudeMax, precision: 6 }, + options: { + required: isRequired, + optionalText: true, + classes: 'govuk-input--width-10', + customValidationMessages: latitudeMessages } - if (longitude < -13.687 || longitude > 1.767) { - return helpers.error('custom.longitude') + }, + { + type: ComponentType.NumberField, + name: `${name}__longitude`, + title: 'Longitude', + schema: { min: longitudeMin, max: longitudeMax, precision: 6 }, + options: { + required: isRequired, + optionalText: true, + classes: 'govuk-input--width-10', + customValidationMessages: longitudeMessages } } - return value - }, - additionalMessages: { - 'custom.latitude': - 'Latitude must be between 49.850 and 60.859 for Great Britain', - 'custom.longitude': - 'Longitude must be between -13.687 and 1.767 for Great Britain' + ], + { ...props, parent: this }, + { + custom: getValidatorLatLong(this), + peers: [`${name}__latitude`, `${name}__longitude`] } + ) + + this.options = options + this.formSchema = this.collection.formSchema + this.stateSchema = this.collection.stateSchema + } + + getFormValueFromState(state: FormSubmissionState) { + const value = super.getFormValueFromState(state) + return LatLongField.isLatLong(value) ? value : undefined + } + + getDisplayStringFromFormValue(value: LatLongState | undefined): string { + if (!value) { + return '' } + + // CYA page format: <> + return `${value.latitude}, ${value.longitude}` } - protected getErrorTemplates() { - return [ - { - type: 'pattern', - template: - 'Enter latitude and longitude in the correct format, for example, 51.5074, -0.1278' - }, - { - type: 'latitude', - template: 'Latitude must be between 49.850 and 60.859 for Great Britain' - }, - { - type: 'longitude', - template: - 'Longitude must be between -13.687 and 1.767 for Great Britain' + getDisplayStringFromState(state: FormSubmissionState) { + const value = this.getFormValueFromState(state) + + return this.getDisplayStringFromFormValue(value) + } + + getContextValueFromFormValue(value: LatLongState | undefined): string | null { + if (!value) { + return null + } + + // Output format: Lat: <>\nLong: <> + return `Lat: ${value.latitude}\nLong: ${value.longitude}` + } + + getContextValueFromState(state: FormSubmissionState) { + const value = this.getFormValueFromState(state) + + return this.getContextValueFromFormValue(value) + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const { collection, name } = this + + const viewModel = super.getViewModel(payload, errors) + let { fieldset, label } = viewModel + + // Check for component errors only + const hasError = errors?.some((error) => error.name === name) + + // Use the component collection to generate the subitems + const items = collection.getViewModel(payload, errors).map(({ model }) => { + let { label, type, value, classes, errorMessage } = model + + if (label) { + label.toString = () => label.text // Use string labels + } + + if (hasError || errorMessage) { + classes = `${classes} govuk-input--error`.trim() + } + + // Allow any `toString()`-able value so non-numeric + // values are shown alongside their error messages + if (!isFormValue(value)) { + value = undefined + } + + return { + label, + id: model.id, + name: model.name, + type, + value, + classes + } + }) + + fieldset ??= { + legend: { + text: label.text, + classes: 'govuk-fieldset__legend--m' } - ] + } + + const result = { + ...viewModel, + fieldset, + items + } + + if (this.instructionText) { + return { + ...result, + instructionText: markdown.parse(this.instructionText, { async: false }) + } + } + + return result + } + + isState(value?: FormStateValue | FormState) { + return LatLongField.isLatLong(value) + } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return LatLongField.getAllPossibleErrors() } /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ - static getAllPossibleErrors() { - const instance = Object.create(LatLongField.prototype) as LatLongField - return instance.getAllPossibleErrors() + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [ + { type: 'required', template: messageTemplate.required }, + { + type: 'latitudeFormat', + template: + 'Enter a valid latitude for [short description] like 51.519450' + }, + { + type: 'longitudeFormat', + template: + 'Enter a valid longitude for [short description] like -0.127758' + } + ], + advancedSettingsErrors: [ + { + type: 'latitudeMin', + template: 'Latitude for [short description] must be between 49 and 60' + }, + { + type: 'latitudeMax', + template: 'Latitude for [short description] must be between 49 and 60' + }, + { + type: 'longitudeMin', + template: 'Longitude for [short description] must be between -9 and 2' + }, + { + type: 'longitudeMax', + template: 'Longitude for [short description] must be between -9 and 2' + } + ] + } } + + static isLatLong(value?: FormStateValue | FormState): value is LatLongState { + return ( + isFormState(value) && + NumberField.isNumber(value.latitude) && + NumberField.isNumber(value.longitude) + ) + } +} + +export function getValidatorLatLong(component: LatLongField) { + const validator: CustomValidator = (payload: FormPayload, helpers) => { + const { collection, name, options } = component + + const values = component.getFormValueFromState( + component.getStateFromValidForm(payload) + ) + + const context: Context = { + missing: collection.keys, + key: name + } + + if (!component.isState(values)) { + return options.required !== false + ? helpers.error('object.required', context) + : payload + } + + return payload + } + + return validator } diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 9c1ee59a7..8c7edf51a 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -1,4 +1,5 @@ -import joi, { type StringSchema } from 'joi' +import { type FormComponentsDef } from '@defra/forms-model' +import joi, { type LanguageMessages, type StringSchema } from 'joi' import { FormComponent, @@ -19,7 +20,7 @@ interface LocationFieldOptions { instructionText?: string required?: boolean customValidationMessage?: string - customValidationMessages?: Record + customValidationMessages?: LanguageMessages } interface ValidationConfig { @@ -29,7 +30,7 @@ interface ValidationConfig { value: string, helpers: joi.CustomHelpers ) => string | joi.ErrorReport - additionalMessages?: Record + additionalMessages?: LanguageMessages } /** @@ -43,16 +44,20 @@ export abstract class LocationFieldBase extends FormComponent { instructionText?: string protected abstract getValidationConfig(): ValidationConfig - protected abstract getErrorTemplates(): Array<{ + protected abstract getErrorTemplates(): { type: string template: string - }> + }[] - constructor(def: any, props: ConstructorParameters[1]) { + constructor( + def: FormComponentsDef, + props: ConstructorParameters[1] + ) { super(def, props) const { options } = def - this.instructionText = options.instructionText + const locationOptions = options as LocationFieldOptions + this.instructionText = locationOptions.instructionText const config = this.getValidationConfig() @@ -71,12 +76,12 @@ export abstract class LocationFieldBase extends FormComponent { formSchema = formSchema.custom(config.customValidation) } - if (options.required === false) { + if (locationOptions.required === false) { formSchema = formSchema.allow('') } - if (options.customValidationMessage) { - const message = options.customValidationMessage + if (locationOptions.customValidationMessage) { + const message = locationOptions.customValidationMessage const messageKeys = [ 'any.required', 'string.empty', @@ -87,22 +92,19 @@ export abstract class LocationFieldBase extends FormComponent { messageKeys.push(...Object.keys(config.additionalMessages)) } - const messages = messageKeys.reduce( - (acc, key) => { - acc[key] = message - return acc - }, - {} as Record - ) + const messages = messageKeys.reduce((acc, key) => { + acc[key] = message + return acc + }, {}) formSchema = formSchema.messages(messages) - } else if (options.customValidationMessages) { - formSchema = formSchema.messages(options.customValidationMessages) + } else if (locationOptions.customValidationMessages) { + formSchema = formSchema.messages(locationOptions.customValidationMessages) } this.formSchema = formSchema.default('') this.stateSchema = formSchema.default(null).allow(null) - this.options = options + this.options = locationOptions } getFormValueFromState(state: FormSubmissionState) { diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index fc7d380da..0060d9e24 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -1,4 +1,5 @@ import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' +import type joi from 'joi' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' @@ -8,8 +9,18 @@ export class NationalGridFieldNumberField extends LocationFieldBase { protected getValidationConfig() { return { pattern: /^[A-Z]{2}\d{8}$/i, - patternErrorMessage: - 'Enter a National Grid field number in the correct format, for example, SO04188589' + patternErrorMessage: `Enter a valid National Grid field number for ${this.title} like NG12345678`, + customValidation: (value: string, helpers: joi.CustomHelpers) => { + // Strip spaces and commas + const cleanValue = value.replace(/[\s,]/g, '') + + // Check if it matches the pattern after cleaning + if (!/^[A-Z]{2}\d{8}$/i.test(cleanValue)) { + return helpers.error('string.pattern.base') + } + + return cleanValue + } } } @@ -18,7 +29,7 @@ export class NationalGridFieldNumberField extends LocationFieldBase { { type: 'pattern', template: - 'Enter a National Grid field number in the correct format, for example, SO04188589' + 'Enter a valid National Grid field number for [short description] like NG12345678' } ] } diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index 5db377329..7a796c30a 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -1,4 +1,5 @@ import { type OsGridRefFieldComponent } from '@defra/forms-model' +import type joi from 'joi' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' @@ -7,9 +8,19 @@ export class OsGridRefField extends LocationFieldBase { protected getValidationConfig() { return { - pattern: /^[A-Z]{2}\d{10}$/i, - patternErrorMessage: - 'Enter an OS grid reference in the correct format, for example, SO7394301364' + pattern: /^[A-Z]{2}\d{6,10}$/i, + patternErrorMessage: `Enter a valid OS grid reference for ${this.title} like TQ123456`, + customValidation: (value: string, helpers: joi.CustomHelpers) => { + // Strip spaces and commas + const cleanValue = value.replace(/[\s,]/g, '') + + // Check if it matches the pattern after cleaning + if (!/^[A-Z]{2}\d{6,10}$/i.test(cleanValue)) { + return helpers.error('string.pattern.base') + } + + return cleanValue + } } } @@ -18,7 +29,7 @@ export class OsGridRefField extends LocationFieldBase { { type: 'pattern', template: - 'Enter an OS grid reference in the correct format, for example, SO7394301364' + 'Enter a valid OS grid reference for [short description] like TQ123456' } ] } diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index fe5deafc1..5620c5fec 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -76,6 +76,7 @@ export const markdown = new Marked({ const tokens: Token['type'][] = [ 'br', 'escape', + 'link', 'list', 'list_item', 'paragraph', diff --git a/src/server/plugins/engine/components/types.ts b/src/server/plugins/engine/components/types.ts index b1458a12d..5370dfbff 100644 --- a/src/server/plugins/engine/components/types.ts +++ b/src/server/plugins/engine/components/types.ts @@ -126,3 +126,13 @@ export interface MonthYearState extends Record { month: number year: number } + +export interface EastingNorthingState extends Record { + easting: number + northing: number +} + +export interface LatLongState extends Record { + latitude: number + longitude: number +} diff --git a/src/server/plugins/engine/views/components/eastingnorthingfield.html b/src/server/plugins/engine/views/components/eastingnorthingfield.html index e0d36f9ed..d12ddbae5 100644 --- a/src/server/plugins/engine/views/components/eastingnorthingfield.html +++ b/src/server/plugins/engine/views/components/eastingnorthingfield.html @@ -1,13 +1,44 @@ -{% from "components/textfield.html" import TextField %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} {% from "govuk/components/details/macro.njk" import govukDetails %} {% macro EastingNorthingField(component) %} - {{ TextField(component) }} + {% set fieldsetHtml %} +
+ {% for item in component.model.items %} +
+
+ {{ govukInput({ + id: item.id, + name: item.name, + label: { + text: item.label, + classes: "govuk-label--s" + }, + classes: item.classes, + value: item.value, + type: "number", + inputmode: "numeric" + }) }} +
+
+ {% endfor %} +
- {% if component.instructionText %} - {{ govukDetails({ - summaryText: "How to find location details", - html: component.instructionText | safe - }) }} - {% endif %} + {% if component.model.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.model.instructionText | safe + }) }} + {% endif %} + {% endset %} + + {{ govukFieldset({ + legend: { + text: component.model.fieldset.legend.text, + classes: component.model.fieldset.legend.classes, + isPageHeading: false + }, + html: fieldsetHtml + }) }} {% endmacro %} \ No newline at end of file diff --git a/src/server/plugins/engine/views/components/latlongfield.html b/src/server/plugins/engine/views/components/latlongfield.html index f7477522e..8e63e895c 100644 --- a/src/server/plugins/engine/views/components/latlongfield.html +++ b/src/server/plugins/engine/views/components/latlongfield.html @@ -1,13 +1,44 @@ -{% from "components/textfield.html" import TextField %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} {% from "govuk/components/details/macro.njk" import govukDetails %} {% macro LatLongField(component) %} - {{ TextField(component) }} + {% set fieldsetHtml %} +
+ {% for item in component.model.items %} +
+
+ {{ govukInput({ + id: item.id, + name: item.name, + label: { + text: item.label, + classes: "govuk-label--s" + }, + classes: item.classes, + value: item.value, + type: "text", + inputmode: "decimal" + }) }} +
+
+ {% endfor %} +
- {% if component.instructionText %} - {{ govukDetails({ - summaryText: "How to find location details", - html: component.instructionText | safe - }) }} - {% endif %} + {% if component.model.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.model.instructionText | safe + }) }} + {% endif %} + {% endset %} + + {{ govukFieldset({ + legend: { + text: component.model.fieldset.legend.text, + classes: component.model.fieldset.legend.classes, + isPageHeading: false + }, + html: fieldsetHtml + }) }} {% endmacro %} \ No newline at end of file diff --git a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html index f2aff5b37..4cfcf789c 100644 --- a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html +++ b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html @@ -4,10 +4,10 @@ {% macro NationalGridFieldNumberField(component) %} {{ TextField(component) }} - {% if component.instructionText %} + {% if component.model.instructionText %} {{ govukDetails({ summaryText: "How to find location details", - html: component.instructionText | safe + html: component.model.instructionText | safe }) }} {% endif %} {% endmacro %} \ No newline at end of file diff --git a/src/server/plugins/engine/views/components/osgridreffield.html b/src/server/plugins/engine/views/components/osgridreffield.html index b84a3b6d5..cd2a7a8df 100644 --- a/src/server/plugins/engine/views/components/osgridreffield.html +++ b/src/server/plugins/engine/views/components/osgridreffield.html @@ -4,10 +4,10 @@ {% macro OsGridRefField(component) %} {{ TextField(component) }} - {% if component.instructionText %} + {% if component.model.instructionText %} {{ govukDetails({ summaryText: "How to find location details", - html: component.instructionText | safe + html: component.model.instructionText | safe }) }} {% endif %} {% endmacro %} \ No newline at end of file From a725c745441d8b460ad24a58a2d8fa73b029d85b Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 24 Oct 2025 18:01:59 +0100 Subject: [PATCH 07/21] refactor: consolidate location field logic into LocationFieldHelpers --- .../engine/components/EastingNorthingField.ts | 95 ++------------ .../plugins/engine/components/LatLongField.ts | 95 ++------------ .../engine/components/LocationFieldHelpers.ts | 121 ++++++++++++++++++ .../components/_location-field-base.html | 45 +++++++ .../components/eastingnorthingfield.html | 43 +------ .../engine/views/components/latlongfield.html | 43 +------ 6 files changed, 186 insertions(+), 256 deletions(-) create mode 100644 src/server/plugins/engine/components/LocationFieldHelpers.ts create mode 100644 src/server/plugins/engine/views/components/_location-field-base.html diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index bbac558a7..9742d5c84 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -2,21 +2,18 @@ import { ComponentType, type EastingNorthingFieldComponent } from '@defra/forms-model' -import { - type Context, - type CustomValidator, - type LanguageMessages, - type ObjectSchema -} from 'joi' +import { type LanguageMessages, type ObjectSchema } from 'joi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { FormComponent, - isFormState, - isFormValue + isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { + createLocationFieldValidator, + getLocationFieldViewModel +} from '~/src/server/plugins/engine/components/LocationFieldHelpers.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' -import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' import { type EastingNorthingState } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -153,63 +150,8 @@ export class EastingNorthingField extends FormComponent { } getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const { collection, name } = this - const viewModel = super.getViewModel(payload, errors) - let { fieldset, label } = viewModel - - // Check for component errors only - const hasError = errors?.some((error) => error.name === name) - - // Use the component collection to generate the subitems - const items = collection.getViewModel(payload, errors).map(({ model }) => { - let { label, type, value, classes, errorMessage } = model - - if (label) { - label.toString = () => label.text // Use string labels - } - - if (hasError || errorMessage) { - classes = `${classes} govuk-input--error`.trim() - } - - // Allow any `toString()`-able value so non-numeric - // values are shown alongside their error messages - if (!isFormValue(value)) { - value = undefined - } - - return { - label, - id: model.id, - name: model.name, - type, - value, - classes - } - }) - - fieldset ??= { - legend: { - text: label.text, - classes: 'govuk-fieldset__legend--m' - } - } - - const result = { - ...viewModel, - fieldset, - items - } - - if (this.instructionText) { - return { - ...result, - instructionText: markdown.parse(this.instructionText, { async: false }) - } - } - - return result + return getLocationFieldViewModel(this, viewModel, payload, errors) } isState(value?: FormStateValue | FormState) { @@ -278,26 +220,5 @@ export class EastingNorthingField extends FormComponent { } export function getValidatorEastingNorthing(component: EastingNorthingField) { - const validator: CustomValidator = (payload: FormPayload, helpers) => { - const { collection, name, options } = component - - const values = component.getFormValueFromState( - component.getStateFromValidForm(payload) - ) - - const context: Context = { - missing: collection.keys, - key: name - } - - if (!component.isState(values)) { - return options.required !== false - ? helpers.error('object.required', context) - : payload - } - - return payload - } - - return validator + return createLocationFieldValidator(component) } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 4b7a286ad..e7e1e479a 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -1,19 +1,16 @@ import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' -import { - type Context, - type CustomValidator, - type LanguageMessages, - type ObjectSchema -} from 'joi' +import { type LanguageMessages, type ObjectSchema } from 'joi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { FormComponent, - isFormState, - isFormValue + isFormState } from '~/src/server/plugins/engine/components/FormComponent.js' +import { + createLocationFieldValidator, + getLocationFieldViewModel +} from '~/src/server/plugins/engine/components/LocationFieldHelpers.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' -import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' import { type LatLongState } from '~/src/server/plugins/engine/components/types.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -147,63 +144,8 @@ export class LatLongField extends FormComponent { } getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const { collection, name } = this - const viewModel = super.getViewModel(payload, errors) - let { fieldset, label } = viewModel - - // Check for component errors only - const hasError = errors?.some((error) => error.name === name) - - // Use the component collection to generate the subitems - const items = collection.getViewModel(payload, errors).map(({ model }) => { - let { label, type, value, classes, errorMessage } = model - - if (label) { - label.toString = () => label.text // Use string labels - } - - if (hasError || errorMessage) { - classes = `${classes} govuk-input--error`.trim() - } - - // Allow any `toString()`-able value so non-numeric - // values are shown alongside their error messages - if (!isFormValue(value)) { - value = undefined - } - - return { - label, - id: model.id, - name: model.name, - type, - value, - classes - } - }) - - fieldset ??= { - legend: { - text: label.text, - classes: 'govuk-fieldset__legend--m' - } - } - - const result = { - ...viewModel, - fieldset, - items - } - - if (this.instructionText) { - return { - ...result, - instructionText: markdown.parse(this.instructionText, { async: false }) - } - } - - return result + return getLocationFieldViewModel(this, viewModel, payload, errors) } isState(value?: FormStateValue | FormState) { @@ -266,26 +208,5 @@ export class LatLongField extends FormComponent { } export function getValidatorLatLong(component: LatLongField) { - const validator: CustomValidator = (payload: FormPayload, helpers) => { - const { collection, name, options } = component - - const values = component.getFormValueFromState( - component.getStateFromValidForm(payload) - ) - - const context: Context = { - missing: collection.keys, - key: name - } - - if (!component.isState(values)) { - return options.required !== false - ? helpers.error('object.required', context) - : payload - } - - return payload - } - - return validator + return createLocationFieldValidator(component) } diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.ts b/src/server/plugins/engine/components/LocationFieldHelpers.ts new file mode 100644 index 000000000..9b33d8b8c --- /dev/null +++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts @@ -0,0 +1,121 @@ +import { type Context, type CustomValidator } from 'joi' + +import { type EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js' +import { isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { type LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' +import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { + type DateInputItem, + type Label, + type ViewModel +} from '~/src/server/plugins/engine/components/types.js' +import { + type FormPayload, + type FormSubmissionError, + type FormValue +} from '~/src/server/plugins/engine/types.js' + +export type LocationField = + | InstanceType + | InstanceType + +export function getLocationFieldViewModel( + component: LocationField, + viewModel: ViewModel & { + label: Label + id: string + name: string + value: FormValue + }, + payload: FormPayload, + errors?: FormSubmissionError[] +) { + const { collection, name } = component + const { fieldset: existingFieldset, label } = viewModel + + // Check for component errors only + const hasError = errors?.some((error) => error.name === name) + + // Use the component collection to generate the subitems + const items: DateInputItem[] = collection + .getViewModel(payload, errors) + .map(({ model }): DateInputItem => { + let { label, type, value, classes, errorMessage } = model + + if (label) { + label.toString = () => label.text // Use string labels + } + + if (hasError || errorMessage) { + classes = `${classes ?? ''} govuk-input--error`.trim() + } + + // Allow any `toString()`-able value so non-numeric + // values are shown alongside their error messages + if (!isFormValue(value)) { + value = undefined + } + + return { + label, + id: model.id, + name: model.name, + type, + value, + classes + } + }) + + const fieldset = existingFieldset ?? { + legend: { + text: label.text, + classes: 'govuk-fieldset__legend--m' + } + } + + const result = { + ...viewModel, + fieldset, + items + } + + if (component.instructionText) { + return { + ...result, + instructionText: markdown.parse(component.instructionText, { + async: false + }) + } + } + + return result +} + +/** + * Validator factory for location-based fields. + * This creates a validator that ensures all required fields are present. + */ +export function createLocationFieldValidator( + component: LocationField +): CustomValidator { + return (payload: FormPayload, helpers) => { + const { collection, name, options } = component + + const values = component.getFormValueFromState( + component.getStateFromValidForm(payload) + ) + + const context: Context = { + missing: collection.keys, + key: name + } + + if (!component.isState(values)) { + return options.required !== false + ? helpers.error('object.required', context) + : payload + } + + return payload + } +} diff --git a/src/server/plugins/engine/views/components/_location-field-base.html b/src/server/plugins/engine/views/components/_location-field-base.html new file mode 100644 index 000000000..6645a9b95 --- /dev/null +++ b/src/server/plugins/engine/views/components/_location-field-base.html @@ -0,0 +1,45 @@ +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} +{% from "govuk/components/details/macro.njk" import govukDetails %} + +{% macro LocationFieldBase(component, inputType, inputMode) %} + {% set fieldsetHtml %} +
+ {% for item in component.model.items %} +
+
+ {{ govukInput({ + id: item.id, + name: item.name, + label: { + text: item.label, + classes: "govuk-label--s" + }, + classes: item.classes, + value: item.value, + type: inputType, + inputmode: inputMode + }) }} +
+
+ {% endfor %} +
+ + {% if component.model.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.model.instructionText | safe + }) }} + {% endif %} + {% endset %} + + {{ govukFieldset({ + legend: { + text: component.model.fieldset.legend.text, + classes: component.model.fieldset.legend.classes, + isPageHeading: false + }, + html: fieldsetHtml + }) }} +{% endmacro %} + diff --git a/src/server/plugins/engine/views/components/eastingnorthingfield.html b/src/server/plugins/engine/views/components/eastingnorthingfield.html index d12ddbae5..f9b8839a6 100644 --- a/src/server/plugins/engine/views/components/eastingnorthingfield.html +++ b/src/server/plugins/engine/views/components/eastingnorthingfield.html @@ -1,44 +1,5 @@ -{% from "govuk/components/input/macro.njk" import govukInput %} -{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} -{% from "govuk/components/details/macro.njk" import govukDetails %} +{% from "./_location-field-base.html" import LocationFieldBase %} {% macro EastingNorthingField(component) %} - {% set fieldsetHtml %} -
- {% for item in component.model.items %} -
-
- {{ govukInput({ - id: item.id, - name: item.name, - label: { - text: item.label, - classes: "govuk-label--s" - }, - classes: item.classes, - value: item.value, - type: "number", - inputmode: "numeric" - }) }} -
-
- {% endfor %} -
- - {% if component.model.instructionText %} - {{ govukDetails({ - summaryText: "How to find location details", - html: component.model.instructionText | safe - }) }} - {% endif %} - {% endset %} - - {{ govukFieldset({ - legend: { - text: component.model.fieldset.legend.text, - classes: component.model.fieldset.legend.classes, - isPageHeading: false - }, - html: fieldsetHtml - }) }} + {{ LocationFieldBase(component, "number", "numeric") }} {% endmacro %} \ No newline at end of file diff --git a/src/server/plugins/engine/views/components/latlongfield.html b/src/server/plugins/engine/views/components/latlongfield.html index 8e63e895c..7c80eb502 100644 --- a/src/server/plugins/engine/views/components/latlongfield.html +++ b/src/server/plugins/engine/views/components/latlongfield.html @@ -1,44 +1,5 @@ -{% from "govuk/components/input/macro.njk" import govukInput %} -{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} -{% from "govuk/components/details/macro.njk" import govukDetails %} +{% from "./_location-field-base.html" import LocationFieldBase %} {% macro LatLongField(component) %} - {% set fieldsetHtml %} -
- {% for item in component.model.items %} -
-
- {{ govukInput({ - id: item.id, - name: item.name, - label: { - text: item.label, - classes: "govuk-label--s" - }, - classes: item.classes, - value: item.value, - type: "text", - inputmode: "decimal" - }) }} -
-
- {% endfor %} -
- - {% if component.model.instructionText %} - {{ govukDetails({ - summaryText: "How to find location details", - html: component.model.instructionText | safe - }) }} - {% endif %} - {% endset %} - - {{ govukFieldset({ - legend: { - text: component.model.fieldset.legend.text, - classes: component.model.fieldset.legend.classes, - isPageHeading: false - }, - html: fieldsetHtml - }) }} + {{ LocationFieldBase(component, "text", "decimal") }} {% endmacro %} \ No newline at end of file From 50af26d7b162d791ba7babac26563eaa3ec260b5 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Fri, 24 Oct 2025 18:21:18 +0100 Subject: [PATCH 08/21] feat: add hint support to LocationFieldBase --- .../engine/views/components/_location-field-base.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/server/plugins/engine/views/components/_location-field-base.html b/src/server/plugins/engine/views/components/_location-field-base.html index 6645a9b95..b12e3020e 100644 --- a/src/server/plugins/engine/views/components/_location-field-base.html +++ b/src/server/plugins/engine/views/components/_location-field-base.html @@ -1,9 +1,17 @@ {% from "govuk/components/input/macro.njk" import govukInput %} {% from "govuk/components/fieldset/macro.njk" import govukFieldset %} {% from "govuk/components/details/macro.njk" import govukDetails %} +{% from "govuk/components/hint/macro.njk" import govukHint %} {% macro LocationFieldBase(component, inputType, inputMode) %} {% set fieldsetHtml %} + {% if component.model.hint %} + {{ govukHint({ + id: component.model.name + "-hint", + text: component.model.hint.text + }) }} + {% endif %} +
{% for item in component.model.items %}
From 4896edfd13adc5756ae50a90eb3a33196b612032 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 27 Oct 2025 14:17:42 +0000 Subject: [PATCH 09/21] test: add comprehensive tests for location field components including EastingNorthingField, LatLongField, and OS grid reference validation --- .../components/EastingNorthingField.test.ts | 665 +++++++++++++++++ .../engine/components/LatLongField.test.ts | 700 ++++++++++++++++++ .../components/LocationFieldBase.test.ts | 253 +++++++ .../engine/components/LocationFieldBase.ts | 3 +- .../components/LocationFieldHelpers.test.ts | 338 +++++++++ .../engine/components/LocationFieldHelpers.ts | 2 +- .../NationalGridFieldNumberField.test.ts | 436 +++++++++++ .../engine/components/OsGridRefField.test.ts | 432 +++++++++++ .../engine/components/helpers/components.ts | 59 +- .../engine/components/helpers/helpers.test.ts | 72 +- .../engine/components/markdownParser.ts | 40 + 11 files changed, 2949 insertions(+), 51 deletions(-) create mode 100644 src/server/plugins/engine/components/EastingNorthingField.test.ts create mode 100644 src/server/plugins/engine/components/LatLongField.test.ts create mode 100644 src/server/plugins/engine/components/LocationFieldBase.test.ts create mode 100644 src/server/plugins/engine/components/LocationFieldHelpers.test.ts create mode 100644 src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts create mode 100644 src/server/plugins/engine/components/OsGridRefField.test.ts create mode 100644 src/server/plugins/engine/components/markdownParser.ts diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts new file mode 100644 index 000000000..c3ab7d533 --- /dev/null +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -0,0 +1,665 @@ +import { + ComponentType, + type EastingNorthingFieldComponent +} from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js' +import { + getAnswer, + type Field +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' + +describe('EastingNorthingField', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: EastingNorthingFieldComponent + let collection: ComponentCollection + let field: Field + + beforeEach(() => { + def = { + title: 'Example easting northing', + shortDescription: 'Example location', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: {}, + schema: {} + } satisfies EastingNorthingFieldComponent + + collection = new ComponentCollection([def], { model }) + field = collection.fields[0] + }) + + describe('Schema', () => { + it('uses collection titles as labels', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent__easting', + expect.objectContaining({ + flags: expect.objectContaining({ label: 'Easting' }) + }) + ) + + expect(keys).toHaveProperty( + 'myComponent__northing', + expect.objectContaining({ + flags: expect.objectContaining({ label: 'Northing' }) + }) + ) + }) + + it('uses collection names as keys', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(field.keys).toEqual([ + 'myComponent', + 'myComponent__easting', + 'myComponent__northing' + ]) + + expect(field.collection?.keys).not.toHaveProperty('myComponent') + + for (const key of field.collection?.keys ?? []) { + expect(keys).toHaveProperty(key) + } + }) + + it('is required by default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent__easting', + expect.objectContaining({ + flags: expect.objectContaining({ presence: 'required' }) + }) + ) + + expect(keys).toHaveProperty( + 'myComponent__northing', + expect.objectContaining({ + flags: expect.objectContaining({ presence: 'required' }) + }) + ) + }) + + it('is optional when configured', () => { + const collectionOptional = new ComponentCollection( + [ + { + title: 'Example easting northing', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: { required: false }, + schema: {} + } + ], + { model } + ) + + const { formSchema } = collectionOptional + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent__easting', + expect.objectContaining({ allow: [''] }) + ) + + expect(keys).toHaveProperty( + 'myComponent__northing', + expect.objectContaining({ allow: [''] }) + ) + + const result1 = collectionOptional.validate( + getFormData({ + easting: '', + northing: '' + }) + ) + + const result2 = collectionOptional.validate( + getFormData({ + easting: '12345', + northing: '' + }) + ) + + expect(result1.errors).toBeUndefined() + expect(result2.errors).toBeTruthy() + expect(result2.errors?.length).toBeGreaterThan(0) + }) + + it('accepts valid values', () => { + const result1 = collection.validate( + getFormData({ + easting: '12345', + northing: '1234567' + }) + ) + + const result2 = collection.validate( + getFormData({ + easting: '0', + northing: '0' + }) + ) + + expect(result1.errors).toBeUndefined() + expect(result2.errors).toBeUndefined() + }) + + it('adds errors for empty value when short description exists', () => { + const result = collection.validate( + getFormData({ + easting: '', + northing: '' + }) + ) + + expect(result.errors).toBeTruthy() + expect(result.errors?.length).toBe(2) + }) + + it('adds errors for invalid values', () => { + const result1 = collection.validate( + getFormData({ + easting: 'invalid', + northing: 'invalid' + }) + ) + + const result2 = collection.validate( + getFormData({ + easting: '12345.5', + northing: '1234567.5' + }) + ) + + expect(result1.errors).toBeTruthy() + expect(result2.errors).toBeTruthy() + }) + }) + + describe('State', () => { + it('returns text from state', () => { + const state1 = getFormState({ + easting: 12345, + northing: 1234567 + }) + const state2 = getFormState({}) + + const answer1 = getAnswer(field, state1) + const answer2 = getAnswer(field, state2) + + expect(answer1).toBe('1234567, 12345') + expect(answer2).toBe('') + }) + + it('returns payload from state', () => { + const state1 = getFormState({ + easting: 12345, + northing: 1234567 + }) + const state2 = getFormState({}) + + const payload1 = field.getFormDataFromState(state1) + const payload2 = field.getFormDataFromState(state2) + + expect(payload1).toEqual( + getFormData({ + easting: 12345, + northing: 1234567 + }) + ) + expect(payload2).toEqual(getFormData({})) + }) + + it('returns value from state', () => { + const state1 = getFormState({ + easting: 12345, + northing: 1234567 + }) + const state2 = getFormState({}) + + const value1 = field.getFormValueFromState(state1) + const value2 = field.getFormValueFromState(state2) + + expect(value1).toEqual({ + easting: 12345, + northing: 1234567 + }) + + expect(value2).toBeUndefined() + }) + + it('returns context for conditions and form submission', () => { + const state1 = getFormState({ + easting: 12345, + northing: 1234567 + }) + const state2 = getFormState({}) + + const value1 = field.getContextValueFromState(state1) + const value2 = field.getContextValueFromState(state2) + + expect(value1).toBe('Northing: 1234567\nEasting: 12345') + expect(value2).toBeNull() + }) + + it('returns state from payload', () => { + const payload1 = getFormData({ + easting: 12345, + northing: 1234567 + }) + const payload2 = getFormData({}) + + const value1 = field.getStateFromValidForm(payload1) + const value2 = field.getStateFromValidForm(payload2) + + expect(value1).toEqual( + getFormState({ + easting: 12345, + northing: 1234567 + }) + ) + expect(value2).toEqual(getFormState({})) + }) + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const payload = getFormData({ + easting: 12345, + northing: 1234567 + }) + const viewModel = field.getViewModel(payload) + + expect(viewModel).toEqual( + expect.objectContaining({ + fieldset: { + legend: { + text: def.title, + classes: 'govuk-fieldset__legend--m' + } + }, + items: [ + expect.objectContaining({ + label: expect.objectContaining({ text: 'Easting' }), + name: 'myComponent__easting', + id: 'myComponent__easting', + value: 12345 + }), + expect.objectContaining({ + label: expect.objectContaining({ text: 'Northing' }), + name: 'myComponent__northing', + id: 'myComponent__northing', + value: 1234567 + }) + ] + }) + ) + }) + + it('includes instruction text when provided', () => { + const componentWithInstruction = new EastingNorthingField( + { + ...def, + options: { instructionText: 'Enter coordinates in **meters**' } + }, + { model } + ) + + const viewModel = componentWithInstruction.getViewModel( + getFormData({ + easting: 12345, + northing: 1234567 + }) + ) + + const instructionText = + 'instructionText' in viewModel ? viewModel.instructionText : undefined + expect(instructionText).toBeTruthy() + expect(instructionText).toContain('meters') + }) + + it('sets error classes when component has errors', () => { + const payload = getFormData({ + easting: '', + northing: '' + }) + + const errors = [ + { + name: 'myComponent', + text: 'Error message', + path: ['myComponent'], + href: '#myComponent' + } + ] + + const viewModel = field.getViewModel(payload, errors) + + expect(viewModel.items?.[0]).toEqual( + expect.objectContaining({ + classes: expect.stringContaining('govuk-input--error') + }) + ) + + expect(viewModel.items?.[1]).toEqual( + expect.objectContaining({ + classes: expect.stringContaining('govuk-input--error') + }) + ) + }) + }) + + describe('AllPossibleErrors', () => { + it('should return errors from instance method', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + + it('should return errors from static method', () => { + const staticErrors = EastingNorthingField.getAllPossibleErrors() + expect(staticErrors.baseErrors).not.toBeEmpty() + expect(staticErrors.advancedSettingsErrors).not.toBeEmpty() + }) + + it('instance method should delegate to static method', () => { + const staticResult = EastingNorthingField.getAllPossibleErrors() + const instanceResult = field.getAllPossibleErrors() + + expect(instanceResult).toEqual(staticResult) + }) + }) + }) + + describe('Validation', () => { + describe.each([ + { + description: 'Trim empty spaces', + component: { + title: 'Example easting northing', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: {}, + schema: {} + } satisfies EastingNorthingFieldComponent, + assertions: [ + { + input: getFormData({ + easting: ' 12345', + northing: ' 1234567' + }), + output: { + value: getFormData({ + easting: 12345, + northing: 1234567 + }) + } + }, + { + input: getFormData({ + easting: '12345 ', + northing: '1234567 ' + }), + output: { + value: getFormData({ + easting: 12345, + northing: 1234567 + }) + } + } + ] + }, + { + description: 'Schema min and max for easting', + component: { + title: 'Example easting northing', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: {}, + schema: { + easting: { + min: 1000, + max: 60000 + } + } + } satisfies EastingNorthingFieldComponent, + assertions: [ + { + input: getFormData({ + easting: '999', + northing: '1234567' + }), + output: { + value: getFormData({ + easting: 999, + northing: 1234567 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Easting for .* must be between 1000 and 60000/ + ) + }) + ] + } + }, + { + input: getFormData({ + easting: '60001', + northing: '1234567' + }), + output: { + value: getFormData({ + easting: 60001, + northing: 1234567 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Easting for .* must be between 1000 and 60000/ + ) + }) + ] + } + } + ] + }, + { + description: 'Schema min and max for northing', + component: { + title: 'Example easting northing', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: {}, + schema: { + northing: { + min: 1000, + max: 1200000 + } + } + } satisfies EastingNorthingFieldComponent, + assertions: [ + { + input: getFormData({ + easting: '12345', + northing: '999' + }), + output: { + value: getFormData({ + easting: 12345, + northing: 999 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Northing for .* must be between 1000 and 1200000/ + ) + }) + ] + } + }, + { + input: getFormData({ + easting: '12345', + northing: '1200001' + }), + output: { + value: getFormData({ + easting: 12345, + northing: 1200001 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Northing for .* must be between 1000 and 1200000/ + ) + }) + ] + } + } + ] + }, + { + description: 'Precision validation', + component: { + title: 'Example easting northing', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: {}, + schema: {} + } satisfies EastingNorthingFieldComponent, + assertions: [ + { + input: getFormData({ + easting: '12345.5', + northing: '1234567' + }), + output: { + value: getFormData({ + easting: 12345.5, + northing: 1234567 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Easting for .* must be between 1 and 5 digits/ + ) + }) + ] + } + }, + { + input: getFormData({ + easting: '12345', + northing: '1234567.5' + }), + output: { + value: getFormData({ + easting: 12345, + northing: 1234567.5 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Northing for .* must be between 1 and 7 digits/ + ) + }) + ] + } + } + ] + }, + { + description: 'Optional field', + component: { + title: 'Example easting northing', + name: 'myComponent', + type: ComponentType.EastingNorthingField, + options: { + required: false + }, + schema: {} + } satisfies EastingNorthingFieldComponent, + assertions: [ + { + input: getFormData({ + easting: '', + northing: '' + }), + output: { + value: getFormData({ + easting: '', + northing: '' + }) + } + } + ] + } + ])('$description', ({ component: def, assertions }) => { + let collection: ComponentCollection + + beforeEach(() => { + collection = new ComponentCollection([def], { model }) + }) + + it.each([...assertions])( + 'validates custom example', + ({ input, output }) => { + const result = collection.validate(input) + expect(result).toEqual(output) + } + ) + }) + }) +}) + +function getFormData( + value: + | { easting?: string | number; northing?: string | number } + | Record +) { + if ('easting' in value || 'northing' in value) { + return { + myComponent__easting: value.easting, + myComponent__northing: value.northing + } + } + return {} +} + +function getFormState( + value: + | { + easting?: number + northing?: number + } + | Record +) { + if ('easting' in value || 'northing' in value) { + return { + myComponent__easting: value.easting ?? null, + myComponent__northing: value.northing ?? null + } + } + return { + myComponent__easting: null, + myComponent__northing: null + } +} diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts new file mode 100644 index 000000000..d542e948e --- /dev/null +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -0,0 +1,700 @@ +import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' +import { + getAnswer, + type Field +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' + +describe('LatLongField', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: LatLongFieldComponent + let collection: ComponentCollection + let field: Field + + beforeEach(() => { + def = { + title: 'Example lat long', + shortDescription: 'Example location', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } satisfies LatLongFieldComponent + + collection = new ComponentCollection([def], { model }) + field = collection.fields[0] + }) + + describe('Schema', () => { + it('uses collection titles as labels', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent__latitude', + expect.objectContaining({ + flags: expect.objectContaining({ label: 'Latitude' }) + }) + ) + + expect(keys).toHaveProperty( + 'myComponent__longitude', + expect.objectContaining({ + flags: expect.objectContaining({ label: 'Longitude' }) + }) + ) + }) + + it('uses collection names as keys', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(field.keys).toEqual([ + 'myComponent', + 'myComponent__latitude', + 'myComponent__longitude' + ]) + + expect(field.collection?.keys).not.toHaveProperty('myComponent') + + for (const key of field.collection?.keys ?? []) { + expect(keys).toHaveProperty(key) + } + }) + + it('is required by default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent__latitude', + expect.objectContaining({ + flags: expect.objectContaining({ presence: 'required' }) + }) + ) + + expect(keys).toHaveProperty( + 'myComponent__longitude', + expect.objectContaining({ + flags: expect.objectContaining({ presence: 'required' }) + }) + ) + }) + + it('is optional when configured', () => { + const collectionOptional = new ComponentCollection( + [ + { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: { required: false }, + schema: {} + } + ], + { model } + ) + + const { formSchema } = collectionOptional + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent__latitude', + expect.objectContaining({ allow: [''] }) + ) + + expect(keys).toHaveProperty( + 'myComponent__longitude', + expect.objectContaining({ allow: [''] }) + ) + + const result1 = collectionOptional.validate( + getFormData({ + latitude: '', + longitude: '' + }) + ) + + const result2 = collectionOptional.validate( + getFormData({ + latitude: '51.5', + longitude: '' + }) + ) + + expect(result1.errors).toBeUndefined() + expect(result2.errors).toBeTruthy() + expect(result2.errors?.length).toBeGreaterThan(0) + }) + + it('accepts valid values', () => { + const result1 = collection.validate( + getFormData({ + latitude: '51.519450', + longitude: '-0.127758' + }) + ) + + const result2 = collection.validate( + getFormData({ + latitude: '49', + longitude: '-9' + }) + ) + + expect(result1.errors).toBeUndefined() + expect(result2.errors).toBeUndefined() + }) + + it('adds errors for empty value when short description exists', () => { + const result = collection.validate( + getFormData({ + latitude: '', + longitude: '' + }) + ) + + expect(result.errors).toBeTruthy() + expect(result.errors?.length).toBe(2) + }) + + it('adds errors for invalid values', () => { + const result1 = collection.validate( + getFormData({ + latitude: 'invalid', + longitude: 'invalid' + }) + ) + + expect(result1.errors).toBeTruthy() + }) + }) + + describe('State', () => { + it('returns text from state', () => { + const state1 = getFormState({ + latitude: 51.51945, + longitude: -0.127758 + }) + const state2 = getFormState({}) + + const answer1 = getAnswer(field, state1) + const answer2 = getAnswer(field, state2) + + expect(answer1).toBe('51.51945, -0.127758') + expect(answer2).toBe('') + }) + + it('returns payload from state', () => { + const state1 = getFormState({ + latitude: 51.51945, + longitude: -0.127758 + }) + const state2 = getFormState({}) + + const payload1 = field.getFormDataFromState(state1) + const payload2 = field.getFormDataFromState(state2) + + expect(payload1).toEqual( + getFormData({ + latitude: 51.51945, + longitude: -0.127758 + }) + ) + expect(payload2).toEqual(getFormData({})) + }) + + it('returns value from state', () => { + const state1 = getFormState({ + latitude: 51.51945, + longitude: -0.127758 + }) + const state2 = getFormState({}) + + const value1 = field.getFormValueFromState(state1) + const value2 = field.getFormValueFromState(state2) + + expect(value1).toEqual({ + latitude: 51.51945, + longitude: -0.127758 + }) + + expect(value2).toBeUndefined() + }) + + it('returns context for conditions and form submission', () => { + const state1 = getFormState({ + latitude: 51.51945, + longitude: -0.127758 + }) + const state2 = getFormState({}) + + const value1 = field.getContextValueFromState(state1) + const value2 = field.getContextValueFromState(state2) + + expect(value1).toBe('Lat: 51.51945\nLong: -0.127758') + expect(value2).toBeNull() + }) + + it('returns state from payload', () => { + const payload1 = getFormData({ + latitude: 51.51945, + longitude: -0.127758 + }) + const payload2 = getFormData({}) + + const value1 = field.getStateFromValidForm(payload1) + const value2 = field.getStateFromValidForm(payload2) + + expect(value1).toEqual( + getFormState({ + latitude: 51.51945, + longitude: -0.127758 + }) + ) + expect(value2).toEqual(getFormState({})) + }) + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const payload = getFormData({ + latitude: 51.51945, + longitude: -0.127758 + }) + const viewModel = field.getViewModel(payload) + + expect(viewModel).toEqual( + expect.objectContaining({ + fieldset: { + legend: { + text: def.title, + classes: 'govuk-fieldset__legend--m' + } + }, + items: [ + expect.objectContaining({ + label: expect.objectContaining({ text: 'Latitude' }), + name: 'myComponent__latitude', + id: 'myComponent__latitude', + value: 51.51945 + }), + expect.objectContaining({ + label: expect.objectContaining({ text: 'Longitude' }), + name: 'myComponent__longitude', + id: 'myComponent__longitude', + value: -0.127758 + }) + ] + }) + ) + }) + + it('includes instruction text when provided', () => { + const componentWithInstruction = new LatLongField( + { + ...def, + options: { instructionText: 'Enter coordinates in **decimal**' } + }, + { model } + ) + + const viewModel = componentWithInstruction.getViewModel( + getFormData({ + latitude: 51.51945, + longitude: -0.127758 + }) + ) + + const instructionText = + 'instructionText' in viewModel ? viewModel.instructionText : undefined + expect(instructionText).toBeTruthy() + expect(instructionText).toContain('decimal') + }) + + it('sets error classes when component has errors', () => { + const payload = getFormData({ + latitude: '', + longitude: '' + }) + + const errors = [ + { + name: 'myComponent', + text: 'Error message', + path: ['myComponent'], + href: '#myComponent' + } + ] + + const viewModel = field.getViewModel(payload, errors) + + expect(viewModel.items?.[0]).toEqual( + expect.objectContaining({ + classes: expect.stringContaining('govuk-input--error') + }) + ) + + expect(viewModel.items?.[1]).toEqual( + expect.objectContaining({ + classes: expect.stringContaining('govuk-input--error') + }) + ) + }) + }) + + describe('AllPossibleErrors', () => { + it('should return errors from instance method', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).not.toBeEmpty() + }) + + it('should return errors from static method', () => { + const staticErrors = LatLongField.getAllPossibleErrors() + expect(staticErrors.baseErrors).not.toBeEmpty() + expect(staticErrors.advancedSettingsErrors).not.toBeEmpty() + }) + + it('instance method should delegate to static method', () => { + const staticResult = LatLongField.getAllPossibleErrors() + const instanceResult = field.getAllPossibleErrors() + + expect(instanceResult).toEqual(staticResult) + }) + }) + }) + + describe('Validation', () => { + describe.each([ + { + description: 'Trim empty spaces', + component: { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } satisfies LatLongFieldComponent, + assertions: [ + { + input: getFormData({ + latitude: ' 51.5', + longitude: ' -0.1' + }), + output: { + value: getFormData({ + latitude: 51.5, + longitude: -0.1 + }) + } + }, + { + input: getFormData({ + latitude: '51.5 ', + longitude: '-0.1 ' + }), + output: { + value: getFormData({ + latitude: 51.5, + longitude: -0.1 + }) + } + } + ] + }, + { + description: 'Schema min and max for latitude', + component: { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: { + latitude: { + min: 50, + max: 55 + } + } + } satisfies LatLongFieldComponent, + assertions: [ + { + input: getFormData({ + latitude: '49.9', + longitude: '-0.1' + }), + output: { + value: getFormData({ + latitude: 49.9, + longitude: -0.1 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Latitude for .* must be between 50 and 55/ + ) + }) + ] + } + }, + { + input: getFormData({ + latitude: '55.1', + longitude: '-0.1' + }), + output: { + value: getFormData({ + latitude: 55.1, + longitude: -0.1 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Latitude for .* must be between 50 and 55/ + ) + }) + ] + } + } + ] + }, + { + description: 'Schema min and max for longitude', + component: { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: { + longitude: { + min: -5, + max: 1 + } + } + } satisfies LatLongFieldComponent, + assertions: [ + { + input: getFormData({ + latitude: '51.5', + longitude: '-5.1' + }), + output: { + value: getFormData({ + latitude: 51.5, + longitude: -5.1 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Longitude for .* must be between -5 and 1/ + ) + }) + ] + } + }, + { + input: getFormData({ + latitude: '51.5', + longitude: '1.1' + }), + output: { + value: getFormData({ + latitude: 51.5, + longitude: 1.1 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Longitude for .* must be between -5 and 1/ + ) + }) + ] + } + } + ] + }, + { + description: 'Precision validation', + component: { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } satisfies LatLongFieldComponent, + assertions: [ + { + input: getFormData({ + latitude: '51.1234567', + longitude: '-0.1' + }), + output: { + value: getFormData({ + latitude: 51.1234567, + longitude: -0.1 + }), + errors: [ + expect.objectContaining({ + text: 'Latitude must be a decimal number' + }) + ] + } + }, + { + input: getFormData({ + latitude: '51.5', + longitude: '-0.1234567' + }), + output: { + value: getFormData({ + latitude: 51.5, + longitude: -0.1234567 + }), + errors: [ + expect.objectContaining({ + text: 'Longitude must be a decimal number' + }) + ] + } + } + ] + }, + { + description: 'Invalid format', + component: { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } satisfies LatLongFieldComponent, + assertions: [ + { + input: getFormData({ + latitude: 'invalid', + longitude: '-0.1' + }), + output: { + value: getFormData({ + latitude: 'invalid', + longitude: -0.1 + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Enter a valid latitude for .* like 51.519450/ + ) + }) + ] + } + }, + { + input: getFormData({ + latitude: '51.5', + longitude: 'invalid' + }), + output: { + value: getFormData({ + latitude: 51.5, + longitude: 'invalid' + }), + errors: [ + expect.objectContaining({ + text: expect.stringMatching( + /Enter a valid longitude for .* like -0.127758/ + ) + }) + ] + } + } + ] + }, + { + description: 'Optional field', + component: { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: { + required: false + }, + schema: {} + } satisfies LatLongFieldComponent, + assertions: [ + { + input: getFormData({ + latitude: '', + longitude: '' + }), + output: { + value: getFormData({ + latitude: '', + longitude: '' + }) + } + } + ] + } + ])('$description', ({ component: def, assertions }) => { + let collection: ComponentCollection + + beforeEach(() => { + collection = new ComponentCollection([def], { model }) + }) + + it.each([...assertions])( + 'validates custom example', + ({ input, output }) => { + const result = collection.validate(input) + expect(result).toEqual(output) + } + ) + }) + }) +}) + +function getFormData( + value: + | { latitude?: string | number; longitude?: string | number } + | Record +) { + if ('latitude' in value || 'longitude' in value) { + return { + myComponent__latitude: value.latitude, + myComponent__longitude: value.longitude + } + } + return {} +} + +function getFormState( + value: + | { + latitude?: number + longitude?: number + } + | Record +) { + if ('latitude' in value || 'longitude' in value) { + return { + myComponent__latitude: value.latitude ?? null, + myComponent__longitude: value.longitude ?? null + } + } + return { + myComponent__latitude: null, + myComponent__longitude: null + } +} diff --git a/src/server/plugins/engine/components/LocationFieldBase.test.ts b/src/server/plugins/engine/components/LocationFieldBase.test.ts new file mode 100644 index 000000000..08a4d302f --- /dev/null +++ b/src/server/plugins/engine/components/LocationFieldBase.test.ts @@ -0,0 +1,253 @@ +import { ComponentType } from '@defra/forms-model' +import type joi from 'joi' +import { type LanguageMessages } from 'joi' + +import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' +import { getFormData } from '~/test/helpers/component-helpers.js' + +class TestLocationField extends LocationFieldBase { + protected getValidationConfig() { + return { + pattern: /^TEST\d{4}$/i, + patternErrorMessage: 'Enter a valid test code like TEST1234', + additionalMessages: { + 'string.custom': 'This is a custom error from additional messages' + } as LanguageMessages, + customValidation: (value: string, helpers: joi.CustomHelpers) => { + if (value === 'FAIL0000') { + return helpers.error('string.custom') + } + return value + } + } + } + + protected getErrorTemplates() { + return [ + { + type: 'pattern', + template: + 'Enter a valid test code for [short description] like TEST1234' + }, + { + type: 'custom', + template: 'This is a custom error template' + } + ] + } +} + +describe('LocationFieldBase', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('customValidationMessage with additionalMessages', () => { + it('should merge custom validation message with additional message keys', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: { + customValidationMessage: 'This is a unified custom error' + }, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + + const result2 = field.formSchema.validate('INVALID') + const result3 = field.formSchema.validate('FAIL0000') + + expect(result2.error?.message).toBe('This is a unified custom error') + expect(result3.error?.message).toBe('This is a unified custom error') + }) + }) + + describe('getViewModel with instructionText', () => { + it('should include parsed markdown instruction text', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: { + instructionText: 'This is **bold** text' + }, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + const viewModel = field.getViewModel(getFormData('TEST1234')) + + const instructionText = + 'instructionText' in viewModel ? viewModel.instructionText : undefined + expect(instructionText).toBeTruthy() + expect(instructionText).toContain('bold') + }) + + it('should not include instructionText when not provided', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: {}, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + const viewModel = field.getViewModel(getFormData('TEST1234')) + + expect( + 'instructionText' in viewModel ? viewModel.instructionText : undefined + ).toBeUndefined() + }) + }) + + describe('getAllPossibleErrors', () => { + it('should return base errors with custom error templates', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: {}, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + const errors = field.getAllPossibleErrors() + + expect(errors.baseErrors).toHaveLength(3) + expect(errors.baseErrors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'required' }), + expect.objectContaining({ type: 'pattern' }), + expect.objectContaining({ type: 'custom' }) + ]) + ) + expect(errors.advancedSettingsErrors).toEqual([]) + }) + }) + + describe('isValue and getFormValue', () => { + it('should correctly identify string values', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: {}, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + + expect(field.isValue('TEST1234')).toBe(true) + expect(field.isValue('')).toBe(false) + expect(field.isValue(null)).toBe(false) + expect(field.isValue(undefined)).toBe(false) + expect(field.isValue(123)).toBe(false) + expect(field.isValue({ test: 'value' })).toBe(false) + }) + + it('should return value when it is a non-empty string', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: {}, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + + expect(field.getFormValue('TEST1234')).toBe('TEST1234') + expect(field.getFormValue('')).toBeUndefined() + expect(field.getFormValue(null)).toBeUndefined() + expect(field.getFormValue(undefined)).toBeUndefined() + }) + + it('should get value from state', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: {}, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + + const state1 = { myComponent: 'TEST1234' } + const state2 = { myComponent: null } + const state3 = { myComponent: '' } + + expect(field.getFormValueFromState(state1)).toBe('TEST1234') + expect(field.getFormValueFromState(state2)).toBeUndefined() + expect(field.getFormValueFromState(state3)).toBeUndefined() + }) + }) + + describe('optional field validation', () => { + it('should allow empty values when required is false', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: { + required: false + }, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + const result = field.formSchema.validate('') + + expect(result.error).toBeUndefined() + expect(result.value).toBe('') + }) + + it('should validate pattern even for optional fields when value is provided', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: { + required: false + }, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + const result = field.formSchema.validate('INVALID') + + expect(result.error).toBeDefined() + }) + }) + + describe('customValidationMessages', () => { + it('should use custom validation messages when provided', () => { + const def = { + title: 'Test location field', + name: 'myComponent', + type: ComponentType.TextField, + options: { + customValidationMessages: { + 'string.pattern.base': 'Custom pattern error message', + 'string.custom': 'Custom error message' + } + }, + schema: {} + } as ConstructorParameters[0] + + const field = new TestLocationField(def, { model }) + const result = field.formSchema.validate('INVALID') + + expect(result.error?.message).toBe('Custom pattern error message') + }) + }) +}) diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 8c7edf51a..3ffb61b77 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -5,7 +5,7 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' -import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, @@ -35,7 +35,6 @@ interface ValidationConfig { /** * Abstract base class for location-based field components - * Reduces code duplication across similar location field types */ export abstract class LocationFieldBase extends FormComponent { declare options: LocationFieldOptions diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.test.ts b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts new file mode 100644 index 000000000..3adf8fee7 --- /dev/null +++ b/src/server/plugins/engine/components/LocationFieldHelpers.test.ts @@ -0,0 +1,338 @@ +import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { type LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' + +describe('LocationFieldHelpers', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('getLocationFieldViewModel', () => { + it('should return view model with fieldset', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + const field = collection.fields[0] as LatLongField + + const payload = { + myComponent__latitude: 51.5, + myComponent__longitude: -0.1 + } + + const viewModel = field.getViewModel(payload) + + expect(viewModel.fieldset).toEqual({ + legend: { + text: def.title, + classes: 'govuk-fieldset__legend--m' + } + }) + + expect(viewModel.items).toHaveLength(2) + }) + + it('should include instruction text in view model when provided', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: { + instructionText: 'Enter coordinates in decimal format' + }, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + const field = collection.fields[0] as LatLongField + + const payload = { + myComponent__latitude: 51.5, + myComponent__longitude: -0.1 + } + + const viewModel = field.getViewModel(payload) + + const instructionText = + 'instructionText' in viewModel ? viewModel.instructionText : undefined + expect(instructionText).toBeTruthy() + expect(instructionText).toContain('decimal format') + }) + + it('should add error classes to items when component has errors', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + const field = collection.fields[0] as LatLongField + + const payload = { + myComponent__latitude: '', + myComponent__longitude: '' + } + + const errors = [ + { + name: 'myComponent', + text: 'Error message', + path: ['myComponent'], + href: '#myComponent' + } + ] + + const viewModel = field.getViewModel(payload, errors) + + expect(viewModel.items[0]).toEqual( + expect.objectContaining({ + classes: expect.stringContaining('govuk-input--error') + }) + ) + + expect(viewModel.items[1]).toEqual( + expect.objectContaining({ + classes: expect.stringContaining('govuk-input--error') + }) + ) + }) + + it('should add error classes to items when subfield has errors', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + const field = collection.fields[0] as LatLongField + + const payload = { + myComponent__latitude: 'invalid', + myComponent__longitude: '-0.1' + } + + const errors = [ + { + name: 'myComponent__latitude', + text: 'Invalid latitude', + path: ['myComponent__latitude'], + href: '#myComponent__latitude' + } + ] + + const viewModel = field.getViewModel(payload, errors) + + expect(viewModel.items[0]).toEqual( + expect.objectContaining({ + classes: expect.stringContaining('govuk-input--error') + }) + ) + }) + + it('should handle labels correctly in view model items', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + const field = collection.fields[0] as LatLongField + + const payload = { + myComponent__latitude: '51.5', + myComponent__longitude: '-0.1' + } + + const viewModel = field.getViewModel(payload) + + const label = viewModel.items[0].label + expect(label).toBeDefined() + expect(label?.text).toBe('Latitude') + + const labelString = + label && 'toString' in label && typeof label.toString === 'function' + ? (label as { toString: () => string }).toString() + : '' + expect(labelString).toBe('Latitude') + }) + + it('should use existing fieldset if provided', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + const field = collection.fields[0] as LatLongField + + const payload = { + myComponent__latitude: 51.5, + myComponent__longitude: -0.1 + } + + const viewModel = field.getViewModel(payload) + + expect(viewModel.fieldset).toBeDefined() + }) + }) + + describe('createLocationFieldValidator', () => { + it('should return error when required field is empty', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + + const payload = { + myComponent__latitude: '', + myComponent__longitude: '' + } + + const result = collection.validate(payload) + + expect(result.errors).toBeTruthy() + expect(result.errors?.length).toBeGreaterThan(0) + }) + + it('should return error when required field has invalid state', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: { + required: true + }, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + + const payload = { + myComponent__latitude: 'not_a_number', + myComponent__longitude: 'also_not_a_number' + } + + const result = collection.validate(payload) + + expect(result.errors).toBeTruthy() + }) + + it('should not return error when optional field is empty', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: { + required: false + }, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + + const payload = { + myComponent__latitude: '', + myComponent__longitude: '' + } + + const result = collection.validate(payload) + + expect(result.errors).toBeUndefined() + }) + + it('should return error when required field is partially filled', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + + const payload = { + myComponent__latitude: '51.5', + myComponent__longitude: '' + } + + const result = collection.validate(payload) + + expect(result.errors).toBeTruthy() + }) + + it('should not return error when all required fields are filled', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: {}, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + + const payload = { + myComponent__latitude: '51.5', + myComponent__longitude: '-0.1' + } + + const result = collection.validate(payload) + + expect(result.errors).toBeUndefined() + }) + + it('should validate optional fields correctly when partially filled', () => { + const def: LatLongFieldComponent = { + title: 'Example lat long', + name: 'myComponent', + type: ComponentType.LatLongField, + options: { + required: false + }, + schema: {} + } + + const collection = new ComponentCollection([def], { model }) + + const payload = { + myComponent__latitude: '51.5', + myComponent__longitude: '' + } + + const result = collection.validate(payload) + + expect(result.errors).toBeTruthy() + expect(result.errors?.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.ts b/src/server/plugins/engine/components/LocationFieldHelpers.ts index 9b33d8b8c..f3f24db50 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts @@ -3,7 +3,7 @@ import { type Context, type CustomValidator } from 'joi' import { type EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js' import { isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' import { type LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' -import { markdown } from '~/src/server/plugins/engine/components/helpers/components.js' +import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' import { type DateInputItem, type Label, diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts new file mode 100644 index 000000000..949a040f3 --- /dev/null +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -0,0 +1,436 @@ +import { + ComponentType, + type NationalGridFieldNumberFieldComponent +} from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js' +import { + getAnswer, + type Field +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' +import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' + +describe('NationalGridFieldNumberField', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: NationalGridFieldNumberFieldComponent + let collection: ComponentCollection + let field: Field + + beforeEach(() => { + def = { + title: 'Example National Grid field number', + name: 'myComponent', + type: ComponentType.NationalGridFieldNumberField, + options: {} + } + + collection = new ComponentCollection([def], { model }) + field = collection.fields[0] + }) + + describe('Schema', () => { + it('uses component title as label as default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + label: 'Example National Grid field number' + }) + }) + ) + }) + + it('uses component name as keys', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(field.keys).toEqual(['myComponent']) + expect(field.collection).toBeUndefined() + + for (const key of field.keys) { + expect(keys).toHaveProperty(key) + } + }) + + it('is required by default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + presence: 'required' + }) + }) + ) + }) + + it('is optional when configured', () => { + const collectionOptional = new ComponentCollection( + [{ ...def, options: { required: false } }], + { model } + ) + + const { formSchema } = collectionOptional + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ allow: [''] }) + ) + + const result = collectionOptional.validate(getFormData('')) + expect(result.errors).toBeUndefined() + }) + + it('accepts valid values', () => { + const result1 = collection.validate(getFormData('NG12345678')) + const result2 = collection.validate(getFormData('ng12345678')) + const result3 = collection.validate(getFormData('AB98765432')) + + expect(result1.errors).toBeUndefined() + expect(result2.errors).toBeUndefined() + expect(result3.errors).toBeUndefined() + }) + + it('strips spaces and commas from input', () => { + const result1 = collection.validate(getFormData('NG 1234 5678')) + const result2 = collection.validate(getFormData('NG12345,678')) + + expect(result1.value.myComponent).toBe('NG12345678') + expect(result2.value.myComponent).toBe('NG12345678') + }) + + it('adds errors for empty value', () => { + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter example National Grid field number' + }) + ]) + }) + + it('adds errors for invalid values', () => { + const result1 = collection.validate(getFormData('NG1234567')) + const result2 = collection.validate(getFormData('N123456789')) + const result3 = collection.validate(getFormData('NGABCDEFGH')) + + expect(result1.errors).toBeTruthy() + expect(result2.errors).toBeTruthy() + expect(result3.errors).toBeTruthy() + }) + }) + + describe('State', () => { + it('returns text from state', () => { + const state1 = getFormState('NG12345678') + const state2 = getFormState(null) + + const answer1 = getAnswer(field, state1) + const answer2 = getAnswer(field, state2) + + expect(answer1).toBe('NG12345678') + expect(answer2).toBe('') + }) + + it('returns payload from state', () => { + const state1 = getFormState('NG12345678') + const state2 = getFormState(null) + + const payload1 = field.getFormDataFromState(state1) + const payload2 = field.getFormDataFromState(state2) + + expect(payload1).toEqual(getFormData('NG12345678')) + expect(payload2).toEqual(getFormData()) + }) + + it('returns value from state', () => { + const state1 = getFormState('NG12345678') + const state2 = getFormState(null) + + const value1 = field.getFormValueFromState(state1) + const value2 = field.getFormValueFromState(state2) + + expect(value1).toBe('NG12345678') + expect(value2).toBeUndefined() + }) + + it('returns context for conditions and form submission', () => { + const state1 = getFormState('NG12345678') + const state2 = getFormState(null) + + const value1 = field.getContextValueFromState(state1) + const value2 = field.getContextValueFromState(state2) + + expect(value1).toBe('NG12345678') + expect(value2).toBeNull() + }) + + it('returns state from payload', () => { + const payload1 = getFormData('NG12345678') + const payload2 = getFormData() + + const value1 = field.getStateFromValidForm(payload1) + const value2 = field.getStateFromValidForm(payload2) + + expect(value1).toEqual(getFormState('NG12345678')) + expect(value2).toEqual(getFormState(null)) + }) + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const viewModel = field.getViewModel(getFormData('NG12345678')) + + expect(viewModel).toEqual( + expect.objectContaining({ + label: { text: def.title }, + name: 'myComponent', + id: 'myComponent', + value: 'NG12345678' + }) + ) + }) + + it('includes instruction text when provided', () => { + const componentWithInstruction = new NationalGridFieldNumberField( + { + ...def, + options: { instructionText: 'Enter in format **NG12345678**' } + }, + { model } + ) + + const viewModel = componentWithInstruction.getViewModel( + getFormData('NG12345678') + ) + + const instructionText = + 'instructionText' in viewModel ? viewModel.instructionText : undefined + expect(instructionText).toBeTruthy() + expect(instructionText).toContain('NG12345678') + }) + }) + + describe('AllPossibleErrors', () => { + it('should return errors from instance method', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toEqual([]) + }) + + it('should return errors from static method', () => { + const staticErrors = NationalGridFieldNumberField.getAllPossibleErrors() + expect(staticErrors.baseErrors).not.toBeEmpty() + expect(staticErrors.advancedSettingsErrors).toEqual([]) + }) + }) + }) + + describe('Validation', () => { + describe.each([ + { + description: 'Trim empty spaces', + component: { + title: 'Example National Grid field number', + name: 'myComponent', + type: ComponentType.NationalGridFieldNumberField, + options: {} + }, + assertions: [ + { + input: getFormData(' NG12345678'), + output: { value: getFormData('NG12345678') } + }, + { + input: getFormData('NG12345678 '), + output: { value: getFormData('NG12345678') } + }, + { + input: getFormData(' NG12345678 \n\n'), + output: { value: getFormData('NG12345678') } + } + ] + }, + { + description: 'Pattern validation', + component: { + title: 'Example National Grid field number', + name: 'myComponent', + type: ComponentType.NationalGridFieldNumberField, + options: {} + }, + assertions: [ + { + input: getFormData('NG1234567'), + output: { + value: getFormData('NG1234567'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'Enter a valid National Grid field number for Example National Grid field number like NG12345678' + }) + ]) + } + }, + { + input: getFormData('N123456789'), + output: { + value: getFormData('N123456789'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'Enter a valid National Grid field number for Example National Grid field number like NG12345678' + }) + ]) + } + }, + { + input: getFormData('NGABCDEFGH'), + output: { + value: getFormData('NGABCDEFGH'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'Enter a valid National Grid field number for Example National Grid field number like NG12345678' + }) + ]) + } + } + ] + }, + { + description: 'Custom validation message', + component: { + title: 'Example National Grid field number', + name: 'myComponent', + type: ComponentType.NationalGridFieldNumberField, + options: { + customValidationMessage: 'This is a custom error' + } + }, + assertions: [ + { + input: getFormData(''), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'This is a custom error' + }) + ] + } + }, + { + input: getFormData('INVALID'), + output: { + value: getFormData('INVALID'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'This is a custom error' + }) + ]) + } + } + ] + }, + { + description: 'Custom validation messages (multiple)', + component: { + title: 'Example National Grid field number', + name: 'myComponent', + type: ComponentType.NationalGridFieldNumberField, + options: { + customValidationMessages: { + 'any.required': 'This is a custom required error', + 'string.empty': 'This is a custom empty string error', + 'string.pattern.base': 'This is a custom pattern error' + } + } + }, + assertions: [ + { + input: getFormData(), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'This is a custom required error' + }) + ] + } + }, + { + input: getFormData(''), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'This is a custom empty string error' + }) + ] + } + }, + { + input: getFormData('INVALID'), + output: { + value: getFormData('INVALID'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'This is a custom pattern error' + }) + ]) + } + } + ] + }, + { + description: 'Optional field', + component: { + title: 'Example National Grid field number', + name: 'myComponent', + type: ComponentType.NationalGridFieldNumberField, + options: { + required: false + } + }, + assertions: [ + { + input: getFormData(''), + output: { value: getFormData('') } + } + ] + } + ])('$description', ({ component: def, assertions }) => { + let collection: ComponentCollection + + beforeEach(() => { + collection = new ComponentCollection( + [def as NationalGridFieldNumberFieldComponent], + { model } + ) + }) + + it.each([...assertions])( + 'validates custom example', + ({ input, output }) => { + const result = collection.validate(input) + expect(result).toEqual(output) + } + ) + }) + }) +}) diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts new file mode 100644 index 000000000..a06b8da4c --- /dev/null +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -0,0 +1,432 @@ +import { ComponentType, type OsGridRefFieldComponent } from '@defra/forms-model' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js' +import { + getAnswer, + type Field +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' +import { getFormData, getFormState } from '~/test/helpers/component-helpers.js' + +describe('OsGridRefField', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('Defaults', () => { + let def: OsGridRefFieldComponent + let collection: ComponentCollection + let field: Field + + beforeEach(() => { + def = { + title: 'Example OS grid reference', + name: 'myComponent', + type: ComponentType.OsGridRefField, + options: {} + } + + collection = new ComponentCollection([def], { model }) + field = collection.fields[0] + }) + + describe('Schema', () => { + it('uses component title as label as default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + label: 'Example OS grid reference' + }) + }) + ) + }) + + it('uses component name as keys', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(field.keys).toEqual(['myComponent']) + expect(field.collection).toBeUndefined() + + for (const key of field.keys) { + expect(keys).toHaveProperty(key) + } + }) + + it('is required by default', () => { + const { formSchema } = collection + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ + flags: expect.objectContaining({ + presence: 'required' + }) + }) + ) + }) + + it('is optional when configured', () => { + const collectionOptional = new ComponentCollection( + [{ ...def, options: { required: false } }], + { model } + ) + + const { formSchema } = collectionOptional + const { keys } = formSchema.describe() + + expect(keys).toHaveProperty( + 'myComponent', + expect.objectContaining({ allow: [''] }) + ) + + const result = collectionOptional.validate(getFormData('')) + expect(result.errors).toBeUndefined() + }) + + it('accepts valid values', () => { + const result1 = collection.validate(getFormData('TQ123456')) + const result2 = collection.validate(getFormData('SU1234567890')) + const result3 = collection.validate(getFormData('nt123456')) + + expect(result1.errors).toBeUndefined() + expect(result2.errors).toBeUndefined() + expect(result3.errors).toBeUndefined() + }) + + it('strips spaces and commas from input', () => { + const result1 = collection.validate(getFormData('TQ 123 456')) + const result2 = collection.validate(getFormData('TQ123,456')) + + expect(result1.value.myComponent).toBe('TQ123456') + expect(result2.value.myComponent).toBe('TQ123456') + }) + + it('adds errors for empty value', () => { + const result = collection.validate(getFormData('')) + + expect(result.errors).toEqual([ + expect.objectContaining({ + text: 'Enter example OS grid reference' + }) + ]) + }) + + it('adds errors for invalid values', () => { + const result1 = collection.validate(getFormData('INVALID')) + const result2 = collection.validate(getFormData('TQ12345')) + const result3 = collection.validate(getFormData('A1234567')) + + expect(result1.errors).toBeTruthy() + expect(result2.errors).toBeTruthy() + expect(result3.errors).toBeTruthy() + }) + }) + + describe('State', () => { + it('returns text from state', () => { + const state1 = getFormState('TQ123456') + const state2 = getFormState(null) + + const answer1 = getAnswer(field, state1) + const answer2 = getAnswer(field, state2) + + expect(answer1).toBe('TQ123456') + expect(answer2).toBe('') + }) + + it('returns payload from state', () => { + const state1 = getFormState('TQ123456') + const state2 = getFormState(null) + + const payload1 = field.getFormDataFromState(state1) + const payload2 = field.getFormDataFromState(state2) + + expect(payload1).toEqual(getFormData('TQ123456')) + expect(payload2).toEqual(getFormData()) + }) + + it('returns value from state', () => { + const state1 = getFormState('TQ123456') + const state2 = getFormState(null) + + const value1 = field.getFormValueFromState(state1) + const value2 = field.getFormValueFromState(state2) + + expect(value1).toBe('TQ123456') + expect(value2).toBeUndefined() + }) + + it('returns context for conditions and form submission', () => { + const state1 = getFormState('TQ123456') + const state2 = getFormState(null) + + const value1 = field.getContextValueFromState(state1) + const value2 = field.getContextValueFromState(state2) + + expect(value1).toBe('TQ123456') + expect(value2).toBeNull() + }) + + it('returns state from payload', () => { + const payload1 = getFormData('TQ123456') + const payload2 = getFormData() + + const value1 = field.getStateFromValidForm(payload1) + const value2 = field.getStateFromValidForm(payload2) + + expect(value1).toEqual(getFormState('TQ123456')) + expect(value2).toEqual(getFormState(null)) + }) + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const viewModel = field.getViewModel(getFormData('TQ123456')) + + expect(viewModel).toEqual( + expect.objectContaining({ + label: { text: def.title }, + name: 'myComponent', + id: 'myComponent', + value: 'TQ123456' + }) + ) + }) + + it('includes instruction text when provided', () => { + const componentWithInstruction = new OsGridRefField( + { + ...def, + options: { instructionText: 'Enter in format **TQ123456**' } + }, + { model } + ) + + const viewModel = componentWithInstruction.getViewModel( + getFormData('TQ123456') + ) + + const instructionText = + 'instructionText' in viewModel ? viewModel.instructionText : undefined + expect(instructionText).toBeTruthy() + expect(instructionText).toContain('TQ123456') + }) + }) + + describe('AllPossibleErrors', () => { + it('should return errors from instance method', () => { + const errors = field.getAllPossibleErrors() + expect(errors.baseErrors).not.toBeEmpty() + expect(errors.advancedSettingsErrors).toEqual([]) + }) + + it('should return errors from static method', () => { + const staticErrors = OsGridRefField.getAllPossibleErrors() + expect(staticErrors.baseErrors).not.toBeEmpty() + expect(staticErrors.advancedSettingsErrors).toEqual([]) + }) + }) + }) + + describe('Validation', () => { + describe.each([ + { + description: 'Trim empty spaces', + component: { + title: 'Example OS grid reference', + name: 'myComponent', + type: ComponentType.OsGridRefField, + options: {} + }, + assertions: [ + { + input: getFormData(' TQ123456'), + output: { value: getFormData('TQ123456') } + }, + { + input: getFormData('TQ123456 '), + output: { value: getFormData('TQ123456') } + }, + { + input: getFormData(' TQ123456 \n\n'), + output: { value: getFormData('TQ123456') } + } + ] + }, + { + description: 'Pattern validation', + component: { + title: 'Example OS grid reference', + name: 'myComponent', + type: ComponentType.OsGridRefField, + options: {} + }, + assertions: [ + { + input: getFormData('TQ12345'), + output: { + value: getFormData('TQ12345'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'Enter a valid OS grid reference for Example OS grid reference like TQ123456' + }) + ]) + } + }, + { + input: getFormData('A1234567'), + output: { + value: getFormData('A1234567'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'Enter a valid OS grid reference for Example OS grid reference like TQ123456' + }) + ]) + } + }, + { + input: getFormData('TQABCDEF'), + output: { + value: getFormData('TQABCDEF'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'Enter a valid OS grid reference for Example OS grid reference like TQ123456' + }) + ]) + } + } + ] + }, + { + description: 'Custom validation message', + component: { + title: 'Example OS grid reference', + name: 'myComponent', + type: ComponentType.OsGridRefField, + options: { + customValidationMessage: 'This is a custom error' + } + }, + assertions: [ + { + input: getFormData(''), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'This is a custom error' + }) + ] + } + }, + { + input: getFormData('INVALID'), + output: { + value: getFormData('INVALID'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'This is a custom error' + }) + ]) + } + } + ] + }, + { + description: 'Custom validation messages (multiple)', + component: { + title: 'Example OS grid reference', + name: 'myComponent', + type: ComponentType.OsGridRefField, + options: { + customValidationMessages: { + 'any.required': 'This is a custom required error', + 'string.empty': 'This is a custom empty string error', + 'string.pattern.base': 'This is a custom pattern error' + } + } + }, + assertions: [ + { + input: getFormData(), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'This is a custom required error' + }) + ] + } + }, + { + input: getFormData(''), + output: { + value: getFormData(''), + errors: [ + expect.objectContaining({ + text: 'This is a custom empty string error' + }) + ] + } + }, + { + input: getFormData('INVALID'), + output: { + value: getFormData('INVALID'), + errors: expect.arrayContaining([ + expect.objectContaining({ + text: 'This is a custom pattern error' + }) + ]) + } + } + ] + }, + { + description: 'Optional field', + component: { + title: 'Example OS grid reference', + name: 'myComponent', + type: ComponentType.OsGridRefField, + options: { + required: false + } + }, + assertions: [ + { + input: getFormData(''), + output: { value: getFormData('') } + } + ] + } + ])('$description', ({ component: def, assertions }) => { + let collection: ComponentCollection + + beforeEach(() => { + collection = new ComponentCollection([def as OsGridRefFieldComponent], { + model + }) + }) + + it.each([...assertions])( + 'validates custom example', + ({ input, output }) => { + const result = collection.validate(input) + expect(result).toEqual(output) + } + ) + }) + }) +}) diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 5620c5fec..ae29bbee2 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -1,11 +1,11 @@ import { ComponentType, type ComponentDef } from '@defra/forms-model' -import { Marked, type Token } from 'marked' import { config } from '~/src/config/index.js' import { type ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js' import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js' import * as Components from '~/src/server/plugins/engine/components/index.js' +import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' import { type FormState } from '~/src/server/plugins/engine/types.js' // All component instances @@ -32,13 +32,13 @@ export type Field = InstanceType< > // Guidance component instances only -export type Guidance = InstanceType< - | typeof Components.Details - | typeof Components.Html - | typeof Components.Markdown - | typeof Components.InsetText - | typeof Components.List -> +export type Guidance = + | InstanceType + | InstanceType + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + | InstanceType + | InstanceType + | InstanceType // List component instances only export type ListField = InstanceType< @@ -51,44 +51,8 @@ export type ListField = InstanceType< export const designerUrl = config.get('designerUrl') -export const markdown = new Marked({ - breaks: true, - gfm: true, - - /** - * Render paragraphs without `

` wrappers - * for check answers summary list `

` - */ - extensions: [ - { - name: 'paragraph', - renderer({ tokens = [] }) { - const text = this.parser.parseInline(tokens) - return tokens.length > 1 ? `${text}
` : text - } - } - ], - - /** - * Restrict allowed Markdown tokens - */ - walkTokens(token) { - const tokens: Token['type'][] = [ - 'br', - 'escape', - 'link', - 'list', - 'list_item', - 'paragraph', - 'space', - 'text' - ] - - if (!tokens.includes(token.type)) { - token.type = 'text' - } - } -}) +// Re-export markdown from its own module to avoid circular dependencies +export { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' /** * Filter known components with lists @@ -96,7 +60,7 @@ export const markdown = new Marked({ export function hasListFormField( field?: Partial ): field is ListFormComponent { - return !!field && isListFieldType(field.type) + return !!field && field.type !== undefined && isListFieldType(field.type) } export function isListFieldType( @@ -120,6 +84,7 @@ export function createComponent( def: ComponentDef, options: ConstructorParameters[1] ): Component { + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents let component: Component | undefined switch (def.type) { diff --git a/src/server/plugins/engine/components/helpers/helpers.test.ts b/src/server/plugins/engine/components/helpers/helpers.test.ts index 20fc10927..ab4a4762a 100644 --- a/src/server/plugins/engine/components/helpers/helpers.test.ts +++ b/src/server/plugins/engine/components/helpers/helpers.test.ts @@ -1,6 +1,10 @@ -import { type ComponentDef } from '@defra/forms-model' +import { ComponentType, type ComponentDef } from '@defra/forms-model' import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' +import { EastingNorthingField } from '~/src/server/plugins/engine/components/EastingNorthingField.js' +import { LatLongField } from '~/src/server/plugins/engine/components/LatLongField.js' +import { NationalGridFieldNumberField } from '~/src/server/plugins/engine/components/NationalGridFieldNumberField.js' +import { OsGridRefField } from '~/src/server/plugins/engine/components/OsGridRefField.js' import { createComponent } from '~/src/server/plugins/engine/components/helpers/components.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import definition from '~/test/form/definitions/basic.js' @@ -22,6 +26,72 @@ describe('helpers tests', () => { ) ).toThrow('Component type invalid-type does not exist') }) + + test('should create EastingNorthingField component', () => { + const component = createComponent( + { + type: ComponentType.EastingNorthingField, + name: 'testField', + title: 'Test Easting Northing', + options: {}, + schema: {} + }, + { model: formModel } + ) + + expect(component).toBeInstanceOf(EastingNorthingField) + expect(component.name).toBe('testField') + expect(component.title).toBe('Test Easting Northing') + }) + + test('should create LatLongField component', () => { + const component = createComponent( + { + type: ComponentType.LatLongField, + name: 'testField', + title: 'Test Lat Long', + options: {}, + schema: {} + }, + { model: formModel } + ) + + expect(component).toBeInstanceOf(LatLongField) + expect(component.name).toBe('testField') + expect(component.title).toBe('Test Lat Long') + }) + + test('should create OsGridRefField component', () => { + const component = createComponent( + { + type: ComponentType.OsGridRefField, + name: 'testField', + title: 'Test OS Grid Ref', + options: {} + }, + { model: formModel } + ) + + expect(component).toBeInstanceOf(OsGridRefField) + expect(component.name).toBe('testField') + expect(component.title).toBe('Test OS Grid Ref') + }) + + test('should create NationalGridFieldNumberField component', () => { + const component = createComponent( + { + type: ComponentType.NationalGridFieldNumberField, + name: 'testField', + title: 'Test National Grid', + options: {} + }, + { model: formModel } + ) + + expect(component).toBeInstanceOf(NationalGridFieldNumberField) + expect(component.name).toBe('testField') + expect(component.title).toBe('Test National Grid') + }) }) describe('ComponentBase tests', () => { diff --git a/src/server/plugins/engine/components/markdownParser.ts b/src/server/plugins/engine/components/markdownParser.ts new file mode 100644 index 000000000..1421fd9ae --- /dev/null +++ b/src/server/plugins/engine/components/markdownParser.ts @@ -0,0 +1,40 @@ +import { Marked, type Token } from 'marked' + +export const markdown = new Marked({ + breaks: true, + gfm: true, + + /** + * Render paragraphs without `

` wrappers + * for check answers summary list `

` + */ + extensions: [ + { + name: 'paragraph', + renderer({ tokens = [] }) { + const text = this.parser.parseInline(tokens) + return tokens.length > 1 ? `${text}
` : text + } + } + ], + + /** + * Restrict allowed Markdown tokens + */ + walkTokens(token) { + const tokens: Token['type'][] = [ + 'br', + 'escape', + 'link', + 'list', + 'list_item', + 'paragraph', + 'space', + 'text' + ] + + if (!tokens.includes(token.type)) { + token.type = 'text' + } + } +}) From c0235af9ef789eafafa9d7bece8e190be220b628 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Mon, 27 Oct 2025 14:44:31 +0000 Subject: [PATCH 10/21] feat: add suffix and prefix support to LatLongField and update related components --- src/server/plugins/engine/components/LatLongField.ts | 2 ++ .../plugins/engine/components/LocationFieldHelpers.ts | 6 ++++-- src/server/plugins/engine/components/types.ts | 2 ++ .../engine/views/components/_location-field-base.html | 4 +++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index e7e1e479a..84a0a97a9 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -80,6 +80,7 @@ export class LatLongField extends FormComponent { required: isRequired, optionalText: true, classes: 'govuk-input--width-10', + suffix: '°', customValidationMessages: latitudeMessages } }, @@ -92,6 +93,7 @@ export class LatLongField extends FormComponent { required: isRequired, optionalText: true, classes: 'govuk-input--width-10', + suffix: '°', customValidationMessages: longitudeMessages } } diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.ts b/src/server/plugins/engine/components/LocationFieldHelpers.ts index f3f24db50..3098d618f 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts @@ -40,7 +40,7 @@ export function getLocationFieldViewModel( const items: DateInputItem[] = collection .getViewModel(payload, errors) .map(({ model }): DateInputItem => { - let { label, type, value, classes, errorMessage } = model + let { label, type, value, classes, prefix, suffix, errorMessage } = model if (label) { label.toString = () => label.text // Use string labels @@ -62,7 +62,9 @@ export function getLocationFieldViewModel( name: model.name, type, value, - classes + classes, + prefix, + suffix } }) diff --git a/src/server/plugins/engine/components/types.ts b/src/server/plugins/engine/components/types.ts index 5370dfbff..9a80fb78b 100644 --- a/src/server/plugins/engine/components/types.ts +++ b/src/server/plugins/engine/components/types.ts @@ -60,6 +60,8 @@ export interface DateInputItem { name?: string value?: Item['value'] classes?: string + prefix?: ComponentText + suffix?: ComponentText condition?: undefined } diff --git a/src/server/plugins/engine/views/components/_location-field-base.html b/src/server/plugins/engine/views/components/_location-field-base.html index b12e3020e..e549fe9cb 100644 --- a/src/server/plugins/engine/views/components/_location-field-base.html +++ b/src/server/plugins/engine/views/components/_location-field-base.html @@ -26,7 +26,9 @@ classes: item.classes, value: item.value, type: inputType, - inputmode: inputMode + inputmode: inputMode, + prefix: item.prefix, + suffix: item.suffix }) }}
From f6f092c1ebf624b7d4278073285247c07308cccc Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 28 Oct 2025 12:27:18 +0000 Subject: [PATCH 11/21] feat: updated formatting and tests --- package-lock.json | 105 ++---- package.json | 4 +- .../components/EastingNorthingField.test.ts | 2 +- .../engine/components/LatLongField.test.ts | 2 +- .../components/helpers/components.test.ts | 270 +++++++++++++ .../engine/components/helpers/components.ts | 14 +- .../adapter/v1.location.test.ts | 356 ++++++++++++++++++ src/server/plugins/engine/types.ts | 4 + src/server/plugins/engine/types/index.ts | 2 + 9 files changed, 677 insertions(+), 82 deletions(-) create mode 100644 src/server/plugins/engine/components/helpers/components.test.ts create mode 100644 src/server/plugins/engine/outputFormatters/adapter/v1.location.test.ts diff --git a/package-lock.json b/package-lock.json index 749790371..d26e0e95e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.560", + "@defra/forms-model": "^3.0.569", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -139,6 +139,31 @@ "npm": "^10.9.0" } }, + "../forms-designer/model": { + "name": "@defra/forms-model", + "version": "3.0.569", + "license": "OGL-UK-3.0", + "dependencies": { + "@joi/date": "^2.1.1", + "marked": "^15.0.12", + "nanoid": "^5.0.7", + "slug": "^11.0.0", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@types/slug": "^5.0.9", + "joi": "^17.13.3", + "joi-to-json": "^4.3.2", + "tsc-alias": "^1.8.11" + }, + "engines": { + "node": "^22.11.0", + "npm": "^10.9.0" + }, + "peerDependencies": { + "joi": "^17.0.0" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -2272,42 +2297,8 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.560", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.560.tgz", - "integrity": "sha512-NQF3EUJmKBwhCypVftLVg+3ZUt0urp0ZdZNG/NaBGx5VwuhVP/MR+TlcXe44xolu8S1PCwN6RtOxGlawzKh9Ew==", - "license": "OGL-UK-3.0", - "dependencies": { - "@joi/date": "^2.1.1", - "marked": "^15.0.12", - "nanoid": "^5.0.7", - "slug": "^11.0.0", - "uuid": "^11.1.0" - }, - "engines": { - "node": "^22.11.0", - "npm": "^10.9.0" - }, - "peerDependencies": { - "joi": "^17.0.0" - } - }, - "node_modules/@defra/forms-model/node_modules/nanoid": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", - "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } + "resolved": "../forms-designer/model", + "link": true }, "node_modules/@defra/hapi-tracing": { "version": "1.26.0", @@ -4218,15 +4209,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@joi/date": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@joi/date/-/date-2.1.1.tgz", - "integrity": "sha512-oXF8vU8M+O9a6tuItgtTQeboO3+Ed6xunLatt6gq7WEFJ7HjawPH64OmrsX0ch3TEsUgQkU8v4MlOGEsf6PHSQ==", - "license": "BSD-3-Clause", - "dependencies": { - "moment": "2.x.x" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -13076,15 +13058,6 @@ "dev": true, "license": "MIT" }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -15929,15 +15902,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/slug": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/slug/-/slug-11.0.0.tgz", - "integrity": "sha512-71pb27F9TII2dIweGr2ybS220IUZo1A9GKZ+e2q8rpUr24mejBb6fTaSStM0SE1ITUUOshilqZze8Yt1BKj+ew==", - "license": "MIT", - "bin": { - "slug": "cli.js" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -17430,19 +17394,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index 3bf0b1341..1ef49c278 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "./components": "./.server/server/plugins/engine/components/index.js", "./services/*": "./.server/server/plugins/engine/services/*", "./engine/*": "./.server/server/plugins/engine/*", - "./helpers.js": "./.server/server/plugins/engine/components/helpers.js", + "./helpers.js": "./.server/server/plugins/engine/components/helpers/index.js", "./schema.js": "./.server/server/schemas/index.js", "./templates/*": "./.server/server/plugins/engine/views/*", "./cache-service.js": "./.server/server/services/cacheService.js", @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.560", + "@defra/forms-model": "^3.0.569", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/server/plugins/engine/components/EastingNorthingField.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts index c3ab7d533..ae493e91d 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.test.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.test.ts @@ -204,7 +204,7 @@ describe('EastingNorthingField', () => { const answer1 = getAnswer(field, state1) const answer2 = getAnswer(field, state2) - expect(answer1).toBe('1234567, 12345') + expect(answer1).toBe('Northing: 1234567
Easting: 12345
') expect(answer2).toBe('') }) diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index d542e948e..2da1611df 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -193,7 +193,7 @@ describe('LatLongField', () => { const answer1 = getAnswer(field, state1) const answer2 = getAnswer(field, state2) - expect(answer1).toBe('51.51945, -0.127758') + expect(answer1).toBe('Lat: 51.51945
Long: -0.127758
') expect(answer2).toBe('') }) diff --git a/src/server/plugins/engine/components/helpers/components.test.ts b/src/server/plugins/engine/components/helpers/components.test.ts new file mode 100644 index 000000000..03e7a07fd --- /dev/null +++ b/src/server/plugins/engine/components/helpers/components.test.ts @@ -0,0 +1,270 @@ +import { + ComponentType, + type EastingNorthingFieldComponent, + type LatLongFieldComponent, + type NationalGridFieldNumberFieldComponent, + type OsGridRefFieldComponent +} from '@defra/forms-model' + +import { + getAnswer, + getAnswerMarkdown +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { + EastingNorthingField, + LatLongField, + NationalGridFieldNumberField, + OsGridRefField +} from '~/src/server/plugins/engine/components/index.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' +import definition from '~/test/form/definitions/blank.js' + +describe('Location field formatting', () => { + let model: FormModel + + beforeEach(() => { + model = new FormModel(definition, { + basePath: 'test' + }) + }) + + describe('EastingNorthingField', () => { + let field: EastingNorthingField + + beforeEach(() => { + const def: EastingNorthingFieldComponent = { + type: ComponentType.EastingNorthingField, + name: 'locationEN', + title: 'Location', + options: {} + } + field = new EastingNorthingField(def, { model }) + }) + + it('formats for email output with labels on separate lines', () => { + const state = { + locationEN__easting: 123456, + locationEN__northing: 654321 + } + + const answer = getAnswer(field, state, { format: 'email' }) + expect(answer).toBe('Northing: 654321\nEasting: 123456\n') + }) + + it('formats for data output', () => { + const state = { + locationEN__easting: 123456, + locationEN__northing: 654321 + } + + const answer = getAnswer(field, state, { format: 'data' }) + expect(answer).toBe('Northing: 654321\nEasting: 123456') + }) + + it('formats for summary display', () => { + const state = { + locationEN__easting: 123456, + locationEN__northing: 654321 + } + + const answer = getAnswer(field, state, { format: 'summary' }) + // Should render as HTML from markdown + expect(answer).toContain('Northing: 654321') + expect(answer).toContain('Easting: 123456') + }) + + it('returns empty string when no values', () => { + const state = {} + + const answer = getAnswer(field, state, { format: 'email' }) + expect(answer).toBe('') + }) + }) + + describe('LatLongField', () => { + let field: LatLongField + + beforeEach(() => { + const def: LatLongFieldComponent = { + type: ComponentType.LatLongField, + name: 'locationLL', + title: 'Coordinates', + options: {} + } + field = new LatLongField(def, { model }) + }) + + it('formats for email output with labels on separate lines', () => { + const state = { + locationLL__latitude: 51.51945, + locationLL__longitude: -0.127758 + } + + const answer = getAnswer(field, state, { format: 'email' }) + expect(answer).toBe('Lat: 51.51945\nLong: -0.127758\n') + }) + + it('formats for data output', () => { + const state = { + locationLL__latitude: 51.51945, + locationLL__longitude: -0.127758 + } + + const answer = getAnswer(field, state, { format: 'data' }) + expect(answer).toBe('Lat: 51.51945\nLong: -0.127758') + }) + + it('formats for summary display', () => { + const state = { + locationLL__latitude: 51.51945, + locationLL__longitude: -0.127758 + } + + const answer = getAnswer(field, state, { format: 'summary' }) + // Should render as HTML from markdown + expect(answer).toContain('Lat: 51.51945') + expect(answer).toContain('Long: -0.127758') + }) + + it('returns empty string when no values', () => { + const state = {} + + const answer = getAnswer(field, state, { format: 'email' }) + expect(answer).toBe('') + }) + }) + + describe('OsGridRefField', () => { + let field: OsGridRefField + + beforeEach(() => { + const def: OsGridRefFieldComponent = { + type: ComponentType.OsGridRefField, + name: 'gridRef', + title: 'OS Grid Reference', + options: {} + } + field = new OsGridRefField(def, { model }) + }) + + it('formats for email output as single value', () => { + const state = { + gridRef: 'TQ123456' + } + + const answer = getAnswer(field, state, { format: 'email' }) + expect(answer).toBe('TQ123456\n') + }) + + it('formats for data output', () => { + const state = { + gridRef: 'TQ123456' + } + + const answer = getAnswer(field, state, { format: 'data' }) + expect(answer).toBe('TQ123456') + }) + + it('formats for summary display', () => { + const state = { + gridRef: 'TQ123456' + } + + const answer = getAnswer(field, state, { format: 'summary' }) + expect(answer).toBe('TQ123456') + }) + }) + + describe('NationalGridFieldNumberField', () => { + let field: NationalGridFieldNumberField + + beforeEach(() => { + const def: NationalGridFieldNumberFieldComponent = { + type: ComponentType.NationalGridFieldNumberField, + name: 'ngField', + title: 'National Grid Field Number', + options: {} + } + field = new NationalGridFieldNumberField(def, { model }) + }) + + it('formats for email output as single value', () => { + const state = { + ngField: 'NG12345678' + } + + const answer = getAnswer(field, state, { format: 'email' }) + expect(answer).toBe('NG12345678\n') + }) + + it('formats for data output', () => { + const state = { + ngField: 'NG12345678' + } + + const answer = getAnswer(field, state, { format: 'data' }) + expect(answer).toBe('NG12345678') + }) + + it('formats for summary display', () => { + const state = { + ngField: 'NG12345678' + } + + const answer = getAnswer(field, state, { format: 'summary' }) + expect(answer).toBe('NG12345678') + }) + }) + + describe('getAnswerMarkdown', () => { + it('formats EastingNorthingField correctly', () => { + const def: EastingNorthingFieldComponent = { + type: ComponentType.EastingNorthingField, + name: 'locationEN', + title: 'Location', + options: {} + } + const field = new EastingNorthingField(def, { model }) + const state = { + locationEN__easting: 123456, + locationEN__northing: 654321 + } + + const answer = getAnswerMarkdown(field, state, { format: 'email' }) + expect(answer).toBe('Northing: 654321\nEasting: 123456\n') + }) + + it('formats LatLongField correctly', () => { + const def: LatLongFieldComponent = { + type: ComponentType.LatLongField, + name: 'locationLL', + title: 'Coordinates', + options: {} + } + const field = new LatLongField(def, { model }) + const state = { + locationLL__latitude: 51.51945, + locationLL__longitude: -0.127758 + } + + const answer = getAnswerMarkdown(field, state, { format: 'email' }) + expect(answer).toBe('Lat: 51.51945\nLong: -0.127758\n') + }) + + it('formats simple location fields correctly', () => { + const def: OsGridRefFieldComponent = { + type: ComponentType.OsGridRefField, + name: 'gridRef', + title: 'OS Grid Reference', + options: {} + } + const field = new OsGridRefField(def, { model }) + const state = { + gridRef: 'TQ123456' + } + + const answer = getAnswerMarkdown(field, state, { format: 'email' }) + expect(answer).toBe('TQ123456\n') + }) + }) +}) diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index ae29bbee2..f04f2ae02 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -20,10 +20,14 @@ export type Field = InstanceType< | typeof Components.YesNoField | typeof Components.CheckboxesField | typeof Components.DatePartsField + | typeof Components.EastingNorthingField | typeof Components.EmailAddressField + | typeof Components.LatLongField | typeof Components.MonthYearField | typeof Components.MultilineTextField + | typeof Components.NationalGridFieldNumberField | typeof Components.NumberField + | typeof Components.OsGridRefField | typeof Components.SelectField | typeof Components.TelephoneNumberField | typeof Components.TextField @@ -216,7 +220,9 @@ export function getAnswer( if ( field instanceof ListFormComponent || field instanceof Components.MultilineTextField || - field instanceof Components.UkAddressField + field instanceof Components.UkAddressField || + field instanceof Components.EastingNorthingField || + field instanceof Components.LatLongField ) { return markdown .parse(getAnswerMarkdown(field, state), { async: false }) @@ -307,6 +313,12 @@ export function getAnswerMarkdown( .map(escapeMarkdown) .join('\n') .concat('\n') + } else if ( + field instanceof Components.EastingNorthingField || + field instanceof Components.LatLongField + ) { + const contextValue = field.getContextValueFromState(state) + answerEscaped = contextValue ? `${contextValue}\n` : '' } return answerEscaped diff --git a/src/server/plugins/engine/outputFormatters/adapter/v1.location.test.ts b/src/server/plugins/engine/outputFormatters/adapter/v1.location.test.ts new file mode 100644 index 000000000..0b853e03a --- /dev/null +++ b/src/server/plugins/engine/outputFormatters/adapter/v1.location.test.ts @@ -0,0 +1,356 @@ +import { + ComponentType, + type EastingNorthingFieldComponent, + type FormDefinition, + type LatLongFieldComponent, + type NationalGridFieldNumberFieldComponent, + type OsGridRefFieldComponent, + type PageQuestion +} from '@defra/forms-model' + +import { + EastingNorthingField, + LatLongField, + NationalGridFieldNumberField, + OsGridRefField +} from '~/src/server/plugins/engine/components/index.js' +import { FormModel } from '~/src/server/plugins/engine/models/index.js' +import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js' +import { format } from '~/src/server/plugins/engine/outputFormatters/adapter/v1.js' +import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { + FormAdapterSubmissionSchemaVersion, + type FormAdapterSubmissionMessagePayload +} from '~/src/server/plugins/engine/types/index.js' +import { FormStatus } from '~/src/server/routes/types.js' + +describe('Adapter V1 formatter - Location fields', () => { + const definition: FormDefinition = { + name: 'Location Test Form', + startPage: '/location', + pages: [ + { + path: '/location', + title: 'Location Page', + next: [], + components: [ + { + type: ComponentType.EastingNorthingField, + name: 'locationEN', + title: 'Easting and Northing', + options: {} + }, + { + type: ComponentType.LatLongField, + name: 'locationLL', + title: 'Latitude and Longitude', + options: {} + }, + { + type: ComponentType.OsGridRefField, + name: 'gridRef', + title: 'OS Grid Reference', + options: {} + }, + { + type: ComponentType.NationalGridFieldNumberField, + name: 'ngField', + title: 'National Grid Field Number', + options: {} + } + ] + } satisfies PageQuestion + ], + lists: [], + sections: [], + conditions: [] + } + + const model = new FormModel(definition, { basePath: 'test' }) + const locationPage = definition.pages[0] as PageQuestion + + const submitResponse = { + message: 'Submit completed', + result: { + files: { + main: '00000000-0000-0000-0000-000000000000', + repeaters: {} + } + } + } + + const pageUrl = new URL('http://example.com/test/location') + + const request = buildFormContextRequest({ + method: 'get', + url: pageUrl, + path: pageUrl.pathname, + params: { + path: 'location', + slug: 'test' + }, + query: {}, + app: { model } + }) + + it('includes EastingNorthingField values in the payload', () => { + const state = { + $$__referenceNumber: 'ABC-123', + locationEN__easting: 123456, + locationEN__northing: 654321, + locationLL__latitude: 51.5, + locationLL__longitude: -0.1, + gridRef: 'TQ123456', + ngField: 'NG12345678' + } + + const context = model.getFormContext(request, state) + + const eastingNorthingField = new EastingNorthingField( + locationPage.components[0] as EastingNorthingFieldComponent, + { model } + ) + + const items: DetailItemField[] = [ + { + name: 'locationEN', + label: 'Easting and Northing', + href: '/location', + title: 'Easting and Northing', + field: eastingNorthingField, + state, + value: '654321, 123456' + } as unknown as DetailItemField + ] + + const result = format(context, items, model, submitResponse, { + state: FormStatus.Live, + isPreview: false + }) + + const payload = JSON.parse(result) as FormAdapterSubmissionMessagePayload + + expect(payload.meta.schemaVersion).toBe( + FormAdapterSubmissionSchemaVersion.V1 + ) + expect(payload.data.main.locationEN).toEqual({ + easting: 123456, + northing: 654321 + }) + }) + + it('includes LatLongField values in the payload', () => { + const state = { + $$__referenceNumber: 'ABC-123', + locationLL__latitude: 51.51945, + locationLL__longitude: -0.127758 + } + + const context = model.getFormContext(request, state) + + const latLongField = new LatLongField( + locationPage.components[1] as LatLongFieldComponent, + { model } + ) + + const items: DetailItemField[] = [ + { + name: 'locationLL', + label: 'Latitude and Longitude', + href: '/location', + title: 'Latitude and Longitude', + field: latLongField, + state, + value: '51.519450, -0.127758' + } as unknown as DetailItemField + ] + + const result = format(context, items, model, submitResponse, { + state: FormStatus.Live, + isPreview: false + }) + + const payload = JSON.parse(result) as FormAdapterSubmissionMessagePayload + + expect(payload.data.main.locationLL).toEqual({ + latitude: 51.51945, + longitude: -0.127758 + }) + }) + + it('includes simple location field values in the payload', () => { + const state = { + $$__referenceNumber: 'ABC-123', + gridRef: 'TQ123456', + ngField: 'NG12345678' + } + + const context = model.getFormContext(request, state) + + const osGridRefField = new OsGridRefField( + locationPage.components[2] as OsGridRefFieldComponent, + { model } + ) + + const nationalGridField = new NationalGridFieldNumberField( + locationPage.components[3] as NationalGridFieldNumberFieldComponent, + { model } + ) + + const items: DetailItemField[] = [ + { + name: 'gridRef', + label: 'OS Grid Reference', + href: '/location', + title: 'OS Grid Reference', + field: osGridRefField, + state, + value: 'TQ123456' + } as unknown as DetailItemField, + { + name: 'ngField', + label: 'National Grid Field Number', + href: '/location', + title: 'National Grid Field Number', + field: nationalGridField, + state, + value: 'NG12345678' + } as unknown as DetailItemField + ] + + const result = format(context, items, model, submitResponse, { + state: FormStatus.Live, + isPreview: false + }) + + const payload = JSON.parse(result) as FormAdapterSubmissionMessagePayload + + expect(payload.data.main.gridRef).toBe('TQ123456') + expect(payload.data.main.ngField).toBe('NG12345678') + }) + + it('handles null values for optional location fields', () => { + const state = { + $$__referenceNumber: 'ABC-123' + } + + const context = model.getFormContext(request, state) + + const eastingNorthingField = new EastingNorthingField( + locationPage.components[0] as EastingNorthingFieldComponent, + { model } + ) + + const items: DetailItemField[] = [ + { + name: 'locationEN', + label: 'Easting and Northing', + href: '/location', + title: 'Easting and Northing', + field: eastingNorthingField, + state, + value: '' + } as unknown as DetailItemField + ] + + const result = format(context, items, model, submitResponse, { + state: FormStatus.Live, + isPreview: false + }) + + const payload = JSON.parse(result) as FormAdapterSubmissionMessagePayload + + expect(payload.data.main.locationEN).toBeNull() + }) + + it('includes all location fields in a mixed form', () => { + const state = { + $$__referenceNumber: 'ABC-123', + locationEN__easting: 123456, + locationEN__northing: 654321, + locationLL__latitude: 51.51945, + locationLL__longitude: -0.127758, + gridRef: 'TQ123456', + ngField: 'NG12345678' + } + + const context = model.getFormContext(request, state) + + const eastingNorthingField = new EastingNorthingField( + locationPage.components[0] as EastingNorthingFieldComponent, + { model } + ) + const latLongField = new LatLongField( + locationPage.components[1] as LatLongFieldComponent, + { model } + ) + const osGridRefField = new OsGridRefField( + locationPage.components[2] as OsGridRefFieldComponent, + { model } + ) + const nationalGridField = new NationalGridFieldNumberField( + locationPage.components[3] as NationalGridFieldNumberFieldComponent, + { model } + ) + + const items: DetailItemField[] = [ + { + name: 'locationEN', + label: 'Easting and Northing', + href: '/location', + title: 'Easting and Northing', + field: eastingNorthingField, + state, + value: '654321, 123456' + } as unknown as DetailItemField, + { + name: 'locationLL', + label: 'Latitude and Longitude', + href: '/location', + title: 'Latitude and Longitude', + field: latLongField, + state, + value: '51.519450, -0.127758' + } as unknown as DetailItemField, + { + name: 'gridRef', + label: 'OS Grid Reference', + href: '/location', + title: 'OS Grid Reference', + field: osGridRefField, + state, + value: 'TQ123456' + } as unknown as DetailItemField, + { + name: 'ngField', + label: 'National Grid Field Number', + href: '/location', + title: 'National Grid Field Number', + field: nationalGridField, + state, + value: 'NG12345678' + } as unknown as DetailItemField + ] + + const result = format(context, items, model, submitResponse, { + state: FormStatus.Live, + isPreview: false + }) + + const payload = JSON.parse(result) as FormAdapterSubmissionMessagePayload + + // Check all location fields are included correctly + expect(payload.data.main).toEqual({ + locationEN: { + easting: 123456, + northing: 654321 + }, + locationLL: { + latitude: 51.51945, + longitude: -0.127758 + }, + gridRef: 'TQ123456', + ngField: 'NG12345678' + }) + }) +}) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index b2dde4e23..61af993be 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -23,6 +23,8 @@ import { type ComponentText, type ComponentViewModel, type DatePartsState, + type EastingNorthingState, + type LatLongState, type MonthYearState } from '~/src/server/plugins/engine/components/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' @@ -464,6 +466,8 @@ export type RichFormValue = | DatePartsState | MonthYearState | UkAddressState + | EastingNorthingState + | LatLongState export interface FormAdapterSubmissionMessageData { main: Record diff --git a/src/server/plugins/engine/types/index.ts b/src/server/plugins/engine/types/index.ts index ac2ffe65a..3deb65d67 100644 --- a/src/server/plugins/engine/types/index.ts +++ b/src/server/plugins/engine/types/index.ts @@ -61,7 +61,9 @@ export type { Content, DateInputItem, DatePartsState, + EastingNorthingState, Label, + LatLongState, ListItem, ListItemLabel, MonthYearState, From 9ad5bf1d30882783178562c6e702080b5fe732e6 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 28 Oct 2025 12:51:45 +0000 Subject: [PATCH 12/21] chore: update package-lock.json --- package-lock.json | 79 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d26e0e95e..54c7098c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -142,6 +142,7 @@ "../forms-designer/model": { "name": "@defra/forms-model", "version": "3.0.569", + "extraneous": true, "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -2297,8 +2298,42 @@ } }, "node_modules/@defra/forms-model": { - "resolved": "../forms-designer/model", - "link": true + "version": "3.0.569", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.569.tgz", + "integrity": "sha512-icmi4k0hTIUVN0V/quVPT7/SfhWGyTN+akFR0lmGmrcIVakXzJvxLwgeEZBFMg8ezI3kLr2u6ppP5rbXjtV55w==", + "license": "OGL-UK-3.0", + "dependencies": { + "@joi/date": "^2.1.1", + "marked": "^15.0.12", + "nanoid": "^5.0.7", + "slug": "^11.0.0", + "uuid": "^11.1.0" + }, + "engines": { + "node": "^22.11.0", + "npm": "^10.9.0" + }, + "peerDependencies": { + "joi": "^17.0.0" + } + }, + "node_modules/@defra/forms-model/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } }, "node_modules/@defra/hapi-tracing": { "version": "1.26.0", @@ -4209,6 +4244,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@joi/date": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@joi/date/-/date-2.1.1.tgz", + "integrity": "sha512-oXF8vU8M+O9a6tuItgtTQeboO3+Ed6xunLatt6gq7WEFJ7HjawPH64OmrsX0ch3TEsUgQkU8v4MlOGEsf6PHSQ==", + "license": "BSD-3-Clause", + "dependencies": { + "moment": "2.x.x" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -13058,6 +13102,15 @@ "dev": true, "license": "MIT" }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -15902,6 +15955,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/slug": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/slug/-/slug-11.0.1.tgz", + "integrity": "sha512-VrM060OM/E7rdLQSnp6JHrzFfJFmqQBp0+TMhZStnEB8PfNliaZ9UWYjTHGHLUFVJorZ8TjVd/aKvIxHWU2O7g==", + "license": "MIT", + "bin": { + "slug": "cli.js" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -17394,6 +17456,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", From 184fb26904f001534511df2d6967d7296f914ccc Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 28 Oct 2025 13:20:13 +0000 Subject: [PATCH 13/21] fix: add missing newline at end of HTML files for location field components --- .../plugins/engine/views/components/eastingnorthingfield.html | 2 +- src/server/plugins/engine/views/components/latlongfield.html | 2 +- .../engine/views/components/nationalgridfieldnumberfield.html | 2 +- src/server/plugins/engine/views/components/osgridreffield.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/views/components/eastingnorthingfield.html b/src/server/plugins/engine/views/components/eastingnorthingfield.html index f9b8839a6..40f0aedcf 100644 --- a/src/server/plugins/engine/views/components/eastingnorthingfield.html +++ b/src/server/plugins/engine/views/components/eastingnorthingfield.html @@ -2,4 +2,4 @@ {% macro EastingNorthingField(component) %} {{ LocationFieldBase(component, "number", "numeric") }} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/src/server/plugins/engine/views/components/latlongfield.html b/src/server/plugins/engine/views/components/latlongfield.html index 7c80eb502..b9ceb1f7e 100644 --- a/src/server/plugins/engine/views/components/latlongfield.html +++ b/src/server/plugins/engine/views/components/latlongfield.html @@ -2,4 +2,4 @@ {% macro LatLongField(component) %} {{ LocationFieldBase(component, "text", "decimal") }} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html index 4cfcf789c..1f8666586 100644 --- a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html +++ b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html @@ -10,4 +10,4 @@ html: component.model.instructionText | safe }) }} {% endif %} -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/src/server/plugins/engine/views/components/osgridreffield.html b/src/server/plugins/engine/views/components/osgridreffield.html index cd2a7a8df..aec74fc95 100644 --- a/src/server/plugins/engine/views/components/osgridreffield.html +++ b/src/server/plugins/engine/views/components/osgridreffield.html @@ -10,4 +10,4 @@ html: component.model.instructionText | safe }) }} {% endif %} -{% endmacro %} \ No newline at end of file +{% endmacro %} From 37680dd660d83b0506bb19ddc3964c058c94866a Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 28 Oct 2025 13:53:09 +0000 Subject: [PATCH 14/21] feat: add support for SummaryWithConfirmationEmail in page controller helpers --- .../engine/pageControllers/helpers/helpers.test.ts | 4 ++++ .../plugins/engine/pageControllers/helpers/pages.ts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/server/plugins/engine/pageControllers/helpers/helpers.test.ts b/src/server/plugins/engine/pageControllers/helpers/helpers.test.ts index 65d774708..de9b64778 100644 --- a/src/server/plugins/engine/pageControllers/helpers/helpers.test.ts +++ b/src/server/plugins/engine/pageControllers/helpers/helpers.test.ts @@ -67,6 +67,10 @@ describe('Page controller helpers', () => { controller = SummaryPageController break + case ControllerType.SummaryWithConfirmationEmail: + controller = SummaryPageController + break + case ControllerType.Status: controller = StatusPageController break diff --git a/src/server/plugins/engine/pageControllers/helpers/pages.ts b/src/server/plugins/engine/pageControllers/helpers/pages.ts index a49389887..560562809 100644 --- a/src/server/plugins/engine/pageControllers/helpers/pages.ts +++ b/src/server/plugins/engine/pageControllers/helpers/pages.ts @@ -11,6 +11,10 @@ import * as PageControllers from '~/src/server/plugins/engine/pageControllers/in export function isPageController( controllerName?: string | ControllerType ): controllerName is keyof typeof PageControllers { + // Handle SummaryWithConfirmationEmail as it uses SummaryPageController + if (controllerName === ControllerType.SummaryWithConfirmationEmail) { + return true + } return isControllerName(controllerName) && controllerName in PageControllers } @@ -52,6 +56,10 @@ export function createPage(model: FormModel, pageDef: Page) { controller = new PageControllers.SummaryPageController(model, pageDef) break + case ControllerType.SummaryWithConfirmationEmail: + controller = new PageControllers.SummaryPageController(model, pageDef) + break + case ControllerType.Status: controller = new PageControllers.StatusPageController(model, pageDef) break From 25bdd22478f7b381777ed3e650c88179a289fc28 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 28 Oct 2025 17:52:12 +0000 Subject: [PATCH 15/21] chore: PR review refactors --- src/client/stylesheets/_location-input.scss | 31 +++++++ src/client/stylesheets/application.scss | 1 + .../NationalGridFieldNumberField.ts | 5 +- .../engine/components/OsGridRefField.test.ts | 93 ++++++++++++------- .../engine/components/OsGridRefField.ts | 40 ++++++-- .../engine/components/helpers/components.ts | 2 - src/server/plugins/engine/components/types.ts | 2 + .../components/_location-field-base.html | 34 ++++--- 8 files changed, 147 insertions(+), 61 deletions(-) create mode 100644 src/client/stylesheets/_location-input.scss diff --git a/src/client/stylesheets/_location-input.scss b/src/client/stylesheets/_location-input.scss new file mode 100644 index 000000000..f1463af75 --- /dev/null +++ b/src/client/stylesheets/_location-input.scss @@ -0,0 +1,31 @@ +@use "govuk-frontend" as *; + +.app-location-input { + @include govuk-clearfix; + font-size: 0; // removes whitespace caused by inline-block +} + +.app-location-input__item { + display: inline-block; + margin-right: govuk-spacing(4); + margin-bottom: 0; + + &:last-child { + margin-right: 0; + } + + .govuk-form-group { + margin-bottom: 0; + display: inline-block; + width: auto; + } + + .govuk-label { + display: block; + } + + .govuk-input { + margin-bottom: 0; + width: auto; + } +} diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index 349c344c2..bb90268a1 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -2,6 +2,7 @@ @use "shared"; @use "code"; @use "tag-env"; +@use "location-input"; // An example of some user-supplied styling // Not great practice but it illustrates the point diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index 0060d9e24..ee9006881 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -8,13 +8,14 @@ export class NationalGridFieldNumberField extends LocationFieldBase { protected getValidationConfig() { return { - pattern: /^[A-Z]{2}\d{8}$/i, + // Pattern allows spaces and commas in the input since custom validation will clean them + pattern: /^[A-Z]{2}[\d\s,]*$/i, patternErrorMessage: `Enter a valid National Grid field number for ${this.title} like NG12345678`, customValidation: (value: string, helpers: joi.CustomHelpers) => { // Strip spaces and commas const cleanValue = value.replace(/[\s,]/g, '') - // Check if it matches the pattern after cleaning + // Check if it matches the exact pattern after cleaning if (!/^[A-Z]{2}\d{8}$/i.test(cleanValue)) { return helpers.error('string.pattern.base') } diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index a06b8da4c..8aaf385ee 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -96,21 +96,40 @@ describe('OsGridRefField', () => { }) it('accepts valid values', () => { - const result1 = collection.validate(getFormData('TQ123456')) - const result2 = collection.validate(getFormData('SU1234567890')) - const result3 = collection.validate(getFormData('nt123456')) + // Test 8-digit parcel ID format (2x4) + const result1 = collection.validate(getFormData('TQ12345678')) + const result2 = collection.validate(getFormData('TQ 1234 5678')) + + // Test 10-digit OS grid reference format (2x5) + const result3 = collection.validate(getFormData('SU1234567890')) + const result4 = collection.validate(getFormData('SU 12345 67890')) + + // Test case-insensitive + const result5 = collection.validate(getFormData('nt12345678')) + + // Test various valid OS grid formats + const result6 = collection.validate(getFormData('SN 1232 1223')) // parcel ID format + const result7 = collection.validate(getFormData('SN 12324 12234')) // OS grid ref format + const result8 = collection.validate(getFormData('ST 6789 6789')) // parcel ID with different letters + const result9 = collection.validate(getFormData('SO 12345 12345')) // OS grid ref with different letters expect(result1.errors).toBeUndefined() expect(result2.errors).toBeUndefined() expect(result3.errors).toBeUndefined() + expect(result4.errors).toBeUndefined() + expect(result5.errors).toBeUndefined() + expect(result6.errors).toBeUndefined() + expect(result7.errors).toBeUndefined() + expect(result8.errors).toBeUndefined() + expect(result9.errors).toBeUndefined() }) - it('strips spaces and commas from input', () => { - const result1 = collection.validate(getFormData('TQ 123 456')) - const result2 = collection.validate(getFormData('TQ123,456')) + it('strips spaces from input', () => { + const result1 = collection.validate(getFormData('TQ 1234 5678')) + const result2 = collection.validate(getFormData('SU 12345 67890')) - expect(result1.value.myComponent).toBe('TQ123456') - expect(result2.value.myComponent).toBe('TQ123456') + expect(result1.value.myComponent).toBe('TQ12345678') + expect(result2.value.myComponent).toBe('SU1234567890') }) it('adds errors for empty value', () => { @@ -125,82 +144,90 @@ describe('OsGridRefField', () => { it('adds errors for invalid values', () => { const result1 = collection.validate(getFormData('INVALID')) - const result2 = collection.validate(getFormData('TQ12345')) - const result3 = collection.validate(getFormData('A1234567')) + const result2 = collection.validate(getFormData('TQ12345')) // Wrong number of digits + const result3 = collection.validate(getFormData('AA12345678')) // Invalid letter combination + const result4 = collection.validate(getFormData('TQ123456')) // 6 digits not allowed + + // Test mismatched digit counts (must be either 4+4 or 5+5, not mixed) + const result5 = collection.validate(getFormData('SN 4444 55555')) // mismatched digit counts + const result6 = collection.validate(getFormData('SN 55555 4444')) // mismatched digit counts expect(result1.errors).toBeTruthy() expect(result2.errors).toBeTruthy() expect(result3.errors).toBeTruthy() + expect(result4.errors).toBeTruthy() + expect(result5.errors).toBeTruthy() + expect(result6.errors).toBeTruthy() }) }) describe('State', () => { it('returns text from state', () => { - const state1 = getFormState('TQ123456') + const state1 = getFormState('TQ12345678') const state2 = getFormState(null) const answer1 = getAnswer(field, state1) const answer2 = getAnswer(field, state2) - expect(answer1).toBe('TQ123456') + expect(answer1).toBe('TQ12345678') expect(answer2).toBe('') }) it('returns payload from state', () => { - const state1 = getFormState('TQ123456') + const state1 = getFormState('TQ12345678') const state2 = getFormState(null) const payload1 = field.getFormDataFromState(state1) const payload2 = field.getFormDataFromState(state2) - expect(payload1).toEqual(getFormData('TQ123456')) + expect(payload1).toEqual(getFormData('TQ12345678')) expect(payload2).toEqual(getFormData()) }) it('returns value from state', () => { - const state1 = getFormState('TQ123456') + const state1 = getFormState('TQ12345678') const state2 = getFormState(null) const value1 = field.getFormValueFromState(state1) const value2 = field.getFormValueFromState(state2) - expect(value1).toBe('TQ123456') + expect(value1).toBe('TQ12345678') expect(value2).toBeUndefined() }) it('returns context for conditions and form submission', () => { - const state1 = getFormState('TQ123456') + const state1 = getFormState('TQ12345678') const state2 = getFormState(null) const value1 = field.getContextValueFromState(state1) const value2 = field.getContextValueFromState(state2) - expect(value1).toBe('TQ123456') + expect(value1).toBe('TQ12345678') expect(value2).toBeNull() }) it('returns state from payload', () => { - const payload1 = getFormData('TQ123456') + const payload1 = getFormData('TQ12345678') const payload2 = getFormData() const value1 = field.getStateFromValidForm(payload1) const value2 = field.getStateFromValidForm(payload2) - expect(value1).toEqual(getFormState('TQ123456')) + expect(value1).toEqual(getFormState('TQ12345678')) expect(value2).toEqual(getFormState(null)) }) }) describe('View model', () => { it('sets Nunjucks component defaults', () => { - const viewModel = field.getViewModel(getFormData('TQ123456')) + const viewModel = field.getViewModel(getFormData('TQ12345678')) expect(viewModel).toEqual( expect.objectContaining({ label: { text: def.title }, name: 'myComponent', id: 'myComponent', - value: 'TQ123456' + value: 'TQ12345678' }) ) }) @@ -209,19 +236,19 @@ describe('OsGridRefField', () => { const componentWithInstruction = new OsGridRefField( { ...def, - options: { instructionText: 'Enter in format **TQ123456**' } + options: { instructionText: 'Enter in format **TQ12345678**' } }, { model } ) const viewModel = componentWithInstruction.getViewModel( - getFormData('TQ123456') + getFormData('TQ12345678') ) const instructionText = 'instructionText' in viewModel ? viewModel.instructionText : undefined expect(instructionText).toBeTruthy() - expect(instructionText).toContain('TQ123456') + expect(instructionText).toContain('TQ12345678') }) }) @@ -252,16 +279,16 @@ describe('OsGridRefField', () => { }, assertions: [ { - input: getFormData(' TQ123456'), - output: { value: getFormData('TQ123456') } + input: getFormData(' TQ12345678'), + output: { value: getFormData('TQ12345678') } }, { - input: getFormData('TQ123456 '), - output: { value: getFormData('TQ123456') } + input: getFormData('TQ12345678 '), + output: { value: getFormData('TQ12345678') } }, { - input: getFormData(' TQ123456 \n\n'), - output: { value: getFormData('TQ123456') } + input: getFormData(' TQ12345678 \n\n'), + output: { value: getFormData('TQ12345678') } } ] }, @@ -286,9 +313,9 @@ describe('OsGridRefField', () => { } }, { - input: getFormData('A1234567'), + input: getFormData('AA1234567'), output: { - value: getFormData('A1234567'), + value: getFormData('AA1234567'), errors: expect.arrayContaining([ expect.objectContaining({ text: 'Enter a valid OS grid reference for Example OS grid reference like TQ123456' diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index 7a796c30a..9f363dc4c 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -7,18 +7,46 @@ export class OsGridRefField extends LocationFieldBase { declare options: OsGridRefFieldComponent['options'] protected getValidationConfig() { + // Regex for OS grid references and parcel IDs + // Validates specific valid OS grid letter combinations and either: + // - 2 blocks of 4 digits (parcel ID) e.g., ST 6789 6789 + // - 2 blocks of 5 digits (OS grid reference) e.g., SO 12345 12345 + const osGridPattern = + /^((([sS]|[nN])[a-hA-Hj-zJ-Z])|(([tT]|[oO])[abfglmqrvwABFGLMQRVW])|([hH][l-zL-Z])|([jJ][lmqrvwLMQRVW]))\s?(([0-9]{4})\s?([0-9]{4})|([0-9]{5})\s?([0-9]{5}))$/ + + // More permissive pattern for initial validation (allows spaces to be cleaned) + const initialPattern = /^[A-Za-z]{2}[\d\s]*$/ + return { - pattern: /^[A-Z]{2}\d{6,10}$/i, + pattern: initialPattern, patternErrorMessage: `Enter a valid OS grid reference for ${this.title} like TQ123456`, customValidation: (value: string, helpers: joi.CustomHelpers) => { - // Strip spaces and commas - const cleanValue = value.replace(/[\s,]/g, '') + // Strip spaces from the input + const cleanValue = value.replace(/\s/g, '') + + // Check if it matches the OS grid pattern + // We need to test with spaces in the right places for validation + const valueWithSpaces = value.trim() + + if (!osGridPattern.test(valueWithSpaces)) { + // Also try without any spaces (e.g., TQ12345678) + const letters = cleanValue.substring(0, 2) + const numbers = cleanValue.substring(2) + + if (numbers.length === 8 || numbers.length === 10) { + // Format with spaces for validation: XX 1234 5678 or XX 12345 67890 + const halfLength = numbers.length / 2 + const formattedValue = `${letters} ${numbers.substring(0, halfLength)} ${numbers.substring(halfLength)}` - // Check if it matches the pattern after cleaning - if (!/^[A-Z]{2}\d{6,10}$/i.test(cleanValue)) { - return helpers.error('string.pattern.base') + if (!osGridPattern.test(formattedValue)) { + return helpers.error('string.pattern.base') + } + } else { + return helpers.error('string.pattern.base') + } } + // Return the cleaned value without spaces for storage return cleanValue } } diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index f04f2ae02..052d2a63d 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -39,7 +39,6 @@ export type Field = InstanceType< export type Guidance = | InstanceType | InstanceType - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents | InstanceType | InstanceType | InstanceType @@ -88,7 +87,6 @@ export function createComponent( def: ComponentDef, options: ConstructorParameters[1] ): Component { - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents let component: Component | undefined switch (def.type) { diff --git a/src/server/plugins/engine/components/types.ts b/src/server/plugins/engine/components/types.ts index 9a80fb78b..0d8e37266 100644 --- a/src/server/plugins/engine/components/types.ts +++ b/src/server/plugins/engine/components/types.ts @@ -60,6 +60,8 @@ export interface DateInputItem { name?: string value?: Item['value'] classes?: string + // Prefix/suffix are used by location fields (e.g., LatLong, EastingNorthing) for units like "°" + // but not by date fields. This interface is reused by both component types. prefix?: ComponentText suffix?: ComponentText condition?: undefined diff --git a/src/server/plugins/engine/views/components/_location-field-base.html b/src/server/plugins/engine/views/components/_location-field-base.html index e549fe9cb..df6dbfffc 100644 --- a/src/server/plugins/engine/views/components/_location-field-base.html +++ b/src/server/plugins/engine/views/components/_location-field-base.html @@ -12,25 +12,23 @@ }) }} {% endif %} -
+
{% for item in component.model.items %} -
-
- {{ govukInput({ - id: item.id, - name: item.name, - label: { - text: item.label, - classes: "govuk-label--s" - }, - classes: item.classes, - value: item.value, - type: inputType, - inputmode: inputMode, - prefix: item.prefix, - suffix: item.suffix - }) }} -
+
+ {{ govukInput({ + id: item.id, + name: item.name, + label: { + text: item.label, + classes: "govuk-label--s" + }, + classes: item.classes, + value: item.value, + type: inputType, + inputmode: inputMode, + prefix: item.prefix, + suffix: item.suffix + }) }}
{% endfor %}
From 2c6239923ddc129bff5007c138248a84d2d5a2b2 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Tue, 28 Oct 2025 18:16:13 +0000 Subject: [PATCH 16/21] refactor: simplify OS grid reference validation regex --- src/server/plugins/engine/components/OsGridRefField.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index 9f363dc4c..968210cad 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -12,7 +12,7 @@ export class OsGridRefField extends LocationFieldBase { // - 2 blocks of 4 digits (parcel ID) e.g., ST 6789 6789 // - 2 blocks of 5 digits (OS grid reference) e.g., SO 12345 12345 const osGridPattern = - /^((([sS]|[nN])[a-hA-Hj-zJ-Z])|(([tT]|[oO])[abfglmqrvwABFGLMQRVW])|([hH][l-zL-Z])|([jJ][lmqrvwLMQRVW]))\s?(([0-9]{4})\s?([0-9]{4})|([0-9]{5})\s?([0-9]{5}))$/ + /^(?:[sn][a-hj-z]|[to][abfglmqrvw]|h[l-z]|j[lmqrvw])\s?(?:\d{4}\s?\d{4}|\d{5}\s?\d{5})$/i // More permissive pattern for initial validation (allows spaces to be cleaned) const initialPattern = /^[A-Za-z]{2}[\d\s]*$/ From 827a4753bb6465425dd6abb36d2b6cfab1a03e08 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 29 Oct 2025 09:55:21 +0000 Subject: [PATCH 17/21] Add new location components to the unicorn form --- .../forms/register-as-a-unicorn-breeder.yaml | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index 3a7a74760..82239c8e4 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -163,6 +163,34 @@ pages: next: - path: '/how-many-members-of-staff-will-look-after-the-unicorns' components: + - name: dfGYuk + options: {} + schema: {} + type: EastingNorthingField + title: Easting and northing + hint: + This is an Easting and Northing component + - name: seTThb + options: {} + schema: {} + type: LatLongField + title: Latitute and longitude + hint: + This is an Latitute and Longitude component + - name: bhjloS + options: {} + schema: {} + type: NationalGridFieldNumberField + title: National grid field number + hint: + This is an National Grid Field Number component + - name: dfQQws + options: {} + schema: {} + type: OsGridRefField + title: Ordnance survey grid reference + hint: + This is an Ordnance survey Grid Reference component - name: bClCvo options: {} schema: {} From 0f47ca038190045595d17dee60a75cc25fabcf4e Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 29 Oct 2025 15:58:23 +0000 Subject: [PATCH 18/21] refactor: PR comments --- src/client/stylesheets/_location-input.scss | 25 ++++++++++ src/client/stylesheets/shared.scss | 1 + .../NationalGridFieldNumberField.test.ts | 22 ++++---- .../NationalGridFieldNumberField.ts | 13 +++-- .../engine/components/OsGridRefField.test.ts | 50 +++++++++++-------- .../engine/components/OsGridRefField.ts | 49 +++++++++--------- 6 files changed, 101 insertions(+), 59 deletions(-) diff --git a/src/client/stylesheets/_location-input.scss b/src/client/stylesheets/_location-input.scss index f1463af75..8a1b5bf60 100644 --- a/src/client/stylesheets/_location-input.scss +++ b/src/client/stylesheets/_location-input.scss @@ -3,6 +3,31 @@ .app-location-input { @include govuk-clearfix; font-size: 0; // removes whitespace caused by inline-block + margin-bottom: govuk-spacing(6); + + &:has(.govuk-input--error) { + border-left: $govuk-border-width-form-group-error solid $govuk-error-colour; + padding-left: govuk-spacing(3); + margin-top: 0; + } +} + +.govuk-hint:has(+ .app-location-input .govuk-input--error) { + border-left: $govuk-border-width-form-group-error solid $govuk-error-colour; + padding-left: govuk-spacing(3); + margin-bottom: 0; +} + +.govuk-fieldset:has(.app-location-input .govuk-input--error) { + .govuk-fieldset__legend { + border-left: $govuk-border-width-form-group-error solid $govuk-error-colour; + padding-left: govuk-spacing(3); + margin-bottom: 0; + } + + .govuk-fieldset__legend + .govuk-hint { + margin-top: 0; + } } .app-location-input__item { diff --git a/src/client/stylesheets/shared.scss b/src/client/stylesheets/shared.scss index 0906cd95d..f5e23d698 100644 --- a/src/client/stylesheets/shared.scss +++ b/src/client/stylesheets/shared.scss @@ -2,6 +2,7 @@ @use "pkg:accessible-autocomplete"; @use "prose"; @use "summary-list"; +@use "location-input"; // Use default GDS Transport font for autocomplete .autocomplete__hint, diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts index 949a040f3..7b505c406 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -108,12 +108,14 @@ describe('NationalGridFieldNumberField', () => { expect(result3.errors).toBeUndefined() }) - it('strips spaces and commas from input', () => { + it('formats values with spaces per GDS guidance', () => { const result1 = collection.validate(getFormData('NG 1234 5678')) - const result2 = collection.validate(getFormData('NG12345,678')) + const result2 = collection.validate(getFormData('NG12345678')) + const result3 = collection.validate(getFormData('NG12345,678')) - expect(result1.value.myComponent).toBe('NG12345678') - expect(result2.value.myComponent).toBe('NG12345678') + expect(result1.value.myComponent).toBe('NG 1234 5678') + expect(result2.value.myComponent).toBe('NG 1234 5678') + expect(result3.value.myComponent).toBe('NG 1234 5678') }) it('adds errors for empty value', () => { @@ -256,15 +258,15 @@ describe('NationalGridFieldNumberField', () => { assertions: [ { input: getFormData(' NG12345678'), - output: { value: getFormData('NG12345678') } + output: { value: getFormData('NG 1234 5678') } }, { input: getFormData('NG12345678 '), - output: { value: getFormData('NG12345678') } + output: { value: getFormData('NG 1234 5678') } }, { input: getFormData(' NG12345678 \n\n'), - output: { value: getFormData('NG12345678') } + output: { value: getFormData('NG 1234 5678') } } ] }, @@ -283,7 +285,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('NG1234567'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for Example National Grid field number like NG12345678' + text: 'Enter a valid National Grid field number for Example National Grid field number like NG 1234 5678' }) ]) } @@ -294,7 +296,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('N123456789'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for Example National Grid field number like NG12345678' + text: 'Enter a valid National Grid field number for Example National Grid field number like NG 1234 5678' }) ]) } @@ -305,7 +307,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('NGABCDEFGH'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for Example National Grid field number like NG12345678' + text: 'Enter a valid National Grid field number for Example National Grid field number like NG 1234 5678' }) ]) } diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index ee9006881..a1efe74c9 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -10,9 +10,9 @@ export class NationalGridFieldNumberField extends LocationFieldBase { return { // Pattern allows spaces and commas in the input since custom validation will clean them pattern: /^[A-Z]{2}[\d\s,]*$/i, - patternErrorMessage: `Enter a valid National Grid field number for ${this.title} like NG12345678`, + patternErrorMessage: `Enter a valid National Grid field number for ${this.title} like NG 1234 5678`, customValidation: (value: string, helpers: joi.CustomHelpers) => { - // Strip spaces and commas + // Strip spaces and commas for validation const cleanValue = value.replace(/[\s,]/g, '') // Check if it matches the exact pattern after cleaning @@ -20,7 +20,12 @@ export class NationalGridFieldNumberField extends LocationFieldBase { return helpers.error('string.pattern.base') } - return cleanValue + // Format with spaces per GDS guidance: NG 1234 5678 + const letters = cleanValue.substring(0, 2) + const numbers = cleanValue.substring(2) + const formattedValue = `${letters} ${numbers.substring(0, 4)} ${numbers.substring(4)}` + + return formattedValue } } } @@ -30,7 +35,7 @@ export class NationalGridFieldNumberField extends LocationFieldBase { { type: 'pattern', template: - 'Enter a valid National Grid field number for [short description] like NG12345678' + 'Enter a valid National Grid field number for [short description] like NG 1234 5678' } ] } diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index 8aaf385ee..6f8303cae 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -96,22 +96,26 @@ describe('OsGridRefField', () => { }) it('accepts valid values', () => { + // Test 6-digit format (common OS grid reference) + const result1 = collection.validate(getFormData('SD865005')) + const result2 = collection.validate(getFormData('SD 865 005')) + // Test 8-digit parcel ID format (2x4) - const result1 = collection.validate(getFormData('TQ12345678')) - const result2 = collection.validate(getFormData('TQ 1234 5678')) + const result3 = collection.validate(getFormData('TQ12345678')) + const result4 = collection.validate(getFormData('TQ 1234 5678')) // Test 10-digit OS grid reference format (2x5) - const result3 = collection.validate(getFormData('SU1234567890')) - const result4 = collection.validate(getFormData('SU 12345 67890')) + const result5 = collection.validate(getFormData('SU1234567890')) + const result6 = collection.validate(getFormData('SU 12345 67890')) // Test case-insensitive - const result5 = collection.validate(getFormData('nt12345678')) + const result7 = collection.validate(getFormData('nt12345678')) // Test various valid OS grid formats - const result6 = collection.validate(getFormData('SN 1232 1223')) // parcel ID format - const result7 = collection.validate(getFormData('SN 12324 12234')) // OS grid ref format - const result8 = collection.validate(getFormData('ST 6789 6789')) // parcel ID with different letters - const result9 = collection.validate(getFormData('SO 12345 12345')) // OS grid ref with different letters + const result8 = collection.validate(getFormData('SN 1232 1223')) // parcel ID format + const result9 = collection.validate(getFormData('SN 12324 12234')) // OS grid ref format + const result10 = collection.validate(getFormData('ST 6789 6789')) // parcel ID with different letters + const result11 = collection.validate(getFormData('SO 12345 12345')) // OS grid ref with different letters expect(result1.errors).toBeUndefined() expect(result2.errors).toBeUndefined() @@ -122,14 +126,20 @@ describe('OsGridRefField', () => { expect(result7.errors).toBeUndefined() expect(result8.errors).toBeUndefined() expect(result9.errors).toBeUndefined() + expect(result10.errors).toBeUndefined() + expect(result11.errors).toBeUndefined() }) - it('strips spaces from input', () => { - const result1 = collection.validate(getFormData('TQ 1234 5678')) - const result2 = collection.validate(getFormData('SU 12345 67890')) + it('formats values with spaces per GDS guidance', () => { + const result1 = collection.validate(getFormData('SD865005')) + const result2 = collection.validate(getFormData('TQ 1234 5678')) + const result3 = collection.validate(getFormData('SU1234567890')) + const result4 = collection.validate(getFormData('TQ12345678')) - expect(result1.value.myComponent).toBe('TQ12345678') - expect(result2.value.myComponent).toBe('SU1234567890') + expect(result1.value.myComponent).toBe('SD 865 005') + expect(result2.value.myComponent).toBe('TQ 1234 5678') + expect(result3.value.myComponent).toBe('SU 12345 67890') + expect(result4.value.myComponent).toBe('TQ 1234 5678') }) it('adds errors for empty value', () => { @@ -144,11 +154,11 @@ describe('OsGridRefField', () => { it('adds errors for invalid values', () => { const result1 = collection.validate(getFormData('INVALID')) - const result2 = collection.validate(getFormData('TQ12345')) // Wrong number of digits + const result2 = collection.validate(getFormData('TQ12345')) // Wrong number of digits (5) const result3 = collection.validate(getFormData('AA12345678')) // Invalid letter combination - const result4 = collection.validate(getFormData('TQ123456')) // 6 digits not allowed + const result4 = collection.validate(getFormData('TQ1234567')) // Wrong number of digits (7) - // Test mismatched digit counts (must be either 4+4 or 5+5, not mixed) + // Test mismatched digit counts (must be either 3+3, 4+4 or 5+5, not mixed) const result5 = collection.validate(getFormData('SN 4444 55555')) // mismatched digit counts const result6 = collection.validate(getFormData('SN 55555 4444')) // mismatched digit counts @@ -280,15 +290,15 @@ describe('OsGridRefField', () => { assertions: [ { input: getFormData(' TQ12345678'), - output: { value: getFormData('TQ12345678') } + output: { value: getFormData('TQ 1234 5678') } }, { input: getFormData('TQ12345678 '), - output: { value: getFormData('TQ12345678') } + output: { value: getFormData('TQ 1234 5678') } }, { input: getFormData(' TQ12345678 \n\n'), - output: { value: getFormData('TQ12345678') } + output: { value: getFormData('TQ 1234 5678') } } ] }, diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index 968210cad..e9d8ce598 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -8,11 +8,12 @@ export class OsGridRefField extends LocationFieldBase { protected getValidationConfig() { // Regex for OS grid references and parcel IDs - // Validates specific valid OS grid letter combinations and either: - // - 2 blocks of 4 digits (parcel ID) e.g., ST 6789 6789 - // - 2 blocks of 5 digits (OS grid reference) e.g., SO 12345 12345 + // Validates specific valid OS grid letter combinations with: + // - 6 digits (e.g., SD865005 or SD 865 005) + // - 8 digits in 2 blocks of 4 (parcel ID) e.g., ST 6789 6789 + // - 10 digits in 2 blocks of 5 (OS grid reference) e.g., SO 12345 12345 const osGridPattern = - /^(?:[sn][a-hj-z]|[to][abfglmqrvw]|h[l-z]|j[lmqrvw])\s?(?:\d{4}\s?\d{4}|\d{5}\s?\d{5})$/i + /^(?:[sn][a-hj-z]|[to][abfglmqrvw]|h[l-z]|j[lmqrvw])\s?(?:\d{3}\s?\d{3}|\d{4}\s?\d{4}|\d{5}\s?\d{5})$/i // More permissive pattern for initial validation (allows spaces to be cleaned) const initialPattern = /^[A-Za-z]{2}[\d\s]*$/ @@ -21,33 +22,31 @@ export class OsGridRefField extends LocationFieldBase { pattern: initialPattern, patternErrorMessage: `Enter a valid OS grid reference for ${this.title} like TQ123456`, customValidation: (value: string, helpers: joi.CustomHelpers) => { - // Strip spaces from the input + // Strip spaces from the input for processing const cleanValue = value.replace(/\s/g, '') + const letters = cleanValue.substring(0, 2) + const numbers = cleanValue.substring(2) - // Check if it matches the OS grid pattern - // We need to test with spaces in the right places for validation - const valueWithSpaces = value.trim() - - if (!osGridPattern.test(valueWithSpaces)) { - // Also try without any spaces (e.g., TQ12345678) - const letters = cleanValue.substring(0, 2) - const numbers = cleanValue.substring(2) + // Validate number length + if ( + numbers.length !== 6 && + numbers.length !== 8 && + numbers.length !== 10 + ) { + return helpers.error('string.pattern.base') + } - if (numbers.length === 8 || numbers.length === 10) { - // Format with spaces for validation: XX 1234 5678 or XX 12345 67890 - const halfLength = numbers.length / 2 - const formattedValue = `${letters} ${numbers.substring(0, halfLength)} ${numbers.substring(halfLength)}` + // Format with spaces: XX 123 456, XX 1234 5678, or XX 12345 67890 + const halfLength = numbers.length / 2 + const formattedValue = `${letters} ${numbers.substring(0, halfLength)} ${numbers.substring(halfLength)}` - if (!osGridPattern.test(formattedValue)) { - return helpers.error('string.pattern.base') - } - } else { - return helpers.error('string.pattern.base') - } + // Validate the formatted value against the OS grid pattern + if (!osGridPattern.test(formattedValue)) { + return helpers.error('string.pattern.base') } - // Return the cleaned value without spaces for storage - return cleanValue + // Return formatted value with spaces per GDS guidance + return formattedValue } } } From baece71a0a20cf9caa32a9f7aa3d961dcdd06ce2 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Wed, 29 Oct 2025 17:17:02 +0000 Subject: [PATCH 19/21] fix: update precision validation for latitude and longitude fields to allow 7 decimal places --- .../plugins/engine/components/LatLongField.test.ts | 12 ++++++------ src/server/plugins/engine/components/LatLongField.ts | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/server/plugins/engine/components/LatLongField.test.ts b/src/server/plugins/engine/components/LatLongField.test.ts index 2da1611df..7acfbddd6 100644 --- a/src/server/plugins/engine/components/LatLongField.test.ts +++ b/src/server/plugins/engine/components/LatLongField.test.ts @@ -537,17 +537,17 @@ describe('LatLongField', () => { assertions: [ { input: getFormData({ - latitude: '51.1234567', + latitude: '51.12345678', longitude: '-0.1' }), output: { value: getFormData({ - latitude: 51.1234567, + latitude: 51.12345678, longitude: -0.1 }), errors: [ expect.objectContaining({ - text: 'Latitude must be a decimal number' + text: 'Latitude must have no more than 7 decimal places' }) ] } @@ -555,16 +555,16 @@ describe('LatLongField', () => { { input: getFormData({ latitude: '51.5', - longitude: '-0.1234567' + longitude: '-0.12345678' }), output: { value: getFormData({ latitude: 51.5, - longitude: -0.1234567 + longitude: -0.12345678 }), errors: [ expect.objectContaining({ - text: 'Longitude must be a decimal number' + text: 'Longitude must have no more than 7 decimal places' }) ] } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 84a0a97a9..1b8d7d31e 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -51,7 +51,8 @@ export class LatLongField extends FormComponent { convertToLanguageMessages({ 'any.required': messageTemplate.objectMissing, 'number.base': messageTemplate.objectMissing, - 'number.precision': '{{#label}} must be a decimal number', + 'number.precision': + '{{#label}} must have no more than 7 decimal places', 'number.unsafe': '{{#label}} must be a valid number' }) @@ -75,7 +76,7 @@ export class LatLongField extends FormComponent { type: ComponentType.NumberField, name: `${name}__latitude`, title: 'Latitude', - schema: { min: latitudeMin, max: latitudeMax, precision: 6 }, + schema: { min: latitudeMin, max: latitudeMax, precision: 7 }, options: { required: isRequired, optionalText: true, @@ -88,7 +89,7 @@ export class LatLongField extends FormComponent { type: ComponentType.NumberField, name: `${name}__longitude`, title: 'Longitude', - schema: { min: longitudeMin, max: longitudeMax, precision: 6 }, + schema: { min: longitudeMin, max: longitudeMax, precision: 7 }, options: { required: isRequired, optionalText: true, From afae6fc454f147b6dc7ccfa38a6f4e2267456437 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 30 Oct 2025 10:55:40 +0000 Subject: [PATCH 20/21] style: adjust margin for location input items and add responsive behavior --- src/client/stylesheets/_location-input.scss | 6 +++++- src/server/plugins/engine/components/LocationFieldBase.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/client/stylesheets/_location-input.scss b/src/client/stylesheets/_location-input.scss index 8a1b5bf60..5cf52f72b 100644 --- a/src/client/stylesheets/_location-input.scss +++ b/src/client/stylesheets/_location-input.scss @@ -33,12 +33,16 @@ .app-location-input__item { display: inline-block; margin-right: govuk-spacing(4); - margin-bottom: 0; + margin-bottom: govuk-spacing(4); &:last-child { margin-right: 0; } + @include govuk-media-query($from: tablet) { + margin-bottom: 0; + } + .govuk-form-group { margin-bottom: 0; display: inline-block; diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 3ffb61b77..e91ca7eed 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -5,6 +5,7 @@ import { FormComponent, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' +import { addClassOptionIfNone } from '~/src/server/plugins/engine/components/helpers/index.js' import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -21,6 +22,7 @@ interface LocationFieldOptions { required?: boolean customValidationMessage?: string customValidationMessages?: LanguageMessages + classes?: string } interface ValidationConfig { @@ -58,6 +60,8 @@ export abstract class LocationFieldBase extends FormComponent { const locationOptions = options as LocationFieldOptions this.instructionText = locationOptions.instructionText + addClassOptionIfNone(locationOptions, 'govuk-input--width-10') + const config = this.getValidationConfig() let formSchema = joi From 81836ed8343053187d35c33d02a2b03c8c21d7f1 Mon Sep 17 00:00:00 2001 From: Mohammed Khalid Date: Thu, 30 Oct 2025 14:11:55 +0000 Subject: [PATCH 21/21] refactor: replace instructionText with options.instructionText and introduce constants for easting/northing limits --- .../engine/components/ComponentBase.ts | 2 +- .../engine/components/EastingNorthingField.ts | 28 +++++++++---------- .../plugins/engine/components/LatLongField.ts | 2 -- .../engine/components/LocationFieldHelpers.ts | 4 +-- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/server/plugins/engine/components/ComponentBase.ts b/src/server/plugins/engine/components/ComponentBase.ts index 180241b05..e341fc671 100644 --- a/src/server/plugins/engine/components/ComponentBase.ts +++ b/src/server/plugins/engine/components/ComponentBase.ts @@ -22,7 +22,7 @@ export class ComponentBase { type: ComponentDef['type'] name: ComponentDef['name'] title: ComponentDef['title'] - schema?: Extract['schema'] + schema?: Extract['schema'] options?: Extract['options'] isFormComponent = false diff --git a/src/server/plugins/engine/components/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts index 9742d5c84..decd70eeb 100644 --- a/src/server/plugins/engine/components/EastingNorthingField.ts +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -26,12 +26,17 @@ import { } from '~/src/server/plugins/engine/types.js' import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' +// British National Grid coordinate limits +const DEFAULT_EASTING_MIN = 0 +const DEFAULT_EASTING_MAX = 70000 +const DEFAULT_NORTHING_MIN = 0 +const DEFAULT_NORTHING_MAX = 1300000 + export class EastingNorthingField extends FormComponent { declare options: EastingNorthingFieldComponent['options'] declare formSchema: ObjectSchema declare stateSchema: ObjectSchema declare collection: ComponentCollection - instructionText?: string constructor( def: EastingNorthingFieldComponent, @@ -42,12 +47,11 @@ export class EastingNorthingField extends FormComponent { const { name, options, schema } = def const isRequired = options.required !== false - this.instructionText = options.instructionText - const eastingMin = schema?.easting?.min ?? 0 - const eastingMax = schema?.easting?.max ?? 70000 - const northingMin = schema?.northing?.min ?? 0 - const northingMax = schema?.northing?.max ?? 1300000 + const eastingMin = schema?.easting?.min ?? DEFAULT_EASTING_MIN + const eastingMax = schema?.easting?.max ?? DEFAULT_EASTING_MAX + const northingMin = schema?.northing?.min ?? DEFAULT_NORTHING_MIN + const northingMax = schema?.northing?.max ?? DEFAULT_NORTHING_MAX const customValidationMessages: LanguageMessages = convertToLanguageMessages({ @@ -186,23 +190,19 @@ export class EastingNorthingField extends FormComponent { advancedSettingsErrors: [ { type: 'eastingMin', - template: - 'Easting for [short description] must be between 0 and 70000' + template: `Easting for [short description] must be between ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}` }, { type: 'eastingMax', - template: - 'Easting for [short description] must be between 0 and 70000' + template: `Easting for [short description] must be between ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}` }, { type: 'northingMin', - template: - 'Northing for [short description] must be between 0 and 1300000' + template: `Northing for [short description] must be between ${DEFAULT_NORTHING_MIN} and ${DEFAULT_NORTHING_MAX}` }, { type: 'northingMax', - template: - 'Northing for [short description] must be between 0 and 1300000' + template: `Northing for [short description] must be between ${DEFAULT_NORTHING_MIN} and ${DEFAULT_NORTHING_MAX}` } ] } diff --git a/src/server/plugins/engine/components/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts index 1b8d7d31e..9e4859c07 100644 --- a/src/server/plugins/engine/components/LatLongField.ts +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -28,7 +28,6 @@ export class LatLongField extends FormComponent { declare formSchema: ObjectSchema declare stateSchema: ObjectSchema declare collection: ComponentCollection - instructionText?: string constructor( def: LatLongFieldComponent, @@ -39,7 +38,6 @@ export class LatLongField extends FormComponent { const { name, options, schema } = def const isRequired = options.required !== false - this.instructionText = options.instructionText // Read schema values from def.schema with fallback defaults const latitudeMin = schema?.latitude?.min ?? 49 diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.ts b/src/server/plugins/engine/components/LocationFieldHelpers.ts index 3098d618f..ec721df2c 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts @@ -81,10 +81,10 @@ export function getLocationFieldViewModel( items } - if (component.instructionText) { + if (component.options.instructionText) { return { ...result, - instructionText: markdown.parse(component.instructionText, { + instructionText: markdown.parse(component.options.instructionText, { async: false }) }