diff --git a/src/client/stylesheets/shared.scss b/src/client/stylesheets/shared.scss index cb7277959..5501a8f63 100644 --- a/src/client/stylesheets/shared.scss +++ b/src/client/stylesheets/shared.scss @@ -31,3 +31,7 @@ content: none; } } + +.govuk-form-group .govuk-form-group { + margin-bottom: 0; +} diff --git a/src/server/plugins/engine/components/LocationFieldBase.ts b/src/server/plugins/engine/components/LocationFieldBase.ts index 824d0f0bf..928599a48 100644 --- a/src/server/plugins/engine/components/LocationFieldBase.ts +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -6,7 +6,6 @@ import { 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 { type ErrorMessageTemplateList, @@ -28,6 +27,7 @@ interface LocationFieldOptions { interface ValidationConfig { pattern: RegExp patternErrorMessage: string + requiredMessage?: string } /** @@ -58,6 +58,14 @@ export abstract class LocationFieldBase extends FormComponent { addClassOptionIfNone(locationOptions, 'govuk-input--width-10') const config = this.getValidationConfig() + const requiredMessage = + config.requiredMessage ?? (messageTemplate.required as string) + + const messages: LanguageMessages = { + 'any.required': requiredMessage, + 'string.empty': requiredMessage, + 'string.pattern.base': config.patternErrorMessage + } let formSchema = joi .string() @@ -65,9 +73,7 @@ export abstract class LocationFieldBase extends FormComponent { .label(this.label) .required() .pattern(config.pattern) - .messages({ - 'string.pattern.base': config.patternErrorMessage - }) + .messages(messages) if (locationOptions.required === false) { formSchema = formSchema.allow('') @@ -115,7 +121,7 @@ export abstract class LocationFieldBase extends FormComponent { if (this.instructionText) { return { ...viewModel, - instructionText: markdown.parse(this.instructionText, { async: false }) + instructionText: this.instructionText } } @@ -123,9 +129,15 @@ export abstract class LocationFieldBase extends FormComponent { } getAllPossibleErrors(): ErrorMessageTemplateList { + const config = this.getValidationConfig() + return { baseErrors: [ - { type: 'required', template: messageTemplate.required }, + { + type: 'required', + template: + config.requiredMessage ?? (messageTemplate.required as string) + }, ...this.getErrorTemplates() ], advancedSettingsErrors: [] diff --git a/src/server/plugins/engine/components/LocationFieldHelpers.ts b/src/server/plugins/engine/components/LocationFieldHelpers.ts index 306bc99a8..50a891d03 100644 --- a/src/server/plugins/engine/components/LocationFieldHelpers.ts +++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts @@ -3,7 +3,6 @@ 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/markdownParser.js' import { type DateInputItem, type Label, @@ -174,9 +173,7 @@ export function getLocationFieldViewModel( if (component.options.instructionText) { return { ...result, - instructionText: markdown.parse(component.options.instructionText, { - async: false - }) + instructionText: component.options.instructionText } } diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts index 00a67d634..b87d4e2e9 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -129,7 +129,7 @@ describe('NationalGridFieldNumberField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Enter example National Grid field number' + text: 'Enter Example National Grid field number' }) ]) }) @@ -293,7 +293,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('NG1234567'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for grid field like NG 1234 5678' + text: 'Enter a valid National Grid field number for Grid field like NG 1234 5678' }) ]) } @@ -304,7 +304,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('N123456789'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for grid field like NG 1234 5678' + text: 'Enter a valid National Grid field number for Grid field like NG 1234 5678' }) ]) } @@ -315,7 +315,7 @@ describe('NationalGridFieldNumberField', () => { value: getFormData('NGABCDEFGH'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid National Grid field number for grid field like NG 1234 5678' + text: 'Enter a valid National Grid field number for Grid field like NG 1234 5678' }) ]) } diff --git a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts index 62c160807..962336b4b 100644 --- a/src/server/plugins/engine/components/NationalGridFieldNumberField.ts +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -1,5 +1,4 @@ import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' -import lowerFirst from 'lodash/lowerFirst.js' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' @@ -15,7 +14,8 @@ export class NationalGridFieldNumberField extends LocationFieldBase { return { pattern, - patternErrorMessage: `Enter a valid National Grid field number for ${lowerFirst(this.label)} like NG 1234 5678` + patternErrorMessage: `Enter a valid National Grid field number for {{#title}} like NG 1234 5678`, + requiredMessage: 'Enter {{#title}}' } } diff --git a/src/server/plugins/engine/components/OsGridRefField.test.ts b/src/server/plugins/engine/components/OsGridRefField.test.ts index 9183fb081..bad21aa81 100644 --- a/src/server/plugins/engine/components/OsGridRefField.test.ts +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -135,7 +135,7 @@ describe('OsGridRefField', () => { expect(result.errors).toEqual([ expect.objectContaining({ - text: 'Enter example OS grid reference' + text: 'Enter Example OS grid reference' }) ]) }) @@ -308,7 +308,7 @@ describe('OsGridRefField', () => { value: getFormData('TQ12345'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid OS grid reference for grid reference like TQ123456' + text: 'Enter a valid OS grid reference for Grid reference like TQ123456' }) ]) } @@ -319,7 +319,7 @@ describe('OsGridRefField', () => { value: getFormData('AA1234567'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid OS grid reference for grid reference like TQ123456' + text: 'Enter a valid OS grid reference for Grid reference like TQ123456' }) ]) } @@ -330,7 +330,7 @@ describe('OsGridRefField', () => { value: getFormData('TQABCDEF'), errors: expect.arrayContaining([ expect.objectContaining({ - text: 'Enter a valid OS grid reference for grid reference like TQ123456' + text: 'Enter a valid OS grid reference for Grid reference like TQ123456' }) ]) } diff --git a/src/server/plugins/engine/components/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts index 7ef85b7a2..4c0d9ec8d 100644 --- a/src/server/plugins/engine/components/OsGridRefField.ts +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -1,5 +1,4 @@ import { type OsGridRefFieldComponent } from '@defra/forms-model' -import lowerFirst from 'lodash/lowerFirst.js' import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' @@ -18,7 +17,8 @@ export class OsGridRefField extends LocationFieldBase { return { pattern, - patternErrorMessage: `Enter a valid OS grid reference for ${lowerFirst(this.label)} like TQ123456` + patternErrorMessage: `Enter a valid OS grid reference for {{#title}} like TQ123456`, + requiredMessage: 'Enter {{#title}}' } } diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.location.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.location.test.ts new file mode 100644 index 000000000..a47ece75b --- /dev/null +++ b/src/server/plugins/engine/outputFormatters/machine/v2.location.test.ts @@ -0,0 +1,341 @@ +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/machine/v2.js' +import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' +import { FormStatus } from '~/src/server/routes/types.js' + +describe('Machine V2 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 } + }) + + const formStatus = { + isPreview: false, + state: FormStatus.Live + } + + it('includes LatLongField values with full property names (latitude/longitude) 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, formStatus) + const payload = JSON.parse(result) + + // Verify the payload uses full property names, not abbreviated + expect(payload.data.main.locationLL).toEqual({ + latitude: 51.51945, + longitude: -0.127758 + }) + + // Ensure abbreviated forms are NOT present + expect(payload.data.main.locationLL).not.toHaveProperty('lat') + expect(payload.data.main.locationLL).not.toHaveProperty('long') + }) + + it('includes EastingNorthingField values with full property names (easting/northing) in the payload', () => { + const state = { + $$__referenceNumber: 'ABC-123', + locationEN__easting: 123456, + locationEN__northing: 654321 + } + + 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, formStatus) + const payload = JSON.parse(result) + + // Verify the payload uses full property names + expect(payload.data.main.locationEN).toEqual({ + easting: 123456, + northing: 654321 + }) + }) + + 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, formStatus) + const payload = JSON.parse(result) + + expect(payload.data.main.gridRef).toBe('TQ123456') + expect(payload.data.main.ngField).toBe('NG12345678') + }) + + it('includes all location fields in a mixed form with correct property names', () => { + 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, formStatus) + const payload = JSON.parse(result) + + // Check all location fields use full property names + expect(payload.data.main).toEqual({ + locationEN: { + easting: 123456, + northing: 654321 + }, + locationLL: { + latitude: 51.51945, + longitude: -0.127758 + }, + gridRef: 'TQ123456', + ngField: 'NG12345678' + }) + + // Explicitly verify no abbreviated forms exist + expect(payload.data.main.locationLL).not.toHaveProperty('lat') + expect(payload.data.main.locationLL).not.toHaveProperty('long') + }) + + it('handles undefined location field values correctly', () => { + const state = { + $$__referenceNumber: 'ABC-123' + } + + 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: '' + } as unknown as DetailItemField + ] + + const result = format(context, items, model, submitResponse, formStatus) + const payload = JSON.parse(result) + + // Undefined location fields should be undefined in v2 (not null like in v1) + expect(payload.data.main.locationLL).toBeUndefined() + }) +}) 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 004f53cba..4a73aa706 100644 --- a/src/server/plugins/engine/views/components/_location-field-base.html +++ b/src/server/plugins/engine/views/components/_location-field-base.html @@ -62,7 +62,7 @@ {% if component.model.instructionText %} {{ govukDetails({ summaryText: "How to find location details", - html: component.model.instructionText | safe, + html: component.model.instructionText | markdown | safe, classes: "govuk-!-margin-top-3" }) }} {% endif %} diff --git a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html index 1f8666586..6611b6337 100644 --- a/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html +++ b/src/server/plugins/engine/views/components/nationalgridfieldnumberfield.html @@ -2,12 +2,16 @@ {% from "govuk/components/details/macro.njk" import govukDetails %} {% macro NationalGridFieldNumberField(component) %} - {{ TextField(component) }} + {% set hasErrors = component.model.errorMessage %} +