diff --git a/package-lock.json b/package-lock.json index 54d14b435..38cb2af71 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,32 @@ "npm": "^10.9.0" } }, + "../forms-designer/model": { + "name": "@defra/forms-model", + "version": "3.0.569", + "extraneous": true, + "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,9 +2298,9 @@ } }, "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==", + "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", @@ -2292,9 +2318,9 @@ } }, "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==", + "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", @@ -15930,9 +15956,9 @@ } }, "node_modules/slug": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/slug/-/slug-11.0.0.tgz", - "integrity": "sha512-71pb27F9TII2dIweGr2ybS220IUZo1A9GKZ+e2q8rpUr24mejBb6fTaSStM0SE1ITUUOshilqZze8Yt1BKj+ew==", + "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" diff --git a/package.json b/package.json index 7883735c7..3b9b2345f 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/client/stylesheets/_location-input.scss b/src/client/stylesheets/_location-input.scss new file mode 100644 index 000000000..5cf52f72b --- /dev/null +++ b/src/client/stylesheets/_location-input.scss @@ -0,0 +1,60 @@ +@use "govuk-frontend" as *; + +.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 { + display: inline-block; + margin-right: govuk-spacing(4); + 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; + 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/client/stylesheets/shared.scss b/src/client/stylesheets/shared.scss index cb7277959..daeadccba 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/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: {} diff --git a/src/server/plugins/engine/components/ComponentBase.ts b/src/server/plugins/engine/components/ComponentBase.ts index 307583354..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.test.ts b/src/server/plugins/engine/components/EastingNorthingField.test.ts new file mode 100644 index 000000000..ae493e91d --- /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('Northing: 1234567
Easting: 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/EastingNorthingField.ts b/src/server/plugins/engine/components/EastingNorthingField.ts new file mode 100644 index 000000000..decd70eeb --- /dev/null +++ b/src/server/plugins/engine/components/EastingNorthingField.ts @@ -0,0 +1,224 @@ +import { + ComponentType, + type EastingNorthingFieldComponent +} from '@defra/forms-model' +import { type LanguageMessages, type ObjectSchema } from 'joi' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { + FormComponent, + 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 { 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' + +// 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 + + constructor( + def: EastingNorthingFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { name, options, schema } = def + + const isRequired = options.required !== false + + 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({ + '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}` + } + + 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 viewModel = super.getViewModel(payload, errors) + return getLocationFieldViewModel(this, viewModel, payload, errors) + } + + 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(): 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 ${DEFAULT_EASTING_MIN} and ${DEFAULT_EASTING_MAX}` + }, + { + type: 'eastingMax', + 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 ${DEFAULT_NORTHING_MIN} and ${DEFAULT_NORTHING_MAX}` + }, + { + type: 'northingMax', + template: `Northing for [short description] must be between ${DEFAULT_NORTHING_MIN} and ${DEFAULT_NORTHING_MAX}` + } + ] + } + } + + static isEastingNorthing( + value?: FormStateValue | FormState + ): value is EastingNorthingState { + return ( + isFormState(value) && + NumberField.isNumber(value.easting) && + NumberField.isNumber(value.northing) + ) + } +} + +export function getValidatorEastingNorthing(component: EastingNorthingField) { + return createLocationFieldValidator(component) +} 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..7acfbddd6 --- /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('Lat: 51.51945
Long: -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.12345678', + longitude: '-0.1' + }), + output: { + value: getFormData({ + latitude: 51.12345678, + longitude: -0.1 + }), + errors: [ + expect.objectContaining({ + text: 'Latitude must have no more than 7 decimal places' + }) + ] + } + }, + { + input: getFormData({ + latitude: '51.5', + longitude: '-0.12345678' + }), + output: { + value: getFormData({ + latitude: 51.5, + longitude: -0.12345678 + }), + errors: [ + expect.objectContaining({ + text: 'Longitude must have no more than 7 decimal places' + }) + ] + } + } + ] + }, + { + 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/LatLongField.ts b/src/server/plugins/engine/components/LatLongField.ts new file mode 100644 index 000000000..9e4859c07 --- /dev/null +++ b/src/server/plugins/engine/components/LatLongField.ts @@ -0,0 +1,213 @@ +import { ComponentType, type LatLongFieldComponent } from '@defra/forms-model' +import { type LanguageMessages, type ObjectSchema } from 'joi' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { + FormComponent, + 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 { 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 FormComponent { + declare options: LatLongFieldComponent['options'] + declare formSchema: ObjectSchema + declare stateSchema: ObjectSchema + declare collection: ComponentCollection + + constructor( + def: LatLongFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { name, options, schema } = def + + const isRequired = options.required !== false + + // 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 have no more than 7 decimal places', + '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: 7 }, + options: { + required: isRequired, + optionalText: true, + classes: 'govuk-input--width-10', + suffix: '°', + customValidationMessages: latitudeMessages + } + }, + { + type: ComponentType.NumberField, + name: `${name}__longitude`, + title: 'Longitude', + schema: { min: longitudeMin, max: longitudeMax, precision: 7 }, + options: { + required: isRequired, + optionalText: true, + classes: 'govuk-input--width-10', + suffix: '°', + customValidationMessages: longitudeMessages + } + } + ], + { ...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}` + } + + 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 viewModel = super.getViewModel(payload, errors) + return getLocationFieldViewModel(this, viewModel, payload, errors) + } + + 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(): 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) { + return createLocationFieldValidator(component) +} 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 new file mode 100644 index 000000000..e91ca7eed --- /dev/null +++ b/src/server/plugins/engine/components/LocationFieldBase.ts @@ -0,0 +1,152 @@ +import { type FormComponentsDef } from '@defra/forms-model' +import joi, { type LanguageMessages, type StringSchema } from 'joi' + +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 { + 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?: LanguageMessages + classes?: string +} + +interface ValidationConfig { + pattern: RegExp + patternErrorMessage: string + customValidation?: ( + value: string, + helpers: joi.CustomHelpers + ) => string | joi.ErrorReport + additionalMessages?: LanguageMessages +} + +/** + * Abstract base class for location-based field components + */ +export abstract class LocationFieldBase extends FormComponent { + declare options: LocationFieldOptions + declare formSchema: StringSchema + declare stateSchema: StringSchema + instructionText?: string + + protected abstract getValidationConfig(): ValidationConfig + protected abstract getErrorTemplates(): { + type: string + template: string + }[] + + constructor( + def: FormComponentsDef, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { options } = def + const locationOptions = options as LocationFieldOptions + this.instructionText = locationOptions.instructionText + + addClassOptionIfNone(locationOptions, 'govuk-input--width-10') + + 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 (locationOptions.required === false) { + formSchema = formSchema.allow('') + } + + if (locationOptions.customValidationMessage) { + const message = locationOptions.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 + }, {}) + + formSchema = formSchema.messages(messages) + } else if (locationOptions.customValidationMessages) { + formSchema = formSchema.messages(locationOptions.customValidationMessages) + } + + this.formSchema = formSchema.default('') + this.stateSchema = formSchema.default(null).allow(null) + this.options = locationOptions + } + + 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/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 new file mode 100644 index 000000000..ec721df2c --- /dev/null +++ b/src/server/plugins/engine/components/LocationFieldHelpers.ts @@ -0,0 +1,123 @@ +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, + 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, prefix, suffix, 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, + prefix, + suffix + } + }) + + const fieldset = existingFieldset ?? { + legend: { + text: label.text, + classes: 'govuk-fieldset__legend--m' + } + } + + const result = { + ...viewModel, + fieldset, + items + } + + if (component.options.instructionText) { + return { + ...result, + instructionText: markdown.parse(component.options.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/components/NationalGridFieldNumberField.test.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts new file mode 100644 index 000000000..7b505c406 --- /dev/null +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.test.ts @@ -0,0 +1,438 @@ +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('formats values with spaces per GDS guidance', () => { + const result1 = collection.validate(getFormData('NG 1234 5678')) + const result2 = collection.validate(getFormData('NG12345678')) + const result3 = collection.validate(getFormData('NG12345,678')) + + 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', () => { + 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('NG 1234 5678') } + }, + { + input: getFormData('NG12345678 '), + output: { value: getFormData('NG 1234 5678') } + }, + { + input: getFormData(' NG12345678 \n\n'), + output: { value: getFormData('NG 1234 5678') } + } + ] + }, + { + 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 NG 1234 5678' + }) + ]) + } + }, + { + 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 NG 1234 5678' + }) + ]) + } + }, + { + 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 NG 1234 5678' + }) + ]) + } + } + ] + }, + { + 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/NationalGridFieldNumberField.ts b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts new file mode 100644 index 000000000..a1efe74c9 --- /dev/null +++ b/src/server/plugins/engine/components/NationalGridFieldNumberField.ts @@ -0,0 +1,52 @@ +import { type NationalGridFieldNumberFieldComponent } from '@defra/forms-model' +import type joi from 'joi' + +import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' + +export class NationalGridFieldNumberField extends LocationFieldBase { + declare options: NationalGridFieldNumberFieldComponent['options'] + + protected getValidationConfig() { + 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 NG 1234 5678`, + customValidation: (value: string, helpers: joi.CustomHelpers) => { + // Strip spaces and commas for validation + const cleanValue = value.replace(/[\s,]/g, '') + + // Check if it matches the exact pattern after cleaning + if (!/^[A-Z]{2}\d{8}$/i.test(cleanValue)) { + return helpers.error('string.pattern.base') + } + + // 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 + } + } + } + + protected getErrorTemplates() { + return [ + { + type: 'pattern', + template: + 'Enter a valid National Grid field number for [short description] like NG 1234 5678' + } + ] + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors() { + const instance = Object.create( + NationalGridFieldNumberField.prototype + ) as NationalGridFieldNumberField + return instance.getAllPossibleErrors() + } +} 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..6f8303cae --- /dev/null +++ b/src/server/plugins/engine/components/OsGridRefField.test.ts @@ -0,0 +1,469 @@ +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', () => { + // 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 result3 = collection.validate(getFormData('TQ12345678')) + const result4 = collection.validate(getFormData('TQ 1234 5678')) + + // Test 10-digit OS grid reference format (2x5) + const result5 = collection.validate(getFormData('SU1234567890')) + const result6 = collection.validate(getFormData('SU 12345 67890')) + + // Test case-insensitive + const result7 = collection.validate(getFormData('nt12345678')) + + // Test various valid OS grid formats + 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() + 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() + expect(result10.errors).toBeUndefined() + expect(result11.errors).toBeUndefined() + }) + + 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('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', () => { + 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')) // Wrong number of digits (5) + const result3 = collection.validate(getFormData('AA12345678')) // Invalid letter combination + const result4 = collection.validate(getFormData('TQ1234567')) // Wrong number of digits (7) + + // 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 + + 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('TQ12345678') + const state2 = getFormState(null) + + const answer1 = getAnswer(field, state1) + const answer2 = getAnswer(field, state2) + + expect(answer1).toBe('TQ12345678') + expect(answer2).toBe('') + }) + + it('returns payload from state', () => { + const state1 = getFormState('TQ12345678') + const state2 = getFormState(null) + + const payload1 = field.getFormDataFromState(state1) + const payload2 = field.getFormDataFromState(state2) + + expect(payload1).toEqual(getFormData('TQ12345678')) + expect(payload2).toEqual(getFormData()) + }) + + it('returns value from state', () => { + const state1 = getFormState('TQ12345678') + const state2 = getFormState(null) + + const value1 = field.getFormValueFromState(state1) + const value2 = field.getFormValueFromState(state2) + + expect(value1).toBe('TQ12345678') + expect(value2).toBeUndefined() + }) + + it('returns context for conditions and form submission', () => { + const state1 = getFormState('TQ12345678') + const state2 = getFormState(null) + + const value1 = field.getContextValueFromState(state1) + const value2 = field.getContextValueFromState(state2) + + expect(value1).toBe('TQ12345678') + expect(value2).toBeNull() + }) + + it('returns state from payload', () => { + const payload1 = getFormData('TQ12345678') + const payload2 = getFormData() + + const value1 = field.getStateFromValidForm(payload1) + const value2 = field.getStateFromValidForm(payload2) + + expect(value1).toEqual(getFormState('TQ12345678')) + expect(value2).toEqual(getFormState(null)) + }) + }) + + describe('View model', () => { + it('sets Nunjucks component defaults', () => { + const viewModel = field.getViewModel(getFormData('TQ12345678')) + + expect(viewModel).toEqual( + expect.objectContaining({ + label: { text: def.title }, + name: 'myComponent', + id: 'myComponent', + value: 'TQ12345678' + }) + ) + }) + + it('includes instruction text when provided', () => { + const componentWithInstruction = new OsGridRefField( + { + ...def, + options: { instructionText: 'Enter in format **TQ12345678**' } + }, + { model } + ) + + const viewModel = componentWithInstruction.getViewModel( + getFormData('TQ12345678') + ) + + const instructionText = + 'instructionText' in viewModel ? viewModel.instructionText : undefined + expect(instructionText).toBeTruthy() + expect(instructionText).toContain('TQ12345678') + }) + }) + + 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(' TQ12345678'), + output: { value: getFormData('TQ 1234 5678') } + }, + { + input: getFormData('TQ12345678 '), + output: { value: getFormData('TQ 1234 5678') } + }, + { + input: getFormData(' TQ12345678 \n\n'), + output: { value: getFormData('TQ 1234 5678') } + } + ] + }, + { + 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('AA1234567'), + output: { + value: getFormData('AA1234567'), + 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/OsGridRefField.ts b/src/server/plugins/engine/components/OsGridRefField.ts new file mode 100644 index 000000000..e9d8ce598 --- /dev/null +++ b/src/server/plugins/engine/components/OsGridRefField.ts @@ -0,0 +1,71 @@ +import { type OsGridRefFieldComponent } from '@defra/forms-model' +import type joi from 'joi' + +import { LocationFieldBase } from '~/src/server/plugins/engine/components/LocationFieldBase.js' + +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 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{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]*$/ + + return { + 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 for processing + const cleanValue = value.replace(/\s/g, '') + 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') + } + + // 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)}` + + // Validate the formatted value against the OS grid pattern + if (!osGridPattern.test(formattedValue)) { + return helpers.error('string.pattern.base') + } + + // Return formatted value with spaces per GDS guidance + return formattedValue + } + } + } + + protected getErrorTemplates() { + return [ + { + type: 'pattern', + template: + 'Enter a valid OS grid reference for [short description] like TQ123456' + } + ] + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors() { + const instance = Object.create(OsGridRefField.prototype) as OsGridRefField + return instance.getAllPossibleErrors() + } +} 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 8fa55556d..052d2a63d 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 @@ -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 @@ -32,13 +36,12 @@ 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 + | InstanceType + | InstanceType + | InstanceType // List component instances only export type ListField = InstanceType< @@ -51,43 +54,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', - '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 @@ -95,7 +63,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( @@ -197,6 +165,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') { @@ -234,7 +218,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 }) @@ -325,6 +311,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/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/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/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' + } + } +}) diff --git a/src/server/plugins/engine/components/types.ts b/src/server/plugins/engine/components/types.ts index b1458a12d..0d8e37266 100644 --- a/src/server/plugins/engine/components/types.ts +++ b/src/server/plugins/engine/components/types.ts @@ -60,6 +60,10 @@ 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 } @@ -126,3 +130,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/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/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 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, 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..df6dbfffc --- /dev/null +++ b/src/server/plugins/engine/views/components/_location-field-base.html @@ -0,0 +1,53 @@ +{% 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 %} +
+ {{ 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 %} +
+ + {% 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 new file mode 100644 index 000000000..40f0aedcf --- /dev/null +++ b/src/server/plugins/engine/views/components/eastingnorthingfield.html @@ -0,0 +1,5 @@ +{% from "./_location-field-base.html" import LocationFieldBase %} + +{% macro EastingNorthingField(component) %} + {{ LocationFieldBase(component, "number", "numeric") }} +{% endmacro %} 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..b9ceb1f7e --- /dev/null +++ b/src/server/plugins/engine/views/components/latlongfield.html @@ -0,0 +1,5 @@ +{% from "./_location-field-base.html" import LocationFieldBase %} + +{% macro LatLongField(component) %} + {{ LocationFieldBase(component, "text", "decimal") }} +{% endmacro %} 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..1f8666586 --- /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.model.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.model.instructionText | safe + }) }} + {% endif %} +{% endmacro %} 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..aec74fc95 --- /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.model.instructionText %} + {{ govukDetails({ + summaryText: "How to find location details", + html: component.model.instructionText | safe + }) }} + {% endif %} +{% endmacro %}