From 9b5e0c153aeb1dc1ec429db14912971bc38f0ac1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 6 Oct 2025 16:46:58 +0100 Subject: [PATCH 01/96] POC external components --- src/server/forms/components.json | 15 + src/server/forms/external-components.json | 942 ++++++++++++++++++ .../CustomerReferenceField.ts | 122 +++ .../CustomerReferenceField/routes.ts | 71 ++ .../engine/components/helpers/components.ts | 5 + src/server/plugins/engine/components/index.ts | 1 + src/server/plugins/engine/models/FormModel.ts | 16 +- .../pageControllers/QuestionPageController.ts | 34 +- src/server/plugins/engine/plugin.ts | 15 +- src/server/plugins/engine/routes/index.ts | 14 + .../engine/services/localFormsService.js | 9 + .../components/customerreferencefield.html | 43 + src/server/schemas/index.ts | 16 +- 13 files changed, 1291 insertions(+), 12 deletions(-) create mode 100644 src/server/forms/external-components.json create mode 100644 src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts create mode 100644 src/server/plugins/engine/components/CustomerReferenceField/routes.ts create mode 100644 src/server/plugins/engine/views/components/customerreferencefield.html diff --git a/src/server/forms/components.json b/src/server/forms/components.json index b96bea36c..8a1c5dcaa 100644 --- a/src/server/forms/components.json +++ b/src/server/forms/components.json @@ -14,6 +14,14 @@ "options": {}, "schema": {} }, + { + "type": "CustomerReferenceField", + "name": "customerReferenceNumber", + "title": "Customer reference number", + "hint": "Help text", + "options": {}, + "schema": {} + }, { "type": "MultilineTextField", "name": "multilineTextField", @@ -120,6 +128,13 @@ "content": "### This is a H3 in markdown\n\n[An internal link](http://localhost:3009/fictional-page)\n\n[An external link](https://defra.gov.uk/fictional-page)", "options": {}, "schema": {} + }, + { + "title": "Summary", + "path": "/summary", + "controller": "SummaryPageController", + "components": [], + "next": [] } ] } diff --git a/src/server/forms/external-components.json b/src/server/forms/external-components.json new file mode 100644 index 000000000..725b2b1a2 --- /dev/null +++ b/src/server/forms/external-components.json @@ -0,0 +1,942 @@ +{ + "conditions": [], + "engine": "V2", + "startPage": "/all-components", + "pages": [ + { + "path": "/all-components", + "title": "Details about you", + "components": [ + { + "type": "TextField", + "name": "applicantName", + "title": "What's your name?", + "shortDescription": "Your name", + "hint": "This must match exactly what's written on your passport.", + "options": {}, + "schema": {} + }, + { + "type": "CustomerReferenceField", + "name": "customerReferenceNumber", + "title": "What's your customer reference number?", + "shortDescription": "Your customer reference number", + "hint": "", + "options": {}, + "schema": {} + }, + { + "type": "TextField", + "name": "applicantProfession", + "title": "What's your job title?", + "shortDescription": "Your job title", + "hint": "If you are unemployed, please enter 'unemployed'.", + "options": {}, + "schema": {} + } + ] + }, + { + "title": "Summary", + "path": "/summary", + "controller": "SummaryPageController", + "next": [] + } + ], + "sections": [ + { + "name": "checkBeforeStart", + "title": "Check before you start" + }, + { + "name": "personalDetails", + "title": "Personal details" + }, + { + "name": "companyDetails", + "title": "Company details" + } + ], + "lists": [ + { + "name": "yesNoUnsure", + "title": "Yes/No/Not sure", + "type": "string", + "items": [ + { + "text": "Yes", + "value": "yes" + }, + { + "text": "No", + "value": "no" + }, + { + "text": "Not sure", + "value": "unsure" + } + ] + }, + { + "name": "companyType", + "title": "Company type", + "type": "string", + "items": [ + { + "text": "Sole trader", + "value": "soleTrader" + }, + { + "text": "Private Limited Company", + "value": "privateLimitedCompany" + }, + { + "text": "Public Limited Company", + "value": "publicLimitedCompany" + }, + { + "text": "Limited Liability Partnership", + "value": "limitedLiabilityPartnership" + }, + { + "text": "Charity", + "value": "charity" + }, + { + "text": "Other", + "value": "other" + } + ] + }, + { + "name": "country", + "title": "Country", + "type": "number", + "items": [ + { + "text": "Afghanistan", + "value": 910400000 + }, + { + "text": "Albania", + "value": 910400001 + }, + { + "text": "Algeria", + "value": 910400002 + }, + { + "text": "Andorra", + "value": 910400003 + }, + { + "text": "Angola", + "value": 910400004 + }, + { + "text": "Antigua and Barbuda", + "value": 910400005 + }, + { + "text": "Argentina", + "value": 910400006 + }, + { + "text": "Armenia", + "value": 910400007 + }, + { + "text": "Australia", + "value": 910400008 + }, + { + "text": "Austria", + "value": 910400009 + }, + { + "text": "Azerbaijan", + "value": 910400010 + }, + { + "text": "Bahrain", + "value": 910400011 + }, + { + "text": "Bangladesh", + "value": 910400012 + }, + { + "text": "Barbados", + "value": 910400013 + }, + { + "text": "Belarus", + "value": 910400014 + }, + { + "text": "Belgium", + "value": 910400015 + }, + { + "text": "Belize", + "value": 910400016 + }, + { + "text": "Benin", + "value": 910400017 + }, + { + "text": "Bhutan", + "value": 910400018 + }, + { + "text": "Bolivia", + "value": 910400019 + }, + { + "text": "Bosnia and Herzegovina", + "value": 910400020 + }, + { + "text": "Botswana", + "value": 910400021 + }, + { + "text": "Brazil", + "value": 910400022 + }, + { + "text": "Brunei", + "value": 910400023 + }, + { + "text": "Bulgaria", + "value": 910400024 + }, + { + "text": "Burkina Faso", + "value": 910400025 + }, + { + "text": "Burma", + "value": 910400026 + }, + { + "text": "Burundi", + "value": 910400027 + }, + { + "text": "Cambodia", + "value": 910400028 + }, + { + "text": "Cameroon", + "value": 910400029 + }, + { + "text": "Canada", + "value": 910400030 + }, + { + "text": "Cape Verde", + "value": 910400031 + }, + { + "text": "Central African Republic", + "value": 910400032 + }, + { + "text": "Chad", + "value": 910400033 + }, + { + "text": "Chile", + "value": 910400034 + }, + { + "text": "China", + "value": 910400035 + }, + { + "text": "Colombia", + "value": 910400036 + }, + { + "text": "Comoros", + "value": 910400037 + }, + { + "text": "Congo", + "value": 910400038 + }, + { + "text": "Congo (Democratic Republic)", + "value": 910400039 + }, + { + "text": "Costa Rica", + "value": 910400040 + }, + { + "text": "Croatia", + "value": 910400041 + }, + { + "text": "Cuba", + "value": 910400042 + }, + { + "text": "Cyprus", + "value": 910400043 + }, + { + "text": "Czech Republic", + "value": 910400044 + }, + { + "text": "Denmark", + "value": 910400045 + }, + { + "text": "Djibouti", + "value": 910400046 + }, + { + "text": "Dominica", + "value": 910400047 + }, + { + "text": "Dominican Republic", + "value": 910400048 + }, + { + "text": "East Timor", + "value": 910400049 + }, + { + "text": "Ecuador", + "value": 910400050 + }, + { + "text": "Egypt", + "value": 910400051 + }, + { + "text": "El Salvador", + "value": 910400052 + }, + { + "text": "Equatorial Guinea", + "value": 910400053 + }, + { + "text": "Eritrea", + "value": 910400054 + }, + { + "text": "Estonia", + "value": 910400055 + }, + { + "text": "Ethiopia", + "value": 910400056 + }, + { + "text": "Fiji", + "value": 910400057 + }, + { + "text": "Finland", + "value": 910400058 + }, + { + "text": "France", + "value": 910400059 + }, + { + "text": "Gabon", + "value": 910400060 + }, + { + "text": "Georgia", + "value": 910400061 + }, + { + "text": "Germany", + "value": 910400062 + }, + { + "text": "Ghana", + "value": 910400063 + }, + { + "text": "Greece", + "value": 910400064 + }, + { + "text": "Grenada", + "value": 910400065 + }, + { + "text": "Guatemala", + "value": 910400066 + }, + { + "text": "Guinea", + "value": 910400067 + }, + { + "text": "Guinea-Bissau", + "value": 910400068 + }, + { + "text": "Guyana", + "value": 910400069 + }, + { + "text": "Haiti", + "value": 910400070 + }, + { + "text": "Honduras", + "value": 910400071 + }, + { + "text": "Hungary", + "value": 910400072 + }, + { + "text": "Iceland", + "value": 910400073 + }, + { + "text": "India", + "value": 910400074 + }, + { + "text": "Indonesia", + "value": 910400075 + }, + { + "text": "Iran", + "value": 910400076 + }, + { + "text": "Iraq", + "value": 910400077 + }, + { + "text": "Ireland", + "value": 910400078 + }, + { + "text": "Israel", + "value": 910400079 + }, + { + "text": "Italy", + "value": 910400080 + }, + { + "text": "Ivory Coast", + "value": 910400081 + }, + { + "text": "Jamaica", + "value": 910400082 + }, + { + "text": "Japan", + "value": 910400083 + }, + { + "text": "Jordan", + "value": 910400084 + }, + { + "text": "Kazakhstan", + "value": 910400085 + }, + { + "text": "Kenya", + "value": 910400086 + }, + { + "text": "Kiribati", + "value": 910400087 + }, + { + "text": "Kosovo", + "value": 910400088 + }, + { + "text": "Kuwait", + "value": 910400089 + }, + { + "text": "Kyrgyzstan", + "value": 910400090 + }, + { + "text": "Laos", + "value": 910400091 + }, + { + "text": "Latvia", + "value": 910400092 + }, + { + "text": "Lebanon", + "value": 910400093 + }, + { + "text": "Lesotho", + "value": 910400094 + }, + { + "text": "Liberia", + "value": 910400095 + }, + { + "text": "Libya", + "value": 910400096 + }, + { + "text": "Liechtenstein", + "value": 910400097 + }, + { + "text": "Lithuania", + "value": 910400098 + }, + { + "text": "Luxembourg", + "value": 910400099 + }, + { + "text": "Macedonia", + "value": 910400100 + }, + { + "text": "Madagascar", + "value": 910400101 + }, + { + "text": "Malawi", + "value": 910400102 + }, + { + "text": "Malaysia", + "value": 910400103 + }, + { + "text": "Maldives", + "value": 910400104 + }, + { + "text": "Mali", + "value": 910400105 + }, + { + "text": "Malta", + "value": 910400106 + }, + { + "text": "Marshall Islands", + "value": 910400107 + }, + { + "text": "Mauritania", + "value": 910400108 + }, + { + "text": "Mauritius", + "value": 910400109 + }, + { + "text": "Mexico", + "value": 910400110 + }, + { + "text": "Micronesia", + "value": 910400111 + }, + { + "text": "Moldova", + "value": 910400112 + }, + { + "text": "Monaco", + "value": 910400113 + }, + { + "text": "Mongolia", + "value": 910400114 + }, + { + "text": "Montenegro", + "value": 910400115 + }, + { + "text": "Morocco", + "value": 910400116 + }, + { + "text": "Mozambique", + "value": 910400117 + }, + { + "text": "Namibia", + "value": 910400118 + }, + { + "text": "Nauru", + "value": 910400119 + }, + { + "text": "Nepal", + "value": 910400120 + }, + { + "text": "Netherlands", + "value": 910400121 + }, + { + "text": "New Zealand", + "value": 910400122 + }, + { + "text": "Nicaragua", + "value": 910400123 + }, + { + "text": "Niger", + "value": 910400124 + }, + { + "text": "Nigeria", + "value": 910400125 + }, + { + "text": "North Korea", + "value": 910400126 + }, + { + "text": "Norway", + "value": 910400127 + }, + { + "text": "Oman", + "value": 910400128 + }, + { + "text": "Pakistan", + "value": 910400129 + }, + { + "text": "Palau", + "value": 910400130 + }, + { + "text": "Panama", + "value": 910400131 + }, + { + "text": "Papua New Guinea", + "value": 910400132 + }, + { + "text": "Paraguay", + "value": 910400133 + }, + { + "text": "Peru", + "value": 910400134 + }, + { + "text": "Philippines", + "value": 910400135 + }, + { + "text": "Poland", + "value": 910400136 + }, + { + "text": "Portugal", + "value": 910400137 + }, + { + "text": "Qatar", + "value": 910400138 + }, + { + "text": "Romania", + "value": 910400139 + }, + { + "text": "Russia", + "value": 910400140 + }, + { + "text": "Rwanda", + "value": 910400141 + }, + { + "text": "Samoa", + "value": 910400142 + }, + { + "text": "San Marino", + "value": 910400143 + }, + { + "text": "Sao Tome and Principe", + "value": 910400144 + }, + { + "text": "Saudi Arabia", + "value": 910400145 + }, + { + "text": "Senegal", + "value": 910400146 + }, + { + "text": "Serbia", + "value": 910400147 + }, + { + "text": "Seychelles", + "value": 910400148 + }, + { + "text": "Sierra Leone", + "value": 910400149 + }, + { + "text": "Singapore", + "value": 910400150 + }, + { + "text": "Slovakia", + "value": 910400151 + }, + { + "text": "Slovenia", + "value": 910400152 + }, + { + "text": "Solomon Islands", + "value": 910400153 + }, + { + "text": "Somalia", + "value": 910400154 + }, + { + "text": "South Africa", + "value": 910400155 + }, + { + "text": "South Korea", + "value": 910400156 + }, + { + "text": "South Sudan", + "value": 910400157 + }, + { + "text": "Spain", + "value": 910400158 + }, + { + "text": "Sri Lanka", + "value": 910400159 + }, + { + "text": "St Kitts and Nevis", + "value": 910400160 + }, + { + "text": "St Lucia", + "value": 910400161 + }, + { + "text": "St Vincent", + "value": 910400162 + }, + { + "text": "Sudan", + "value": 910400163 + }, + { + "text": "Suriname", + "value": 910400164 + }, + { + "text": "Swaziland", + "value": 910400165 + }, + { + "text": "Sweden", + "value": 910400166 + }, + { + "text": "Switzerland", + "value": 910400167 + }, + { + "text": "Syria", + "value": 910400168 + }, + { + "text": "Tajikistan", + "value": 910400169 + }, + { + "text": "Tanzania", + "value": 910400170 + }, + { + "text": "Thailand", + "value": 910400171 + }, + { + "text": "The Bahamas", + "value": 910400172 + }, + { + "text": "The Gambia", + "value": 910400173 + }, + { + "text": "Togo", + "value": 910400174 + }, + { + "text": "Tonga", + "value": 910400175 + }, + { + "text": "Trinidad and Tobago", + "value": 910400176 + }, + { + "text": "Tunisia", + "value": 910400177 + }, + { + "text": "Turkey", + "value": 910400178 + }, + { + "text": "Turkmenistan", + "value": 910400179 + }, + { + "text": "Tuvalu", + "value": 910400180 + }, + { + "text": "Uganda", + "value": 910400181 + }, + { + "text": "Ukraine", + "value": 910400182 + }, + { + "text": "United Arab Emirates", + "value": 910400183 + }, + { + "text": "United Kingdom", + "value": 910400184 + }, + { + "text": "United States", + "value": 910400185 + }, + { + "text": "Uruguay", + "value": 910400186 + }, + { + "text": "Uzbekistan", + "value": 910400187 + }, + { + "text": "Vanuatu", + "value": 910400188 + }, + { + "text": "Vatican City", + "value": 910400189 + }, + { + "text": "Venezuela", + "value": 910400190 + }, + { + "text": "Vietnam", + "value": 910400191 + }, + { + "text": "Yemen", + "value": 910400192 + }, + { + "text": "Zambia", + "value": 910400193 + }, + { + "text": "Zimbabwe", + "value": 910400194 + }, + { + "text": "England", + "value": 910400195 + }, + { + "text": "Wales", + "value": 910400196 + }, + { + "text": "Scotland", + "value": 910400197 + }, + { + "text": "Northern Ireland", + "value": 910400198 + } + ] + }, + { + "name": "horseBreed", + "title": "Horse breed", + "type": "string", + "items": [ + { + "text": "Arabian", + "value": "Arabian" + }, + { + "text": "Patomine", + "value": "Patomine" + }, + { + "text": "Shire", + "value": "Shire" + }, + { + "text": "Shetland", + "value": "Shetland" + }, + { + "text": "Race", + "value": "Race" + } + ] + } + ] +} diff --git a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts new file mode 100644 index 000000000..49ec75957 --- /dev/null +++ b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts @@ -0,0 +1,122 @@ +import { type FormComponentsDef } from '@defra/forms-model' +import joi, { type ObjectSchema } from 'joi' + +import { type FormRequestPayload } from '../../types/index.js' + +import { getRoutes } from './routes.js' + +import { + FormComponent, + isFormValue +} from '~/src/server/plugins/engine/components/FormComponent.js' +import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' +import { + type ErrorMessageTemplateList, + type FormState, + type FormStateValue, + type FormSubmissionState +} from '~/src/server/plugins/engine/types.js' + +export class CustomerReferenceField extends FormComponent { + declare options: CustomerReferenceFieldComponent['options'] + + declare schema: CustomerReferenceFieldComponent['schema'] + + declare formSchema: ObjectSchema + declare stateSchema: ObjectSchema + + constructor( + def: CustomerReferenceFieldComponent, + props: ConstructorParameters[1] + ) { + super(def, props) + + const { options } = def + const schema = 'schema' in def ? def.schema : {} + + this.formSchema = joi.string().required() + this.stateSchema = joi + .object() + .keys({ + reference: joi.string().required(), + _id: joi.string().required() + }) + .required() + this.options = options + this.schema = schema + } + + getFormValueFromState(state: FormSubmissionState) { + const { name } = this + return this.getFormValue(state[name]) + } + + getFormValue(value?: FormStateValue | FormState) { + return this.isValue(value) ? value.reference : undefined + } + + isValue(value?: FormStateValue | FormState): value is CustomerReferenceState { + return CustomerReferenceField.isCustomerReferenceField(value) + } + + // getFormDataFromState(state: FormSubmissionState): FormPayload { + // const { collection, name } = this + + // if (collection) { + // return collection.getFormDataFromState(state) + // } + + // return { + // [name]: this.getFormValue(state[name]) + // } + // } + + /** + * For error preview page that shows all possible errors on a component + */ + getAllPossibleErrors(): ErrorMessageTemplateList { + return CustomerReferenceField.getAllPossibleErrors() + } + + /** + * Static version of getAllPossibleErrors that doesn't require a component instance. + */ + static getAllPossibleErrors(): ErrorMessageTemplateList { + return { + baseErrors: [{ type: 'required', template: messageTemplate.required }], + advancedSettingsErrors: [ + { type: 'min', template: messageTemplate.min }, + { type: 'max', template: messageTemplate.max } + ] + } + } + + static isCustomerReferenceField( + value?: FormStateValue | FormState + ): value is CustomerReferenceState { + return value !== null && typeof value === 'object' && '_id' in value + } + + getRoutes() { + return { + routes: getRoutes(), + entrypoint: '/customer-reference-field/confirm' + } + } +} + +export interface CustomerReferenceFieldComponent extends FormComponentsDef { + id?: string + type: 'CustomerReferenceField' + shortDescription?: string + name: string + title: string + hint?: string + options: object + schema: object +} + +interface CustomerReferenceState { + _id: string + reference: string +} diff --git a/src/server/plugins/engine/components/CustomerReferenceField/routes.ts b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts new file mode 100644 index 000000000..25b215747 --- /dev/null +++ b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts @@ -0,0 +1,71 @@ +import { type Request, type ResponseToolkit, type ServerRoute } from '@hapi/hapi' +import Joi from 'joi' + +function randomReference() { + // Example: 3 groups of 3 digits + return `${Math.floor(100+Math.random()*900)}-${Math.floor(100+Math.random()*900)}-${Math.floor(100+Math.random()*900)}` +} + +function randomId() { + // Example: 32 hex chars + return Array.from({length:32},()=>Math.floor(Math.random()*16).toString(16)).join("") +} + +export function initiateHandler(request: Request, h: ResponseToolkit) { + const returnUrl = request.query.returnUrl + const component = request.query.component + + const data = { + reference: randomReference(), + _id: randomId() + } + + request.yar.set('returnUrl', returnUrl) + request.yar.set('component', component) + request.yar.set('data', data) + + return h.response( + ` +

Simulated external service page

+ +

You have been generated a reference number: ${data.reference}.

+ +
+ +
+ ` + ) +} + +export function confirmHandler(request: Request, h: ResponseToolkit) { + const component = request.yar.get('component') + const data = request.yar.get('data') + const returnUrl = request.yar.get('returnUrl') + + return h.redirect( + `${returnUrl}?component=${component}&data=${JSON.stringify(data)}` + ) +} + +export function getRoutes(): ServerRoute[] { + return [ + { + method: 'get', + path: '/customer-reference-field/confirm', + handler: initiateHandler, + options: { + validate: { + query: Joi.object().keys({ + component: Joi.string().required(), + returnUrl: Joi.string().uri().required() + }) + } + } + }, + { + method: 'post', + path: '/customer-reference-field/confirm', + handler: confirmHandler + } + ] +} diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index 8fa55556d..3cc542474 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -29,6 +29,7 @@ export type Field = InstanceType< | typeof Components.TextField | typeof Components.UkAddressField | typeof Components.FileUploadField + | typeof Components.CustomerReferenceField > // Guidance component instances only @@ -197,6 +198,10 @@ export function createComponent( case ComponentType.FileUploadField: component = new Components.FileUploadField(def, options) break + + case 'CustomerReferenceField': + component = new Components.CustomerReferenceField(def, options) + break } if (typeof component === 'undefined') { diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 8b6d001b7..611105632 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -23,3 +23,4 @@ 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 { CustomerReferenceField } from '~/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.js' diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index e6fa7506b..0c88f34cb 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -551,7 +551,12 @@ function validateFormPayload( // Skip validation GET requests or other actions if ( !request.payload || - (action && ![FormAction.Validate, FormAction.SaveAndExit].includes(action)) + (action && + ![ + FormAction.Validate, + FormAction.SaveAndExit, + 'external-component-edit-customerReferenceNumber' + ].includes(action)) ) { return context } @@ -575,6 +580,15 @@ function validateFormPayload( }) // Add sanitised payload (ready to save) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + // const formState = { + // ...page.getStateFromValidForm(request, state, value), + // // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + // ...(request.query.data ? JSON.parse(request.query.data) : {}) // TOOD + // } + + // Add sanitised payload (ready to save) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const formState = page.getStateFromValidForm(request, state, value) return { diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index b912a6449..8ade16a8e 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -12,12 +12,15 @@ import Boom from '@hapi/boom' import { type RouteOptions } from '@hapi/hapi' import { type ValidationErrorItem } from 'joi' +import { CustomerReferenceField } from '../components/index.js' + import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' import { getCacheService, getErrors, + getPage, getSaveAndExitHelpers, normalisePath, proceed @@ -493,11 +496,16 @@ export class QuestionPageController extends PageController { const { collection, viewName, model } = this const { isForceAccess, state, evaluationState } = context + const action = request.payload.action ?? '' + /** * If there are any errors, render the page with the parsed errors * @todo Refactor to match POST REDIRECT GET pattern */ - if (context.errors || isForceAccess) { + if ( + (!action.startsWith('external-component-edit') && context.errors) || // ensure that normal components still pass + isForceAccess + ) { const viewModel = this.getViewModel(request, context) viewModel.errors = collection.getViewErrors(viewModel.errors) @@ -514,8 +522,30 @@ export class QuestionPageController extends PageController { // Save state await this.setState(request, state) + if ( + action && + action === 'external-component-edit-customerReferenceNumber' && + context.errors + ) { + const errorComponents = + context.errors?.map((error) => + request.app.model?.componentMap.get(error.name) + ) ?? [] + + const componentName = action.split('external-component-edit-')[1] + + for (const errorComponent of errorComponents) { + if (typeof errorComponent?.getRoutes === 'function') { + const { entrypoint } = errorComponent.getRoutes() + + return h.redirect( + `${entrypoint}?component=${componentName}&returnUrl=${encodeURI(request.url.href)}` + ) + } + } + } + // Check if this is a save-and-exit action - const { action } = request.payload if (action === FormAction.SaveAndExit) { return this.handleSaveAndExit(request, context, h) } diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 2c783d38d..08d579c93 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -6,6 +6,7 @@ import { type ServerRoute } from '@hapi/hapi' +import * as Components from '~/src/server/plugins/engine/components/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { validatePluginOptions } from '~/src/server/plugins/engine/options.js' import { getRoutes as getFileUploadStatusRoutes } from '~/src/server/plugins/engine/routes/file-upload.js' @@ -79,6 +80,17 @@ export const plugin = { ] } + // Collect routes from components with a static getRoutes method + const componentRoutes = [] + for (const comp of Object.values(Components)) { + if (typeof comp?.prototype.getRoutes === 'function') { + const { routes } = comp.prototype.getRoutes() + if (Array.isArray(routes)) { + componentRoutes.push(...routes) + } + } + } + const routes = [ ...getQuestionRoutes( getRouteOptions, @@ -87,7 +99,8 @@ export const plugin = { ), ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions), ...getRepeaterItemDeleteRoutes(getRouteOptions, postRouteOptions), - ...getFileUploadStatusRoutes() + ...getFileUploadStatusRoutes(), + ...componentRoutes ] server.route(routes as unknown as ServerRoute[]) // TODO diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 93ea2584c..6fd8f2852 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -6,6 +6,8 @@ import { } from '@hapi/hapi' import { isEqual } from 'date-fns' +import { CustomerReferenceField } from '../components/index.js' + import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { checkEmailAddressForLiveFormSubmission, @@ -64,6 +66,18 @@ export async function redirectOrMakeHandler( }) } + if ( + request.query.component && + request.query.data && + typeof request.query.data === 'string' + ) { + state = await page.mergeState(request, state, { + ...(request.query.data + ? { [request.query.component]: JSON.parse(request.query.data) } + : {}) + }) + } + const flash = cacheService.getFlash(request) const context = model.getFormContext(request, state, flash?.errors) const relevantPath = page.getRelevantPath(request, context) diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index c824bad34..afc6d70ef 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -51,5 +51,14 @@ export const formsService = async () => { slug: 'components' }) + // external-components + + await loader.addForm('src/server/forms/external-components.json', { + ...metadata, + id: 'z6a872d3b-13f9e-804ce3e-4830-5c45fb32', + title: 'external-components', + slug: 'external-components' + }) + return loader.toFormsService() } diff --git a/src/server/plugins/engine/views/components/customerreferencefield.html b/src/server/plugins/engine/views/components/customerreferencefield.html new file mode 100644 index 000000000..409b5ac76 --- /dev/null +++ b/src/server/plugins/engine/views/components/customerreferencefield.html @@ -0,0 +1,43 @@ + +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/hint/macro.njk" import govukHint %} + +{% macro CustomerReferenceField(component) %} + {# TODO if component.isExternal, modify component.model.label.classes in QuestionPageController.ts to use govuk-heading-m not govuk-label-m #} + {#

{{ component.model.label.text }}

#} +

{{ component.model.label.text }}

+ + {% if component.model.hint %} + {{ govukHint({ + id: component.model.name + "-hint", + text: component.model.hint.text + }) }} + {# TODO check accessibility #} + {% endif %} + + {# TODO externalise
into a generic base for external components
#} + {% if component.model.value %} +

Your reference number: {{ component.model.value }}

+ + {{ govukButton({ + text: "Edit reference", + type: "submit", + name: "action", + value: "external-component-edit-" + component.model.name, + preventDoubleClick: true, + classes: "govuk-button--secondary" + }) }} + {% else %} +
+ Use the button below to look up your reference number from name of external system. +
+ {{ govukButton({ + text: "Fetch reference", + type: "submit", + name: "action", + value: "external-component-edit-" + component.model.name, + preventDoubleClick: true, + classes: "govuk-button--secondary" + }) }} + {% endif %} +{% endmacro %} diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts index f181e7f01..8d4c75467 100644 --- a/src/server/schemas/index.ts +++ b/src/server/schemas/index.ts @@ -8,14 +8,14 @@ export const stateSchema = Joi.string() .required() export const actionSchema = Joi.string() - .valid( - FormAction.Continue, - FormAction.Validate, - FormAction.Delete, - FormAction.AddAnother, - FormAction.Send, - FormAction.SaveAndExit - ) + // .valid( + // FormAction.Continue, + // FormAction.Validate, + // FormAction.Delete, + // FormAction.AddAnother, + // FormAction.Send, + // FormAction.SaveAndExit + // ) .default(FormAction.Validate) .optional() From 0d1ddb33e1ca9555d0a689a33f636e0623f66662 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 7 Oct 2025 11:02:19 +0100 Subject: [PATCH 02/96] external components should work even if not in error state --- .../CustomerReferenceField.ts | 49 ++++++++----------- .../pageControllers/QuestionPageController.ts | 30 +++++------- .../plugins/engine/validationHelpers.ts | 23 +++++++++ 3 files changed, 55 insertions(+), 47 deletions(-) create mode 100644 src/server/plugins/engine/validationHelpers.ts diff --git a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts index 49ec75957..b70cf1818 100644 --- a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts +++ b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts @@ -1,20 +1,17 @@ import { type FormComponentsDef } from '@defra/forms-model' import joi, { type ObjectSchema } from 'joi' -import { type FormRequestPayload } from '../../types/index.js' import { getRoutes } from './routes.js' -import { - FormComponent, - isFormValue -} from '~/src/server/plugins/engine/components/FormComponent.js' +import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, + type FormPayload, type FormState, type FormStateValue, - type FormSubmissionState + type FormSubmissionError } from '~/src/server/plugins/engine/types.js' export class CustomerReferenceField extends FormComponent { @@ -34,43 +31,22 @@ export class CustomerReferenceField extends FormComponent { const { options } = def const schema = 'schema' in def ? def.schema : {} - this.formSchema = joi.string().required() - this.stateSchema = joi + this.formSchema = joi .object() .keys({ reference: joi.string().required(), _id: joi.string().required() }) .required() + this.stateSchema = this.formSchema this.options = options this.schema = schema } - getFormValueFromState(state: FormSubmissionState) { - const { name } = this - return this.getFormValue(state[name]) - } - - getFormValue(value?: FormStateValue | FormState) { - return this.isValue(value) ? value.reference : undefined - } - isValue(value?: FormStateValue | FormState): value is CustomerReferenceState { return CustomerReferenceField.isCustomerReferenceField(value) } - // getFormDataFromState(state: FormSubmissionState): FormPayload { - // const { collection, name } = this - - // if (collection) { - // return collection.getFormDataFromState(state) - // } - - // return { - // [name]: this.getFormValue(state[name]) - // } - // } - /** * For error preview page that shows all possible errors on a component */ @@ -91,6 +67,21 @@ export class CustomerReferenceField extends FormComponent { } } + getDisplayStringFromFormValue(value?: FormStateValue | FormState): string { + if (this.isValue(value)) { + return value.reference + } + return '' + } + + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + const viewModel = super.getViewModel(payload, errors) + + viewModel.value = this.getDisplayStringFromFormValue(payload[this.name]) + + return viewModel + } + static isCustomerReferenceField( value?: FormStateValue | FormState ): value is CustomerReferenceState { diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 8ade16a8e..1ed77abc3 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -12,15 +12,12 @@ import Boom from '@hapi/boom' import { type RouteOptions } from '@hapi/hapi' import { type ValidationErrorItem } from 'joi' -import { CustomerReferenceField } from '../components/index.js' - import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' import { getCacheService, getErrors, - getPage, getSaveAndExitHelpers, normalisePath, proceed @@ -38,6 +35,7 @@ import { type FormStateValue, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js' import { FormAction, type FormRequest, @@ -524,25 +522,21 @@ export class QuestionPageController extends PageController { if ( action && - action === 'external-component-edit-customerReferenceNumber' && - context.errors + action === 'external-component-edit-customerReferenceNumber' ) { - const errorComponents = - context.errors?.map((error) => - request.app.model?.componentMap.get(error.name) - ) ?? [] + const { externalComponents } = getComponentsByType( + request.app.model?.componentMap ?? new Map() + ) const componentName = action.split('external-component-edit-')[1] - for (const errorComponent of errorComponents) { - if (typeof errorComponent?.getRoutes === 'function') { - const { entrypoint } = errorComponent.getRoutes() - - return h.redirect( - `${entrypoint}?component=${componentName}&returnUrl=${encodeURI(request.url.href)}` - ) - } - } + const selectedComponent = externalComponents.get(componentName) + const { entrypoint } = ( + selectedComponent as { getRoutes: () => { entrypoint: string } } + ).getRoutes() + return h.redirect( + `${entrypoint}?component=${componentName}&returnUrl=${encodeURI(`${request.url.origin}${request.url.pathname}`)}` + ) } // Check if this is a save-and-exit action diff --git a/src/server/plugins/engine/validationHelpers.ts b/src/server/plugins/engine/validationHelpers.ts new file mode 100644 index 000000000..daa1b22b1 --- /dev/null +++ b/src/server/plugins/engine/validationHelpers.ts @@ -0,0 +1,23 @@ +/** + * Returns internal and external components from a componentMap, regardless of error state. + * @param componentMap - Map of component names to component instances + * @returns An object containing internalComponents and externalComponents arrays + */ +export function getComponentsByType( + componentMap: Map +): { internalComponents: Map; externalComponents: Map } { + const internalComponents = new Map() + const externalComponents = new Map() + + for (const [name, component] of componentMap.entries()) { + if ( + typeof (component as { getRoutes?: unknown }).getRoutes === 'function' + ) { + externalComponents.set(name, component) + } else { + internalComponents.set(name, component) + } + } + + return { internalComponents, externalComponents } +} From 881535d5c6c9950d64de293c236d21b67d670630 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 7 Oct 2025 16:11:53 +0100 Subject: [PATCH 03/96] Improved typing --- .../CustomerReferenceField.ts | 6 ++-- .../pageControllers/QuestionPageController.ts | 29 ++++++++++++------- src/server/plugins/engine/plugin.ts | 18 +++++------- .../plugins/engine/validationHelpers.ts | 29 ++++++++++++++----- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts index b70cf1818..94c1d4921 100644 --- a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts +++ b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts @@ -1,9 +1,7 @@ import { type FormComponentsDef } from '@defra/forms-model' import joi, { type ObjectSchema } from 'joi' - -import { getRoutes } from './routes.js' - +import { getRoutes } from '~/src/server/plugins/engine/components/CustomerReferenceField/routes.js' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { @@ -88,7 +86,7 @@ export class CustomerReferenceField extends FormComponent { return value !== null && typeof value === 'object' && '_id' in value } - getRoutes() { + static getRoutes() { return { routes: getRoutes(), entrypoint: '/customer-reference-field/confirm' diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 1ed77abc3..30fa49d44 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -520,20 +520,27 @@ export class QuestionPageController extends PageController { // Save state await this.setState(request, state) - if ( - action && - action === 'external-component-edit-customerReferenceNumber' - ) { - const { externalComponents } = getComponentsByType( - request.app.model?.componentMap ?? new Map() - ) + if (action && action.startsWith('external-component-edit-')) { + const { externalComponents } = getComponentsByType() const componentName = action.split('external-component-edit-')[1] - const selectedComponent = externalComponents.get(componentName) - const { entrypoint } = ( - selectedComponent as { getRoutes: () => { entrypoint: string } } - ).getRoutes() + const component = model.componentDefMap.get(componentName) + const componentType = component?.type + + if (!componentType) { + throw Boom.internal( + `External component of type ${componentType} not found` + ) + } + + const selectedComponent = externalComponents.get(componentType) + + if (!selectedComponent) { + throw Boom.internal(`External component ${componentName} not found`) + } + + const { entrypoint } = selectedComponent.getRoutes() return h.redirect( `${entrypoint}?component=${componentName}&returnUrl=${encodeURI(`${request.url.origin}${request.url.pathname}`)}` ) diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 08d579c93..a3159b37e 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -6,7 +6,6 @@ import { type ServerRoute } from '@hapi/hapi' -import * as Components from '~/src/server/plugins/engine/components/index.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { validatePluginOptions } from '~/src/server/plugins/engine/options.js' import { getRoutes as getFileUploadStatusRoutes } from '~/src/server/plugins/engine/routes/file-upload.js' @@ -15,6 +14,7 @@ import { getRoutes as getQuestionRoutes } from '~/src/server/plugins/engine/rout import { getRoutes as getRepeaterItemDeleteRoutes } from '~/src/server/plugins/engine/routes/repeaters/item-delete.js' import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engine/routes/repeaters/summary.js' import { type PluginOptions } from '~/src/server/plugins/engine/types.js' +import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js' import { registerVision } from '~/src/server/plugins/engine/vision.js' import { type FormRequestPayloadRefs, @@ -80,16 +80,12 @@ export const plugin = { ] } - // Collect routes from components with a static getRoutes method - const componentRoutes = [] - for (const comp of Object.values(Components)) { - if (typeof comp?.prototype.getRoutes === 'function') { - const { routes } = comp.prototype.getRoutes() - if (Array.isArray(routes)) { - componentRoutes.push(...routes) - } - } - } + // Collect routes from components with a static getRoutes method using getComponentsByType + const { externalComponents } = getComponentsByType() + + const componentRoutes = Array.from(externalComponents.values()).flatMap( + (comp) => comp.getRoutes().routes + ) const routes = [ ...getQuestionRoutes( diff --git a/src/server/plugins/engine/validationHelpers.ts b/src/server/plugins/engine/validationHelpers.ts index daa1b22b1..c2c1d8930 100644 --- a/src/server/plugins/engine/validationHelpers.ts +++ b/src/server/plugins/engine/validationHelpers.ts @@ -1,18 +1,33 @@ +import * as Components from '~/src/server/plugins/engine/components/index.js' + +// Type guard for ExternalComponent +export function isExternalComponent( + component: unknown +): component is ExternalComponent { + return typeof (component as ExternalComponent).getRoutes === 'function' +} + +// External components are guaranteed to have getRoutes +export interface ExternalComponent { + getRoutes(): { routes: unknown[]; entrypoint: string } +} + /** * Returns internal and external components from a componentMap, regardless of error state. * @param componentMap - Map of component names to component instances * @returns An object containing internalComponents and externalComponents arrays */ -export function getComponentsByType( - componentMap: Map -): { internalComponents: Map; externalComponents: Map } { +export function getComponentsByType(): { + internalComponents: Map + externalComponents: Map +} { const internalComponents = new Map() - const externalComponents = new Map() + const externalComponents = new Map() + + const componentMap = new Map(Object.entries(Components)) for (const [name, component] of componentMap.entries()) { - if ( - typeof (component as { getRoutes?: unknown }).getRoutes === 'function' - ) { + if (isExternalComponent(component)) { externalComponents.set(name, component) } else { internalComponents.set(name, component) From e798f382b9cd4b0c5ed6e6898b1ea34fa2a6e939 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 9 Oct 2025 12:29:15 +0100 Subject: [PATCH 04/96] Use yar.flash rather than redirects to avoid user manipulation --- .../CustomerReferenceField.ts | 11 +++- .../CustomerReferenceField/routes.ts | 22 +++++-- src/server/plugins/engine/models/FormModel.ts | 9 --- src/server/plugins/engine/routes/index.ts | 66 +++++++++++++++---- 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts index 94c1d4921..0c2f5f14e 100644 --- a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts +++ b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts @@ -45,6 +45,10 @@ export class CustomerReferenceField extends FormComponent { return CustomerReferenceField.isCustomerReferenceField(value) } + isState(value?: FormStateValue | FormState): value is FormState { + return CustomerReferenceField.isCustomerReferenceField(value) + } + /** * For error preview page that shows all possible errors on a component */ @@ -83,7 +87,12 @@ export class CustomerReferenceField extends FormComponent { static isCustomerReferenceField( value?: FormStateValue | FormState ): value is CustomerReferenceState { - return value !== null && typeof value === 'object' && '_id' in value + return ( + value !== null && + typeof value === 'object' && + '_id' in value && + 'reference' in value + ) } static getRoutes() { diff --git a/src/server/plugins/engine/components/CustomerReferenceField/routes.ts b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts index 25b215747..2486546be 100644 --- a/src/server/plugins/engine/components/CustomerReferenceField/routes.ts +++ b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts @@ -1,14 +1,20 @@ -import { type Request, type ResponseToolkit, type ServerRoute } from '@hapi/hapi' +import { + type Request, + type ResponseToolkit, + type ServerRoute +} from '@hapi/hapi' import Joi from 'joi' function randomReference() { // Example: 3 groups of 3 digits - return `${Math.floor(100+Math.random()*900)}-${Math.floor(100+Math.random()*900)}-${Math.floor(100+Math.random()*900)}` + return `${Math.floor(100 + Math.random() * 900)}-${Math.floor(100 + Math.random() * 900)}-${Math.floor(100 + Math.random() * 900)}` } function randomId() { // Example: 32 hex chars - return Array.from({length:32},()=>Math.floor(Math.random()*16).toString(16)).join("") + return Array.from({ length: 32 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join('') } export function initiateHandler(request: Request, h: ResponseToolkit) { @@ -42,9 +48,15 @@ export function confirmHandler(request: Request, h: ResponseToolkit) { const data = request.yar.get('data') const returnUrl = request.yar.get('returnUrl') - return h.redirect( - `${returnUrl}?component=${component}&data=${JSON.stringify(data)}` + request.yar.flash( + 'externalStateAppendage', + JSON.stringify({ + component, + data + }) ) + + return h.redirect(returnUrl) } export function getRoutes(): ServerRoute[] { diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 0c88f34cb..8e8f4fd3a 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -580,15 +580,6 @@ function validateFormPayload( }) // Add sanitised payload (ready to save) - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - // const formState = { - // ...page.getStateFromValidForm(request, state, value), - // // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - // ...(request.query.data ? JSON.parse(request.query.data) : {}) // TOOD - // } - - // Add sanitised payload (ready to save) - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const formState = page.getStateFromValidForm(request, state, value) return { diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 6fd8f2852..358a32e0c 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -6,9 +6,8 @@ import { } from '@hapi/hapi' import { isEqual } from 'date-fns' -import { CustomerReferenceField } from '../components/index.js' - import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' +import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -25,6 +24,7 @@ import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { type AnyFormRequest, type FormContext, + type FormSubmissionState, type PluginOptions } from '~/src/server/plugins/engine/types.js' import { @@ -66,17 +66,7 @@ export async function redirectOrMakeHandler( }) } - if ( - request.query.component && - request.query.data && - typeof request.query.data === 'string' - ) { - state = await page.mergeState(request, state, { - ...(request.query.data - ? { [request.query.component]: JSON.parse(request.query.data) } - : {}) - }) - } + state = await importExternalComponentState(request, page, state) const flash = cacheService.getFlash(request) const context = model.getFormContext(request, state, flash?.errors) @@ -99,6 +89,56 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } +function importExternalComponentState( + request: AnyFormRequest, + page: PageControllerClass, + state: FormSubmissionState +): Promise { + const externalComponentData = request.yar.flash('externalStateAppendage')[0] + + if (!externalComponentData) { + return Promise.resolve(state) + } + + let componentName + let stateAppendage + + try { + const parsedStateAppendage = JSON.parse(externalComponentData) + + componentName = parsedStateAppendage.component + stateAppendage = parsedStateAppendage.data + } catch (e) { + request.server.logger.error( + e, + 'Error parsing external component state JSON' + ) + throw new Error('Error parsing external component state JSON') + } + + const component = request.app.model?.componentMap.get(componentName) + + if (!component) { + throw new Error(`Component ${componentName} not found in form`) + } + + if (!(component instanceof FormComponent)) { + throw new Error( + `Component ${componentName} is not a FormComponent and does not support isState` + ) + } + + const isStateValid = component.isState(stateAppendage) + + if (!isStateValid) { + throw new Error(`State for component ${componentName} is invalid`) + } + + return page.mergeState(request, state, { + ...(stateAppendage ? { [componentName]: stateAppendage } : {}) + }) +} + export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong const prefix = server.realm.modifiers.route.prefix ?? '' From 77c7be7d4ef18c394148b969635f7e7d1cd8f79d Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 9 Oct 2025 17:05:14 +0100 Subject: [PATCH 05/96] Move external component into shared macro --- .../components/customerreferencefield.html | 46 +++++-------------- .../views/components/externalcomponent.html | 35 ++++++++++++++ 2 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 src/server/plugins/engine/views/components/externalcomponent.html diff --git a/src/server/plugins/engine/views/components/customerreferencefield.html b/src/server/plugins/engine/views/components/customerreferencefield.html index 409b5ac76..d5846ba74 100644 --- a/src/server/plugins/engine/views/components/customerreferencefield.html +++ b/src/server/plugins/engine/views/components/customerreferencefield.html @@ -1,43 +1,21 @@ {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/hint/macro.njk" import govukHint %} +{% from "components/externalcomponent.html" import ExternalComponent %} -{% macro CustomerReferenceField(component) %} - {# TODO if component.isExternal, modify component.model.label.classes in QuestionPageController.ts to use govuk-heading-m not govuk-label-m #} - {#

{{ component.model.label.text }}

#} -

{{ component.model.label.text }}

- - {% if component.model.hint %} - {{ govukHint({ - id: component.model.name + "-hint", - text: component.model.hint.text - }) }} - {# TODO check accessibility #} - {% endif %} - {# TODO externalise
into a generic base for external components
#} +{% macro CustomerReferenceField(component) %} {% if component.model.value %} -

Your reference number: {{ component.model.value }}

- - {{ govukButton({ - text: "Edit reference", - type: "submit", - name: "action", - value: "external-component-edit-" + component.model.name, - preventDoubleClick: true, - classes: "govuk-button--secondary" - }) }} + {% set bodyHtml %} +

Your reference number: {{ component.model.value }}

+ {% endset %} {% else %} -
- Use the button below to look up your reference number from name of external system. -
- {{ govukButton({ - text: "Fetch reference", - type: "submit", - name: "action", - value: "external-component-edit-" + component.model.name, - preventDoubleClick: true, - classes: "govuk-button--secondary" - }) }} + {% set bodyHtml %} +
+ Use the button below to look up your reference number from name of external system. +
+ {% endset %} {% endif %} + + {{ ExternalComponent(component, bodyHtml) }} {% endmacro %} diff --git a/src/server/plugins/engine/views/components/externalcomponent.html b/src/server/plugins/engine/views/components/externalcomponent.html new file mode 100644 index 000000000..59b0253b6 --- /dev/null +++ b/src/server/plugins/engine/views/components/externalcomponent.html @@ -0,0 +1,35 @@ +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/hint/macro.njk" import govukHint %} + +{% macro ExternalComponent(component, bodyHtml) %} +

{{ component.model.label.text }}

+ + {% if component.model.hint %} + {{ govukHint({ + id: component.model.name + "-hint", + text: component.model.hint.text + }) }} + {% endif %} + + {{ bodyHtml | safe }} + + {% if component.model.value %} + {{ govukButton({ + text: "Edit reference", + type: "submit", + name: "action", + value: "external-component-edit-" + component.model.name, + preventDoubleClick: true, + classes: "govuk-button--secondary" + }) }} + {% else %} + {{ govukButton({ + text: "Fetch reference", + type: "submit", + name: "action", + value: "external-component-edit-" + component.model.name, + preventDoubleClick: true, + classes: "govuk-button--secondary" + }) }} + {% endif %} +{% endmacro %} From 0de6735075ee6c17fbebd72a6262842d8f7df301 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 9 Oct 2025 17:13:10 +0100 Subject: [PATCH 06/96] add error message support to external components --- .../engine/views/components/externalcomponent.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/views/components/externalcomponent.html b/src/server/plugins/engine/views/components/externalcomponent.html index 59b0253b6..87a22eeab 100644 --- a/src/server/plugins/engine/views/components/externalcomponent.html +++ b/src/server/plugins/engine/views/components/externalcomponent.html @@ -2,6 +2,7 @@ {% from "govuk/components/hint/macro.njk" import govukHint %} {% macro ExternalComponent(component, bodyHtml) %} +

{{ component.model.label.text }}

{% if component.model.hint %} @@ -11,11 +12,17 @@

{{ component.model.label.text }}

}) }} {% endif %} + {% if component.model.errors | length %} +

+ Error: {{ component.model.errors[0].text }} +

+ {% endif %} + {{ bodyHtml | safe }} {% if component.model.value %} {{ govukButton({ - text: "Edit reference", + text: "Edit", type: "submit", name: "action", value: "external-component-edit-" + component.model.name, @@ -24,7 +31,7 @@

{{ component.model.label.text }}

}) }} {% else %} {{ govukButton({ - text: "Fetch reference", + text: "Select", type: "submit", name: "action", value: "external-component-edit-" + component.model.name, @@ -32,4 +39,5 @@

{{ component.model.label.text }}

classes: "govuk-button--secondary" }) }} {% endif %} +
{% endmacro %} From b4dcf20749473245eb56ba5c3a5aa3de5e55b636 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 11:26:12 +0100 Subject: [PATCH 07/96] Postcode lookup configuration --- src/config/index.ts | 8 ++++++++ src/index.ts | 9 +++++---- .../plugins/engine/components/UkAddressField.ts | 11 ++++++++++- .../plugins/engine/configureEnginePlugin.ts | 6 ++++-- src/server/plugins/engine/models/FormModel.ts | 8 +++++++- src/server/plugins/engine/options.js | 3 ++- src/server/plugins/engine/plugin.ts | 15 ++++++++++++++- src/server/plugins/engine/routes/index.ts | 9 +++++++-- src/server/plugins/engine/types.ts | 1 + src/server/plugins/engine/vision.ts | 6 ++++++ src/server/types.ts | 1 + 11 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index e6300f769..302e224f5 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -252,6 +252,14 @@ export const config = convict({ format: String, default: '', env: 'SUBMISSION_EMAIL_ADDRESS' + } as SchemaObj, + + ordnanceSurveyApiKey: { + doc: 'The ordnance survey api key use by the postcode lookup plugin', + format: String, + nullable: true, + default: '', + env: 'ORDNANCE_SURVEY_API_KEY' } as SchemaObj }) diff --git a/src/index.ts b/src/index.ts index e6809c961..fa8799fd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,19 +13,20 @@ process.on('unhandledRejection', (error) => { throw error }) +const port = config.get('port') +const ordnanceSurveyApiKey = config.get('ordnanceSurveyApiKey') + /** * Main entrypoint to the application. */ async function startServer() { - const server = await createServer() + const server = await createServer({ ordnanceSurveyApiKey }) await server.start() process.send?.('online') server.logger.info('Server started successfully') - server.logger.info( - `Access your frontend on http://localhost:${config.get('port')}` - ) + server.logger.info(`Access your frontend on http://localhost:${port}`) } startServer().catch((error: unknown) => { diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index aa73936f8..560cd3ffb 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -175,10 +175,19 @@ export class UkAddressField extends FormComponent { components = collection.getViewModel(payload, errors) + const usePostcodeLookup = !!( + this.options.usePostcodeLookup && this.model.ordnanceSurveyApiKey + ) + const value = usePostcodeLookup + ? this.getDisplayStringFromState(payload) + : undefined + return { ...viewModel, + value, fieldset, - components + components, + usePostcodeLookup } } diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index dba5fa230..990824d66 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -21,7 +21,8 @@ export const configureEnginePlugin = async ( controllers, preparePageEventRequestOptions, onRequest, - saveAndExit + saveAndExit, + ordnanceSurveyApiKey }: RouteConfig = {}, cache?: CacheService ): Promise<{ @@ -63,7 +64,8 @@ export const configureEnginePlugin = async ( preparePageEventRequestOptions, onRequest, baseUrl: 'http://localhost:3009', // always runs locally - saveAndExit + saveAndExit, + ordnanceSurveyApiKey } } } diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index e6fa7506b..f30d4d078 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -77,6 +77,7 @@ export class FormModel { values: FormDefinition basePath: string versionNumber?: number + ordnanceSurveyApiKey?: string conditions: Partial> pages: PageControllerClass[] services: Services @@ -95,7 +96,11 @@ export class FormModel { constructor( def: typeof this.def, - options: { basePath: string; versionNumber?: number }, + options: { + basePath: string + versionNumber?: number + ordnanceSurveyApiKey?: string + }, services: Services = defaultServices, controllers?: Record ) { @@ -150,6 +155,7 @@ export class FormModel { this.values = result.value this.basePath = options.basePath this.versionNumber = options.versionNumber + this.ordnanceSurveyApiKey = options.ordnanceSurveyApiKey this.conditions = {} this.services = services this.controllers = controllers diff --git a/src/server/plugins/engine/options.js b/src/server/plugins/engine/options.js index 3efb29e87..ac1ad1380 100644 --- a/src/server/plugins/engine/options.js +++ b/src/server/plugins/engine/options.js @@ -25,7 +25,8 @@ const pluginRegistrationOptionsSchema = Joi.object({ preparePageEventRequestOptions: Joi.function().optional(), onRequest: Joi.function().optional(), baseUrl: Joi.string().uri().required(), - saveAndExit: Joi.function().optional() + saveAndExit: Joi.function().optional(), + ordnanceSurveyApiKey: Joi.string().optional() }) /** diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 2c783d38d..15adb0b76 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -15,6 +15,7 @@ import { getRoutes as getRepeaterItemDeleteRoutes } from '~/src/server/plugins/e import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engine/routes/repeaters/summary.js' import { type PluginOptions } from '~/src/server/plugins/engine/types.js' import { registerVision } from '~/src/server/plugins/engine/vision.js' +import { postcodeLookupPlugin } from '~/src/server/plugins/postcode-lookup/index.js' import { type FormRequestPayloadRefs, type FormRequestRefs @@ -34,7 +35,8 @@ export const plugin = { saveAndExit, nunjucks: nunjucksOptions, viewContext, - preparePageEventRequestOptions + preparePageEventRequestOptions, + ordnanceSurveyApiKey } = options const cacheService = @@ -44,6 +46,17 @@ export const plugin = { await registerVision(server, options) + // Register the postcode lookup plugin only if we have an OS api key + if (ordnanceSurveyApiKey) { + await server.register({ + plugin: postcodeLookupPlugin, + options: { + ordnanceSurveyApiKey, + enginePluginOptions: options + } + }) + } + server.expose('baseLayoutPath', nunjucksOptions.baseLayoutPath) server.expose('viewContext', viewContext) server.expose('cacheService', cacheService) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 93ea2584c..8f5290770 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -89,7 +89,12 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong const prefix = server.realm.modifiers.route.prefix ?? '' - const { services = defaultServices, controllers, onRequest } = options + const { + services = defaultServices, + controllers, + onRequest, + ordnanceSurveyApiKey + } = options const { formsService } = services @@ -156,7 +161,7 @@ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { // Construct the form model const model = new FormModel( definition, - { basePath, versionNumber }, + { basePath, versionNumber, ordnanceSurveyApiKey }, services, controllers ) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 1ff21d013..3673daf39 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -396,6 +396,7 @@ export interface PluginOptions { preparePageEventRequestOptions?: PreparePageEventRequestOptions onRequest?: OnRequestCallback baseUrl: string // base URL of the application, protocol and hostname e.g. "https://myapp.com" + ordnanceSurveyApiKey?: string } export interface FormAdapterSubmissionMessageMeta { diff --git a/src/server/plugins/engine/vision.ts b/src/server/plugins/engine/vision.ts index 048aa9481..99a976b2e 100644 --- a/src/server/plugins/engine/vision.ts +++ b/src/server/plugins/engine/vision.ts @@ -13,6 +13,7 @@ import { prepareNunjucksEnvironment } from '~/src/server/plugins/engine/index.js' import { type PluginOptions } from '~/src/server/plugins/engine/types.js' +import { VIEW_PATH as POSTCODE_LOOKUP_VIEW_PATH } from '~/src/server/plugins/postcode-lookup/index.js' export async function registerVision( server: Server, @@ -24,10 +25,15 @@ export async function registerVision( ) const viewPathResolved = join(packageRoot, VIEW_PATH) + const postcodeLookupPathResolved = join( + packageRoot, + POSTCODE_LOOKUP_VIEW_PATH + ) const paths = [ ...pluginOptions.nunjucks.paths, viewPathResolved, + postcodeLookupPathResolved, join(govukFrontendPath, 'dist') ] diff --git a/src/server/types.ts b/src/server/types.ts index b5179ae00..b20881f28 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -53,6 +53,7 @@ export interface RouteConfig { onRequest?: OnRequestCallback saveAndExit?: PluginOptions['saveAndExit'] cacheServiceCreator?: (server: Server) => CacheService + ordnanceSurveyApiKey?: string } export interface OutputService { From b2fd01bb0b31bf3a16f4f2c22f31719fb8f4fba5 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 13:00:57 +0100 Subject: [PATCH 08/96] Add postcode lookup plugin --- .../forms/register-as-a-unicorn-breeder.yaml | 17 +- .../views/components/ukaddressfield.html | 44 +- src/server/plugins/postcode-lookup/index.js | 46 ++ .../plugins/postcode-lookup/models/index.js | 549 ++++++++++++++++++ .../plugins/postcode-lookup/routes/index.js | 234 ++++++++ src/server/plugins/postcode-lookup/service.js | 196 +++++++ src/server/plugins/postcode-lookup/types.js | 136 +++++ .../views/postcode-lookup-details.html | 83 +++ 8 files changed, 1299 insertions(+), 6 deletions(-) create mode 100644 src/server/plugins/postcode-lookup/index.js create mode 100644 src/server/plugins/postcode-lookup/models/index.js create mode 100644 src/server/plugins/postcode-lookup/routes/index.js create mode 100644 src/server/plugins/postcode-lookup/service.js create mode 100644 src/server/plugins/postcode-lookup/types.js create mode 100644 src/server/plugins/postcode-lookup/views/postcode-lookup-details.html diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index 3c20764c8..e09138f23 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -56,8 +56,23 @@ pages: - name: wZLWPy options: required: true + usePostcodeLookup: true type: UkAddressField - title: Address + title: What is your billing address + schema: {} + hint: This is a UK address. Users must enter address line 1, town and a postcode + - name: uvBxTz + options: + required: true + type: EmailAddressField + title: What is your email adress + schema: {} + hint: This is an email address. An email address must contain an at sign @ + - name: drGHuj + options: + required: true + type: UkAddressField + title: What is your delivery address schema: {} hint: This is a UK address. Users must enter address line 1, town and a postcode next: diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index 12bd4965f..c5d1281ef 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -1,8 +1,40 @@ {% from "partials/components.html" import componentList %} {% from "govuk/components/fieldset/macro.njk" import govukFieldset %} {% from "govuk/components/hint/macro.njk" import govukHint %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/inset-text/macro.njk" import govukInsetText %} {% macro UkAddressField(component) %} + {% set usePostcodeLookup = component.model.usePostcodeLookup %} + + {% if usePostcodeLookup %} + {% set value = component.model.value %} + {% set postcodeLookupHref = "/postcode-lookup/" + slug + page.path + "/" + component.model.name %} + + {% if value %} + {% set insetHtml %} + Selected address: +

+ {{ value }} +

+ Use a different address + {% endset %} + + {{ govukInsetText({ + html: insetHtml + }) }} + {% else %} +
+ {{ govukButton({ + text: "Find an address", + href: postcodeLookupHref, + classes: "govuk-button--secondary govuk-!-margin-right-1" + }) }} +

or enter address manually

+
+ {% endif %} + {% endif %} + {% set fieldset = component.model.fieldset %} {% set addressFieldHtml = componentList(component.model.components) %} @@ -17,9 +49,11 @@ {% set addressFieldHtml = addressHintHtml + addressFieldHtml %} {% endif %} - {{ govukFieldset({ - legend: fieldset.legend, - attributes: fieldset.attributes, - html: addressFieldHtml - }) if fieldset else addressFieldHtml }} +
+ {{ govukFieldset({ + legend: fieldset.legend, + attributes: fieldset.attributes, + html: addressFieldHtml + }) if fieldset else addressFieldHtml }} +
{% endmacro %} diff --git a/src/server/plugins/postcode-lookup/index.js b/src/server/plugins/postcode-lookup/index.js new file mode 100644 index 000000000..6011130e2 --- /dev/null +++ b/src/server/plugins/postcode-lookup/index.js @@ -0,0 +1,46 @@ +import { makeLoadFormPreHandler } from '~/src/server/plugins/engine/routes/index.js' +import { getRoutes } from '~/src/server/plugins/postcode-lookup/routes/index.js' + +export const VIEW_PATH = 'src/server/plugins/postcode-lookup/views' + +/** + * @satisfies {NamedPlugin} + */ +export const postcodeLookupPlugin = { + name: '@defra/forms-engine-plugin/postcode-lookup', + dependencies: ['@hapi/vision'], + multiple: false, + register(server, options) { + const loadFormPreHandler = makeLoadFormPreHandler( + server, + options.enginePluginOptions + ) + + const getRouteOptions = { + pre: [ + { + method: loadFormPreHandler + } + ] + } + + server.route( + /** @type {ServerRoute[]} */ ( + // @ts-expect-error - Request typing + getRoutes(getRouteOptions, options.ordnanceSurveyApiKey) + ) + ) + } +} + +/** + * @typedef {{ + * ordnanceSurveyApiKey: string + * enginePluginOptions: PluginOptions + * }} PostcodeLookupConfiguration + */ + +/** + * @import { NamedPlugin, ServerRoute } from '@hapi/hapi' + * @import { PluginOptions } from '~/src/server/plugins/engine/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js new file mode 100644 index 000000000..858c57f20 --- /dev/null +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -0,0 +1,549 @@ +import { + hasComponentsEvenIfNoNext, + hasFormField, + slugSchema +} from '@defra/forms-model' +import Joi from 'joi' + +import { crumbSchema, stateSchema } from '@defra/forms-engine-plugin/schema.js' +import { FORM_PREFIX } from '~/src/server/constants.js' +import * as service from '~/src/server/plugins/postcode-lookup/service.js' +import { pathSchema } from '~/src/server/schemas/index.js' + +// Field names/ids +const postcodeQueryFieldName = 'postcodeQuery' +const buildingNameQueryFieldName = 'buildingNameQuery' +const uprnFieldName = 'uprn' + +const line1FieldName = 'addressLine1' +const line2FieldName = 'addressLine2' +const townFieldName = 'town' +const countyFieldName = 'county' +const postcodeFieldName = 'postcode' + +export const steps = { + // Step 1: Postcode/building name input + details: 'details', + // Step 2: Select address + select: 'select', + // Step 3: Manual address + manual: 'manual' +} + +export const JOURNEY_BASE_URL = '/postcode-lookup' + +/** + * Build form errors + * @param {Error} [err] + */ +function buildErrors(err) { + const hasErrors = Joi.isError(err) && err.details.length > 0 + + if (!hasErrors) { + return {} + } + + const postcodeQueryError = err.details.find( + (item) => item.path[0] === postcodeQueryFieldName + ) + const buildingNameQueryError = err.details.find( + (item) => item.path[0] === buildingNameQueryFieldName + ) + const uprnError = err.details.find((item) => item.path[0] === uprnFieldName) + const line1Error = err.details.find((item) => item.path[0] === line1FieldName) + const line2Error = err.details.find((item) => item.path[0] === line2FieldName) + const townError = err.details.find((item) => item.path[0] === townFieldName) + const countyError = err.details.find( + (item) => item.path[0] === countyFieldName + ) + const postcodeError = err.details.find( + (item) => item.path[0] === postcodeFieldName + ) + + const errors = [] + + if (postcodeQueryError) { + errors.push({ + text: postcodeQueryError.message, + href: `#${postcodeQueryFieldName}` + }) + } + + if (buildingNameQueryError) { + errors.push({ + text: buildingNameQueryError.message, + href: `#${buildingNameQueryFieldName}` + }) + } + + if (uprnError) { + errors.push({ + text: uprnError.message, + href: `#${uprnFieldName}` + }) + } + + if (line1Error) { + errors.push({ + text: line1Error.message, + href: `#${line1FieldName}` + }) + } + + if (line2Error) { + errors.push({ + text: line2Error.message, + href: `#${line2FieldName}` + }) + } + + if (townError) { + errors.push({ + text: townError.message, + href: `#${townFieldName}` + }) + } + + if (countyError) { + errors.push({ + text: countyError.message, + href: `#${countyFieldName}` + }) + } + + if (postcodeError) { + errors.push({ + text: postcodeError.message, + href: `#${postcodeFieldName}` + }) + } + + return { + errors, + postcodeQueryError, + buildingNameQueryError, + uprnError, + line1Error, + line2Error, + townError, + countyError, + postcodeError + } +} + +/** + * @param {string} slug + * @param {FormStatus} [status] + */ +function constructFormUrl(slug, status) { + if (!status) { + return `${FORM_PREFIX}/${slug}` + } + + return `${FORM_PREFIX}/preview/${status}/${slug}` +} + +/** + * @param {string} formPath + * @param {string} path + */ +function constructFormPageUrl(formPath, path) { + return `${formPath}${path}` +} + +/** + * Postcode lookup params schema + */ +export const paramsSchema = Joi.object() + .keys({ + slug: slugSchema, + path: pathSchema, + componentName: Joi.string().required(), + state: stateSchema.optional() + }) + .required() + +export const stepSchema = Joi.string() + .valid(...Object.keys(steps)) + .required() + +const sharedPayloadSchemaKeys = { + crumb: crumbSchema, + step: stepSchema, + [postcodeQueryFieldName]: Joi.string().required().messages({ + '*': 'Enter a postcode' + }), + [buildingNameQueryFieldName]: Joi.string().required().allow('').trim() +} + +/** + * Postcode lookup details form payload schema + * @type {ObjectSchema} + */ +export const detailsPayloadSchema = Joi.object() + .keys(sharedPayloadSchemaKeys) + .required() + +/** + * Postcode lookup select form payload schema + * @type {ObjectSchema} + */ +export const selectPayloadSchema = Joi.object() + .keys({ + ...sharedPayloadSchemaKeys, + [uprnFieldName]: Joi.number().required().messages({ + '*': 'Select an address' + }) + }) + .required() + +/** + * Postcode lookup manual form payload schema + * @type {ObjectSchema} + */ +export const manualPayloadSchema = Joi.object() + .keys({ + crumb: crumbSchema, + step: stepSchema, + [line1FieldName]: Joi.string().required().messages({ + '*': 'Enter address line 1' + }), + [line2FieldName]: Joi.string().allow('').required(), + [townFieldName]: Joi.string().required().messages({ + '*': 'Enter town or city' + }), + [countyFieldName]: Joi.string().allow('').required(), + [postcodeFieldName]: Joi.string().required().messages({ + '*': 'Enter postcode' + }) + }) + .required() + +/** + * Gets page title + * @param {Page} page + * @param {ComponentDef} component + */ +export function getComponentTitle(page, component) { + if (hasComponentsEvenIfNoNext(page)) { + const formFields = page.components.filter(hasFormField) + + // When there's more than 1 form component on the page, use the component title + if (formFields.length > 1 || formFields[0] !== component) { + return component.title + } + } + + // Otherwise use the page title + return page.title +} + +/** + * Get postcode lookup session key + * @param {string} slug + * @param {FormStatus} [state] + */ +export function getKey(slug, state) { + return `postcode-lookup-${slug}-${state ?? ''}` +} + +/** + * Get the postcode lookup href + * @param {string} slug - the form slug + * @param {Page} page - the form page + * @param {UkAddressFieldComponent} component - the form component + * @param {FormStatus} [status] - the form status + * @param {string} [step] - the postcode lookup step + */ +function getHref(slug, page, component, status, step) { + return `${JOURNEY_BASE_URL}/${slug}${page.path}/${component.name}${status ? `/${status}` : ''}${step ? `?step=${step}` : ''}` +} + +/** + * The postcode lookup details form view model data + * @typedef {object} DetailsModelData + * @property {string} slug - the form slug + * @property {string} title - the form title + * @property {Page} page - the form page + * @property {UkAddressFieldComponent} component - the form component + * @property {FormStatus} [status] - the form status + */ + +/** + * The postcode lookup details form view model + * @param {DetailsModelData} data + * @param {PostcodeLookupDetailsPayload} [payload] + * @param {Error} [err] + */ +export function detailsViewModel(data, payload, err) { + const { slug, title, page, component, status } = data + const pageTitle = getComponentTitle(page, component) + const formPath = constructFormUrl(slug, status) + const pagePath = constructFormPageUrl(formPath, page.path) + + const backLink = { + href: pagePath + } + + const { errors, postcodeQueryError, buildingNameQueryError } = + buildErrors(err) + + // Model fields + const fields = { + [postcodeQueryFieldName]: { + id: postcodeQueryFieldName, + name: postcodeQueryFieldName, + label: { + text: 'Postcode' + }, + hint: { + text: 'For example, AA3 1AB' + }, + value: payload?.postcodeQuery, + errorMessage: postcodeQueryError && { text: postcodeQueryError.message } + }, + [buildingNameQueryFieldName]: { + id: buildingNameQueryFieldName, + name: buildingNameQueryFieldName, + label: { + text: 'Building name or number (optional)' + }, + hint: { + text: 'For example, 15 or Prospect Cottage' + }, + value: payload?.buildingNameQuery, + errorMessage: buildingNameQueryError && { + text: buildingNameQueryError.message + } + } + } + + // Model buttons + const continueButton = { + text: 'Find address' + } + const manualLink = { + text: 'Enter address manually', + href: getHref(slug, page, component, status, steps.manual) + } + + return { + step: steps.details, + showTitle: true, + name: title, + serviceUrl: formPath, + pageTitle, + backLink, + errors, + fields, + buttons: { continueButton, manualLink } + } +} + +/** + * The postcode lookup select form view model + * @param {PostcodeLookupSelectModelData} data + * @param {PostcodeLookupSelectPayload} [payload] + * @param {Error} [err] + */ +export async function selectViewModel(data, payload, err) { + const { slug, page, component, details, status, apiKey } = data + + const { postcodeQuery, buildingNameQuery } = details + + // Search for addresses + const addresses = await service.search( + postcodeQuery, + buildingNameQuery, + apiKey + ) + const addressCount = addresses.length + const singleAddress = addressCount === 1 ? addresses.at(0) : undefined + const hasAddresses = addressCount > 0 + const hasMultipleAddresses = addressCount > 1 + + const title = hasAddresses + ? getComponentTitle(page, component) + : 'No address found' + const formPath = constructFormUrl(slug, status) + const pagePath = constructFormPageUrl(formPath, page.path) + + const backLink = { + href: pagePath + } + + const { errors, uprnError } = buildErrors(err) + + // Model fields + const fields = { + [postcodeQueryFieldName]: { + id: postcodeQueryFieldName, + name: postcodeQueryFieldName, + type: 'hidden', + value: details.postcodeQuery + }, + [buildingNameQueryFieldName]: { + id: buildingNameQueryFieldName, + name: buildingNameQueryFieldName, + type: 'hidden', + value: details.buildingNameQuery + }, + [uprnFieldName]: { + id: uprnFieldName, + name: uprnFieldName, + label: hasMultipleAddresses + ? { + text: 'Select an address' + } + : undefined, + value: singleAddress ? singleAddress.uprn : payload?.uprn, + errorMessage: uprnError && { text: uprnError.message }, + items: hasMultipleAddresses + ? [{ text: 'Select an address' }].concat( + addresses.map((item) => ({ + text: item.address, + value: item.uprn + })) + ) + : undefined, + type: singleAddress ? 'hidden' : undefined + } + } + + const href = getHref(slug, page, component, status) + const searchAgainLink = { + text: 'Search again', + href + } + + // Model buttons + const continueButton = { + href: !hasAddresses ? href : undefined, + text: hasAddresses ? 'Use this address' : 'Search again' + } + const manualLink = { + text: 'Enter address manually', + href: `${href}?step=${steps.manual}` + } + + return { + step: steps.select, + showTitle: true, + name: title, + serviceUrl: formPath, + pageTitle: title, + backLink, + errors, + searchAgainLink, + fields, + details, + addressCount, + singleAddress, + hasAddresses, + hasMultipleAddresses, + buttons: { continueButton, manualLink } + } +} + +/** + * The postcode lookup manual form view model + * @param {DetailsModelData} data + * @param {PostcodeLookupManualPayload} [payload] + * @param {Error} [err] + */ +export function manualViewModel(data, payload, err) { + const { slug, title, page, component, status } = data + const pageTitle = getComponentTitle(page, component) + const formPath = constructFormUrl(slug, status) + const pagePath = constructFormPageUrl(formPath, page.path) + + const backLink = { + href: pagePath + } + + const { + errors, + line1Error, + line2Error, + townError, + countyError, + postcodeError + } = buildErrors(err) + + // Model fields + const fields = { + [line1FieldName]: { + id: line1FieldName, + name: line1FieldName, + label: { + text: 'Address line 1' + }, + value: payload?.addressLine1, + errorMessage: line1Error && { text: line1Error.message } + }, + [line2FieldName]: { + id: line2FieldName, + name: line2FieldName, + label: { + text: 'Address line 2 (optional)' + }, + value: payload?.addressLine2, + errorMessage: line2Error && { text: line2Error.message } + }, + [townFieldName]: { + id: townFieldName, + name: townFieldName, + label: { + text: 'Town or city' + }, + value: payload?.town, + errorMessage: townError && { text: townError.message } + }, + [countyFieldName]: { + id: countyFieldName, + name: countyFieldName, + label: { + text: 'County (optional)' + }, + value: payload?.county, + errorMessage: countyError && { text: countyError.message } + }, + [postcodeFieldName]: { + id: postcodeFieldName, + name: postcodeFieldName, + label: { + text: 'Postcode' + }, + value: payload?.postcode, + errorMessage: postcodeError && { text: postcodeError.message } + } + } + + // Model buttons + const continueButton = { + text: 'Use this address' + } + const lookupLink = { + text: 'Find an address instead', + href: getHref(slug, page, component, status) + } + + return { + step: steps.manual, + showTitle: true, + name: title, + serviceUrl: formPath, + pageTitle, + backLink, + errors, + fields, + buttons: { continueButton, lookupLink } + } +} + +/** + * @import { UkAddressFieldComponent, Page, ComponentDef } from '@defra/forms-model' + * @import { ObjectSchema } from 'joi' + * @import { FormStatus } from '@defra/forms-engine-plugin/types' + * @import { PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectModelData, PostcodeLookupSelectPayload } from '~/src/server/plugins/postcode-lookup/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js new file mode 100644 index 000000000..b89473e30 --- /dev/null +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -0,0 +1,234 @@ +import { ComponentType, hasComponentsEvenIfNoNext } from '@defra/forms-model' +import Boom from '@hapi/boom' +import { StatusCodes } from 'http-status-codes' +import Joi from 'joi' + +import { FORM_PREFIX } from '~/src/server/constants.js' +import { + checkFormStatus, + getCacheService +} from '~/src/server/plugins/engine/helpers.js' +import { + JOURNEY_BASE_URL, + detailsPayloadSchema, + detailsViewModel, + getKey, + manualPayloadSchema, + manualViewModel, + paramsSchema, + selectPayloadSchema, + selectViewModel, + stepSchema, + steps +} from '~/src/server/plugins/postcode-lookup/models/index.js' +import * as service from '~/src/server/plugins/postcode-lookup/service.js' + +/** + * Get the details of the source form elements associated with this journey + * @param {PostcodeLookupRequest} request + */ +function getJourneyDetails(request) { + const { app, params } = request + const { model } = app + const { path, componentName } = params + + if (!model) { + throw Boom.notFound(`No model found for ${path}`) + } + + const { isPreview, state: status } = checkFormStatus(params) + const title = model.name + const page = model.pageDefMap.get(`/${path}`) + + if (!page) { + throw Boom.notFound(`No page found for ${path}`) + } + + const component = hasComponentsEvenIfNoNext(page) + ? page.components.find((c) => c.name === componentName) + : undefined + + if (!component) { + throw Boom.notFound(`No component found for name ${componentName}`) + } + + if (component.type !== ComponentType.UkAddressField) { + throw Boom.internal( + `Invalid component type, expected UkAddressFieldComponent got ${component.type}` + ) + } + + return { model, title, page, component, isPreview, status } +} + +/** + * Update form component state + * @param {PostcodeLookupRequest} request - the request + * @param {string} componentName - the component name + * @param {Address | PostcodeLookupManualPayload} address - the address from ordnance survey or manually entered + */ +async function updateComponentState(request, componentName, address) { + // TODO: Set state another way + const addressState = { + [`${componentName}__addressLine1`]: address.addressLine1, + [`${componentName}__addressLine2`]: address.addressLine2, + [`${componentName}__town`]: address.town, + [`${componentName}__county`]: address.county, + [`${componentName}__postcode`]: address.postcode + } + + const cacheService = getCacheService(request.server) + // @ts-expect-error - Request typing + const state = await cacheService.getState(request) + // @ts-expect-error - Request typing + await cacheService.setState(request, { + ...state, + ...addressState + }) +} + +/** + * Gets the postcode lookup routes + * @param {RouteOptions} getRouteOptions + * @param {string} apiKey + */ +export function getRoutes(getRouteOptions, apiKey) { + return [ + /** + * @satisfies {ServerRoute} + */ + ({ + method: 'GET', + path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, + handler(request, h) { + const { params, query } = request + const { slug, state: status } = params + const { title, page, component } = getJourneyDetails(request) + + // Get the previous details from session + const previous = request.yar.get(getKey(slug, status)) + + const data = { slug, page, title, component, status } + const model = + query.step === steps.manual + ? manualViewModel(data) + : detailsViewModel(data, previous) + + return h.view('postcode-lookup-details', model) + }, + // @ts-expect-error - Request typing + options: { + ...getRouteOptions, + validate: { + params: paramsSchema, + query: Joi.object() + .keys({ + step: Joi.string().allow(steps.details, steps.manual).optional() + }) + .optional() + } + } + }), + /** + * @satisfies {ServerRoute} + */ + ({ + method: 'POST', + path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, + async handler(request, h) { + const { params, payload } = request + const { slug, path, componentName, state: status } = params + const { title, page, component } = getJourneyDetails(request) + const { step } = payload + + switch (step) { + case steps.details: { + const { value: details, error } = + detailsPayloadSchema.validate(payload) + + if (error) { + const data = { slug, title, page, component, status } + const model = detailsViewModel(data, details, error) + + return h.view('postcode-lookup-details', model) + } + + // Store the details in session + request.yar.set(getKey(slug, status), details) + + const data = { slug, page, component, details, status, apiKey } + const model = await selectViewModel(data) + + return h.view('postcode-lookup-details', model) + } + case steps.select: { + const { value: select, error } = + selectPayloadSchema.validate(payload) + + if (error) { + const { postcodeQuery, buildingNameQuery } = select + const details = { postcodeQuery, buildingNameQuery } + const data = { slug, page, component, details, status, apiKey } + const model = await selectViewModel(data, select, error) + + return h.view('postcode-lookup-details', model) + } + + const addresses = await service.searchByUPRN(select.uprn, apiKey) + const property = addresses.at(0) + + if (!property) { + throw Boom.internal(`UPRN ${property} not found`) + } + + await updateComponentState(request, componentName, property) + + // Redirect back to the source form page + return h + .redirect(`${FORM_PREFIX}/${slug}/${path}`) + .code(StatusCodes.SEE_OTHER) + } + case steps.manual: { + const { value: manual, error } = manualPayloadSchema.validate( + payload, + { abortEarly: false } + ) + + if (error) { + const data = { slug, title, page, component, status } + const model = manualViewModel(data, manual, error) + + return h.view('postcode-lookup-details', model) + } + + await updateComponentState(request, componentName, manual) + + // Redirect back to the source form page + return h + .redirect(`${FORM_PREFIX}/${slug}/${path}`) + .code(StatusCodes.SEE_OTHER) + } + default: + throw Boom.badRequest(`Invalid step ${step}`) + } + }, + // @ts-expect-error - Request typing + options: { + ...getRouteOptions, + validate: { + params: paramsSchema, + payload: Joi.object() + .keys({ + step: stepSchema + }) + .unknown(true) + } + } + }) + ] +} + +/** + * @import { RouteOptions, ServerRoute } from '@hapi/hapi' + * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupRequestRefs } from '~/src/server/plugins/postcode-lookup/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js new file mode 100644 index 000000000..bc772105e --- /dev/null +++ b/src/server/plugins/postcode-lookup/service.js @@ -0,0 +1,196 @@ +import { getErrorMessage } from '@defra/forms-model' + +import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { getJson } from '~/src/server/services/httpService.js' + +const logger = createLogger() + +/** + * OS places search + * @param {string} query - the search term + * @param {string} apiKey - the OS api key + */ +export async function searchByQuery(query, apiKey) { + const getJsonByType = + /** @type {typeof getJson} */ (getJson) + + const url = `https://api.os.uk/search/places/v1/find?query=${encodeURIComponent(query)}&key=${apiKey}` + + const response = await getJsonByType(url) + + if (response.error) { + const error = response.error + + logger.error( + error, + `Exception occured calling OS places find ${getErrorMessage(error)}` + ) + + return [] + } + + const results = response.payload.results + + if (!Array.isArray(results)) { + return [] + } + + return results.map((result) => formatAddress(result.DPA)) +} + +/** + * OS postcode search + * @param {string} postcode - the postcode + * @param {string} apiKey - the OS api key + */ +export async function searchByPostcode(postcode, apiKey) { + const getJsonByType = + /** @type {typeof getJson} */ (getJson) + + const url = `https://api.os.uk/search/places/v1/postcode?postcode=${encodeURIComponent(postcode.replace(/\s/g, ''))}&key=${apiKey}` + + const response = await getJsonByType(url) + + if (response.error) { + const error = response.error + + logger.error( + error, + `Exception occured calling OS places postcode ${getErrorMessage(error)}` + ) + + return [] + } + + const results = response.payload.results + + if (!Array.isArray(results)) { + return [] + } + + return results.map((result) => formatAddress(result.DPA)) +} + +/** + * OS UPRN search + * @param {number} uprn - the unique property reference number + * @param {string} apiKey - the OS api key + */ +export async function searchByUPRN(uprn, apiKey) { + const getJsonByType = + /** @type {typeof getJson} */ (getJson) + + const url = `https://api.os.uk/search/places/v1/uprn?uprn=${uprn}&key=${apiKey}` + + const response = await getJsonByType(url) + + if (response.error) { + const error = response.error + + logger.error( + error, + `Exception occured calling OS places UPRN ${getErrorMessage(error)}` + ) + + return [] + } + + const results = response.payload.results + + if (!Array.isArray(results)) { + return [] + } + + return results.map((result) => formatAddress(result.DPA)) +} + +/** + * OS postcode and building name search + * @param {string} postcodeQuery - the postcode query + * @param {string} buildingNameQuery - the building name query + * @param {string} apiKey - the OS api key + */ +export async function search(postcodeQuery, buildingNameQuery, apiKey) { + let addresses = await searchByPostcode(postcodeQuery, apiKey) + + if (buildingNameQuery) { + addresses = addresses.filter((item) => + item.address.includes(buildingNameQuery.toUpperCase()) + ) + } + + return addresses +} + +/** + * Converts a delivery point address to an address + * @param {DeliveryPointAddress} dpa + */ +function formatAddress(dpa) { + const buildingName = + dpa.ORGANISATION_NAME || dpa.SUB_BUILDING_NAME || dpa.BUILDING_NAME + ? [ + dpa.ORGANISATION_NAME || '', + dpa.SUB_BUILDING_NAME || '', + dpa.BUILDING_NAME || '' + ] + .filter((item) => !!item) + .join(' ') + : '' + const numberStreet = + dpa.BUILDING_NUMBER || dpa.THOROUGHFARE_NAME + ? [ + dpa.BUILDING_NUMBER ? dpa.BUILDING_NUMBER.toString() : '', + dpa.THOROUGHFARE_NAME || '' + ] + .filter((item) => !!item) + .join(' ') + : '' + + // const lines = [ + // buildingName, + // numberStreet, + // dpa.POST_TOWN || '', + // dpa.POSTCODE || '' + // ] + + // const formatted = titleCase( + // lines + // .filter((i) => i) + // .slice(0, -1) + // .join(', ') + // ) + + // ', ' + + // (dpa.POSTCODE || '') + + /** + * @type {Address} + */ + const address = { + uprn: dpa.UPRN, + address: dpa.ADDRESS, + addressLine1: buildingName, + addressLine2: numberStreet, + town: dpa.POST_TOWN, + county: '', + postcode: dpa.POSTCODE + // formatted + } + + return address +} + +// /** +// * +// * @param {string} address +// */ +// function titleCase(address) { +// return address +// .split(' ') +// .map((item) => item.charAt(0).toUpperCase() + item.slice(1).toLowerCase()) +// .join(' ') +// } + +/** + * @import { Address, DeliveryPointAddress, DeliveryPointAddressResult } from '~/src/server/plugins/postcode-lookup/types.js' + */ diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js new file mode 100644 index 000000000..933d46e83 --- /dev/null +++ b/src/server/plugins/postcode-lookup/types.js @@ -0,0 +1,136 @@ +// +// Model types +// + +/** + * The postcode lookup details form view model data + * @typedef {object} PostcodeLookupDetailsData + * @property {string} postcodeQuery - postcode query + * @property {string} buildingNameQuery - Building name or number query + */ + +/** + * The postcode lookup select form view model data + * @typedef {object} PostcodeLookupSelectModelData + * @property {string} slug - the form slug + * @property {Page} page - the form page + * @property {UkAddressFieldComponent} component - the form component + * @property {PostcodeLookupDetailsData} details - the lookup details + * @property {string} apiKey - the ordnance survey api key + * @property {FormStatus} [status] - the form status + */ + +// +// Payload/Param types +// + +// /** +// * @typedef {object} PostcodeLookupParamsType +// * @property {string} componentName - the source component name +// * @typedef {FormParams & PostcodeLookupParamsType} PostcodeLookupParams +// */ + +/** + * @typedef {object} PostcodeLookupParams + * @property {string} slug - the source form slug + * @property {string} path - the source page path + * @property {string} componentName - the source component name + * @property {FormStatus} [state] - the source form status (draft/live) when in preview mode + */ + +/** + * @typedef {object} PostcodeLookupDetailsPayloadProperties + * @property {string} step - step + */ + +/** + * @typedef {PostcodeLookupDetailsData & PostcodeLookupDetailsPayloadProperties} PostcodeLookupDetailsPayload + */ + +/** + * @typedef {object} PostcodeLookupSelectPayloadProperties + * @property {string} step - step + * @property {number} uprn - postcode + */ + +/** + * @typedef {PostcodeLookupDetailsPayload & PostcodeLookupSelectPayloadProperties} PostcodeLookupSelectPayload + */ + +/** + * Postcode lookup get request + * @typedef {object} PostcodeLookupGetRequestRefs + * @property {PostcodeLookupParams} Params - Request parameters + * @property {{ step?: string }} Query - Request query + */ + +/** + * Postcode lookup post request + * @typedef {object} PostcodeLookupPostRequestRefs + * @property {PostcodeLookupParams} Params - Request parameters + * @property {PostcodeLookupDetailsPayload | PostcodeLookupSelectPayload} Payload - Request payload + */ + +/** + * @typedef {PostcodeLookupGetRequestRefs | PostcodeLookupPostRequestRefs} PostcodeLookupRequestRefs + * @typedef {Request} PostcodeLookupGetRequest + * @typedef {Request} PostcodeLookupPostRequest + * @typedef {PostcodeLookupGetRequest | PostcodeLookupPostRequest} PostcodeLookupRequest + */ + +/** + * @typedef {object} PostcodeLookupManualPayload + * @property {string} addressLine1 - The address line 1 + * @property {string} addressLine2 - The address line 2 + * @property {string} town - The address town or city + * @property {string} county - The address county + * @property {number} postcode - The address postcode + */ + +// +// Service types +// + +/** + * @typedef {object} Address + * @property {number} uprn - The unique property reference + * @property {string} address - The full address + * @property {string} addressLine1 - Address line 1 + * @property {string} addressLine2 - Address line 2 + * @property {string} town - Address town + * @property {string} county - Address county + * @property {string} postcode - Address postcode + */ + +/** + * OS places address response + * @typedef {object} DeliveryPointAddress + * @property {number} UPRN - Unique property reference number + * @property {number} UDPRN - Unique delivery point Reference Number + * @property {string} ADDRESS - Address + * @property {string} ORGANISATION_NAME - Organisation name + * @property {string} SUB_BUILDING_NAME - Sub building name + * @property {string} BUILDING_NAME - Building name + * @property {number} BUILDING_NUMBER - Building number + * @property {string} THOROUGHFARE_NAME - Throughfare name + * @property {string} POST_TOWN - Post town + * @property {string} POSTCODE - Postcode + */ + +/** + * OS places DPA response + * @typedef {object} DeliveryPointAddressItem + * @property {DeliveryPointAddress} DPA - Delivery point address + */ + +/** + * OS places DPA response + * @typedef {object} DeliveryPointAddressResult + * @property {DeliveryPointAddressItem[]} [results] - Delivery point address results + */ + +/** + * @import { Request } from '@hapi/hapi' + * @import { UkAddressFieldComponent, Page } from '@defra/forms-model' + * @import { FormParams, FormRequestRefs, FormStatus } from '@defra/forms-engine-plugin/types' + */ diff --git a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html new file mode 100644 index 000000000..7443e1f77 --- /dev/null +++ b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html @@ -0,0 +1,83 @@ +{% extends baseLayoutPath %} + +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/select/macro.njk" import govukSelect %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/inset-text/macro.njk" import govukInsetText %} + +{% block content %} +
+
+ {% if errors %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: errors + }) }} + {% endif %} + + {% include "partials/heading.html" %} + +
+ + + + {% switch step %} + {% case "details" %} + + {{ govukInput(fields.postcodeQuery) }} + {{ govukInput(fields.buildingNameQuery) }} + {% case "select" %} + + + {{ govukInput(fields.postcodeQuery) }} + {{ govukInput(fields.buildingNameQuery) }} + + + {%- set detailsHtml -%} + {{ details.postcodeQuery }}{% if details.buildingNameQuery %} and {{ details.buildingNameQuery }}{% endif %} + {%- endset -%} + + {% if hasAddresses %} +

+ {{addressCount}} address{{ "es" if hasMultipleAddresses }} found for {{ detailsHtml | safe }}. {{ searchAgainLink.text }} +

+ {% endif %} + + {% if hasMultipleAddresses %} + + {{ govukSelect(fields.uprn) }} + {% elif singleAddress %} + + {{ govukInput(fields.uprn) }} + {{ govukInsetText({ + text: singleAddress.address + }) }} + {% else %} + +

We could not find an address that matches {{ detailsHtml | safe }}.

+ {% endif %} + {% case "manual" %} + + {{ govukInput(fields.addressLine1) }} + {{ govukInput(fields.addressLine2) }} + {{ govukInput(fields.town) }} + {{ govukInput(fields.county) }} + {{ govukInput(fields.postcode) }} + {% endswitch %} + +
+ {{ govukButton(buttons.continueButton) }} +
+ +

+ {% if buttons.manualLink %} + {{buttons.manualLink.text}} + {% elif buttons.lookupLink %} + {{buttons.lookupLink.text}} + {% endif %} +

+
+
+
+{% endblock %} From 20602e3a700388ed8f710503f662ee1434ee3946 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 13:05:47 +0100 Subject: [PATCH 09/96] Use local types location --- src/server/plugins/postcode-lookup/models/index.js | 9 ++++++--- src/server/plugins/postcode-lookup/types.js | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 858c57f20..3bc5bb975 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -5,10 +5,13 @@ import { } from '@defra/forms-model' import Joi from 'joi' -import { crumbSchema, stateSchema } from '@defra/forms-engine-plugin/schema.js' import { FORM_PREFIX } from '~/src/server/constants.js' import * as service from '~/src/server/plugins/postcode-lookup/service.js' -import { pathSchema } from '~/src/server/schemas/index.js' +import { + crumbSchema, + pathSchema, + stateSchema +} from '~/src/server/schemas/index.js' // Field names/ids const postcodeQueryFieldName = 'postcodeQuery' @@ -544,6 +547,6 @@ export function manualViewModel(data, payload, err) { /** * @import { UkAddressFieldComponent, Page, ComponentDef } from '@defra/forms-model' * @import { ObjectSchema } from 'joi' - * @import { FormStatus } from '@defra/forms-engine-plugin/types' + * @import { FormStatus } from '~/src/server/routes/types.js' * @import { PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectModelData, PostcodeLookupSelectPayload } from '~/src/server/plugins/postcode-lookup/types.js' */ diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index 933d46e83..9f45b0e6f 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -132,5 +132,5 @@ /** * @import { Request } from '@hapi/hapi' * @import { UkAddressFieldComponent, Page } from '@defra/forms-model' - * @import { FormParams, FormRequestRefs, FormStatus } from '@defra/forms-engine-plugin/types' + * @import { FormStatus } from '~/src/server/routes/types.js' */ From d9ec21e5db9b4f6ea0e184dc2118a735544ef0f8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 13:09:25 +0100 Subject: [PATCH 10/96] Sonar fix (const duplicate) --- src/server/plugins/postcode-lookup/models/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 3bc5bb975..a425fb9e7 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -24,6 +24,8 @@ const townFieldName = 'town' const countyFieldName = 'county' const postcodeFieldName = 'postcode' +const selectLabelText = 'Select an address' + export const steps = { // Step 1: Postcode/building name input details: 'details', @@ -195,7 +197,7 @@ export const selectPayloadSchema = Joi.object() .keys({ ...sharedPayloadSchemaKeys, [uprnFieldName]: Joi.number().required().messages({ - '*': 'Select an address' + '*': selectLabelText }) }) .required() @@ -396,13 +398,13 @@ export async function selectViewModel(data, payload, err) { name: uprnFieldName, label: hasMultipleAddresses ? { - text: 'Select an address' + text: selectLabelText } : undefined, value: singleAddress ? singleAddress.uprn : payload?.uprn, errorMessage: uprnError && { text: uprnError.message }, items: hasMultipleAddresses - ? [{ text: 'Select an address' }].concat( + ? [{ text: selectLabelText }].concat( addresses.map((item) => ({ text: item.address, value: item.uprn From f13f304b54532b3dd1bcf041d9e14a1303b76fa8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 13:10:33 +0100 Subject: [PATCH 11/96] Sonar fix (const duplicate) --- src/server/plugins/postcode-lookup/routes/index.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index b89473e30..fff5ae40a 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -23,6 +23,8 @@ import { } from '~/src/server/plugins/postcode-lookup/models/index.js' import * as service from '~/src/server/plugins/postcode-lookup/service.js' +const viewName = 'postcode-lookup-details' + /** * Get the details of the source form elements associated with this journey * @param {PostcodeLookupRequest} request @@ -114,7 +116,7 @@ export function getRoutes(getRouteOptions, apiKey) { ? manualViewModel(data) : detailsViewModel(data, previous) - return h.view('postcode-lookup-details', model) + return h.view(viewName, model) }, // @ts-expect-error - Request typing options: { @@ -150,7 +152,7 @@ export function getRoutes(getRouteOptions, apiKey) { const data = { slug, title, page, component, status } const model = detailsViewModel(data, details, error) - return h.view('postcode-lookup-details', model) + return h.view(viewName, model) } // Store the details in session @@ -159,7 +161,7 @@ export function getRoutes(getRouteOptions, apiKey) { const data = { slug, page, component, details, status, apiKey } const model = await selectViewModel(data) - return h.view('postcode-lookup-details', model) + return h.view(viewName, model) } case steps.select: { const { value: select, error } = @@ -171,7 +173,7 @@ export function getRoutes(getRouteOptions, apiKey) { const data = { slug, page, component, details, status, apiKey } const model = await selectViewModel(data, select, error) - return h.view('postcode-lookup-details', model) + return h.view(viewName, model) } const addresses = await service.searchByUPRN(select.uprn, apiKey) @@ -198,7 +200,7 @@ export function getRoutes(getRouteOptions, apiKey) { const data = { slug, title, page, component, status } const model = manualViewModel(data, manual, error) - return h.view('postcode-lookup-details', model) + return h.view(viewName, model) } await updateComponentState(request, componentName, manual) From e6d4d6865af4f5a683bb85f639df35838d401531 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 13:21:20 +0100 Subject: [PATCH 12/96] Sonar fixes (Functional complexity) --- .../plugins/postcode-lookup/models/index.js | 108 +++++++----------- 1 file changed, 39 insertions(+), 69 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index a425fb9e7..befc9ebda 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -48,80 +48,50 @@ function buildErrors(err) { return {} } - const postcodeQueryError = err.details.find( - (item) => item.path[0] === postcodeQueryFieldName - ) - const buildingNameQueryError = err.details.find( - (item) => item.path[0] === buildingNameQueryFieldName - ) - const uprnError = err.details.find((item) => item.path[0] === uprnFieldName) - const line1Error = err.details.find((item) => item.path[0] === line1FieldName) - const line2Error = err.details.find((item) => item.path[0] === line2FieldName) - const townError = err.details.find((item) => item.path[0] === townFieldName) - const countyError = err.details.find( - (item) => item.path[0] === countyFieldName - ) - const postcodeError = err.details.find( - (item) => item.path[0] === postcodeFieldName - ) - - const errors = [] - - if (postcodeQueryError) { - errors.push({ - text: postcodeQueryError.message, - href: `#${postcodeQueryFieldName}` - }) - } - - if (buildingNameQueryError) { - errors.push({ - text: buildingNameQueryError.message, - href: `#${buildingNameQueryFieldName}` - }) - } - - if (uprnError) { - errors.push({ - text: uprnError.message, - href: `#${uprnFieldName}` - }) + /** + * Get error by path + * @param {string} fieldName + */ + const getError = (fieldName) => { + return err.details.find((item) => item.path[0] === fieldName) } - if (line1Error) { - errors.push({ - text: line1Error.message, - href: `#${line1FieldName}` - }) - } - - if (line2Error) { - errors.push({ - text: line2Error.message, - href: `#${line2FieldName}` - }) - } - - if (townError) { - errors.push({ - text: townError.message, - href: `#${townFieldName}` - }) - } + const postcodeQueryError = getError(postcodeQueryFieldName) + const buildingNameQueryError = getError(buildingNameQueryFieldName) + const uprnError = getError(uprnFieldName) + const line1Error = getError(line1FieldName) + const line2Error = getError(line2FieldName) + const townError = getError(townFieldName) + const countyError = getError(countyFieldName) + const postcodeError = getError(postcodeFieldName) + + /** + * @type {{ text: string, href: string }[]} + */ + const errors = [] - if (countyError) { - errors.push({ - text: countyError.message, - href: `#${countyFieldName}` - }) + /** + * Push error + * @param {string} fieldName - the field name + * @param {Joi.ValidationErrorItem} [err] - the joi validation error + */ + const pushError = (fieldName, err) => { + if (err) { + errors.push({ + text: err.message, + href: `#${fieldName}` + }) + } } - if (postcodeError) { - errors.push({ - text: postcodeError.message, - href: `#${postcodeFieldName}` - }) - } + pushError(postcodeQueryFieldName, postcodeQueryError) + pushError(buildingNameQueryFieldName, buildingNameQueryError) + pushError(uprnFieldName, uprnError) + pushError(line1FieldName, line1Error) + pushError(line2FieldName, line2Error) + pushError(townFieldName, townError) + pushError(countyFieldName, countyError) + pushError(postcodeFieldName, postcodeError) return { errors, From 55ae28b210a3687be7b45fee670e17fc7baaf1cf Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 13:25:34 +0100 Subject: [PATCH 13/96] Sonar fixes (Functional complexity) --- src/server/plugins/postcode-lookup/service.js | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index bc772105e..9fa0ea7c8 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -127,25 +127,8 @@ export async function search(postcodeQuery, buildingNameQuery, apiKey) { * @param {DeliveryPointAddress} dpa */ function formatAddress(dpa) { - const buildingName = - dpa.ORGANISATION_NAME || dpa.SUB_BUILDING_NAME || dpa.BUILDING_NAME - ? [ - dpa.ORGANISATION_NAME || '', - dpa.SUB_BUILDING_NAME || '', - dpa.BUILDING_NAME || '' - ] - .filter((item) => !!item) - .join(' ') - : '' - const numberStreet = - dpa.BUILDING_NUMBER || dpa.THOROUGHFARE_NAME - ? [ - dpa.BUILDING_NUMBER ? dpa.BUILDING_NUMBER.toString() : '', - dpa.THOROUGHFARE_NAME || '' - ] - .filter((item) => !!item) - .join(' ') - : '' + const addressLine1 = formatAddressLine1(dpa) + const addressLine2 = formatAddressLine2(dpa) // const lines = [ // buildingName, @@ -169,8 +152,8 @@ function formatAddress(dpa) { const address = { uprn: dpa.UPRN, address: dpa.ADDRESS, - addressLine1: buildingName, - addressLine2: numberStreet, + addressLine1, + addressLine2, town: dpa.POST_TOWN, county: '', postcode: dpa.POSTCODE @@ -180,6 +163,35 @@ function formatAddress(dpa) { return address } +/** + * @param {DeliveryPointAddress} dpa + */ +function formatAddressLine2(dpa) { + return dpa.BUILDING_NUMBER || dpa.THOROUGHFARE_NAME + ? [ + dpa.BUILDING_NUMBER ? dpa.BUILDING_NUMBER.toString() : '', + dpa.THOROUGHFARE_NAME || '' + ] + .filter((item) => !!item) + .join(' ') + : '' +} + +/** + * @param {DeliveryPointAddress} dpa + */ +function formatAddressLine1(dpa) { + return dpa.ORGANISATION_NAME || dpa.SUB_BUILDING_NAME || dpa.BUILDING_NAME + ? [ + dpa.ORGANISATION_NAME || '', + dpa.SUB_BUILDING_NAME || '', + dpa.BUILDING_NAME || '' + ] + .filter((item) => !!item) + .join(' ') + : '' +} + // /** // * // * @param {string} address From 68dbdafe3f1eda3dfaee63f0d52eec51b2fe4e42 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 14:07:02 +0100 Subject: [PATCH 14/96] Sonar fixes (useless assignment) --- src/server/plugins/engine/components/UkAddressField.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 560cd3ffb..c6068a3db 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -150,7 +150,7 @@ export class UkAddressField extends FormComponent { const { collection, name, options } = this const viewModel = super.getViewModel(payload, errors) - let { components, fieldset, hint, label } = viewModel + let { fieldset, hint, label } = viewModel fieldset ??= { legend: { @@ -173,7 +173,7 @@ export class UkAddressField extends FormComponent { } } - components = collection.getViewModel(payload, errors) + const components = collection.getViewModel(payload, errors) const usePostcodeLookup = !!( this.options.usePostcodeLookup && this.model.ordnanceSurveyApiKey From 500cbd2317b4fb0b9e47c67931d6bf382973e5e7 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 14:08:41 +0100 Subject: [PATCH 15/96] Sonar fixes (declared in the upper scope) --- src/server/plugins/postcode-lookup/models/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index befc9ebda..46c18ea0d 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -73,12 +73,12 @@ function buildErrors(err) { /** * Push error * @param {string} fieldName - the field name - * @param {Joi.ValidationErrorItem} [err] - the joi validation error + * @param {Joi.ValidationErrorItem} [item] - the joi validation error */ - const pushError = (fieldName, err) => { - if (err) { + const pushError = (fieldName, item) => { + if (item) { errors.push({ - text: err.message, + text: item.message, href: `#${fieldName}` }) } From 5325f9b112b8887dae077a957cb118225c86cd59 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 14:10:52 +0100 Subject: [PATCH 16/96] Sonar fixes (nested template literals) --- src/server/plugins/postcode-lookup/models/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 46c18ea0d..da95e8482 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -231,7 +231,10 @@ export function getKey(slug, state) { * @param {string} [step] - the postcode lookup step */ function getHref(slug, page, component, status, step) { - return `${JOURNEY_BASE_URL}/${slug}${page.path}/${component.name}${status ? `/${status}` : ''}${step ? `?step=${step}` : ''}` + const query = step ? `?step=${step}` : '' + const state = status ? `/${status}` : '' + + return `${JOURNEY_BASE_URL}/${slug}${page.path}/${component.name}${state}${query}` } /** From c026c8e6927aadff06a40b9a7efa5d83baa798fc Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 16:02:53 +0100 Subject: [PATCH 17/96] Sonar fixes (variable in upper scope) --- .../plugins/postcode-lookup/routes/index.js | 164 +++++++++++------- 1 file changed, 99 insertions(+), 65 deletions(-) diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index fff5ae40a..7dc6b059f 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -95,6 +95,99 @@ async function updateComponentState(request, componentName, address) { * @param {string} apiKey */ export function getRoutes(getRouteOptions, apiKey) { + /** + * Post handler for the details step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + */ + async function detailsPostHandler(request, h) { + const { params, payload } = request + const { slug, state: status } = params + const { title, page, component } = getJourneyDetails(request) + + const { value: details, error } = detailsPayloadSchema.validate(payload) + + if (error) { + const data = { slug, title, page, component, status } + const model = detailsViewModel(data, details, error) + + return h.view(viewName, model) + } + + // Store the details in session + request.yar.set(getKey(slug, status), details) + + const data = { slug, page, component, details, status, apiKey } + const model = await selectViewModel(data) + + return h.view(viewName, model) + } + + /** + * Post handler for the select step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + */ + async function selectPostHandler(request, h) { + const { params, payload } = request + const { slug, path, componentName, state: status } = params + const { page, component } = getJourneyDetails(request) + + const { value: select, error } = selectPayloadSchema.validate(payload) + + if (error) { + const { postcodeQuery, buildingNameQuery } = select + const details = { postcodeQuery, buildingNameQuery } + const data = { slug, page, component, details, status, apiKey } + const model = await selectViewModel(data, select, error) + + return h.view(viewName, model) + } + + const addresses = await service.searchByUPRN(select.uprn, apiKey) + const property = addresses.at(0) + + if (!property) { + throw Boom.internal(`UPRN ${property} not found`) + } + + await updateComponentState(request, componentName, property) + + // Redirect back to the source form page + return h + .redirect(`${FORM_PREFIX}/${slug}/${path}`) + .code(StatusCodes.SEE_OTHER) + } + + /** + * Post handler for the manual step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + */ + async function manualPostHandler(request, h) { + const { params, payload } = request + const { slug, path, componentName, state: status } = params + const { title, page, component } = getJourneyDetails(request) + + const { value: manual, error } = manualPayloadSchema.validate(payload, { + abortEarly: false + }) + + if (error) { + const data = { slug, title, page, component, status } + const model = manualViewModel(data, manual, error) + + return h.view(viewName, model) + } + + await updateComponentState(request, componentName, manual) + + // Redirect back to the source form page + return h + .redirect(`${FORM_PREFIX}/${slug}/${path}`) + .code(StatusCodes.SEE_OTHER) + } + return [ /** * @satisfies {ServerRoute} @@ -138,77 +231,18 @@ export function getRoutes(getRouteOptions, apiKey) { method: 'POST', path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, async handler(request, h) { - const { params, payload } = request - const { slug, path, componentName, state: status } = params - const { title, page, component } = getJourneyDetails(request) + const { payload } = request const { step } = payload switch (step) { case steps.details: { - const { value: details, error } = - detailsPayloadSchema.validate(payload) - - if (error) { - const data = { slug, title, page, component, status } - const model = detailsViewModel(data, details, error) - - return h.view(viewName, model) - } - - // Store the details in session - request.yar.set(getKey(slug, status), details) - - const data = { slug, page, component, details, status, apiKey } - const model = await selectViewModel(data) - - return h.view(viewName, model) + return detailsPostHandler(request, h) } case steps.select: { - const { value: select, error } = - selectPayloadSchema.validate(payload) - - if (error) { - const { postcodeQuery, buildingNameQuery } = select - const details = { postcodeQuery, buildingNameQuery } - const data = { slug, page, component, details, status, apiKey } - const model = await selectViewModel(data, select, error) - - return h.view(viewName, model) - } - - const addresses = await service.searchByUPRN(select.uprn, apiKey) - const property = addresses.at(0) - - if (!property) { - throw Boom.internal(`UPRN ${property} not found`) - } - - await updateComponentState(request, componentName, property) - - // Redirect back to the source form page - return h - .redirect(`${FORM_PREFIX}/${slug}/${path}`) - .code(StatusCodes.SEE_OTHER) + return selectPostHandler(request, h) } case steps.manual: { - const { value: manual, error } = manualPayloadSchema.validate( - payload, - { abortEarly: false } - ) - - if (error) { - const data = { slug, title, page, component, status } - const model = manualViewModel(data, manual, error) - - return h.view(viewName, model) - } - - await updateComponentState(request, componentName, manual) - - // Redirect back to the source form page - return h - .redirect(`${FORM_PREFIX}/${slug}/${path}`) - .code(StatusCodes.SEE_OTHER) + return manualPostHandler(request, h) } default: throw Boom.badRequest(`Invalid step ${step}`) @@ -231,6 +265,6 @@ export function getRoutes(getRouteOptions, apiKey) { } /** - * @import { RouteOptions, ServerRoute } from '@hapi/hapi' - * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupRequestRefs } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { ResponseToolkit, RouteOptions, ServerRoute } from '@hapi/hapi' + * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupRequestRefs, PostcodeLookupPostRequest } from '~/src/server/plugins/postcode-lookup/types.js' */ From 1e6b77f132443f88eec071f8eebfe79b3cc98838 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 16:13:02 +0100 Subject: [PATCH 18/96] Sonar fixes (variable in upper scope) --- src/server/plugins/postcode-lookup/routes/index.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 7dc6b059f..af55e8329 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -107,9 +107,11 @@ export function getRoutes(getRouteOptions, apiKey) { const { value: details, error } = detailsPayloadSchema.validate(payload) + let data, model + if (error) { - const data = { slug, title, page, component, status } - const model = detailsViewModel(data, details, error) + data = { slug, title, page, component, status } + model = detailsViewModel(data, details, error) return h.view(viewName, model) } @@ -117,8 +119,8 @@ export function getRoutes(getRouteOptions, apiKey) { // Store the details in session request.yar.set(getKey(slug, status), details) - const data = { slug, page, component, details, status, apiKey } - const model = await selectViewModel(data) + data = { slug, page, component, details, status, apiKey } + model = await selectViewModel(data) return h.view(viewName, model) } From 94c1082eefd636f67d8685c0a0524ad775de6087 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 16:35:05 +0100 Subject: [PATCH 19/96] Sonar fixes (function line length) --- .../plugins/postcode-lookup/models/index.js | 350 +++++++++++------- src/server/plugins/postcode-lookup/types.js | 10 + 2 files changed, 227 insertions(+), 133 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index da95e8482..81607fb32 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -73,7 +73,7 @@ function buildErrors(err) { /** * Push error * @param {string} fieldName - the field name - * @param {Joi.ValidationErrorItem} [item] - the joi validation error + * @param {ValidationErrorItem} [item] - the joi validation error */ const pushError = (fieldName, item) => { if (item) { @@ -106,6 +106,32 @@ function buildErrors(err) { } } +/** + * Search ordnance survey for addresses + * @param {string} postcodeQuery + * @param {string} buildingNameQuery + * @param {string} apiKey + */ +async function getAddresses(postcodeQuery, buildingNameQuery, apiKey) { + const addresses = await service.search( + postcodeQuery, + buildingNameQuery, + apiKey + ) + const addressCount = addresses.length + const singleAddress = addressCount === 1 ? addresses.at(0) : undefined + const hasAddresses = addressCount > 0 + const hasMultipleAddresses = addressCount > 1 + + return { + hasAddresses, + hasMultipleAddresses, + singleAddress, + addresses, + addressCount + } +} + /** * @param {string} slug * @param {FormStatus} [status] @@ -118,6 +144,162 @@ function constructFormUrl(slug, status) { return `${FORM_PREFIX}/preview/${status}/${slug}` } +/** + * Get the details view fields + * @param {PostcodeLookupDetailsPayload | undefined} payload + * @param {OptionalValidationErrorItem} postcodeQueryError + * @param {OptionalValidationErrorItem} buildingNameQueryError + */ +function getDetailsFields(payload, postcodeQueryError, buildingNameQueryError) { + return { + [postcodeQueryFieldName]: { + id: postcodeQueryFieldName, + name: postcodeQueryFieldName, + label: { + text: 'Postcode' + }, + hint: { + text: 'For example, AA3 1AB' + }, + value: payload?.postcodeQuery, + errorMessage: postcodeQueryError && { text: postcodeQueryError.message } + }, + [buildingNameQueryFieldName]: { + id: buildingNameQueryFieldName, + name: buildingNameQueryFieldName, + label: { + text: 'Building name or number (optional)' + }, + hint: { + text: 'For example, 15 or Prospect Cottage' + }, + value: payload?.buildingNameQuery, + errorMessage: buildingNameQueryError && { + text: buildingNameQueryError.message + } + } + } +} + +/** + * Get the select view fields + * @param {PostcodeLookupDetailsData} details + * @param {boolean} hasMultipleAddresses + * @param {Address | undefined} singleAddress + * @param {PostcodeLookupSelectPayload | undefined} payload + * @param {OptionalValidationErrorItem} uprnError + * @param {Address[]} addresses + */ +function getSelectViewFields( + details, + hasMultipleAddresses, + singleAddress, + payload, + uprnError, + addresses +) { + return { + [postcodeQueryFieldName]: { + id: postcodeQueryFieldName, + name: postcodeQueryFieldName, + type: 'hidden', + value: details.postcodeQuery + }, + [buildingNameQueryFieldName]: { + id: buildingNameQueryFieldName, + name: buildingNameQueryFieldName, + type: 'hidden', + value: details.buildingNameQuery + }, + [uprnFieldName]: { + id: uprnFieldName, + name: uprnFieldName, + label: hasMultipleAddresses + ? { + text: selectLabelText + } + : undefined, + value: singleAddress ? singleAddress.uprn : payload?.uprn, + errorMessage: uprnError && { text: uprnError.message }, + items: hasMultipleAddresses + ? [{ text: selectLabelText }].concat( + addresses.map((item) => ({ + text: item.address, + value: item.uprn + })) + ) + : undefined, + type: singleAddress ? 'hidden' : undefined + } + } +} + +/** + * Get the manual view fields + * @param {PostcodeLookupManualPayload | undefined} payload + * @param {OptionalValidationErrorItem} line1Error + * @param {OptionalValidationErrorItem} line2Error + * @param {OptionalValidationErrorItem} townError + * @param {OptionalValidationErrorItem} countyError + * @param {OptionalValidationErrorItem} postcodeError + */ +function getManualFields( + payload, + line1Error, + line2Error, + townError, + countyError, + postcodeError +) { + return { + [line1FieldName]: { + id: line1FieldName, + name: line1FieldName, + label: { + text: 'Address line 1' + }, + value: payload?.addressLine1, + errorMessage: line1Error && { text: line1Error.message } + }, + [line2FieldName]: { + id: line2FieldName, + name: line2FieldName, + label: { + text: 'Address line 2 (optional)' + }, + value: payload?.addressLine2, + errorMessage: line2Error && { text: line2Error.message } + }, + [townFieldName]: { + id: townFieldName, + name: townFieldName, + label: { + text: 'Town or city' + }, + value: payload?.town, + errorMessage: townError && { text: townError.message } + }, + [countyFieldName]: { + id: countyFieldName, + name: countyFieldName, + label: { + text: 'County (optional)' + }, + value: payload?.county, + errorMessage: countyError && { text: countyError.message } + }, + [postcodeFieldName]: { + id: postcodeFieldName, + name: postcodeFieldName, + label: { + text: 'Postcode' + }, + value: payload?.postcode, + errorMessage: postcodeError && { text: postcodeError.message } + } + } +} + /** * @param {string} formPath * @param {string} path @@ -237,19 +419,9 @@ function getHref(slug, page, component, status, step) { return `${JOURNEY_BASE_URL}/${slug}${page.path}/${component.name}${state}${query}` } -/** - * The postcode lookup details form view model data - * @typedef {object} DetailsModelData - * @property {string} slug - the form slug - * @property {string} title - the form title - * @property {Page} page - the form page - * @property {UkAddressFieldComponent} component - the form component - * @property {FormStatus} [status] - the form status - */ - /** * The postcode lookup details form view model - * @param {DetailsModelData} data + * @param {PostcodeLookupDetailsModelData} data * @param {PostcodeLookupDetailsPayload} [payload] * @param {Error} [err] */ @@ -267,34 +439,11 @@ export function detailsViewModel(data, payload, err) { buildErrors(err) // Model fields - const fields = { - [postcodeQueryFieldName]: { - id: postcodeQueryFieldName, - name: postcodeQueryFieldName, - label: { - text: 'Postcode' - }, - hint: { - text: 'For example, AA3 1AB' - }, - value: payload?.postcodeQuery, - errorMessage: postcodeQueryError && { text: postcodeQueryError.message } - }, - [buildingNameQueryFieldName]: { - id: buildingNameQueryFieldName, - name: buildingNameQueryFieldName, - label: { - text: 'Building name or number (optional)' - }, - hint: { - text: 'For example, 15 or Prospect Cottage' - }, - value: payload?.buildingNameQuery, - errorMessage: buildingNameQueryError && { - text: buildingNameQueryError.message - } - } - } + const fields = getDetailsFields( + payload, + postcodeQueryError, + buildingNameQueryError + ) // Model buttons const continueButton = { @@ -330,15 +479,13 @@ export async function selectViewModel(data, payload, err) { const { postcodeQuery, buildingNameQuery } = details // Search for addresses - const addresses = await service.search( - postcodeQuery, - buildingNameQuery, - apiKey - ) - const addressCount = addresses.length - const singleAddress = addressCount === 1 ? addresses.at(0) : undefined - const hasAddresses = addressCount > 0 - const hasMultipleAddresses = addressCount > 1 + const { + hasAddresses, + hasMultipleAddresses, + singleAddress, + addresses, + addressCount + } = await getAddresses(postcodeQuery, buildingNameQuery, apiKey) const title = hasAddresses ? getComponentTitle(page, component) @@ -353,40 +500,14 @@ export async function selectViewModel(data, payload, err) { const { errors, uprnError } = buildErrors(err) // Model fields - const fields = { - [postcodeQueryFieldName]: { - id: postcodeQueryFieldName, - name: postcodeQueryFieldName, - type: 'hidden', - value: details.postcodeQuery - }, - [buildingNameQueryFieldName]: { - id: buildingNameQueryFieldName, - name: buildingNameQueryFieldName, - type: 'hidden', - value: details.buildingNameQuery - }, - [uprnFieldName]: { - id: uprnFieldName, - name: uprnFieldName, - label: hasMultipleAddresses - ? { - text: selectLabelText - } - : undefined, - value: singleAddress ? singleAddress.uprn : payload?.uprn, - errorMessage: uprnError && { text: uprnError.message }, - items: hasMultipleAddresses - ? [{ text: selectLabelText }].concat( - addresses.map((item) => ({ - text: item.address, - value: item.uprn - })) - ) - : undefined, - type: singleAddress ? 'hidden' : undefined - } - } + const fields = getSelectViewFields( + details, + hasMultipleAddresses, + singleAddress, + payload, + uprnError, + addresses + ) const href = getHref(slug, page, component, status) const searchAgainLink = { @@ -425,7 +546,7 @@ export async function selectViewModel(data, payload, err) { /** * The postcode lookup manual form view model - * @param {DetailsModelData} data + * @param {PostcodeLookupDetailsModelData} data * @param {PostcodeLookupManualPayload} [payload] * @param {Error} [err] */ @@ -449,53 +570,14 @@ export function manualViewModel(data, payload, err) { } = buildErrors(err) // Model fields - const fields = { - [line1FieldName]: { - id: line1FieldName, - name: line1FieldName, - label: { - text: 'Address line 1' - }, - value: payload?.addressLine1, - errorMessage: line1Error && { text: line1Error.message } - }, - [line2FieldName]: { - id: line2FieldName, - name: line2FieldName, - label: { - text: 'Address line 2 (optional)' - }, - value: payload?.addressLine2, - errorMessage: line2Error && { text: line2Error.message } - }, - [townFieldName]: { - id: townFieldName, - name: townFieldName, - label: { - text: 'Town or city' - }, - value: payload?.town, - errorMessage: townError && { text: townError.message } - }, - [countyFieldName]: { - id: countyFieldName, - name: countyFieldName, - label: { - text: 'County (optional)' - }, - value: payload?.county, - errorMessage: countyError && { text: countyError.message } - }, - [postcodeFieldName]: { - id: postcodeFieldName, - name: postcodeFieldName, - label: { - text: 'Postcode' - }, - value: payload?.postcode, - errorMessage: postcodeError && { text: postcodeError.message } - } - } + const fields = getManualFields( + payload, + line1Error, + line2Error, + townError, + countyError, + postcodeError + ) // Model buttons const continueButton = { @@ -519,9 +601,11 @@ export function manualViewModel(data, payload, err) { } } +/** @typedef { ValidationErrorItem | undefined } OptionalValidationErrorItem */ + /** * @import { UkAddressFieldComponent, Page, ComponentDef } from '@defra/forms-model' - * @import { ObjectSchema } from 'joi' + * @import { ObjectSchema, ValidationErrorItem } from 'joi' * @import { FormStatus } from '~/src/server/routes/types.js' - * @import { PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectModelData, PostcodeLookupSelectPayload } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { Address, PostcodeLookupDetailsData, PostcodeLookupDetailsModelData, PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectModelData, PostcodeLookupSelectPayload } from '~/src/server/plugins/postcode-lookup/types.js' */ diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index 9f45b0e6f..89a28b620 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -9,6 +9,16 @@ * @property {string} buildingNameQuery - Building name or number query */ +/** + * The postcode lookup details form view model data + * @typedef {object} PostcodeLookupDetailsModelData + * @property {string} slug - the form slug + * @property {string} title - the form title + * @property {Page} page - the form page + * @property {UkAddressFieldComponent} component - the form component + * @property {FormStatus} [status] - the form status + */ + /** * The postcode lookup select form view model data * @typedef {object} PostcodeLookupSelectModelData From 89a602715847d6109ce9170006a0875f95a0ed24 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 16:46:38 +0100 Subject: [PATCH 20/96] Sonar fixes (function line length) --- .../plugins/postcode-lookup/routes/index.js | 300 +++++++++--------- 1 file changed, 155 insertions(+), 145 deletions(-) diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index af55e8329..acd0a6f44 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -91,179 +91,189 @@ async function updateComponentState(request, componentName, address) { /** * Gets the postcode lookup routes - * @param {RouteOptions} getRouteOptions - * @param {string} apiKey + * @param {RouteOptions} getRouteOptions - hapi route options + * @param {string} apiKey - ordnance survey api key */ export function getRoutes(getRouteOptions, apiKey) { - /** - * Post handler for the details step - * @param {PostcodeLookupPostRequest} request - * @param {ResponseToolkit} h - */ - async function detailsPostHandler(request, h) { - const { params, payload } = request - const { slug, state: status } = params - const { title, page, component } = getJourneyDetails(request) - - const { value: details, error } = detailsPayloadSchema.validate(payload) - - let data, model + return [getRoute(getRouteOptions), postRoute(getRouteOptions, apiKey)] +} - if (error) { - data = { slug, title, page, component, status } - model = detailsViewModel(data, details, error) +/** + * @param {RouteOptions} getRouteOptions + * @returns {ServerRoute} + */ +function getRoute(getRouteOptions) { + return { + method: 'GET', + path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, + handler(request, h) { + const { params, query } = request + const { slug, state: status } = params + const { title, page, component } = getJourneyDetails(request) + + // Get the previous details from session + const previous = request.yar.get(getKey(slug, status)) + + const data = { slug, page, title, component, status } + const model = + query.step === steps.manual + ? manualViewModel(data) + : detailsViewModel(data, previous) return h.view(viewName, model) + }, + // @ts-expect-error - Request typing + options: { + ...getRouteOptions, + validate: { + params: paramsSchema, + query: Joi.object() + .keys({ + step: Joi.string().allow(steps.details, steps.manual).optional() + }) + .optional() + } + } + } +} + +/** + * @param {RouteOptions} getRouteOptions + * @param {string} apiKey - ordnance survey api key + * @returns {ServerRoute} + */ +function postRoute(getRouteOptions, apiKey) { + return { + method: 'POST', + path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, + async handler(request, h) { + const { payload } = request + const { step } = payload + + switch (step) { + case steps.details: { + return detailsPostHandler(request, h, apiKey) + } + case steps.select: { + return selectPostHandler(request, h, apiKey) + } + case steps.manual: { + return manualPostHandler(request, h) + } + default: + throw Boom.badRequest(`Invalid step ${step}`) + } + }, + // @ts-expect-error - Request typing + options: { + ...getRouteOptions, + validate: { + params: paramsSchema, + payload: Joi.object() + .keys({ + step: stepSchema + }) + .unknown(true) + } } + } +} + +/** + * Post handler for the details step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + * @param {string} apiKey - ordnance survey api key + */ +async function detailsPostHandler(request, h, apiKey) { + const { params, payload } = request + const { slug, state: status } = params + const { title, page, component } = getJourneyDetails(request) + + const { value: details, error } = detailsPayloadSchema.validate(payload) - // Store the details in session - request.yar.set(getKey(slug, status), details) + let data, model - data = { slug, page, component, details, status, apiKey } - model = await selectViewModel(data) + if (error) { + data = { slug, title, page, component, status } + model = detailsViewModel(data, details, error) return h.view(viewName, model) } - /** - * Post handler for the select step - * @param {PostcodeLookupPostRequest} request - * @param {ResponseToolkit} h - */ - async function selectPostHandler(request, h) { - const { params, payload } = request - const { slug, path, componentName, state: status } = params - const { page, component } = getJourneyDetails(request) + // Store the details in session + request.yar.set(getKey(slug, status), details) - const { value: select, error } = selectPayloadSchema.validate(payload) + data = { slug, page, component, details, status, apiKey } + model = await selectViewModel(data) - if (error) { - const { postcodeQuery, buildingNameQuery } = select - const details = { postcodeQuery, buildingNameQuery } - const data = { slug, page, component, details, status, apiKey } - const model = await selectViewModel(data, select, error) + return h.view(viewName, model) +} - return h.view(viewName, model) - } +/** + * Post handler for the select step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + * @param {string} apiKey - ordnance survey api key + */ +async function selectPostHandler(request, h, apiKey) { + const { params, payload } = request + const { slug, path, componentName, state: status } = params + const { page, component } = getJourneyDetails(request) - const addresses = await service.searchByUPRN(select.uprn, apiKey) - const property = addresses.at(0) + const { value: select, error } = selectPayloadSchema.validate(payload) - if (!property) { - throw Boom.internal(`UPRN ${property} not found`) - } + if (error) { + const { postcodeQuery, buildingNameQuery } = select + const details = { postcodeQuery, buildingNameQuery } + const data = { slug, page, component, details, status, apiKey } + const model = await selectViewModel(data, select, error) + + return h.view(viewName, model) + } - await updateComponentState(request, componentName, property) + const addresses = await service.searchByUPRN(select.uprn, apiKey) + const property = addresses.at(0) - // Redirect back to the source form page - return h - .redirect(`${FORM_PREFIX}/${slug}/${path}`) - .code(StatusCodes.SEE_OTHER) + if (!property) { + throw Boom.internal(`UPRN ${property} not found`) } - /** - * Post handler for the manual step - * @param {PostcodeLookupPostRequest} request - * @param {ResponseToolkit} h - */ - async function manualPostHandler(request, h) { - const { params, payload } = request - const { slug, path, componentName, state: status } = params - const { title, page, component } = getJourneyDetails(request) + await updateComponentState(request, componentName, property) - const { value: manual, error } = manualPayloadSchema.validate(payload, { - abortEarly: false - }) + // Redirect back to the source form page + return h + .redirect(`${FORM_PREFIX}/${slug}/${path}`) + .code(StatusCodes.SEE_OTHER) +} - if (error) { - const data = { slug, title, page, component, status } - const model = manualViewModel(data, manual, error) +/** + * Post handler for the manual step + * @param {PostcodeLookupPostRequest} request + * @param {ResponseToolkit} h + */ +async function manualPostHandler(request, h) { + const { params, payload } = request + const { slug, path, componentName, state: status } = params + const { title, page, component } = getJourneyDetails(request) - return h.view(viewName, model) - } + const { value: manual, error } = manualPayloadSchema.validate(payload, { + abortEarly: false + }) - await updateComponentState(request, componentName, manual) + if (error) { + const data = { slug, title, page, component, status } + const model = manualViewModel(data, manual, error) - // Redirect back to the source form page - return h - .redirect(`${FORM_PREFIX}/${slug}/${path}`) - .code(StatusCodes.SEE_OTHER) + return h.view(viewName, model) } - return [ - /** - * @satisfies {ServerRoute} - */ - ({ - method: 'GET', - path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, - handler(request, h) { - const { params, query } = request - const { slug, state: status } = params - const { title, page, component } = getJourneyDetails(request) - - // Get the previous details from session - const previous = request.yar.get(getKey(slug, status)) - - const data = { slug, page, title, component, status } - const model = - query.step === steps.manual - ? manualViewModel(data) - : detailsViewModel(data, previous) - - return h.view(viewName, model) - }, - // @ts-expect-error - Request typing - options: { - ...getRouteOptions, - validate: { - params: paramsSchema, - query: Joi.object() - .keys({ - step: Joi.string().allow(steps.details, steps.manual).optional() - }) - .optional() - } - } - }), - /** - * @satisfies {ServerRoute} - */ - ({ - method: 'POST', - path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, - async handler(request, h) { - const { payload } = request - const { step } = payload - - switch (step) { - case steps.details: { - return detailsPostHandler(request, h) - } - case steps.select: { - return selectPostHandler(request, h) - } - case steps.manual: { - return manualPostHandler(request, h) - } - default: - throw Boom.badRequest(`Invalid step ${step}`) - } - }, - // @ts-expect-error - Request typing - options: { - ...getRouteOptions, - validate: { - params: paramsSchema, - payload: Joi.object() - .keys({ - step: stepSchema - }) - .unknown(true) - } - } - }) - ] + await updateComponentState(request, componentName, manual) + + // Redirect back to the source form page + return h + .redirect(`${FORM_PREFIX}/${slug}/${path}`) + .code(StatusCodes.SEE_OTHER) } /** From 5733036c9e93692bcbedc2bc73d0f1ae868a64b5 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 16:48:24 +0100 Subject: [PATCH 21/96] Sonar fixes (remove this commented out code) --- src/server/plugins/postcode-lookup/service.js | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index 9fa0ea7c8..376ebd755 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -130,22 +130,6 @@ function formatAddress(dpa) { const addressLine1 = formatAddressLine1(dpa) const addressLine2 = formatAddressLine2(dpa) - // const lines = [ - // buildingName, - // numberStreet, - // dpa.POST_TOWN || '', - // dpa.POSTCODE || '' - // ] - - // const formatted = titleCase( - // lines - // .filter((i) => i) - // .slice(0, -1) - // .join(', ') - // ) + - // ', ' + - // (dpa.POSTCODE || '') - /** * @type {Address} */ @@ -157,7 +141,6 @@ function formatAddress(dpa) { town: dpa.POST_TOWN, county: '', postcode: dpa.POSTCODE - // formatted } return address @@ -192,17 +175,6 @@ function formatAddressLine1(dpa) { : '' } -// /** -// * -// * @param {string} address -// */ -// function titleCase(address) { -// return address -// .split(' ') -// .map((item) => item.charAt(0).toUpperCase() + item.slice(1).toLowerCase()) -// .join(' ') -// } - /** * @import { Address, DeliveryPointAddress, DeliveryPointAddressResult } from '~/src/server/plugins/postcode-lookup/types.js' */ From 02288aea1d8e444ad9920f388444e69d6f1975ff Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 8 Oct 2025 20:42:00 +0100 Subject: [PATCH 22/96] Bump @defra/forms-model to v3.0.560 --- package-lock.json | 8 +++++++- package.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d91c0999e..b2f9a49b1 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.559", + "@defra/forms-model": "^3.0.560", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2272,9 +2272,15 @@ } }, "node_modules/@defra/forms-model": { +<<<<<<< HEAD "version": "3.0.559", "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.559.tgz", "integrity": "sha512-dSMrTnhUXnapflHKdeQLMGDwK2QlFhp/08XwzLNHzLHmgx7pqHAgelzVeRsyHtzYDu7B7tF4r5cyR+SxI4UmXw==", +======= + "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==", +>>>>>>> 84b33f02 (Bump @defra/forms-model to v3.0.560) "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index a6487bfaf..acc7432c1 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ }, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.559", + "@defra/forms-model": "^3.0.560", "@defra/hapi-tracing": "^1.26.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", From f764b644b39719090a26db588ecd6eeb4f5ab4d3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 9 Oct 2025 10:55:28 +0100 Subject: [PATCH 23/96] Show address hint text on the source page --- .../views/components/ukaddressfield.html | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index c5d1281ef..00d4d983e 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -5,8 +5,32 @@ {% from "govuk/components/inset-text/macro.njk" import govukInsetText %} {% macro UkAddressField(component) %} + {% set fieldset = component.model.fieldset %} {% set usePostcodeLookup = component.model.usePostcodeLookup %} + {% set addressFieldHtml %} +
+ {{ componentList(component.model.components) }} +
+ {% endset %} + + {% if component.model.hint %} + {% set addressHintHtml %} + {{ govukHint({ + id: component.model.name + "-hint", + text: component.model.hint.text + } if fieldset else component.model.hint) }} + {% endset %} + + {% set addressFieldHtml = addressHintHtml + addressFieldHtml %} + {% endif %} + + {{ govukFieldset({ + legend: fieldset.legend, + attributes: fieldset.attributes, + html: addressFieldHtml + }) if fieldset else addressFieldHtml }} + {% if usePostcodeLookup %} {% set value = component.model.value %} {% set postcodeLookupHref = "/postcode-lookup/" + slug + page.path + "/" + component.model.name %} @@ -21,7 +45,8 @@ {% endset %} {{ govukInsetText({ - html: insetHtml + html: insetHtml, + classes: "govuk-!-margin-top-2" }) }} {% else %}
@@ -34,26 +59,4 @@
{% endif %} {% endif %} - - {% set fieldset = component.model.fieldset %} - {% set addressFieldHtml = componentList(component.model.components) %} - - {% if component.model.hint %} - {% set addressHintHtml %} - {{ govukHint({ - id: component.model.name + "-hint", - text: component.model.hint.text - } if fieldset else component.model.hint) }} - {% endset %} - - {% set addressFieldHtml = addressHintHtml + addressFieldHtml %} - {% endif %} - -
- {{ govukFieldset({ - legend: fieldset.legend, - attributes: fieldset.attributes, - html: addressFieldHtml - }) if fieldset else addressFieldHtml }} -
{% endmacro %} From 712640dd9cdf416d8539f25508c42dd3681ff1f9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 9 Oct 2025 11:07:28 +0100 Subject: [PATCH 24/96] Add hint to the manual entry form --- src/server/plugins/postcode-lookup/models/index.js | 6 ++++++ .../postcode-lookup/views/postcode-lookup-details.html | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 81607fb32..2a1fbc769 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -569,6 +569,11 @@ export function manualViewModel(data, payload, err) { postcodeError } = buildErrors(err) + // Model hint + const hint = component.hint && { + text: component.hint + } + // Model fields const fields = getManualFields( payload, @@ -596,6 +601,7 @@ export function manualViewModel(data, payload, err) { pageTitle, backLink, errors, + hint, fields, buttons: { continueButton, lookupLink } } diff --git a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html index 7443e1f77..287f1aeed 100644 --- a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html +++ b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html @@ -5,6 +5,8 @@ {% from "govuk/components/select/macro.njk" import govukSelect %} {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/inset-text/macro.njk" import govukInsetText %} +{% from "govuk/components/hint/macro.njk" import govukHint %} +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} {% block content %}
@@ -59,6 +61,10 @@ {% endif %} {% case "manual" %} + {% if hint %} + {{ govukHint(hint) }} + {% endif %} + {{ govukInput(fields.addressLine1) }} {{ govukInput(fields.addressLine2) }} {{ govukInput(fields.town) }} From 2ff6f5efead85436854a03202b9bd16217acd583 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 9 Oct 2025 12:35:19 +0100 Subject: [PATCH 25/96] Format address lowercase and include village (DEPENDENT_LOCALITY) --- .../plugins/postcode-lookup/models/index.js | 6 +- src/server/plugins/postcode-lookup/service.js | 63 ++++++++++++------- src/server/plugins/postcode-lookup/types.js | 2 + 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 2a1fbc769..b61e8057a 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -190,7 +190,7 @@ function getDetailsFields(payload, postcodeQueryError, buildingNameQueryError) { * @param {OptionalValidationErrorItem} uprnError * @param {Address[]} addresses */ -function getSelectViewFields( +function getSelectFields( details, hasMultipleAddresses, singleAddress, @@ -224,7 +224,7 @@ function getSelectViewFields( items: hasMultipleAddresses ? [{ text: selectLabelText }].concat( addresses.map((item) => ({ - text: item.address, + text: item.formatted, value: item.uprn })) ) @@ -500,7 +500,7 @@ export async function selectViewModel(data, payload, err) { const { errors, uprnError } = buildErrors(err) // Model fields - const fields = getSelectViewFields( + const fields = getSelectFields( details, hasMultipleAddresses, singleAddress, diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index 376ebd755..38105b66f 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -129,6 +129,10 @@ export async function search(postcodeQuery, buildingNameQuery, apiKey) { function formatAddress(dpa) { const addressLine1 = formatAddressLine1(dpa) const addressLine2 = formatAddressLine2(dpa) + const town = titleCase(dpa.POST_TOWN || '') + const postcode = dpa.POSTCODE || '' + const lines = [addressLine1, addressLine2, town] + const formatted = `${lines.filter((i) => i).join(', ')}, ${postcode}` /** * @type {Address} @@ -138,9 +142,10 @@ function formatAddress(dpa) { address: dpa.ADDRESS, addressLine1, addressLine2, - town: dpa.POST_TOWN, + town, county: '', - postcode: dpa.POSTCODE + postcode, + formatted } return address @@ -149,30 +154,46 @@ function formatAddress(dpa) { /** * @param {DeliveryPointAddress} dpa */ -function formatAddressLine2(dpa) { - return dpa.BUILDING_NUMBER || dpa.THOROUGHFARE_NAME - ? [ - dpa.BUILDING_NUMBER ? dpa.BUILDING_NUMBER.toString() : '', - dpa.THOROUGHFARE_NAME || '' - ] - .filter((item) => !!item) - .join(' ') - : '' +function formatAddressLine1(dpa) { + return titleCase( + dpa.ORGANISATION_NAME || dpa.SUB_BUILDING_NAME || dpa.BUILDING_NAME + ? [ + dpa.ORGANISATION_NAME || '', + dpa.SUB_BUILDING_NAME || '', + dpa.BUILDING_NAME || '' + ] + .filter((item) => !!item) + .join(' ') + : '' + ) } /** * @param {DeliveryPointAddress} dpa */ -function formatAddressLine1(dpa) { - return dpa.ORGANISATION_NAME || dpa.SUB_BUILDING_NAME || dpa.BUILDING_NAME - ? [ - dpa.ORGANISATION_NAME || '', - dpa.SUB_BUILDING_NAME || '', - dpa.BUILDING_NAME || '' - ] - .filter((item) => !!item) - .join(' ') - : '' +function formatAddressLine2(dpa) { + return titleCase( + dpa.BUILDING_NUMBER || dpa.THOROUGHFARE_NAME + ? [ + dpa.BUILDING_NUMBER ? dpa.BUILDING_NUMBER.toString() : '', + dpa.THOROUGHFARE_NAME || '', + dpa.DEPENDENT_LOCALITY || '' + ] + .filter((item) => !!item) + .join(', ') + : '' + ) +} + +/** + * Title case address + * @param {string} address + */ +function titleCase(address) { + return address + .split(' ') + .map((item) => item.charAt(0).toUpperCase() + item.slice(1).toLowerCase()) + .join(' ') } /** diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index 89a28b620..13aba62ae 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -110,6 +110,7 @@ * @property {string} town - Address town * @property {string} county - Address county * @property {string} postcode - Address postcode + * @property {string} formatted - The full formatted address */ /** @@ -123,6 +124,7 @@ * @property {string} BUILDING_NAME - Building name * @property {number} BUILDING_NUMBER - Building number * @property {string} THOROUGHFARE_NAME - Throughfare name + * @property {string} DEPENDENT_LOCALITY - Dependent locality * @property {string} POST_TOWN - Post town * @property {string} POSTCODE - Postcode */ From 6bebdead49519daf8af368756c983d6e9b529cae Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 9 Oct 2025 12:55:19 +0100 Subject: [PATCH 26/96] Fix building number type --- src/server/plugins/postcode-lookup/service.js | 16 ++++++++-------- src/server/plugins/postcode-lookup/types.js | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index 38105b66f..d7e2e0441 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -156,11 +156,15 @@ function formatAddress(dpa) { */ function formatAddressLine1(dpa) { return titleCase( - dpa.ORGANISATION_NAME || dpa.SUB_BUILDING_NAME || dpa.BUILDING_NAME + dpa.ORGANISATION_NAME || + dpa.SUB_BUILDING_NAME || + dpa.BUILDING_NAME || + dpa.BUILDING_NUMBER ? [ dpa.ORGANISATION_NAME || '', dpa.SUB_BUILDING_NAME || '', - dpa.BUILDING_NAME || '' + dpa.BUILDING_NAME || '', + dpa.BUILDING_NUMBER || '' ] .filter((item) => !!item) .join(' ') @@ -173,12 +177,8 @@ function formatAddressLine1(dpa) { */ function formatAddressLine2(dpa) { return titleCase( - dpa.BUILDING_NUMBER || dpa.THOROUGHFARE_NAME - ? [ - dpa.BUILDING_NUMBER ? dpa.BUILDING_NUMBER.toString() : '', - dpa.THOROUGHFARE_NAME || '', - dpa.DEPENDENT_LOCALITY || '' - ] + dpa.THOROUGHFARE_NAME || dpa.DEPENDENT_LOCALITY + ? [dpa.THOROUGHFARE_NAME || '', dpa.DEPENDENT_LOCALITY || ''] .filter((item) => !!item) .join(', ') : '' diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index 13aba62ae..8faaeab8d 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -122,7 +122,7 @@ * @property {string} ORGANISATION_NAME - Organisation name * @property {string} SUB_BUILDING_NAME - Sub building name * @property {string} BUILDING_NAME - Building name - * @property {number} BUILDING_NUMBER - Building number + * @property {string} BUILDING_NUMBER - Building number * @property {string} THOROUGHFARE_NAME - Throughfare name * @property {string} DEPENDENT_LOCALITY - Dependent locality * @property {string} POST_TOWN - Post town From ad0da3f5e30786f4932d445443214e5822ef2dd9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 9 Oct 2025 13:25:16 +0100 Subject: [PATCH 27/96] Consistent link placement --- .../plugins/postcode-lookup/models/index.js | 19 +++++++++++-------- .../views/postcode-lookup-details.html | 10 ++++------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index b61e8057a..c24e732db 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -447,10 +447,11 @@ export function detailsViewModel(data, payload, err) { // Model buttons const continueButton = { - text: 'Find address' + text: 'Find address', + classes: 'govuk-!-margin-right-1' } const manualLink = { - text: 'Enter address manually', + text: 'enter address manually', href: getHref(slug, page, component, status, steps.manual) } @@ -518,10 +519,11 @@ export async function selectViewModel(data, payload, err) { // Model buttons const continueButton = { href: !hasAddresses ? href : undefined, - text: hasAddresses ? 'Use this address' : 'Search again' + text: hasAddresses ? 'Use this address' : 'Search again', + classes: 'govuk-!-margin-right-1' } const manualLink = { - text: 'Enter address manually', + text: 'enter address manually', href: `${href}?step=${steps.manual}` } @@ -586,10 +588,11 @@ export function manualViewModel(data, payload, err) { // Model buttons const continueButton = { - text: 'Use this address' + text: 'Use this address', + classes: 'govuk-!-margin-right-1' } - const lookupLink = { - text: 'Find an address instead', + const detailsLink = { + text: 'find an address instead', href: getHref(slug, page, component, status) } @@ -603,7 +606,7 @@ export function manualViewModel(data, payload, err) { errors, hint, fields, - buttons: { continueButton, lookupLink } + buttons: { continueButton, detailsLink } } } diff --git a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html index 287f1aeed..9b77ebe5a 100644 --- a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html +++ b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html @@ -74,15 +74,13 @@
{{ govukButton(buttons.continueButton) }} -
-

{% if buttons.manualLink %} - {{buttons.manualLink.text}} - {% elif buttons.lookupLink %} - {{buttons.lookupLink.text}} +

or {{buttons.manualLink.text}}

+ {% elif buttons.detailsLink %} +

or {{buttons.detailsLink.text}}

{% endif %} -

+
From 9f30f409965574e087ef3e47c36a54fff000835c Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 9 Oct 2025 13:28:39 +0100 Subject: [PATCH 28/96] Back links --- src/server/plugins/postcode-lookup/models/index.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index c24e732db..daa185f01 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -492,11 +492,9 @@ export async function selectViewModel(data, payload, err) { ? getComponentTitle(page, component) : 'No address found' const formPath = constructFormUrl(slug, status) - const pagePath = constructFormPageUrl(formPath, page.path) + const href = getHref(slug, page, component, status) - const backLink = { - href: pagePath - } + const backLink = { href } const { errors, uprnError } = buildErrors(err) @@ -510,7 +508,6 @@ export async function selectViewModel(data, payload, err) { addresses ) - const href = getHref(slug, page, component, status) const searchAgainLink = { text: 'Search again', href @@ -556,10 +553,10 @@ export function manualViewModel(data, payload, err) { const { slug, title, page, component, status } = data const pageTitle = getComponentTitle(page, component) const formPath = constructFormUrl(slug, status) - const pagePath = constructFormPageUrl(formPath, page.path) + const href = getHref(slug, page, component, status) const backLink = { - href: pagePath + href } const { @@ -593,7 +590,7 @@ export function manualViewModel(data, payload, err) { } const detailsLink = { text: 'find an address instead', - href: getHref(slug, page, component, status) + href } return { From aa7d58ed28839db011c6d95b373fec0295dfd382 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 9 Oct 2025 15:23:47 +0100 Subject: [PATCH 29/96] Manual address field widths --- src/server/plugins/postcode-lookup/models/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index daa185f01..47cedc085 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -276,6 +276,7 @@ function getManualFields( label: { text: 'Town or city' }, + classes: 'govuk-!-width-two-thirds', value: payload?.town, errorMessage: townError && { text: townError.message } }, @@ -294,6 +295,7 @@ function getManualFields( label: { text: 'Postcode' }, + classes: 'govuk-input--width-10', value: payload?.postcode, errorMessage: postcodeError && { text: postcodeError.message } } From 6b0d9cb3f171231271e174afbca5fa88182a1d0b Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 10 Oct 2025 10:03:35 +0100 Subject: [PATCH 30/96] Clear stored postcode lookup session state on entering a new journey --- .../engine/views/components/ukaddressfield.html | 4 ++-- src/server/plugins/postcode-lookup/routes/index.js | 14 +++++++++++--- src/server/plugins/postcode-lookup/types.js | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index 00d4d983e..43dc370e7 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -33,7 +33,7 @@ {% if usePostcodeLookup %} {% set value = component.model.value %} - {% set postcodeLookupHref = "/postcode-lookup/" + slug + page.path + "/" + component.model.name %} + {% set postcodeLookupHref = "/postcode-lookup/" + slug + page.path + "/" + component.model.name + "?clear=true" %} {% if value %} {% set insetHtml %} @@ -55,7 +55,7 @@ href: postcodeLookupHref, classes: "govuk-button--secondary govuk-!-margin-right-1" }) }} -

or enter address manually

+

or enter address manually

{% endif %} {% endif %} diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index acd0a6f44..fa8f10ef8 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -109,14 +109,21 @@ function getRoute(getRouteOptions) { handler(request, h) { const { params, query } = request const { slug, state: status } = params + const { step, clear } = query const { title, page, component } = getJourneyDetails(request) // Get the previous details from session - const previous = request.yar.get(getKey(slug, status)) + let previous + + if (clear) { + request.yar.clear(getKey(slug, status)) + } else { + previous = request.yar.get(getKey(slug, status)) + } const data = { slug, page, title, component, status } const model = - query.step === steps.manual + step === steps.manual ? manualViewModel(data) : detailsViewModel(data, previous) @@ -129,7 +136,8 @@ function getRoute(getRouteOptions) { params: paramsSchema, query: Joi.object() .keys({ - step: Joi.string().allow(steps.details, steps.manual).optional() + step: Joi.string().allow(steps.details, steps.manual).optional(), + clear: Joi.boolean().optional() }) .optional() } diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index 8faaeab8d..b2e04566e 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -71,7 +71,7 @@ * Postcode lookup get request * @typedef {object} PostcodeLookupGetRequestRefs * @property {PostcodeLookupParams} Params - Request parameters - * @property {{ step?: string }} Query - Request query + * @property {{ step?: string, clear?: boolean }} Query - Request query */ /** From 041f388619227dd01d270c99ea386ac10571c315 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 10 Oct 2025 10:04:30 +0100 Subject: [PATCH 31/96] Handle OS service exceptions --- src/server/plugins/postcode-lookup/service.js | 120 ++++++++---------- 1 file changed, 53 insertions(+), 67 deletions(-) diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index d7e2e0441..41e914a07 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -1,4 +1,5 @@ import { getErrorMessage } from '@defra/forms-model' +import Boom from '@hapi/boom' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' import { getJson } from '~/src/server/services/httpService.js' @@ -6,36 +7,63 @@ import { getJson } from '~/src/server/services/httpService.js' const logger = createLogger() /** - * OS places search - * @param {string} query - the search term - * @param {string} apiKey - the OS api key + * Returns an empty result set */ -export async function searchByQuery(query, apiKey) { +function empty() { + return [] +} + +/** + * Logs OS places errors + * @param {unknown} err - the error + * @param {string} endpoint - the OS api endpoint + */ +function logErrorAndReturnEmpty(err, endpoint) { + const msg = `${getErrorMessage(err)} ${(Boom.isBoom(err) && err.data?.payload?.error?.message) ?? ''}` + + logger.error(err, `Exception occured calling OS places ${endpoint} - ${msg}}`) + + return empty() +} + +/** + * Fetch data from OS API + * @param {string} url - the url to get address json data from + * @param {string} endpoint - the url endpoint description for logging + */ +async function getAddressData(url, endpoint) { const getJsonByType = /** @type {typeof getJson} */ (getJson) - const url = `https://api.os.uk/search/places/v1/find?query=${encodeURIComponent(query)}&key=${apiKey}` + try { + const response = await getJsonByType(url) - const response = await getJsonByType(url) + if (response.error) { + return logErrorAndReturnEmpty(response.error, endpoint) + } - if (response.error) { - const error = response.error + const results = response.payload.results - logger.error( - error, - `Exception occured calling OS places find ${getErrorMessage(error)}` - ) + if (!Array.isArray(results)) { + return empty() + } - return [] + return results.map((result) => formatAddress(result.DPA)) + } catch (err) { + return logErrorAndReturnEmpty(err, endpoint) } +} - const results = response.payload.results - - if (!Array.isArray(results)) { - return [] - } +/** + * OS places search + * @param {string} query - the search term + * @param {string} apiKey - the OS api key + */ +export async function searchByQuery(query, apiKey) { + const endpoint = 'find' + const url = `https://api.os.uk/search/places/v1/${endpoint}?query=${encodeURIComponent(query)}&key=${apiKey}` - return results.map((result) => formatAddress(result.DPA)) + return getAddressData(url, endpoint) } /** @@ -44,31 +72,10 @@ export async function searchByQuery(query, apiKey) { * @param {string} apiKey - the OS api key */ export async function searchByPostcode(postcode, apiKey) { - const getJsonByType = - /** @type {typeof getJson} */ (getJson) - - const url = `https://api.os.uk/search/places/v1/postcode?postcode=${encodeURIComponent(postcode.replace(/\s/g, ''))}&key=${apiKey}` - - const response = await getJsonByType(url) - - if (response.error) { - const error = response.error - - logger.error( - error, - `Exception occured calling OS places postcode ${getErrorMessage(error)}` - ) - - return [] - } - - const results = response.payload.results - - if (!Array.isArray(results)) { - return [] - } + const endpoint = 'postcode' + const url = `https://api.os.uk/search/places/v1/${endpoint}?postcode=${encodeURIComponent(postcode.replace(/\s/g, ''))}&key=${apiKey}` - return results.map((result) => formatAddress(result.DPA)) + return getAddressData(url, endpoint) } /** @@ -77,31 +84,10 @@ export async function searchByPostcode(postcode, apiKey) { * @param {string} apiKey - the OS api key */ export async function searchByUPRN(uprn, apiKey) { - const getJsonByType = - /** @type {typeof getJson} */ (getJson) - - const url = `https://api.os.uk/search/places/v1/uprn?uprn=${uprn}&key=${apiKey}` - - const response = await getJsonByType(url) - - if (response.error) { - const error = response.error - - logger.error( - error, - `Exception occured calling OS places UPRN ${getErrorMessage(error)}` - ) - - return [] - } - - const results = response.payload.results - - if (!Array.isArray(results)) { - return [] - } + const endpoint = 'uprn' + const url = `https://api.os.uk/search/places/v1/${endpoint}?uprn=${uprn}&key=${apiKey}` - return results.map((result) => formatAddress(result.DPA)) + return getAddressData(url, endpoint) } /** From 5cc85951d0845bf20fb2dd28e10a6ab322b66dd7 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 10 Oct 2025 10:07:04 +0100 Subject: [PATCH 32/96] Trim inputs --- src/server/plugins/postcode-lookup/models/index.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 47cedc085..2c2dd2fb7 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -329,10 +329,10 @@ export const stepSchema = Joi.string() const sharedPayloadSchemaKeys = { crumb: crumbSchema, step: stepSchema, - [postcodeQueryFieldName]: Joi.string().required().messages({ + [postcodeQueryFieldName]: Joi.string().trim().required().messages({ '*': 'Enter a postcode' }), - [buildingNameQueryFieldName]: Joi.string().required().allow('').trim() + [buildingNameQueryFieldName]: Joi.string().trim().required().allow('').trim() } /** @@ -364,15 +364,15 @@ export const manualPayloadSchema = Joi.object() .keys({ crumb: crumbSchema, step: stepSchema, - [line1FieldName]: Joi.string().required().messages({ + [line1FieldName]: Joi.string().trim().required().messages({ '*': 'Enter address line 1' }), - [line2FieldName]: Joi.string().allow('').required(), - [townFieldName]: Joi.string().required().messages({ + [line2FieldName]: Joi.string().trim().allow('').required(), + [townFieldName]: Joi.string().trim().required().messages({ '*': 'Enter town or city' }), - [countyFieldName]: Joi.string().allow('').required(), - [postcodeFieldName]: Joi.string().required().messages({ + [countyFieldName]: Joi.string().trim().allow('').required(), + [postcodeFieldName]: Joi.string().trim().required().messages({ '*': 'Enter postcode' }) }) From 8c200150a3c5c3b01c258b0bc5a2d51e993ac859 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 10 Oct 2025 17:26:18 +0100 Subject: [PATCH 33/96] Add UPRN to state --- src/client/stylesheets/application.scss | 5 + .../engine/components/UkAddressField.ts | 53 ++++++- .../views/components/ukaddressfield.html | 4 +- src/server/plugins/postcode-lookup/index.js | 11 +- .../plugins/postcode-lookup/models/index.js | 4 +- .../plugins/postcode-lookup/routes/index.js | 132 ++++++++++++++---- src/server/plugins/postcode-lookup/types.js | 39 ++++-- .../views/postcode-lookup-details.html | 2 +- 8 files changed, 193 insertions(+), 57 deletions(-) diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index 349c344c2..dd004291e 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -12,3 +12,8 @@ .govuk-header__container { border-bottom: 10px solid #003d16; } + +.app-hidden { + display: none; + visibility: hidden; +} diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index c6068a3db..fc6d57c75 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -8,6 +8,7 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' +import { type FormQuery } from '~/src/server/plugins/engine/types/index.js' import { type ErrorMessageTemplateList, type FormPayload, @@ -16,6 +17,7 @@ import { type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +import { JOURNEY_BASE_URL } from '~/src/server/plugins/postcode-lookup/models/index.js' export class UkAddressField extends FormComponent { declare options: UkAddressFieldComponent['options'] @@ -37,6 +39,16 @@ export class UkAddressField extends FormComponent { this.collection = new ComponentCollection( [ + { + type: ComponentType.TextField, + name: `${name}__uprn`, + title: 'UPRN', + schema: {}, + options: { + required: false, + classes: 'hidden' + } + }, { type: ComponentType.TextField, name: `${name}__addressLine1`, @@ -115,7 +127,9 @@ export class UkAddressField extends FormComponent { return null } - return Object.values(value).filter(Boolean) + return Object.entries(value) + .filter(([key, value]) => key !== 'uprn' && Boolean(value)) + .map(([, value]) => value) } getContextValueFromState(state: FormSubmissionState) { @@ -146,7 +160,11 @@ export class UkAddressField extends FormComponent { ) } - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { + getViewModel( + payload: FormPayload, + errors?: FormSubmissionError[], + query?: FormQuery + ) { const { collection, name, options } = this const viewModel = super.getViewModel(payload, errors) @@ -175,6 +193,16 @@ export class UkAddressField extends FormComponent { const components = collection.getViewModel(payload, errors) + // Hide UPRN + const uprn = components.at(0) + + if (!uprn) { + throw new Error('No UPRN') + } + + uprn.model.formGroup = { classes: 'app-hidden' } + + // Postcode lookup const usePostcodeLookup = !!( this.options.usePostcodeLookup && this.model.ordnanceSurveyApiKey ) @@ -182,12 +210,30 @@ export class UkAddressField extends FormComponent { ? this.getDisplayStringFromState(payload) : undefined + let postcodeLookupBaseUrl + let postcodeLookupQuery + + if (usePostcodeLookup) { + const searchParams = new URLSearchParams([['clear', 'true']]) + + if (query) { + Object.entries(query).forEach(([key, value]) => { + searchParams.append(key, value ?? '') + }) + } + + postcodeLookupBaseUrl = JOURNEY_BASE_URL + postcodeLookupQuery = searchParams.toString() + } + return { ...viewModel, value, fieldset, components, - usePostcodeLookup + usePostcodeLookup, + postcodeLookupBaseUrl, + postcodeLookupQuery } } @@ -230,6 +276,7 @@ export class UkAddressField extends FormComponent { } export interface UkAddressState extends Record { + uprn: string addressLine1: string addressLine2: string town: string diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index 43dc370e7..a805d281f 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -32,8 +32,10 @@ }) if fieldset else addressFieldHtml }} {% if usePostcodeLookup %} + {% set postcodeLookupBaseUrl = component.model.postcodeLookupBaseUrl %} + {% set postcodeLookupQuery = component.model.postcodeLookupQuery %} {% set value = component.model.value %} - {% set postcodeLookupHref = "/postcode-lookup/" + slug + page.path + "/" + component.model.name + "?clear=true" %} + {% set postcodeLookupHref = postcodeLookupBaseUrl + "/" + slug + page.path + "/" + component.model.name + "?" + postcodeLookupQuery %} {% if value %} {% set insetHtml %} diff --git a/src/server/plugins/postcode-lookup/index.js b/src/server/plugins/postcode-lookup/index.js index 6011130e2..41a8ae1e6 100644 --- a/src/server/plugins/postcode-lookup/index.js +++ b/src/server/plugins/postcode-lookup/index.js @@ -27,20 +27,13 @@ export const postcodeLookupPlugin = { server.route( /** @type {ServerRoute[]} */ ( // @ts-expect-error - Request typing - getRoutes(getRouteOptions, options.ordnanceSurveyApiKey) + getRoutes(getRouteOptions, options) ) ) } } -/** - * @typedef {{ - * ordnanceSurveyApiKey: string - * enginePluginOptions: PluginOptions - * }} PostcodeLookupConfiguration - */ - /** * @import { NamedPlugin, ServerRoute } from '@hapi/hapi' - * @import { PluginOptions } from '~/src/server/plugins/engine/types.js' + * @import { PostcodeLookupConfiguration } from '~/src/server/plugins/postcode-lookup/types.js' */ diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 2c2dd2fb7..46a04bdf1 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -350,7 +350,7 @@ export const detailsPayloadSchema = Joi.object() export const selectPayloadSchema = Joi.object() .keys({ ...sharedPayloadSchemaKeys, - [uprnFieldName]: Joi.number().required().messages({ + [uprnFieldName]: Joi.string().required().messages({ '*': selectLabelText }) }) @@ -615,5 +615,5 @@ export function manualViewModel(data, payload, err) { * @import { UkAddressFieldComponent, Page, ComponentDef } from '@defra/forms-model' * @import { ObjectSchema, ValidationErrorItem } from 'joi' * @import { FormStatus } from '~/src/server/routes/types.js' - * @import { Address, PostcodeLookupDetailsData, PostcodeLookupDetailsModelData, PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectModelData, PostcodeLookupSelectPayload } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { Address, PostcodeLookupDetailsData, PostcodeLookupDetailsModelData, PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectModelData, PostcodeLookupSelectPayload, PostcodeLookupSessionState } from '~/src/server/plugins/postcode-lookup/types.js' */ diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index fa8f10ef8..1667f9272 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -79,6 +79,11 @@ async function updateComponentState(request, componentName, address) { [`${componentName}__postcode`]: address.postcode } + // Assign UPRN if available + if ('uprn' in address && address.uprn) { + addressState[`${componentName}__uprn`] = address.uprn + } + const cacheService = getCacheService(request.server) // @ts-expect-error - Request typing const state = await cacheService.getState(request) @@ -92,10 +97,10 @@ async function updateComponentState(request, componentName, address) { /** * Gets the postcode lookup routes * @param {RouteOptions} getRouteOptions - hapi route options - * @param {string} apiKey - ordnance survey api key + * @param {PostcodeLookupConfiguration} options - ordnance survey api key */ -export function getRoutes(getRouteOptions, apiKey) { - return [getRoute(getRouteOptions), postRoute(getRouteOptions, apiKey)] +export function getRoutes(getRouteOptions, options) { + return [getRoute(getRouteOptions), postRoute(getRouteOptions, options)] } /** @@ -112,11 +117,22 @@ function getRoute(getRouteOptions) { const { step, clear } = query const { title, page, component } = getJourneyDetails(request) - // Get the previous details from session + /** + * Get the previous details from session + * @type {PostcodeLookupSessionState | undefined} + */ let previous if (clear) { - request.yar.clear(getKey(slug, status)) + /** + * @type {PostcodeLookupSessionState} + */ + const state = { + query, + details: undefined + } + + request.yar.set(getKey(slug, status), state) } else { previous = request.yar.get(getKey(slug, status)) } @@ -125,7 +141,7 @@ function getRoute(getRouteOptions) { const model = step === steps.manual ? manualViewModel(data) - : detailsViewModel(data, previous) + : detailsViewModel(data, previous?.details) return h.view(viewName, model) }, @@ -137,7 +153,9 @@ function getRoute(getRouteOptions) { query: Joi.object() .keys({ step: Joi.string().allow(steps.details, steps.manual).optional(), - clear: Joi.boolean().optional() + clear: Joi.boolean().optional(), + returnUrl: Joi.string().optional(), + force: Joi.boolean().optional() }) .optional() } @@ -147,10 +165,10 @@ function getRoute(getRouteOptions) { /** * @param {RouteOptions} getRouteOptions - * @param {string} apiKey - ordnance survey api key + * @param {PostcodeLookupConfiguration} options * @returns {ServerRoute} */ -function postRoute(getRouteOptions, apiKey) { +function postRoute(getRouteOptions, options) { return { method: 'POST', path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, @@ -160,13 +178,13 @@ function postRoute(getRouteOptions, apiKey) { switch (step) { case steps.details: { - return detailsPostHandler(request, h, apiKey) + return detailsPostHandler(request, h, options) } case steps.select: { - return selectPostHandler(request, h, apiKey) + return selectPostHandler(request, h, options) } case steps.manual: { - return manualPostHandler(request, h) + return manualPostHandler(request, h, options) } default: throw Boom.badRequest(`Invalid step ${step}`) @@ -191,13 +209,13 @@ function postRoute(getRouteOptions, apiKey) { * Post handler for the details step * @param {PostcodeLookupPostRequest} request * @param {ResponseToolkit} h - * @param {string} apiKey - ordnance survey api key + * @param {PostcodeLookupConfiguration} options */ -async function detailsPostHandler(request, h, apiKey) { +async function detailsPostHandler(request, h, options) { const { params, payload } = request const { slug, state: status } = params const { title, page, component } = getJourneyDetails(request) - + const { ordnanceSurveyApiKey: apiKey } = options const { value: details, error } = detailsPayloadSchema.validate(payload) let data, model @@ -208,13 +226,20 @@ async function detailsPostHandler(request, h, apiKey) { return h.view(viewName, model) } - - // Store the details in session - request.yar.set(getKey(slug, status), details) - data = { slug, page, component, details, status, apiKey } model = await selectViewModel(data) + const key = getKey(slug, status) + + /** + * Get the previous details from session + * @type {PostcodeLookupSessionState | undefined} + */ + const previous = request.yar.get(key) + + // Store the new details in session + request.yar.set(key, previous ? { ...previous, details } : { details }) + return h.view(viewName, model) } @@ -222,13 +247,13 @@ async function detailsPostHandler(request, h, apiKey) { * Post handler for the select step * @param {PostcodeLookupPostRequest} request * @param {ResponseToolkit} h - * @param {string} apiKey - ordnance survey api key + * @param {PostcodeLookupConfiguration} options */ -async function selectPostHandler(request, h, apiKey) { +async function selectPostHandler(request, h, options) { const { params, payload } = request const { slug, path, componentName, state: status } = params const { page, component } = getJourneyDetails(request) - + const { ordnanceSurveyApiKey: apiKey } = options const { value: select, error } = selectPayloadSchema.validate(payload) if (error) { @@ -250,17 +275,41 @@ async function selectPostHandler(request, h, apiKey) { await updateComponentState(request, componentName, property) // Redirect back to the source form page - return h - .redirect(`${FORM_PREFIX}/${slug}/${path}`) - .code(StatusCodes.SEE_OTHER) + const key = getKey(slug, status) + + /** + * Get the previous details from session + * @type {PostcodeLookupSessionState | undefined} + */ + const previous = request.yar.get(key) + const url = new URL( + `${FORM_PREFIX}/${slug}/${path}`, + options.enginePluginOptions.baseUrl + ) + + if (previous?.query) { + const query = previous.query + + if (query.returnUrl) { + url.searchParams.append('returnUrl', query.returnUrl) + } + + if (query.force !== undefined) { + url.searchParams.append('force', `${query.force}`) + } + } + + // Redirect back to the source form page + return h.redirect(url.toString()).code(StatusCodes.SEE_OTHER) } /** * Post handler for the manual step * @param {PostcodeLookupPostRequest} request * @param {ResponseToolkit} h + * @param {PostcodeLookupConfiguration} options */ -async function manualPostHandler(request, h) { +async function manualPostHandler(request, h, options) { const { params, payload } = request const { slug, path, componentName, state: status } = params const { title, page, component } = getJourneyDetails(request) @@ -279,12 +328,35 @@ async function manualPostHandler(request, h) { await updateComponentState(request, componentName, manual) // Redirect back to the source form page - return h - .redirect(`${FORM_PREFIX}/${slug}/${path}`) - .code(StatusCodes.SEE_OTHER) + const key = getKey(slug, status) + + /** + * Get the previous details from session + * @type {PostcodeLookupSessionState | undefined} + */ + const previous = request.yar.get(key) + const url = new URL( + `${FORM_PREFIX}/${slug}/${path}`, + options.enginePluginOptions.baseUrl + ) + + if (previous?.query) { + const query = previous.query + + if (query.returnUrl) { + url.searchParams.append('returnUrl', query.returnUrl) + } + + if (query.force !== undefined) { + url.searchParams.append('force', `${query.force}`) + } + } + + // Redirect back to the source form page + return h.redirect(url.toString()).code(StatusCodes.SEE_OTHER) } /** * @import { ResponseToolkit, RouteOptions, ServerRoute } from '@hapi/hapi' - * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupRequestRefs, PostcodeLookupPostRequest } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupRequestRefs, PostcodeLookupPostRequest, PostcodeLookupSessionState, PostcodeLookupConfiguration } from '~/src/server/plugins/postcode-lookup/types.js' */ diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index b2e04566e..9c74331d3 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -1,3 +1,10 @@ +/** + * @typedef {{ + * ordnanceSurveyApiKey: string + * enginePluginOptions: PluginOptions + * }} PostcodeLookupConfiguration + */ + // // Model types // @@ -30,16 +37,16 @@ * @property {FormStatus} [status] - the form status */ +/** + * @typedef {object} PostcodeLookupSessionState + * @property {PostcodeLookupQuery} query - the source form page query + * @property {PostcodeLookupDetailsPayload | undefined} details - the current postcode lookup details + */ + // -// Payload/Param types +// Route types // -// /** -// * @typedef {object} PostcodeLookupParamsType -// * @property {string} componentName - the source component name -// * @typedef {FormParams & PostcodeLookupParamsType} PostcodeLookupParams -// */ - /** * @typedef {object} PostcodeLookupParams * @property {string} slug - the source form slug @@ -48,6 +55,15 @@ * @property {FormStatus} [state] - the source form status (draft/live) when in preview mode */ +/** + * Postcode lookup query params + * @typedef {object} PostcodeLookupQuery + * @property {string} [step] - step + * @property {boolean} [clear] - Clear session state flag + * @property {boolean} [force] - Force param (preview mode) + * @property {string} [returnUrl] - Return url (Back to summary page) + */ + /** * @typedef {object} PostcodeLookupDetailsPayloadProperties * @property {string} step - step @@ -71,7 +87,7 @@ * Postcode lookup get request * @typedef {object} PostcodeLookupGetRequestRefs * @property {PostcodeLookupParams} Params - Request parameters - * @property {{ step?: string, clear?: boolean }} Query - Request query + * @property {PostcodeLookupQuery} Query - Request query */ /** @@ -103,7 +119,7 @@ /** * @typedef {object} Address - * @property {number} uprn - The unique property reference + * @property {string} uprn - The unique property reference * @property {string} address - The full address * @property {string} addressLine1 - Address line 1 * @property {string} addressLine2 - Address line 2 @@ -116,8 +132,8 @@ /** * OS places address response * @typedef {object} DeliveryPointAddress - * @property {number} UPRN - Unique property reference number - * @property {number} UDPRN - Unique delivery point Reference Number + * @property {string} UPRN - Unique property reference number + * @property {string} UDPRN - Unique delivery point Reference Number * @property {string} ADDRESS - Address * @property {string} ORGANISATION_NAME - Organisation name * @property {string} SUB_BUILDING_NAME - Sub building name @@ -144,5 +160,6 @@ /** * @import { Request } from '@hapi/hapi' * @import { UkAddressFieldComponent, Page } from '@defra/forms-model' + * @import { PluginOptions } from '~/src/server/plugins/engine/types.js' * @import { FormStatus } from '~/src/server/routes/types.js' */ diff --git a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html index 9b77ebe5a..48bbb26aa 100644 --- a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html +++ b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html @@ -53,7 +53,7 @@ {{ govukInput(fields.uprn) }} {{ govukInsetText({ - text: singleAddress.address + text: singleAddress.formatted }) }} {% else %} From 74bed9e23b744842323d699fc991e0018c788341 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 13 Oct 2025 10:06:12 +0100 Subject: [PATCH 34/96] Empty From 5953759ec2606e6c1d0e0f51341a460f4b584749 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 10:40:59 +0100 Subject: [PATCH 35/96] Add ExternalAction types --- src/server/plugins/engine/models/FormModel.ts | 4 +++- src/server/routes/types.ts | 8 +++++++- src/server/schemas/index.ts | 12 +++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index f30d4d078..f3802afb4 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -557,7 +557,9 @@ function validateFormPayload( // Skip validation GET requests or other actions if ( !request.payload || - (action && ![FormAction.Validate, FormAction.SaveAndExit].includes(action)) + (action && + ![FormAction.Validate, FormAction.SaveAndExit].includes(action) && + !action.startsWith(FormAction.External)) ) { return context } diff --git a/src/server/routes/types.ts b/src/server/routes/types.ts index 5639840fb..506b92f2a 100644 --- a/src/server/routes/types.ts +++ b/src/server/routes/types.ts @@ -45,10 +45,16 @@ export enum FormAction { Delete = 'delete', AddAnother = 'add-another', Send = 'send', - SaveAndExit = 'save-and-exit' + SaveAndExit = 'save-and-exit', + External = 'external' } export enum FormStatus { Draft = 'draft', Live = 'live' } + +export enum ExternalActions { + PostcodeLookup = 'postcode-lookup', + AnotherExternalAction = 'another-external-action' +} diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts index f181e7f01..b4e22af31 100644 --- a/src/server/schemas/index.ts +++ b/src/server/schemas/index.ts @@ -8,13 +8,11 @@ export const stateSchema = Joi.string() .required() export const actionSchema = Joi.string() - .valid( - FormAction.Continue, - FormAction.Validate, - FormAction.Delete, - FormAction.AddAnother, - FormAction.Send, - FormAction.SaveAndExit + .pattern(new RegExp(`^${FormAction.External}-[a-zA-Z-:]*$`)) + .allow( + ...Object.values(FormAction).filter( + (value) => value !== FormAction.External + ) ) .default(FormAction.Validate) .optional() From 9165e449d691fb09113faf88de3484255a8c2a8c Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 10:41:28 +0100 Subject: [PATCH 36/96] Add button styled as a link --- src/client/stylesheets/application.scss | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index dd004291e..de7abe1d0 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -17,3 +17,12 @@ display: none; visibility: hidden; } + +.govuk-button--link { + @extend %govuk-link; + @include govuk-font($size: 19); + color: $govuk-link-colour; + border: none; + cursor: pointer; + background-color: transparent; +} From 817f423d5dbe4c692e21701ae627d7c59434331d Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 10:41:53 +0100 Subject: [PATCH 37/96] Remove enginePluginOptions from postcode plugin --- src/server/plugins/engine/plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 15adb0b76..cde8ed853 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -51,8 +51,7 @@ export const plugin = { await server.register({ plugin: postcodeLookupPlugin, options: { - ordnanceSurveyApiKey, - enginePluginOptions: options + ordnanceSurveyApiKey } }) } From 40e26d0415807be35159bd59179b4754187143da Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 10:42:33 +0100 Subject: [PATCH 38/96] Remove lookup hrefs from address component --- .../engine/components/UkAddressField.ts | 28 ++----------------- .../views/components/ukaddressfield.html | 17 ++++++----- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index fc6d57c75..34153a2ff 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -8,7 +8,6 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' -import { type FormQuery } from '~/src/server/plugins/engine/types/index.js' import { type ErrorMessageTemplateList, type FormPayload, @@ -17,7 +16,6 @@ import { type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' -import { JOURNEY_BASE_URL } from '~/src/server/plugins/postcode-lookup/models/index.js' export class UkAddressField extends FormComponent { declare options: UkAddressFieldComponent['options'] @@ -160,11 +158,7 @@ export class UkAddressField extends FormComponent { ) } - getViewModel( - payload: FormPayload, - errors?: FormSubmissionError[], - query?: FormQuery - ) { + getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const { collection, name, options } = this const viewModel = super.getViewModel(payload, errors) @@ -210,30 +204,12 @@ export class UkAddressField extends FormComponent { ? this.getDisplayStringFromState(payload) : undefined - let postcodeLookupBaseUrl - let postcodeLookupQuery - - if (usePostcodeLookup) { - const searchParams = new URLSearchParams([['clear', 'true']]) - - if (query) { - Object.entries(query).forEach(([key, value]) => { - searchParams.append(key, value ?? '') - }) - } - - postcodeLookupBaseUrl = JOURNEY_BASE_URL - postcodeLookupQuery = searchParams.toString() - } - return { ...viewModel, value, fieldset, components, - usePostcodeLookup, - postcodeLookupBaseUrl, - postcodeLookupQuery + usePostcodeLookup } } diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index a805d281f..41f473d75 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -32,10 +32,7 @@ }) if fieldset else addressFieldHtml }} {% if usePostcodeLookup %} - {% set postcodeLookupBaseUrl = component.model.postcodeLookupBaseUrl %} - {% set postcodeLookupQuery = component.model.postcodeLookupQuery %} - {% set value = component.model.value %} - {% set postcodeLookupHref = postcodeLookupBaseUrl + "/" + slug + page.path + "/" + component.model.name + "?" + postcodeLookupQuery %} + {% set value = component.model.value %} {% if value %} {% set insetHtml %} @@ -43,7 +40,10 @@

{{ value }}

- Use a different address +

+ +

{% endset %} {{ govukInsetText({ @@ -54,10 +54,13 @@
{{ govukButton({ text: "Find an address", - href: postcodeLookupHref, + attributes: { + name: "action", + value: "external-postcode-lookup--name:" + component.model.name + }, classes: "govuk-button--secondary govuk-!-margin-right-1" }) }} -

or enter address manually

+

or

{% endif %} {% endif %} From 8c01ca40a4e8a8750b01955e1375db640a3dd694 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 10:42:57 +0100 Subject: [PATCH 39/96] Add ExternalAction handlng --- src/server/plugins/engine/routes/index.ts | 57 ++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 8f5290770..ef48ede48 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -1,3 +1,4 @@ +import { ComponentType } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, @@ -25,8 +26,13 @@ import { type FormContext, type PluginOptions } from '~/src/server/plugins/engine/types.js' +import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' +import { type PostcodeLookupDispatchArgs } from '~/src/server/plugins/postcode-lookup/types.js' import { + ExternalActions, + FormAction, type FormRequest, + type FormRequestPayload, type FormResponseToolkit } from '~/src/server/routes/types.js' @@ -38,7 +44,7 @@ export async function redirectOrMakeHandler( context: FormContext ) => ResponseObject | Promise ) { - const { app, params } = request + const { app, params, payload } = request const { model } = app if (!model) { @@ -64,6 +70,55 @@ export async function redirectOrMakeHandler( }) } + // External journey redirect + const { action = '' } = page.getFormParams(request) + if (payload && action.startsWith(FormAction.External)) { + // Find the external action and arguments + // `external-{externalAction}--{argname1}:{argvalue1}--{argname2}:{argvalue2}` + // E.g. external-postcode-lookup--name:wDFtgf--step:manual + const externalActionsWithArgs = action + .slice(`${FormAction.External}-`.length) + .split('--') + const externalAction = externalActionsWithArgs[0] as ExternalActions + const externalActionArgs = externalActionsWithArgs + .slice(1) + .map((arg) => arg.split(':')) + + switch (externalAction) { + case ExternalActions.PostcodeLookup: { + const args = Object.fromEntries( + externalActionArgs + ) as PostcodeLookupDispatchArgs + const componentName = args.name + const component = model.componentDefMap.get(componentName) + + if (!component) { + throw Boom.notFound(`No component found for ${componentName}`) + } + + if (component.type !== ComponentType.UkAddressField) { + throw Boom.internal( + `Invalid component type, expected UkAddressFieldComponent got ${component.type}` + ) + } + + return dispatch(request as FormRequestPayload, h, { + payload, + formName: model.name, + componentName, + componentHint: component.hint, + componentTitle: component.title || page.title, + step: args.step, + sourceUrl: request.url.toString() + }) + } + default: + throw Boom.internal( + `Invalid external action, expected one of '${Object.values(ExternalActions).join('|')}' got '${externalAction}'` + ) + } + } + const flash = cacheService.getFlash(request) const context = model.getFormContext(request, state, flash?.errors) const relevantPath = page.getRelevantPath(request, context) From 823cc848bc0dec7968d9b0b85c2b752cb98b7f36 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 10:43:16 +0100 Subject: [PATCH 40/96] Remove loadFormPreHandler from postcode lookup routes --- src/server/plugins/postcode-lookup/index.js | 22 ++------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/src/server/plugins/postcode-lookup/index.js b/src/server/plugins/postcode-lookup/index.js index 41a8ae1e6..813ed4df9 100644 --- a/src/server/plugins/postcode-lookup/index.js +++ b/src/server/plugins/postcode-lookup/index.js @@ -1,4 +1,3 @@ -import { makeLoadFormPreHandler } from '~/src/server/plugins/engine/routes/index.js' import { getRoutes } from '~/src/server/plugins/postcode-lookup/routes/index.js' export const VIEW_PATH = 'src/server/plugins/postcode-lookup/views' @@ -11,25 +10,8 @@ export const postcodeLookupPlugin = { dependencies: ['@hapi/vision'], multiple: false, register(server, options) { - const loadFormPreHandler = makeLoadFormPreHandler( - server, - options.enginePluginOptions - ) - - const getRouteOptions = { - pre: [ - { - method: loadFormPreHandler - } - ] - } - - server.route( - /** @type {ServerRoute[]} */ ( - // @ts-expect-error - Request typing - getRoutes(getRouteOptions, options) - ) - ) + // @ts-expect-error - Request typing + server.route(getRoutes(options)) } } From 32a2d8bc99d6df7b14f8dc695a9ca03909edcf53 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 10:43:46 +0100 Subject: [PATCH 41/96] Update postcode lookup plugin to us external actions --- .../plugins/postcode-lookup/models/index.js | 166 ++++-------- .../plugins/postcode-lookup/routes/index.js | 237 +++++------------- .../views/postcode-lookup-details.html | 4 - 3 files changed, 112 insertions(+), 295 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 46a04bdf1..7e36a1cb0 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -1,17 +1,7 @@ -import { - hasComponentsEvenIfNoNext, - hasFormField, - slugSchema -} from '@defra/forms-model' import Joi from 'joi' -import { FORM_PREFIX } from '~/src/server/constants.js' import * as service from '~/src/server/plugins/postcode-lookup/service.js' -import { - crumbSchema, - pathSchema, - stateSchema -} from '~/src/server/schemas/index.js' +import { crumbSchema } from '~/src/server/schemas/index.js' // Field names/ids const postcodeQueryFieldName = 'postcodeQuery' @@ -132,25 +122,13 @@ async function getAddresses(postcodeQuery, buildingNameQuery, apiKey) { } } -/** - * @param {string} slug - * @param {FormStatus} [status] - */ -function constructFormUrl(slug, status) { - if (!status) { - return `${FORM_PREFIX}/${slug}` - } - - return `${FORM_PREFIX}/preview/${status}/${slug}` -} - /** * Get the details view fields - * @param {PostcodeLookupDetailsPayload | undefined} payload + * @param {PostcodeLookupDetailsData | undefined} details * @param {OptionalValidationErrorItem} postcodeQueryError * @param {OptionalValidationErrorItem} buildingNameQueryError */ -function getDetailsFields(payload, postcodeQueryError, buildingNameQueryError) { +function getDetailsFields(details, postcodeQueryError, buildingNameQueryError) { return { [postcodeQueryFieldName]: { id: postcodeQueryFieldName, @@ -161,7 +139,7 @@ function getDetailsFields(payload, postcodeQueryError, buildingNameQueryError) { hint: { text: 'For example, AA3 1AB' }, - value: payload?.postcodeQuery, + value: details?.postcodeQuery, errorMessage: postcodeQueryError && { text: postcodeQueryError.message } }, [buildingNameQueryFieldName]: { @@ -173,7 +151,7 @@ function getDetailsFields(payload, postcodeQueryError, buildingNameQueryError) { hint: { text: 'For example, 15 or Prospect Cottage' }, - value: payload?.buildingNameQuery, + value: details?.buildingNameQuery, errorMessage: buildingNameQueryError && { text: buildingNameQueryError.message } @@ -222,7 +200,7 @@ function getSelectFields( value: singleAddress ? singleAddress.uprn : payload?.uprn, errorMessage: uprnError && { text: uprnError.message }, items: hasMultipleAddresses - ? [{ text: selectLabelText }].concat( + ? [{ text: selectLabelText, value: '' }].concat( addresses.map((item) => ({ text: item.formatted, value: item.uprn @@ -302,37 +280,13 @@ function getManualFields( } } -/** - * @param {string} formPath - * @param {string} path - */ -function constructFormPageUrl(formPath, path) { - return `${formPath}${path}` -} - -/** - * Postcode lookup params schema - */ -export const paramsSchema = Joi.object() - .keys({ - slug: slugSchema, - path: pathSchema, - componentName: Joi.string().required(), - state: stateSchema.optional() - }) - .required() - export const stepSchema = Joi.string() .valid(...Object.keys(steps)) .required() const sharedPayloadSchemaKeys = { crumb: crumbSchema, - step: stepSchema, - [postcodeQueryFieldName]: Joi.string().trim().required().messages({ - '*': 'Enter a postcode' - }), - [buildingNameQueryFieldName]: Joi.string().trim().required().allow('').trim() + step: stepSchema } /** @@ -340,7 +294,17 @@ const sharedPayloadSchemaKeys = { * @type {ObjectSchema} */ export const detailsPayloadSchema = Joi.object() - .keys(sharedPayloadSchemaKeys) + .keys({ + ...sharedPayloadSchemaKeys, + [postcodeQueryFieldName]: Joi.string().trim().required().messages({ + '*': 'Enter a postcode' + }), + [buildingNameQueryFieldName]: Joi.string() + .trim() + .required() + .allow('') + .trim() + }) .required() /** @@ -362,8 +326,7 @@ export const selectPayloadSchema = Joi.object() */ export const manualPayloadSchema = Joi.object() .keys({ - crumb: crumbSchema, - step: stepSchema, + ...sharedPayloadSchemaKeys, [line1FieldName]: Joi.string().trim().required().messages({ '*': 'Enter address line 1' }), @@ -378,63 +341,27 @@ export const manualPayloadSchema = Joi.object() }) .required() -/** - * Gets page title - * @param {Page} page - * @param {ComponentDef} component - */ -export function getComponentTitle(page, component) { - if (hasComponentsEvenIfNoNext(page)) { - const formFields = page.components.filter(hasFormField) - - // When there's more than 1 form component on the page, use the component title - if (formFields.length > 1 || formFields[0] !== component) { - return component.title - } - } - - // Otherwise use the page title - return page.title -} - -/** - * Get postcode lookup session key - * @param {string} slug - * @param {FormStatus} [state] - */ -export function getKey(slug, state) { - return `postcode-lookup-${slug}-${state ?? ''}` -} - /** * Get the postcode lookup href - * @param {string} slug - the form slug - * @param {Page} page - the form page - * @param {UkAddressFieldComponent} component - the form component - * @param {FormStatus} [status] - the form status * @param {string} [step] - the postcode lookup step */ -function getHref(slug, page, component, status, step) { +function getHref(step) { const query = step ? `?step=${step}` : '' - const state = status ? `/${status}` : '' - return `${JOURNEY_BASE_URL}/${slug}${page.path}/${component.name}${state}${query}` + return `${JOURNEY_BASE_URL}${query}` } /** * The postcode lookup details form view model - * @param {PostcodeLookupDetailsModelData} data - * @param {PostcodeLookupDetailsPayload} [payload] + * @param {PostcodeLookupSessionData} data + * @param {PostcodeLookupDetailsData} [payload] * @param {Error} [err] */ export function detailsViewModel(data, payload, err) { - const { slug, title, page, component, status } = data - const pageTitle = getComponentTitle(page, component) - const formPath = constructFormUrl(slug, status) - const pagePath = constructFormPageUrl(formPath, page.path) + const { componentTitle: pageTitle, formName, sourceUrl } = data.initial const backLink = { - href: pagePath + href: sourceUrl } const { errors, postcodeQueryError, buildingNameQueryError } = @@ -442,7 +369,7 @@ export function detailsViewModel(data, payload, err) { // Model fields const fields = getDetailsFields( - payload, + payload ?? data.details, postcodeQueryError, buildingNameQueryError ) @@ -454,14 +381,14 @@ export function detailsViewModel(data, payload, err) { } const manualLink = { text: 'enter address manually', - href: getHref(slug, page, component, status, steps.manual) + href: getHref(steps.manual) } return { step: steps.details, showTitle: true, - name: title, - serviceUrl: formPath, + name: formName, + serviceUrl: sourceUrl, pageTitle, backLink, errors, @@ -472,13 +399,13 @@ export function detailsViewModel(data, payload, err) { /** * The postcode lookup select form view model - * @param {PostcodeLookupSelectModelData} data + * @param {{ session: PostcodeLookupSessionData, apiKey: string }} data * @param {PostcodeLookupSelectPayload} [payload] * @param {Error} [err] */ export async function selectViewModel(data, payload, err) { - const { slug, page, component, details, status, apiKey } = data - + const { session, apiKey } = data + const { details, initial } = session const { postcodeQuery, buildingNameQuery } = details // Search for addresses @@ -490,11 +417,9 @@ export async function selectViewModel(data, payload, err) { addressCount } = await getAddresses(postcodeQuery, buildingNameQuery, apiKey) - const title = hasAddresses - ? getComponentTitle(page, component) - : 'No address found' - const formPath = constructFormUrl(slug, status) - const href = getHref(slug, page, component, status) + const title = hasAddresses ? initial.componentTitle : 'No address found' + const formPath = initial.sourceUrl + const href = getHref() const backLink = { href } @@ -547,15 +472,14 @@ export async function selectViewModel(data, payload, err) { /** * The postcode lookup manual form view model - * @param {PostcodeLookupDetailsModelData} data + * @param {PostcodeLookupSessionData} data * @param {PostcodeLookupManualPayload} [payload] * @param {Error} [err] */ export function manualViewModel(data, payload, err) { - const { slug, title, page, component, status } = data - const pageTitle = getComponentTitle(page, component) - const formPath = constructFormUrl(slug, status) - const href = getHref(slug, page, component, status) + const { componentTitle, sourceUrl, componentHint } = data.initial + const formPath = sourceUrl + const href = getHref() const backLink = { href @@ -571,8 +495,8 @@ export function manualViewModel(data, payload, err) { } = buildErrors(err) // Model hint - const hint = component.hint && { - text: component.hint + const hint = componentHint && { + text: componentHint } // Model fields @@ -598,9 +522,9 @@ export function manualViewModel(data, payload, err) { return { step: steps.manual, showTitle: true, - name: title, + name: componentTitle, serviceUrl: formPath, - pageTitle, + pageTitle: componentTitle, backLink, errors, hint, @@ -612,8 +536,6 @@ export function manualViewModel(data, payload, err) { /** @typedef { ValidationErrorItem | undefined } OptionalValidationErrorItem */ /** - * @import { UkAddressFieldComponent, Page, ComponentDef } from '@defra/forms-model' * @import { ObjectSchema, ValidationErrorItem } from 'joi' - * @import { FormStatus } from '~/src/server/routes/types.js' - * @import { Address, PostcodeLookupDetailsData, PostcodeLookupDetailsModelData, PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectModelData, PostcodeLookupSelectPayload, PostcodeLookupSessionState } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { Address, PostcodeLookupDetailsData, PostcodeLookupDetailsPayload, PostcodeLookupManualPayload, PostcodeLookupSelectPayload, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js' */ diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 1667f9272..683051763 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -1,21 +1,14 @@ -import { ComponentType, hasComponentsEvenIfNoNext } from '@defra/forms-model' import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' -import { FORM_PREFIX } from '~/src/server/constants.js' -import { - checkFormStatus, - getCacheService -} from '~/src/server/plugins/engine/helpers.js' +import { getCacheService } from '~/src/server/plugins/engine/helpers.js' import { JOURNEY_BASE_URL, detailsPayloadSchema, detailsViewModel, - getKey, manualPayloadSchema, manualViewModel, - paramsSchema, selectPayloadSchema, selectViewModel, stepSchema, @@ -26,41 +19,20 @@ import * as service from '~/src/server/plugins/postcode-lookup/service.js' const viewName = 'postcode-lookup-details' /** - * Get the details of the source form elements associated with this journey + * Get the session state associated with this journey * @param {PostcodeLookupRequest} request */ -function getJourneyDetails(request) { - const { app, params } = request - const { model } = app - const { path, componentName } = params - - if (!model) { - throw Boom.notFound(`No model found for ${path}`) - } - - const { isPreview, state: status } = checkFormStatus(params) - const title = model.name - const page = model.pageDefMap.get(`/${path}`) - - if (!page) { - throw Boom.notFound(`No page found for ${path}`) - } - - const component = hasComponentsEvenIfNoNext(page) - ? page.components.find((c) => c.name === componentName) - : undefined - - if (!component) { - throw Boom.notFound(`No component found for name ${componentName}`) - } +function getSessionState(request) { + /** + * @type {PostcodeLookupSessionData | undefined} + */ + const data = request.yar.get(JOURNEY_BASE_URL) - if (component.type !== ComponentType.UkAddressField) { - throw Boom.internal( - `Invalid component type, expected UkAddressFieldComponent got ${component.type}` - ) + if (!data) { + throw Boom.notFound(`No data found for ${JOURNEY_BASE_URL}`) } - return { model, title, page, component, isPreview, status } + return data } /** @@ -94,68 +66,60 @@ async function updateComponentState(request, componentName, address) { }) } +/** + * Initialises and dispatches the request to the postcode lookup journey + * @param {FormRequestPayload} request - the source page + * @param {FormResponseToolkit} h - the source page + * @param {PostcodeLookupDispatchData} initial - the source data + */ +export function dispatch(request, h, initial) { + /** + * @type {PostcodeLookupSessionData} + */ + const data = { + initial, + details: { postcodeQuery: '', buildingNameQuery: '' } + } + + request.yar.set(JOURNEY_BASE_URL, data) + + const query = initial.step ? `?step=${initial.step}` : '' + + return h.redirect(`${JOURNEY_BASE_URL}${query}`).code(StatusCodes.SEE_OTHER) +} + /** * Gets the postcode lookup routes - * @param {RouteOptions} getRouteOptions - hapi route options * @param {PostcodeLookupConfiguration} options - ordnance survey api key */ -export function getRoutes(getRouteOptions, options) { - return [getRoute(getRouteOptions), postRoute(getRouteOptions, options)] +export function getRoutes(options) { + return [getRoute(), postRoute(options)] } /** - * @param {RouteOptions} getRouteOptions * @returns {ServerRoute} */ -function getRoute(getRouteOptions) { +function getRoute() { return { method: 'GET', - path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, + path: JOURNEY_BASE_URL, handler(request, h) { - const { params, query } = request - const { slug, state: status } = params - const { step, clear } = query - const { title, page, component } = getJourneyDetails(request) - - /** - * Get the previous details from session - * @type {PostcodeLookupSessionState | undefined} - */ - let previous - - if (clear) { - /** - * @type {PostcodeLookupSessionState} - */ - const state = { - query, - details: undefined - } + const { query } = request + const { step } = query + const session = getSessionState(request) - request.yar.set(getKey(slug, status), state) - } else { - previous = request.yar.get(getKey(slug, status)) - } - - const data = { slug, page, title, component, status } const model = step === steps.manual - ? manualViewModel(data) - : detailsViewModel(data, previous?.details) + ? manualViewModel(session) + : detailsViewModel(session) return h.view(viewName, model) }, - // @ts-expect-error - Request typing options: { - ...getRouteOptions, validate: { - params: paramsSchema, query: Joi.object() .keys({ - step: Joi.string().allow(steps.details, steps.manual).optional(), - clear: Joi.boolean().optional(), - returnUrl: Joi.string().optional(), - force: Joi.boolean().optional() + step: Joi.string().allow(steps.details, steps.manual).optional() }) .optional() } @@ -164,14 +128,13 @@ function getRoute(getRouteOptions) { } /** - * @param {RouteOptions} getRouteOptions * @param {PostcodeLookupConfiguration} options * @returns {ServerRoute} */ -function postRoute(getRouteOptions, options) { +function postRoute(options) { return { method: 'POST', - path: `${JOURNEY_BASE_URL}/{slug}/{path}/{componentName}/{state?}`, + path: JOURNEY_BASE_URL, async handler(request, h) { const { payload } = request const { step } = payload @@ -184,17 +147,14 @@ function postRoute(getRouteOptions, options) { return selectPostHandler(request, h, options) } case steps.manual: { - return manualPostHandler(request, h, options) + return manualPostHandler(request, h) } default: throw Boom.badRequest(`Invalid step ${step}`) } }, - // @ts-expect-error - Request typing options: { - ...getRouteOptions, validate: { - params: paramsSchema, payload: Joi.object() .keys({ step: stepSchema @@ -212,33 +172,26 @@ function postRoute(getRouteOptions, options) { * @param {PostcodeLookupConfiguration} options */ async function detailsPostHandler(request, h, options) { - const { params, payload } = request - const { slug, state: status } = params - const { title, page, component } = getJourneyDetails(request) + const { payload } = request + const session = getSessionState(request) const { ordnanceSurveyApiKey: apiKey } = options const { value: details, error } = detailsPayloadSchema.validate(payload) - let data, model + let model if (error) { - data = { slug, title, page, component, status } - model = detailsViewModel(data, details, error) + model = detailsViewModel(session, details, error) return h.view(viewName, model) } - data = { slug, page, component, details, status, apiKey } - model = await selectViewModel(data) - const key = getKey(slug, status) + const { postcodeQuery, buildingNameQuery } = details + session.details = { postcodeQuery, buildingNameQuery } - /** - * Get the previous details from session - * @type {PostcodeLookupSessionState | undefined} - */ - const previous = request.yar.get(key) + // Store the updated session + request.yar.set(JOURNEY_BASE_URL, session) - // Store the new details in session - request.yar.set(key, previous ? { ...previous, details } : { details }) + model = await selectViewModel({ session, apiKey }) return h.view(viewName, model) } @@ -250,17 +203,13 @@ async function detailsPostHandler(request, h, options) { * @param {PostcodeLookupConfiguration} options */ async function selectPostHandler(request, h, options) { - const { params, payload } = request - const { slug, path, componentName, state: status } = params - const { page, component } = getJourneyDetails(request) + const { payload } = request + const session = getSessionState(request) const { ordnanceSurveyApiKey: apiKey } = options const { value: select, error } = selectPayloadSchema.validate(payload) if (error) { - const { postcodeQuery, buildingNameQuery } = select - const details = { postcodeQuery, buildingNameQuery } - const data = { slug, page, component, details, status, apiKey } - const model = await selectViewModel(data, select, error) + const model = await selectViewModel({ session, apiKey }, select, error) return h.view(viewName, model) } @@ -272,91 +221,41 @@ async function selectPostHandler(request, h, options) { throw Boom.internal(`UPRN ${property} not found`) } + const { componentName, sourceUrl } = session.initial await updateComponentState(request, componentName, property) // Redirect back to the source form page - const key = getKey(slug, status) - - /** - * Get the previous details from session - * @type {PostcodeLookupSessionState | undefined} - */ - const previous = request.yar.get(key) - const url = new URL( - `${FORM_PREFIX}/${slug}/${path}`, - options.enginePluginOptions.baseUrl - ) - - if (previous?.query) { - const query = previous.query - - if (query.returnUrl) { - url.searchParams.append('returnUrl', query.returnUrl) - } - - if (query.force !== undefined) { - url.searchParams.append('force', `${query.force}`) - } - } - - // Redirect back to the source form page - return h.redirect(url.toString()).code(StatusCodes.SEE_OTHER) + return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER) } /** * Post handler for the manual step * @param {PostcodeLookupPostRequest} request * @param {ResponseToolkit} h - * @param {PostcodeLookupConfiguration} options */ -async function manualPostHandler(request, h, options) { - const { params, payload } = request - const { slug, path, componentName, state: status } = params - const { title, page, component } = getJourneyDetails(request) +async function manualPostHandler(request, h) { + const { payload } = request + const session = getSessionState(request) const { value: manual, error } = manualPayloadSchema.validate(payload, { abortEarly: false }) if (error) { - const data = { slug, title, page, component, status } - const model = manualViewModel(data, manual, error) + const model = manualViewModel(session, manual, error) return h.view(viewName, model) } + const { componentName, sourceUrl } = session.initial await updateComponentState(request, componentName, manual) // Redirect back to the source form page - const key = getKey(slug, status) - - /** - * Get the previous details from session - * @type {PostcodeLookupSessionState | undefined} - */ - const previous = request.yar.get(key) - const url = new URL( - `${FORM_PREFIX}/${slug}/${path}`, - options.enginePluginOptions.baseUrl - ) - - if (previous?.query) { - const query = previous.query - - if (query.returnUrl) { - url.searchParams.append('returnUrl', query.returnUrl) - } - - if (query.force !== undefined) { - url.searchParams.append('force', `${query.force}`) - } - } - - // Redirect back to the source form page - return h.redirect(url.toString()).code(StatusCodes.SEE_OTHER) + return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER) } /** - * @import { ResponseToolkit, RouteOptions, ServerRoute } from '@hapi/hapi' - * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupRequestRefs, PostcodeLookupPostRequest, PostcodeLookupSessionState, PostcodeLookupConfiguration } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { ResponseToolkit, ServerRoute } from '@hapi/hapi' + * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupPostRequest, PostcodeLookupConfiguration, PostcodeLookupDispatchData, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { FormRequestPayload, FormResponseToolkit } from '~/src/server/routes/types.js' */ diff --git a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html index 48bbb26aa..6e3d98482 100644 --- a/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html +++ b/src/server/plugins/postcode-lookup/views/postcode-lookup-details.html @@ -31,10 +31,6 @@ {{ govukInput(fields.buildingNameQuery) }} {% case "select" %} - - {{ govukInput(fields.postcodeQuery) }} - {{ govukInput(fields.buildingNameQuery) }} - {%- set detailsHtml -%} {{ details.postcodeQuery }}{% if details.buildingNameQuery %} and {{ details.buildingNameQuery }}{% endif %} From 0aae0161b43f62d4bd7b8168129086fc0b1607be Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 10:44:01 +0100 Subject: [PATCH 42/96] Update postcode lookup types --- src/server/plugins/postcode-lookup/types.js | 75 ++++++++------------- 1 file changed, 27 insertions(+), 48 deletions(-) diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index 9c74331d3..2a1cb9d12 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -1,67 +1,54 @@ /** * @typedef {{ * ordnanceSurveyApiKey: string - * enginePluginOptions: PluginOptions * }} PostcodeLookupConfiguration */ -// -// Model types -// - /** - * The postcode lookup details form view model data - * @typedef {object} PostcodeLookupDetailsData - * @property {string} postcodeQuery - postcode query - * @property {string} buildingNameQuery - Building name or number query + * @typedef {{ + * name: string + * step?: string + * }} PostcodeLookupDispatchArgs */ /** - * The postcode lookup details form view model data - * @typedef {object} PostcodeLookupDetailsModelData - * @property {string} slug - the form slug - * @property {string} title - the form title - * @property {Page} page - the form page - * @property {UkAddressFieldComponent} component - the form component - * @property {FormStatus} [status] - the form status + * @typedef {{ + * sourceUrl: string, + * formName: string + * componentName: string + * componentTitle: string, + * componentHint?: string + * step?: string, + * payload: FormPayload + * }} PostcodeLookupDispatchData */ /** - * The postcode lookup select form view model data - * @typedef {object} PostcodeLookupSelectModelData - * @property {string} slug - the form slug - * @property {Page} page - the form page - * @property {UkAddressFieldComponent} component - the form component - * @property {PostcodeLookupDetailsData} details - the lookup details - * @property {string} apiKey - the ordnance survey api key - * @property {FormStatus} [status] - the form status + * @typedef {{ + * initial: PostcodeLookupDispatchData + * details: PostcodeLookupDetailsData + * }} PostcodeLookupSessionData */ +// +// Model types +// + /** - * @typedef {object} PostcodeLookupSessionState - * @property {PostcodeLookupQuery} query - the source form page query - * @property {PostcodeLookupDetailsPayload | undefined} details - the current postcode lookup details + * The postcode lookup details form view model data + * @typedef {object} PostcodeLookupDetailsData + * @property {string} postcodeQuery - postcode query + * @property {string} buildingNameQuery - Building name or number query */ // // Route types // -/** - * @typedef {object} PostcodeLookupParams - * @property {string} slug - the source form slug - * @property {string} path - the source page path - * @property {string} componentName - the source component name - * @property {FormStatus} [state] - the source form status (draft/live) when in preview mode - */ - /** * Postcode lookup query params * @typedef {object} PostcodeLookupQuery * @property {string} [step] - step - * @property {boolean} [clear] - Clear session state flag - * @property {boolean} [force] - Force param (preview mode) - * @property {string} [returnUrl] - Return url (Back to summary page) */ /** @@ -74,26 +61,20 @@ */ /** - * @typedef {object} PostcodeLookupSelectPayloadProperties + * @typedef {object} PostcodeLookupSelectPayload * @property {string} step - step * @property {number} uprn - postcode */ -/** - * @typedef {PostcodeLookupDetailsPayload & PostcodeLookupSelectPayloadProperties} PostcodeLookupSelectPayload - */ - /** * Postcode lookup get request * @typedef {object} PostcodeLookupGetRequestRefs - * @property {PostcodeLookupParams} Params - Request parameters * @property {PostcodeLookupQuery} Query - Request query */ /** * Postcode lookup post request * @typedef {object} PostcodeLookupPostRequestRefs - * @property {PostcodeLookupParams} Params - Request parameters * @property {PostcodeLookupDetailsPayload | PostcodeLookupSelectPayload} Payload - Request payload */ @@ -159,7 +140,5 @@ /** * @import { Request } from '@hapi/hapi' - * @import { UkAddressFieldComponent, Page } from '@defra/forms-model' - * @import { PluginOptions } from '~/src/server/plugins/engine/types.js' - * @import { FormStatus } from '~/src/server/routes/types.js' + * @import { FormPayload } from '~/src/server/plugins/engine/types.js' */ From d8d04589dd22baea52d8ee17cd4024271d65c91c Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 11:12:46 +0100 Subject: [PATCH 43/96] Sonar fixes (define a constant) --- src/server/plugins/postcode-lookup/models/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 7e36a1cb0..b841b79bb 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -16,6 +16,8 @@ const postcodeFieldName = 'postcode' const selectLabelText = 'Select an address' +const GOVUK_MARGIN_RIGHT_1 = 'govuk-!-margin-right-1' + export const steps = { // Step 1: Postcode/building name input details: 'details', @@ -377,7 +379,7 @@ export function detailsViewModel(data, payload, err) { // Model buttons const continueButton = { text: 'Find address', - classes: 'govuk-!-margin-right-1' + classes: GOVUK_MARGIN_RIGHT_1 } const manualLink = { text: 'enter address manually', @@ -444,7 +446,7 @@ export async function selectViewModel(data, payload, err) { const continueButton = { href: !hasAddresses ? href : undefined, text: hasAddresses ? 'Use this address' : 'Search again', - classes: 'govuk-!-margin-right-1' + classes: GOVUK_MARGIN_RIGHT_1 } const manualLink = { text: 'enter address manually', @@ -512,7 +514,7 @@ export function manualViewModel(data, payload, err) { // Model buttons const continueButton = { text: 'Use this address', - classes: 'govuk-!-margin-right-1' + classes: GOVUK_MARGIN_RIGHT_1 } const detailsLink = { text: 'find an address instead', From 2c1d68a90fbf0bef034693326bf5a9c36cd15b17 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 12:04:28 +0100 Subject: [PATCH 44/96] Sonar fixes (cognitive complexity) --- src/server/plugins/engine/routes/index.ts | 106 +++++++++++++--------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index ef48ede48..b0f600da5 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -24,6 +24,7 @@ import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { type AnyFormRequest, type FormContext, + type FormPayload, type PluginOptions } from '~/src/server/plugins/engine/types.js' import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' @@ -73,50 +74,9 @@ export async function redirectOrMakeHandler( // External journey redirect const { action = '' } = page.getFormParams(request) if (payload && action.startsWith(FormAction.External)) { - // Find the external action and arguments - // `external-{externalAction}--{argname1}:{argvalue1}--{argname2}:{argvalue2}` - // E.g. external-postcode-lookup--name:wDFtgf--step:manual - const externalActionsWithArgs = action - .slice(`${FormAction.External}-`.length) - .split('--') - const externalAction = externalActionsWithArgs[0] as ExternalActions - const externalActionArgs = externalActionsWithArgs - .slice(1) - .map((arg) => arg.split(':')) - - switch (externalAction) { - case ExternalActions.PostcodeLookup: { - const args = Object.fromEntries( - externalActionArgs - ) as PostcodeLookupDispatchArgs - const componentName = args.name - const component = model.componentDefMap.get(componentName) - - if (!component) { - throw Boom.notFound(`No component found for ${componentName}`) - } - - if (component.type !== ComponentType.UkAddressField) { - throw Boom.internal( - `Invalid component type, expected UkAddressFieldComponent got ${component.type}` - ) - } - - return dispatch(request as FormRequestPayload, h, { - payload, - formName: model.name, - componentName, - componentHint: component.hint, - componentTitle: component.title || page.title, - step: args.step, - sourceUrl: request.url.toString() - }) - } - default: - throw Boom.internal( - `Invalid external action, expected one of '${Object.values(ExternalActions).join('|')}' got '${externalAction}'` - ) - } + const opts = { action, model, payload, page } + + return dispatchExternalHandler(request, h, opts) } const flash = cacheService.getFlash(request) @@ -140,6 +100,64 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } +function dispatchExternalHandler( + request: AnyFormRequest, + h: FormResponseToolkit, + options: { + action: string + model: FormModel + payload: FormPayload + page: PageControllerClass + } +) { + const { action, model, payload, page } = options + + // Find the external action and arguments + // `external-{externalAction}--{argname1}:{argvalue1}--{argname2}:{argvalue2}` + // E.g. external-postcode-lookup--name:wDFtgf--step:manual + const externalActionsWithArgs = action + .slice(`${FormAction.External}-`.length) + .split('--') + const externalAction = externalActionsWithArgs[0] as ExternalActions + const externalActionArgs = externalActionsWithArgs + .slice(1) + .map((arg) => arg.split(':')) + + switch (externalAction) { + case ExternalActions.PostcodeLookup: { + const args = Object.fromEntries( + externalActionArgs + ) as PostcodeLookupDispatchArgs + const componentName = args.name + const component = model.componentDefMap.get(componentName) + + if (!component) { + throw Boom.notFound(`No component found for ${componentName}`) + } + + if (component.type !== ComponentType.UkAddressField) { + throw Boom.internal( + `Invalid component type, expected UkAddressFieldComponent got ${component.type}` + ) + } + + return dispatch(request as FormRequestPayload, h, { + payload, + formName: model.name, + componentName, + componentHint: component.hint, + componentTitle: component.title || page.title, + step: args.step, + sourceUrl: request.url.toString() + }) + } + default: + throw Boom.internal( + `Invalid external action, expected one of '${Object.values(ExternalActions).join('|')}' got '${externalAction}'` + ) + } +} + export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong const prefix = server.realm.modifiers.route.prefix ?? '' From 119157269930838133cab48667e37f6fbf791ca3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 12:20:21 +0100 Subject: [PATCH 45/96] Remove unused import --- src/server/plugins/postcode-lookup/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/postcode-lookup/index.js b/src/server/plugins/postcode-lookup/index.js index 813ed4df9..f9c4ad72b 100644 --- a/src/server/plugins/postcode-lookup/index.js +++ b/src/server/plugins/postcode-lookup/index.js @@ -16,6 +16,6 @@ export const postcodeLookupPlugin = { } /** - * @import { NamedPlugin, ServerRoute } from '@hapi/hapi' + * @import { NamedPlugin } from '@hapi/hapi' * @import { PostcodeLookupConfiguration } from '~/src/server/plugins/postcode-lookup/types.js' */ From 26920490495836b085ce052253b39efa3dca8d8e Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 13:05:45 +0100 Subject: [PATCH 46/96] Add postcode lookup service tests --- src/server/plugins/postcode-lookup/service.js | 2 +- .../plugins/postcode-lookup/service.test.js | 177 ++++++++ .../test/__stubs__/postcode.js | 397 ++++++++++++++++++ .../postcode-lookup/test/__stubs__/query.js | 202 +++++++++ .../postcode-lookup/test/__stubs__/uprn.js | 55 +++ src/server/plugins/postcode-lookup/types.js | 4 +- 6 files changed, 834 insertions(+), 3 deletions(-) create mode 100644 src/server/plugins/postcode-lookup/service.test.js create mode 100644 src/server/plugins/postcode-lookup/test/__stubs__/postcode.js create mode 100644 src/server/plugins/postcode-lookup/test/__stubs__/query.js create mode 100644 src/server/plugins/postcode-lookup/test/__stubs__/uprn.js diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index 41e914a07..e6de22aba 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -80,7 +80,7 @@ export async function searchByPostcode(postcode, apiKey) { /** * OS UPRN search - * @param {number} uprn - the unique property reference number + * @param {string} uprn - the unique property reference number * @param {string} apiKey - the OS api key */ export async function searchByUPRN(uprn, apiKey) { diff --git a/src/server/plugins/postcode-lookup/service.test.js b/src/server/plugins/postcode-lookup/service.test.js new file mode 100644 index 000000000..cd0a475d5 --- /dev/null +++ b/src/server/plugins/postcode-lookup/service.test.js @@ -0,0 +1,177 @@ +import Boom from '@hapi/boom' + +import * as service from '~/src/server/plugins/postcode-lookup/service.js' +import { result as postcodeResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js' +import { result as queryResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/query.js' +import { result as uprnResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js' +import { getJson } from '~/src/server/services/httpService.js' + +jest.mock('~/src/server/services/httpService.ts') + +describe('Postcode lookup service', () => { + describe('searchByPostcode', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: postcodeResult, + error: undefined + }) + + const results = await service.searchByPostcode('cw8 2at', 'apikey') + + expect(results).toHaveLength(10) + expect(results.at(0)).toEqual({ + address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + addressLine1: 'Forest Dene', + addressLine2: 'Forest Hill, Hartford', + county: '', + formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', + postcode: 'CW8 2AT', + town: 'Northwich', + uprn: '200003232010' + }) + }) + + it('should return an empty response when an error is encountered', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 300, + headers: {} + }), + payload: undefined, + error: new Error('Unknown error') + }) + + const results = await service.searchByPostcode('cw8 2at', 'apikey') + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + + it('should return an empty response when a non 200 response is encountered', async () => { + jest + .mocked(getJson) + .mockRejectedValueOnce( + Boom.badRequest( + 'OS API error', + new Error('Invalid postcode segments') + ) + ) + + const results = await service.searchByPostcode( + 'invalid postcode', + 'apikey' + ) + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + + it('should return an empty response when no results are returned', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: { results: undefined }, + error: undefined + }) + + const results = await service.searchByPostcode('cw8 2at', 'apikey') + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + }) + + describe('searchByUPRN', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: uprnResult, + error: undefined + }) + + const results = await service.searchByUPRN('200003232010', 'apikey') + + expect(results).toHaveLength(1) + expect(results.at(0)).toEqual({ + address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + addressLine1: 'Forest Dene', + addressLine2: 'Forest Hill, Hartford', + county: '', + formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', + postcode: 'CW8 2AT', + town: 'Northwich', + uprn: '200003232010' + }) + }) + }) + + describe('searchByQuery', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: queryResult, + error: undefined + }) + + const results = await service.searchByQuery( + 'forest dene northwich', + 'apikey' + ) + + expect(results).toHaveLength(5) + expect(results.at(0)).toEqual({ + address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + addressLine1: 'Forest Dene', + addressLine2: 'Forest Hill, Hartford', + county: '', + formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', + postcode: 'CW8 2AT', + town: 'Northwich', + uprn: '200003232010' + }) + }) + }) + + describe('search', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: postcodeResult, + error: undefined + }) + + const results = await service.search('cw8 2at', 'Dene', 'apikey') + + expect(results).toHaveLength(1) + expect(results.at(0)).toEqual({ + address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + addressLine1: 'Forest Dene', + addressLine2: 'Forest Hill, Hartford', + county: '', + formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', + postcode: 'CW8 2AT', + town: 'Northwich', + uprn: '200003232010' + }) + }) + }) +}) + +/** + * @import { IncomingMessage } from 'node:http' + */ diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js b/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js new file mode 100644 index 000000000..26d425ed8 --- /dev/null +++ b/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js @@ -0,0 +1,397 @@ +export const result = { + header: { + uri: 'https://api.os.uk/search/places/v1/postcode?postcode=cw8%202at', + query: 'postcode=cw8 2at', + offset: 0, + totalresults: 10, + format: 'JSON', + dataset: 'DPA', + lr: 'EN,CY', + maxresults: 100, + epoch: '120', + lastupdate: '2025-10-13', + output_srs: 'EPSG:27700' + }, + results: [ + { + DPA: { + UPRN: '200003232010', + UDPRN: '6132431', + ADDRESS: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'FOREST DENE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '1', + X_COORDINATE: 361852.0, + Y_COORDINATE: 371487.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905836', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '24/04/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1J' + } + }, + { + DPA: { + UPRN: '200003232009', + UDPRN: '6132430', + ADDRESS: 'FOREST HILL WEST, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'FOREST HILL WEST', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '2', + X_COORDINATE: 361898.0, + Y_COORDINATE: 371376.51, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD04', + CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905856', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '29/05/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1H' + } + }, + { + DPA: { + UPRN: '200003232002', + UDPRN: '6132432', + ADDRESS: 'FOREST HOUSE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'FOREST HOUSE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '2', + X_COORDINATE: 361893.0, + Y_COORDINATE: 371365.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD04', + CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '3', + BLPU_STATE_CODE_DESCRIPTION: 'Unoccupied', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905855', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '31/01/2024', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '30/01/2024', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1A' + } + }, + { + DPA: { + UPRN: '200003232003', + UDPRN: '6132433', + ADDRESS: 'FOREST LODGE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'FOREST LODGE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '1', + X_COORDINATE: 361908.0, + Y_COORDINATE: 371359.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD04', + CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905854', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '24/04/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1B' + } + }, + { + DPA: { + UPRN: '200003232004', + UDPRN: '6132434', + ADDRESS: 'NORTH HOUSE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'NORTH HOUSE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '1', + X_COORDINATE: 361903.0, + Y_COORDINATE: 371386.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD04', + CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905857', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '24/04/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1D' + } + }, + { + DPA: { + UPRN: '200003232008', + UDPRN: '6132435', + ADDRESS: 'ORCHARDS, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'ORCHARDS', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '2', + X_COORDINATE: 361983.0, + Y_COORDINATE: 371426.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905842', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '29/05/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1G' + } + }, + { + DPA: { + UPRN: '200003239517', + UDPRN: '6132428', + ADDRESS: 'THE COACH HOUSE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'THE COACH HOUSE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '1', + X_COORDINATE: 361927.0, + Y_COORDINATE: 371397.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD04', + CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905845', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '24/04/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1N' + } + }, + { + DPA: { + UPRN: '200003232007', + UDPRN: '6132429', + ADDRESS: 'THE COTTAGE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'THE COTTAGE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '1', + X_COORDINATE: 361923.0, + Y_COORDINATE: 371375.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905851', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '24/04/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1F' + } + }, + { + DPA: { + UPRN: '200003232011', + UDPRN: '6132436', + ADDRESS: 'THE SPINNEY, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'THE SPINNEY', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '1', + X_COORDINATE: 361964.0, + Y_COORDINATE: 371379.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905843', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '24/04/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1L' + } + }, + { + DPA: { + UPRN: '200003232005', + UDPRN: '6132437', + ADDRESS: 'WATLING HOUSE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'WATLING HOUSE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '2', + X_COORDINATE: 361888.0, + Y_COORDINATE: 371441.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905838', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '29/05/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1E' + } + } + ] +} diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/query.js b/src/server/plugins/postcode-lookup/test/__stubs__/query.js new file mode 100644 index 000000000..1091052cb --- /dev/null +++ b/src/server/plugins/postcode-lookup/test/__stubs__/query.js @@ -0,0 +1,202 @@ +export const result = { + header: { + uri: 'https://api.os.uk/search/places/v1/find?query=forest%20dene%20northwich', + query: 'query=forest dene northwich', + offset: 0, + totalresults: 109974, + format: 'JSON', + dataset: 'DPA', + lr: 'EN,CY', + maxresults: 100, + matchprecision: 1, + epoch: '120', + lastupdate: '2025-10-13', + output_srs: 'EPSG:27700' + }, + results: [ + { + DPA: { + UPRN: '200003232010', + UDPRN: '6132431', + ADDRESS: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'FOREST DENE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '1', + X_COORDINATE: 361852.0, + Y_COORDINATE: 371487.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905836', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '24/04/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 0.5, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '1J' + } + }, + { + DPA: { + UPRN: '10006510253', + UDPRN: '13787372', + ADDRESS: 'FOREST DENE, EAGLE MOOR, LINCOLN, LN6 9DP', + BUILDING_NAME: 'FOREST DENE', + DEPENDENT_LOCALITY: 'EAGLE MOOR', + POST_TOWN: 'LINCOLN', + POSTCODE: 'LN6 9DP', + RPC: '1', + X_COORDINATE: 488846.0, + Y_COORDINATE: 368028.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 2520, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'NORTH KESTEVEN', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000026613592', + WARD_CODE: 'E05014441', + PARISH_CODE: 'E04005802', + LAST_UPDATE_DATE: '02/03/2019', + ENTRY_DATE: '16/12/2003', + BLPU_STATE_DATE: '09/07/2009', + LANGUAGE: 'EN', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '1A' + } + }, + { + DPA: { + UPRN: '100060115799', + UDPRN: '20267855', + ADDRESS: 'FOREST DENE, WYCH CROSS, FOREST ROW, RH18 5JN', + BUILDING_NAME: 'FOREST DENE', + DEPENDENT_LOCALITY: 'WYCH CROSS', + POST_TOWN: 'FOREST ROW', + POSTCODE: 'RH18 5JN', + RPC: '2', + X_COORDINATE: 542820.56, + Y_COORDINATE: 131070.66, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 1435, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'WEALDEN', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000006967575', + WARD_CODE: 'E05011634', + PARISH_CODE: 'E04003837', + LAST_UPDATE_DATE: '24/04/2021', + ENTRY_DATE: '03/05/2001', + BLPU_STATE_DATE: '07/09/2007', + LANGUAGE: 'EN', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '1N' + } + }, + { + DPA: { + UPRN: '100091247570', + UDPRN: '53973575', + ADDRESS: 'FOREST DENE, FOREST TERRACE, HIGH ROAD, CHIGWELL, IG7 5BW', + BUILDING_NAME: 'FOREST DENE', + DEPENDENT_THOROUGHFARE_NAME: 'FOREST TERRACE', + THOROUGHFARE_NAME: 'HIGH ROAD', + POST_TOWN: 'CHIGWELL', + POSTCODE: 'IG7 5BW', + RPC: '1', + X_COORDINATE: 543059.0, + Y_COORDINATE: 192310.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 1535, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'EPPING FOREST', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000001806748797', + WARD_CODE: 'E05015729', + PARISH_CODE: 'E04004039', + LAST_UPDATE_DATE: '29/06/2022', + ENTRY_DATE: '16/04/2001', + BLPU_STATE_DATE: '16/04/2001', + LANGUAGE: 'EN', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '1X' + } + }, + { + DPA: { + UPRN: '100060107548', + UDPRN: '24605177', + ADDRESS: '9, FOREST DENE, CROWBOROUGH, TN6 2HB', + BUILDING_NUMBER: '9', + THOROUGHFARE_NAME: 'FOREST DENE', + POST_TOWN: 'CROWBOROUGH', + POSTCODE: 'TN6 2HB', + RPC: '2', + X_COORDINATE: 553625.0, + Y_COORDINATE: 130056.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD06', + CLASSIFICATION_CODE_DESCRIPTION: + 'Self Contained Flat (Includes Maisonette / Apartment)', + LOCAL_CUSTODIAN_CODE: 1435, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'WEALDEN', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000000365578', + WARD_CODE: 'E05011629', + PARISH_CODE: 'E04003835', + LAST_UPDATE_DATE: '24/04/2021', + ENTRY_DATE: '03/05/2001', + BLPU_STATE_DATE: '07/09/2007', + LANGUAGE: 'EN', + MATCH: 0.4, + MATCH_DESCRIPTION: 'NO MATCH', + DELIVERY_POINT_SUFFIX: '2S' + } + } + ] +} diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js b/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js new file mode 100644 index 000000000..2af8fe16b --- /dev/null +++ b/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js @@ -0,0 +1,55 @@ +export const result = { + header: { + uri: 'https://api.os.uk/search/places/v1/uprn?uprn=200003232010', + query: 'uprn=200003232010', + offset: 0, + totalresults: 1, + format: 'JSON', + dataset: 'DPA', + lr: 'EN,CY', + maxresults: 100, + epoch: '120', + lastupdate: '2025-10-13', + output_srs: 'EPSG:27700' + }, + results: [ + { + DPA: { + UPRN: '200003232010', + UDPRN: '6132431', + ADDRESS: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + BUILDING_NAME: 'FOREST DENE', + THOROUGHFARE_NAME: 'FOREST HILL', + DEPENDENT_LOCALITY: 'HARTFORD', + POST_TOWN: 'NORTHWICH', + POSTCODE: 'CW8 2AT', + RPC: '1', + X_COORDINATE: 361852.0, + Y_COORDINATE: 371487.0, + STATUS: 'APPROVED', + LOGICAL_STATUS_CODE: '1', + CLASSIFICATION_CODE: 'RD02', + CLASSIFICATION_CODE_DESCRIPTION: 'Detached', + LOCAL_CUSTODIAN_CODE: 665, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + COUNTRY_CODE: 'E', + COUNTRY_CODE_DESCRIPTION: 'This record is within England', + POSTAL_ADDRESS_CODE: 'D', + POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', + BLPU_STATE_CODE: '2', + BLPU_STATE_CODE_DESCRIPTION: 'In use', + TOPOGRAPHY_LAYER_TOID: 'osgb1000035905836', + WARD_CODE: 'E05012219', + PARISH_CODE: 'E04012543', + PARENT_UPRN: '10011719253', + LAST_UPDATE_DATE: '24/04/2019', + ENTRY_DATE: '04/09/2002', + BLPU_STATE_DATE: '15/11/2007', + LANGUAGE: 'EN', + MATCH: 1.0, + MATCH_DESCRIPTION: 'EXACT', + DELIVERY_POINT_SUFFIX: '1J' + } + } + ] +} diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index 2a1cb9d12..ae876b1f8 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -63,7 +63,7 @@ /** * @typedef {object} PostcodeLookupSelectPayload * @property {string} step - step - * @property {number} uprn - postcode + * @property {string} uprn - postcode */ /** @@ -91,7 +91,7 @@ * @property {string} addressLine2 - The address line 2 * @property {string} town - The address town or city * @property {string} county - The address county - * @property {number} postcode - The address postcode + * @property {string} postcode - The address postcode */ // From ce2d1ce49ceafc726e84bf3dcff8469cc52eb0e9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 13:14:54 +0100 Subject: [PATCH 47/96] Exclude stubs from Sonar --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 66b27ddef..fa4e914a7 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,7 +11,7 @@ sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.sourceEncoding=UTF-8 sonar.sources=src,scripts/generate-schema-docs.js -sonar.exclusions=**/*.test.*,src/server/forms/* +sonar.exclusions=**/*.test.*,src/server/forms/*,**/__stubs__/* sonar.tests=src,test sonar.test.inclusions=**/*.test.* sonar.cpd.exclusions=**/*.test.* From a7ccf8923a3f2c42a8d1299de0f7949028ea42c1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 14:15:15 +0100 Subject: [PATCH 48/96] Add UPRN to UKAddress tests --- .../engine/components/UkAddressField.test.ts | 77 ++++++++++++------- .../QuestionPageController.test.ts | 1 + test/form/fields-optional.test.js | 3 +- test/form/fields-required.test.js | 6 +- test/form/govuk-notify.test.js | 1 + 5 files changed, 58 insertions(+), 30 deletions(-) diff --git a/src/server/plugins/engine/components/UkAddressField.test.ts b/src/server/plugins/engine/components/UkAddressField.test.ts index 784e19c68..aa5853958 100644 --- a/src/server/plugins/engine/components/UkAddressField.test.ts +++ b/src/server/plugins/engine/components/UkAddressField.test.ts @@ -91,6 +91,7 @@ describe('UkAddressField', () => { expect(field.keys).toEqual([ 'myComponent', + 'myComponent__uprn', 'myComponent__addressLine1', 'myComponent__addressLine2', 'myComponent__town', @@ -194,7 +195,8 @@ describe('UkAddressField', () => { addressLine2: '', town: '', county: '', - postcode: '' + postcode: '', + uprn: '' }) ) @@ -208,7 +210,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }) ) @@ -218,7 +221,8 @@ describe('UkAddressField', () => { addressLine2: '', // Optional field town: 'Warrington', county: '', // Optional field - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }) ) @@ -233,7 +237,8 @@ describe('UkAddressField', () => { addressLine2: '', town: '', county: '', - postcode: '' + postcode: '', + uprn: '' }) ) @@ -302,7 +307,7 @@ describe('UkAddressField', () => { 'postal-code' ] - ukAddressField.collection.components.forEach((component) => { + ukAddressField.collection.components.slice(1).forEach((component) => { const addressFieldOptions = component.options as TextFieldComponent['options'] @@ -319,7 +324,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '123456789' } it('returns text from state', () => { @@ -481,7 +487,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' } const addressLine1Invalid = @@ -514,7 +521,8 @@ describe('UkAddressField', () => { addressLine2: ' Knutsford Road', town: ' Warrington', county: 'Cheshire', - postcode: ' WA4 1HT' + postcode: ' WA4 1HT', + uprn: '' }), output: { value: getFormData(address), @@ -527,7 +535,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road ', town: 'Warrington ', county: 'Cheshire ', - postcode: 'WA4 1HT ' + postcode: 'WA4 1HT ', + uprn: '' }), output: { value: getFormData(address), @@ -540,7 +549,8 @@ describe('UkAddressField', () => { addressLine2: ' Knutsford Road \n\n', town: ' Warrington \n\n', county: ' Cheshire \n\n', - postcode: ' WA4 1HT \n\n' + postcode: ' WA4 1HT \n\n', + uprn: '' }), output: { value: getFormData(address), @@ -564,7 +574,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), output: { value: getFormData({ @@ -572,7 +583,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), errors: [ expect.objectContaining({ @@ -587,7 +599,8 @@ describe('UkAddressField', () => { addressLine2: addressLine2Invalid, town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), output: { value: getFormData({ @@ -595,7 +608,8 @@ describe('UkAddressField', () => { addressLine2: addressLine2Invalid, town: 'Warrington', county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), errors: [ expect.objectContaining({ @@ -610,7 +624,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: townInvalid, county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), output: { value: getFormData({ @@ -618,7 +633,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: townInvalid, county: 'Cheshire', - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), errors: [ expect.objectContaining({ @@ -633,7 +649,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: countyInvalid, - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), output: { value: getFormData({ @@ -641,7 +658,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: countyInvalid, - postcode: 'WA4 1HT' + postcode: 'WA4 1HT', + uprn: '' }), errors: [ expect.objectContaining({ @@ -656,7 +674,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: postcodeInvalid + postcode: postcodeInvalid, + uprn: '' }), output: { value: getFormData({ @@ -664,7 +683,8 @@ describe('UkAddressField', () => { addressLine2: 'Knutsford Road', town: 'Warrington', county: 'Cheshire', - postcode: postcodeInvalid + postcode: postcodeInvalid, + uprn: '' }), errors: [ expect.objectContaining({ @@ -679,7 +699,8 @@ describe('UkAddressField', () => { addressLine2: '', town: '', county: '', - postcode: postcodeInvalid + postcode: postcodeInvalid, + uprn: '' }), output: { value: getFormData({ @@ -687,7 +708,8 @@ describe('UkAddressField', () => { addressLine2: '', town: '', county: '', - postcode: postcodeInvalid + postcode: postcodeInvalid, + uprn: '' }), errors: [ expect.objectContaining({ @@ -761,7 +783,8 @@ function getFormData(address: FormPayload): FormPayload { myComponent__addressLine2: address.addressLine2, myComponent__town: address.town, myComponent__county: address.county, - myComponent__postcode: address.postcode + myComponent__postcode: address.postcode, + myComponent__uprn: address.uprn } } @@ -769,15 +792,15 @@ function getFormData(address: FormPayload): FormPayload { * UK address session state */ function getFormState(address: FormPayload): FormState { - const [addressLine1, addressLine2, town, county, postcode] = Object.values( - getFormData(address) - ) + const [addressLine1, addressLine2, town, county, postcode, uprn] = + Object.values(getFormData(address)) return { myComponent__addressLine1: addressLine1 ?? null, myComponent__addressLine2: addressLine2 ?? null, myComponent__town: town ?? null, myComponent__county: county ?? null, - myComponent__postcode: postcode ?? null + myComponent__postcode: postcode ?? null, + myComponent__uprn: uprn ?? null } } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts index 32ef8a4e9..369823993 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts @@ -602,6 +602,7 @@ describe('QuestionPageController', () => { addressField__town: 'Town or city', addressField__county: 'Cheshire', addressField__postcode: 'CW1 1AB', + addressField__uprn: '', radiosField: 'privateLimitedCompany', selectField: 910400000, autocompleteField: 910400044, diff --git a/test/form/fields-optional.test.js b/test/form/fields-optional.test.js index 8df8bd1ef..61c8c1833 100644 --- a/test/form/fields-optional.test.js +++ b/test/form/fields-optional.test.js @@ -95,7 +95,8 @@ describe('Form fields (optional)', () => { addressField__addressLine2: '', addressField__town: '', addressField__county: '', - addressField__postcode: '' + addressField__postcode: '', + addressField__uprn: '' } } }, diff --git a/test/form/fields-required.test.js b/test/form/fields-required.test.js index acf08d397..b2b146446 100644 --- a/test/form/fields-required.test.js +++ b/test/form/fields-required.test.js @@ -111,14 +111,16 @@ describe('Form fields (required)', () => { addressField__addressLine2: '', addressField__town: '', addressField__county: '', - addressField__postcode: '' + addressField__postcode: '', + addressField__uprn: '' }, valid: { addressField__addressLine1: 'Richard Fairclough House', addressField__addressLine2: 'Knutsford Road', addressField__town: 'Warrington', addressField__county: 'Cheshire', - addressField__postcode: 'WA4 1HT' + addressField__postcode: 'WA4 1HT', + addressField__uprn: '' } } }, diff --git a/test/form/govuk-notify.test.js b/test/form/govuk-notify.test.js index 4faa98f55..7a4b1b3b7 100644 --- a/test/form/govuk-notify.test.js +++ b/test/form/govuk-notify.test.js @@ -358,6 +358,7 @@ describe('Submission journey test', () => { addressField__town: 'Town or city', addressField__county: 'Cheshire', addressField__postcode: 'CW1 1AB', + addressField__uprn: '', radiosField: 'privateLimitedCompany', selectField: '910400000', autocompleteField: '910400044', From 52fa40b6326c2cca7c196e437207a1a6971eea4d Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 14:41:17 +0100 Subject: [PATCH 49/96] Add postcode service tests --- test/service.test.js | 177 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 test/service.test.js diff --git a/test/service.test.js b/test/service.test.js new file mode 100644 index 000000000..cd0a475d5 --- /dev/null +++ b/test/service.test.js @@ -0,0 +1,177 @@ +import Boom from '@hapi/boom' + +import * as service from '~/src/server/plugins/postcode-lookup/service.js' +import { result as postcodeResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js' +import { result as queryResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/query.js' +import { result as uprnResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js' +import { getJson } from '~/src/server/services/httpService.js' + +jest.mock('~/src/server/services/httpService.ts') + +describe('Postcode lookup service', () => { + describe('searchByPostcode', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: postcodeResult, + error: undefined + }) + + const results = await service.searchByPostcode('cw8 2at', 'apikey') + + expect(results).toHaveLength(10) + expect(results.at(0)).toEqual({ + address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + addressLine1: 'Forest Dene', + addressLine2: 'Forest Hill, Hartford', + county: '', + formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', + postcode: 'CW8 2AT', + town: 'Northwich', + uprn: '200003232010' + }) + }) + + it('should return an empty response when an error is encountered', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 300, + headers: {} + }), + payload: undefined, + error: new Error('Unknown error') + }) + + const results = await service.searchByPostcode('cw8 2at', 'apikey') + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + + it('should return an empty response when a non 200 response is encountered', async () => { + jest + .mocked(getJson) + .mockRejectedValueOnce( + Boom.badRequest( + 'OS API error', + new Error('Invalid postcode segments') + ) + ) + + const results = await service.searchByPostcode( + 'invalid postcode', + 'apikey' + ) + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + + it('should return an empty response when no results are returned', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: { results: undefined }, + error: undefined + }) + + const results = await service.searchByPostcode('cw8 2at', 'apikey') + + expect(results).toHaveLength(0) + expect(results).toEqual([]) + }) + }) + + describe('searchByUPRN', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: uprnResult, + error: undefined + }) + + const results = await service.searchByUPRN('200003232010', 'apikey') + + expect(results).toHaveLength(1) + expect(results.at(0)).toEqual({ + address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + addressLine1: 'Forest Dene', + addressLine2: 'Forest Hill, Hartford', + county: '', + formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', + postcode: 'CW8 2AT', + town: 'Northwich', + uprn: '200003232010' + }) + }) + }) + + describe('searchByQuery', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: queryResult, + error: undefined + }) + + const results = await service.searchByQuery( + 'forest dene northwich', + 'apikey' + ) + + expect(results).toHaveLength(5) + expect(results.at(0)).toEqual({ + address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + addressLine1: 'Forest Dene', + addressLine2: 'Forest Hill, Hartford', + county: '', + formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', + postcode: 'CW8 2AT', + town: 'Northwich', + uprn: '200003232010' + }) + }) + }) + + describe('search', () => { + it('should return formatted addresses', async () => { + jest.mocked(getJson).mockResolvedValueOnce({ + res: /** @type {IncomingMessage} */ ({ + statusCode: 200, + headers: {} + }), + payload: postcodeResult, + error: undefined + }) + + const results = await service.search('cw8 2at', 'Dene', 'apikey') + + expect(results).toHaveLength(1) + expect(results.at(0)).toEqual({ + address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', + addressLine1: 'Forest Dene', + addressLine2: 'Forest Hill, Hartford', + county: '', + formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', + postcode: 'CW8 2AT', + town: 'Northwich', + uprn: '200003232010' + }) + }) + }) +}) + +/** + * @import { IncomingMessage } from 'node:http' + */ From f1830d529a8a3d9c9173e5b9c54f5dc91571befa Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 14:58:38 +0100 Subject: [PATCH 50/96] Revert "Add postcode service tests" This reverts commit 52fa40b6326c2cca7c196e437207a1a6971eea4d. --- test/service.test.js | 177 ------------------------------------------- 1 file changed, 177 deletions(-) delete mode 100644 test/service.test.js diff --git a/test/service.test.js b/test/service.test.js deleted file mode 100644 index cd0a475d5..000000000 --- a/test/service.test.js +++ /dev/null @@ -1,177 +0,0 @@ -import Boom from '@hapi/boom' - -import * as service from '~/src/server/plugins/postcode-lookup/service.js' -import { result as postcodeResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js' -import { result as queryResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/query.js' -import { result as uprnResult } from '~/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js' -import { getJson } from '~/src/server/services/httpService.js' - -jest.mock('~/src/server/services/httpService.ts') - -describe('Postcode lookup service', () => { - describe('searchByPostcode', () => { - it('should return formatted addresses', async () => { - jest.mocked(getJson).mockResolvedValueOnce({ - res: /** @type {IncomingMessage} */ ({ - statusCode: 200, - headers: {} - }), - payload: postcodeResult, - error: undefined - }) - - const results = await service.searchByPostcode('cw8 2at', 'apikey') - - expect(results).toHaveLength(10) - expect(results.at(0)).toEqual({ - address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - addressLine1: 'Forest Dene', - addressLine2: 'Forest Hill, Hartford', - county: '', - formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', - postcode: 'CW8 2AT', - town: 'Northwich', - uprn: '200003232010' - }) - }) - - it('should return an empty response when an error is encountered', async () => { - jest.mocked(getJson).mockResolvedValueOnce({ - res: /** @type {IncomingMessage} */ ({ - statusCode: 300, - headers: {} - }), - payload: undefined, - error: new Error('Unknown error') - }) - - const results = await service.searchByPostcode('cw8 2at', 'apikey') - - expect(results).toHaveLength(0) - expect(results).toEqual([]) - }) - - it('should return an empty response when a non 200 response is encountered', async () => { - jest - .mocked(getJson) - .mockRejectedValueOnce( - Boom.badRequest( - 'OS API error', - new Error('Invalid postcode segments') - ) - ) - - const results = await service.searchByPostcode( - 'invalid postcode', - 'apikey' - ) - - expect(results).toHaveLength(0) - expect(results).toEqual([]) - }) - - it('should return an empty response when no results are returned', async () => { - jest.mocked(getJson).mockResolvedValueOnce({ - res: /** @type {IncomingMessage} */ ({ - statusCode: 200, - headers: {} - }), - payload: { results: undefined }, - error: undefined - }) - - const results = await service.searchByPostcode('cw8 2at', 'apikey') - - expect(results).toHaveLength(0) - expect(results).toEqual([]) - }) - }) - - describe('searchByUPRN', () => { - it('should return formatted addresses', async () => { - jest.mocked(getJson).mockResolvedValueOnce({ - res: /** @type {IncomingMessage} */ ({ - statusCode: 200, - headers: {} - }), - payload: uprnResult, - error: undefined - }) - - const results = await service.searchByUPRN('200003232010', 'apikey') - - expect(results).toHaveLength(1) - expect(results.at(0)).toEqual({ - address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - addressLine1: 'Forest Dene', - addressLine2: 'Forest Hill, Hartford', - county: '', - formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', - postcode: 'CW8 2AT', - town: 'Northwich', - uprn: '200003232010' - }) - }) - }) - - describe('searchByQuery', () => { - it('should return formatted addresses', async () => { - jest.mocked(getJson).mockResolvedValueOnce({ - res: /** @type {IncomingMessage} */ ({ - statusCode: 200, - headers: {} - }), - payload: queryResult, - error: undefined - }) - - const results = await service.searchByQuery( - 'forest dene northwich', - 'apikey' - ) - - expect(results).toHaveLength(5) - expect(results.at(0)).toEqual({ - address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - addressLine1: 'Forest Dene', - addressLine2: 'Forest Hill, Hartford', - county: '', - formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', - postcode: 'CW8 2AT', - town: 'Northwich', - uprn: '200003232010' - }) - }) - }) - - describe('search', () => { - it('should return formatted addresses', async () => { - jest.mocked(getJson).mockResolvedValueOnce({ - res: /** @type {IncomingMessage} */ ({ - statusCode: 200, - headers: {} - }), - payload: postcodeResult, - error: undefined - }) - - const results = await service.search('cw8 2at', 'Dene', 'apikey') - - expect(results).toHaveLength(1) - expect(results.at(0)).toEqual({ - address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - addressLine1: 'Forest Dene', - addressLine2: 'Forest Hill, Hartford', - county: '', - formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', - postcode: 'CW8 2AT', - town: 'Northwich', - uprn: '200003232010' - }) - }) - }) -}) - -/** - * @import { IncomingMessage } from 'node:http' - */ From e7e8bacab83ab8fc73eb9a6c6efbc6f534673a82 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 15:07:46 +0100 Subject: [PATCH 51/96] Fix package-lock.json --- package-lock.json | 558 ---------------------------------------------- 1 file changed, 558 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2f9a49b1..34a3c4361 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2272,15 +2272,9 @@ } }, "node_modules/@defra/forms-model": { -<<<<<<< HEAD - "version": "3.0.559", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.559.tgz", - "integrity": "sha512-dSMrTnhUXnapflHKdeQLMGDwK2QlFhp/08XwzLNHzLHmgx7pqHAgelzVeRsyHtzYDu7B7tF4r5cyR+SxI4UmXw==", -======= "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==", ->>>>>>> 84b33f02 (Bump @defra/forms-model to v3.0.560) "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -2364,40 +2358,6 @@ "node": ">=10" } }, - "node_modules/@emnapi/core": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz", - "integrity": "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.4", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.4.tgz", - "integrity": "sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@es-joy/jsdoccomment": { "version": "0.49.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz", @@ -3892,19 +3852,6 @@ "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4167,17 +4114,6 @@ "node": ">=10.13.0" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", - "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4928,188 +4864,6 @@ "dev": true, "license": "ISC" }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", @@ -5138,65 +4892,6 @@ "linux" ] }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -8998,21 +8693,6 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -14961,193 +14641,6 @@ "sass-embedded-win32-x64": "1.89.2" } }, - "node_modules/sass-embedded-android-arm": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.89.2.tgz", - "integrity": "sha512-oHAPTboBHRZlDBhyRB6dvDKh4KvFs+DZibDHXbkSI6dBZxMTT+Yb2ivocHnctVGucKTLQeT7+OM5DjWHyynL/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.89.2.tgz", - "integrity": "sha512-+pq7a7AUpItNyPu61sRlP6G2A8pSPpyazASb+8AK2pVlFayCSPAEgpwpCE9A2/Xj86xJZeMizzKUHxM2CBCUxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-riscv64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.89.2.tgz", - "integrity": "sha512-HfJJWp/S6XSYvlGAqNdakeEMPOdhBkj2s2lN6SHnON54rahKem+z9pUbCriUJfM65Z90lakdGuOfidY61R9TYg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-android-x64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.89.2.tgz", - "integrity": "sha512-BGPzq53VH5z5HN8de6jfMqJjnRe1E6sfnCWFd4pK+CAiuM7iw5Fx6BQZu3ikfI1l2GY0y6pRXzsVLdp/j4EKEA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.89.2.tgz", - "integrity": "sha512-UCm3RL/tzMpG7DsubARsvGUNXC5pgfQvP+RRFJo9XPIi6elopY5B6H4m9dRYDpHA+scjVthdiDwkPYr9+S/KGw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-darwin-x64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.89.2.tgz", - "integrity": "sha512-D9WxtDY5VYtMApXRuhQK9VkPHB8R79NIIR6xxVlN2MIdEid/TZWi1MHNweieETXhWGrKhRKglwnHxxyKdJYMnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.89.2.tgz", - "integrity": "sha512-leP0t5U4r95dc90o8TCWfxNXwMAsQhpWxTkdtySDpngoqtTy3miMd7EYNYd1znI0FN1CBaUvbdCMbnbPwygDlA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.89.2.tgz", - "integrity": "sha512-2N4WW5LLsbtrWUJ7iTpjvhajGIbmDR18ZzYRywHdMLpfdPApuHPMDF5CYzHbS+LLx2UAx7CFKBnj5LLjY6eFgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.89.2.tgz", - "integrity": "sha512-Z6gG2FiVEEdxYHRi2sS5VIYBmp17351bWtOCUZ/thBM66+e70yiN6Eyqjz80DjL8haRUegNQgy9ZJqsLAAmr9g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.89.2.tgz", - "integrity": "sha512-nTyuaBX6U1A/cG7WJh0pKD1gY8hbg1m2SnzsyoFG+exQ0lBX/lwTLHq3nyhF+0atv7YYhYKbmfz+sjPP8CZ9lw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-linux-musl-riscv64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.89.2.tgz", - "integrity": "sha512-N6oul+qALO0SwGY8JW7H/Vs0oZIMrRMBM4GqX3AjM/6y8JsJRxkAwnfd0fDyK+aICMFarDqQonQNIx99gdTZqw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded-linux-musl-x64": { "version": "1.89.2", "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.89.2.tgz", @@ -15165,23 +14658,6 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded-linux-riscv64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.89.2.tgz", - "integrity": "sha512-g9nTbnD/3yhOaskeqeBQETbtfDQWRgsjHok6bn7DdAuwBsyrR3JlSFyqKc46pn9Xxd9SQQZU8AzM4IR+sY0A0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded-linux-x64": { "version": "1.89.2", "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.89.2.tgz", @@ -15199,40 +14675,6 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded-win32-arm64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.89.2.tgz", - "integrity": "sha512-j96iJni50ZUsfD6tRxDQE2QSYQ2WrfHxeiyAXf41Kw0V4w5KYR/Sf6rCZQLMTUOHnD16qTMVpQi20LQSqf4WGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sass-embedded-win32-x64": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.89.2.tgz", - "integrity": "sha512-cS2j5ljdkQsb4PaORiClaVYynE9OAPZG/XjbOMxpQmjRIf7UroY4PEIH+Waf+y47PfXFX9SyxhYuw2NIKGbEng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded/node_modules/immutable": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.2.tgz", From d76665476b73dc0151b70c3030fc72aefd8d6fa0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 15:17:05 +0100 Subject: [PATCH 52/96] Remove ununsed property in FormModel --- src/server/plugins/engine/models/FormModel.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index f3802afb4..76dfd97cf 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -77,7 +77,6 @@ export class FormModel { values: FormDefinition basePath: string versionNumber?: number - ordnanceSurveyApiKey?: string conditions: Partial> pages: PageControllerClass[] services: Services @@ -96,11 +95,7 @@ export class FormModel { constructor( def: typeof this.def, - options: { - basePath: string - versionNumber?: number - ordnanceSurveyApiKey?: string - }, + options: { basePath: string; versionNumber?: number }, services: Services = defaultServices, controllers?: Record ) { @@ -155,7 +150,6 @@ export class FormModel { this.values = result.value this.basePath = options.basePath this.versionNumber = options.versionNumber - this.ordnanceSurveyApiKey = options.ordnanceSurveyApiKey this.conditions = {} this.services = services this.controllers = controllers From fe39e14674743c8396d2e226eff4c9edfbeac2b1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 14 Oct 2025 20:32:16 +0100 Subject: [PATCH 53/96] Revert "Remove ununsed property in FormModel" This reverts commit d76665476b73dc0151b70c3030fc72aefd8d6fa0. --- src/server/plugins/engine/models/FormModel.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 76dfd97cf..f3802afb4 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -77,6 +77,7 @@ export class FormModel { values: FormDefinition basePath: string versionNumber?: number + ordnanceSurveyApiKey?: string conditions: Partial> pages: PageControllerClass[] services: Services @@ -95,7 +96,11 @@ export class FormModel { constructor( def: typeof this.def, - options: { basePath: string; versionNumber?: number }, + options: { + basePath: string + versionNumber?: number + ordnanceSurveyApiKey?: string + }, services: Services = defaultServices, controllers?: Record ) { @@ -150,6 +155,7 @@ export class FormModel { this.values = result.value this.basePath = options.basePath this.versionNumber = options.versionNumber + this.ordnanceSurveyApiKey = options.ordnanceSurveyApiKey this.conditions = {} this.services = services this.controllers = controllers From df277e86be25fcf712df76503cdf0d0d00d21251 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 11:00:14 +0100 Subject: [PATCH 54/96] Add postcode GET tests --- jest.setup.cjs | 1 + .../plugins/engine/configureEnginePlugin.ts | 2 +- src/server/postcode-lookup.test.ts | 64 +++++++ test/form/definitions/postcode-lookup.js | 49 +++++ test/form/postcode-lookup.test.js | 178 ++++++++++++++++++ 5 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/server/postcode-lookup.test.ts create mode 100644 test/form/definitions/postcode-lookup.js create mode 100644 test/form/postcode-lookup.test.js diff --git a/jest.setup.cjs b/jest.setup.cjs index 97146163b..d9e1df3b6 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -12,3 +12,4 @@ process.env.UPLOADER_URL = 'https://test-uploader.cdp-int.defra.cloud' process.env.UPLOADER_BUCKET_NAME = 'dummy-bucket' process.env.GOOGLE_ANALYTICS_TRACKING_ID = 'G-123456789' process.env.SUBMISSION_EMAIL_ADDRESS = 'dummy@defra.gov.uk' +process.env.ORDNANCE_SURVEY_API_KEY = 'dummy' diff --git a/src/server/plugins/engine/configureEnginePlugin.ts b/src/server/plugins/engine/configureEnginePlugin.ts index 990824d66..aace62854 100644 --- a/src/server/plugins/engine/configureEnginePlugin.ts +++ b/src/server/plugins/engine/configureEnginePlugin.ts @@ -39,7 +39,7 @@ export const configureEnginePlugin = async ( model = new FormModel( definition, - { basePath: initialBasePath }, + { basePath: initialBasePath, ordnanceSurveyApiKey }, services, controllers ) diff --git a/src/server/postcode-lookup.test.ts b/src/server/postcode-lookup.test.ts new file mode 100644 index 000000000..e8d468fcd --- /dev/null +++ b/src/server/postcode-lookup.test.ts @@ -0,0 +1,64 @@ +import { type Server } from '@hapi/hapi' + +import { createServer } from '~/src/server/index.js' +import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import * as defaultServices from '~/src/server/plugins/engine/services/index.js' +import * as fixtures from '~/test/fixtures/index.js' + +jest.mock('~/src/server/plugins/engine/services/formsService.js') +jest.mock('~/src/server/plugins/engine/services/uploadService.js') + +describe('Postcode lookup plugin', () => { + let server: Server + + beforeEach(() => { + jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + }) + + afterAll(async () => { + await server.stop() + }) + + describe('Plugin registration', () => { + test('Registers plugin with ordnance survey key', async () => { + server = await createServer({ + services: defaultServices, + ordnanceSurveyApiKey: 'dummy' + }) + await server.initialize() + + expect(server.registrations).toHaveProperty( + '@defra/forms-engine-plugin/postcode-lookup', + { + name: '@defra/forms-engine-plugin/postcode-lookup', + options: { ordnanceSurveyApiKey: 'dummy' }, + version: undefined + } + ) + + expect( + server.table().find((route) => route.path === '/postcode-lookup') + ).toBeDefined() + }) + + test('Does not register plugin when no ordnance survey key is provided', async () => { + server = await createServer({ + services: defaultServices + }) + await server.initialize() + + expect(server.registrations).not.toHaveProperty( + '@defra/forms-engine-plugin/postcode-lookup', + { + name: '@defra/forms-engine-plugin/postcode-lookup', + options: { ordnanceSurveyApiKey: 'dummy' }, + version: undefined + } + ) + + expect( + server.table().find((route) => route.path === '/postcode-lookup') + ).toBeUndefined() + }) + }) +}) diff --git a/test/form/definitions/postcode-lookup.js b/test/form/definitions/postcode-lookup.js new file mode 100644 index 000000000..68cd472d9 --- /dev/null +++ b/test/form/definitions/postcode-lookup.js @@ -0,0 +1,49 @@ +import { + ComponentType, + ControllerType, + Engine, + SchemaVersion +} from '@defra/forms-model' + +export default /** @satisfies {FormDefinition} */ ({ + name: 'UkAddressField with Postcode lookup', + engine: Engine.V2, + schema: SchemaVersion.V2, + startPage: '/address', + pages: [ + { + title: 'Address', + path: '/address', + components: [ + { + type: ComponentType.UkAddressField, + title: 'What is your address?', + name: 'ybMHIv', + shortDescription: 'Address', + hint: '', + options: { + required: true, + usePostcodeLookup: true + }, + id: 'ebc6cc6d-2596-4860-b62d-98510b277ac4' + } + ], + next: [], + id: 'c7ab16e8-819a-43bd-83fa-14c479d23961' + }, + { + title: 'Summary', + path: '/summary', + controller: ControllerType.Summary, + components: [], + id: '8d2aba52-314e-4a5b-b502-47e205877de5' + } + ], + conditions: [], + sections: [], + lists: [] +}) + +/** + * @import { FormDefinition } from '@defra/forms-model' + */ diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js new file mode 100644 index 000000000..a793b7c6c --- /dev/null +++ b/test/form/postcode-lookup.test.js @@ -0,0 +1,178 @@ +import { join } from 'node:path' + +import { StatusCodes } from 'http-status-codes' + +import { FORM_PREFIX } from '~/src/server/constants.js' +import { createServer } from '~/src/server/index.js' +import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import * as fixtures from '~/test/fixtures/index.js' +import { renderResponse } from '~/test/helpers/component-helpers.js' +import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' +const basePath = `${FORM_PREFIX}/postcode-lookup` + +jest.mock('~/src/server/plugins/engine/services/formsService.js') +jest.mock('~/src/server/plugins/postcode-lookup/service.js') + +/** + * + * @param {Server} server + */ +async function initialiseJourney(server) { + const response = await server.inject({ + url: `${basePath}/address` + }) + + // Extract the session cookie + const csrfToken = getCookie(response, 'crumb') + const headers = getCookieHeader(response, ['session', 'crumb']) + + return { csrfToken, response, headers } +} + +describe('Postcode lookup form pages', () => { + /** @type {Server} */ + let server + + beforeAll(async () => { + server = await createServer({ + formFileName: 'postcode-lookup.js', + formFilePath: join(import.meta.dirname, 'definitions'), + enforceCsrf: true, + ordnanceSurveyApiKey: 'dummy' + }) + + await server.initialize() + }) + + beforeEach(() => { + jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) + }) + + it('should render the source form page with a postcode lookup buttons', async () => { + const { container } = await renderResponse(server, { + url: `${basePath}/address` + }) + + const $actionButton = container.getByRole('button', { + name: 'Find an address' + }) + + expect($actionButton).toBeInTheDocument() + expect($actionButton.getAttribute('name')).toBe('action') + expect($actionButton.getAttribute('value')).toBe( + 'external-postcode-lookup--name:ybMHIv' + ) + + const $manualButton = container.getByRole('button', { + name: 'enter address manually' + }) + + expect($manualButton).toBeInTheDocument() + expect($manualButton.getAttribute('name')).toBe('action') + expect($manualButton.getAttribute('value')).toBe( + 'external-postcode-lookup--name:ybMHIv--step:manual' + ) + }) + + it('should dispatch to details page on POST', async () => { + let { csrfToken, response, headers } = await initialiseJourney(server) + + const payload = { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + + response = await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/postcode-lookup') + }) + + it('should dispatch to manual page on POST with step arg', async () => { + let { csrfToken, response, headers } = await initialiseJourney(server) + + const payload = { + action: 'external-postcode-lookup--name:ybMHIv--step:manual', + crumb: csrfToken + } + + response = await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/postcode-lookup?step=manual') + }) + + it('should render the details page', async () => { + let { csrfToken, response, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + const payload = { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + + response = await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/postcode-lookup') + + headers = getCookieHeader(response, ['session']) + + response = await server.inject({ + url: '/postcode-lookup', + method: 'GET', + headers + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + + it('should render the manual page', async () => { + let { csrfToken, response, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + const payload = { + action: 'external-postcode-lookup--name:ybMHIv--step:manual', + crumb: csrfToken + } + + response = await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toBe('/postcode-lookup?step=manual') + + headers = getCookieHeader(response, ['session']) + + response = await server.inject({ + url: '/postcode-lookup?step=manual', + method: 'GET', + headers + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) +}) + +/** + * @import { Server } from '@hapi/hapi' + */ From def788f999c84298c2b8aba29827d4419e4b3816 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 12:36:37 +0100 Subject: [PATCH 55/96] Update postcode lookup stubs --- .../test/__stubs__/postcode.js | 459 +++++++++--------- .../postcode-lookup/test/__stubs__/query.js | 246 +++++----- .../postcode-lookup/test/__stubs__/uprn.js | 52 +- 3 files changed, 369 insertions(+), 388 deletions(-) diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js b/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js index 26d425ed8..bcbcc3a92 100644 --- a/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js +++ b/src/server/plugins/postcode-lookup/test/__stubs__/postcode.js @@ -1,396 +1,381 @@ export const result = { header: { - uri: 'https://api.os.uk/search/places/v1/postcode?postcode=cw8%202at', - query: 'postcode=cw8 2at', + uri: 'https://api.os.uk/search/places/v1/postcode?postcode=NW1%206XE', + query: 'postcode=NW1 6XE', offset: 0, totalresults: 10, format: 'JSON', dataset: 'DPA', lr: 'EN,CY', maxresults: 100, - epoch: '120', - lastupdate: '2025-10-13', + epoch: '121', + lastupdate: '2025-10-14', output_srs: 'EPSG:27700' }, results: [ { DPA: { - UPRN: '200003232010', - UDPRN: '6132431', - ADDRESS: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'FOREST DENE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', - RPC: '1', - X_COORDINATE: 361852.0, - Y_COORDINATE: 371487.0, + UPRN: '10033619968', + UDPRN: '50825076', + ADDRESS: "EMILIA'S CRAFTED PASTA, 215, BAKER STREET, LONDON, NW1 6XE", + ORGANISATION_NAME: "EMILIA'S CRAFTED PASTA", + BUILDING_NUMBER: '215', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '2', + X_COORDINATE: 527870.4, + Y_COORDINATE: 182081.17, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'CR07', + CLASSIFICATION_CODE_DESCRIPTION: 'Restaurant / Cafeteria', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905836', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '24/04/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158430', + WARD_CODE: 'E05013805', + PARENT_UPRN: '10033659670', + LAST_UPDATE_DATE: '20/01/2025', + ENTRY_DATE: '13/02/2013', + BLPU_STATE_DATE: '13/02/2013', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1J' + DELIVERY_POINT_SUFFIX: '1H' } }, { DPA: { - UPRN: '200003232009', - UDPRN: '6132430', - ADDRESS: 'FOREST HILL WEST, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'FOREST HILL WEST', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', - RPC: '2', - X_COORDINATE: 361898.0, - Y_COORDINATE: 371376.51, + UPRN: '10033619969', + UDPRN: '50825094', + ADDRESS: 'FLAT 1-86, 219, BAKER STREET, LONDON, NW1 6XE', + SUB_BUILDING_NAME: 'FLAT 1-86', + BUILDING_NUMBER: '219', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527868.08, + Y_COORDINATE: 182090.78, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD04', - CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'PP', + CLASSIFICATION_CODE_DESCRIPTION: 'Property Shell', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905856', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '29/05/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158430', + WARD_CODE: 'E05013805', + PARENT_UPRN: '10033659670', + LAST_UPDATE_DATE: '20/01/2025', + ENTRY_DATE: '13/02/2013', + BLPU_STATE_DATE: '13/02/2013', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1H' + DELIVERY_POINT_SUFFIX: '7J' } }, { DPA: { - UPRN: '200003232002', - UDPRN: '6132432', - ADDRESS: 'FOREST HOUSE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'FOREST HOUSE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', - RPC: '2', - X_COORDINATE: 361893.0, - Y_COORDINATE: 371365.0, - STATUS: 'APPROVED', - LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD04', - CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + UPRN: '10033625299', + UDPRN: '54761212', + ADDRESS: + 'PARKVIEW ESTATES MANAGEMENT LTD, 219, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'PARKVIEW ESTATES MANAGEMENT LTD', + BUILDING_NUMBER: '219', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527868.08, + Y_COORDINATE: 182090.78, + STATUS: 'HISTORICAL', + LOGICAL_STATUS_CODE: '8', + CLASSIFICATION_CODE: 'CL06', + CLASSIFICATION_CODE_DESCRIPTION: + 'Indoor / Outdoor Leisure / Sporting Activity / Centre', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', - BLPU_STATE_CODE: '3', - BLPU_STATE_CODE_DESCRIPTION: 'Unoccupied', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905855', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '31/01/2024', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '30/01/2024', + BLPU_STATE_CODE: '4', + BLPU_STATE_CODE_DESCRIPTION: 'No longer existing', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158430', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '22/02/2024', + ENTRY_DATE: '20/10/2014', + BLPU_STATE_DATE: '25/11/2022', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1A' + DELIVERY_POINT_SUFFIX: '1G' } }, { DPA: { - UPRN: '200003232003', - UDPRN: '6132433', - ADDRESS: 'FOREST LODGE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'FOREST LODGE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', + UPRN: '100022723861', + UDPRN: '17646245', + ADDRESS: '235, BAKER STREET, LONDON, NW1 6XE', + BUILDING_NUMBER: '235', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', RPC: '1', - X_COORDINATE: 361908.0, - Y_COORDINATE: 371359.0, + X_COORDINATE: 527850.0, + Y_COORDINATE: 182134.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD04', - CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'PP', + CLASSIFICATION_CODE_DESCRIPTION: 'Property Shell', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905854', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '24/04/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158434', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '10/02/2016', + ENTRY_DATE: '19/03/2001', + BLPU_STATE_DATE: '19/03/2001', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1B' + DELIVERY_POINT_SUFFIX: '2H' } }, { DPA: { - UPRN: '200003232004', - UDPRN: '6132434', - ADDRESS: 'NORTH HOUSE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'NORTH HOUSE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', + UPRN: '100023072608', + UDPRN: '17646236', + ADDRESS: '237, BAKER STREET, LONDON, NW1 6XE', + BUILDING_NUMBER: '237', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', RPC: '1', - X_COORDINATE: 361903.0, - Y_COORDINATE: 371386.0, - STATUS: 'APPROVED', - LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD04', - CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + X_COORDINATE: 527850.0, + Y_COORDINATE: 182139.0, + STATUS: 'HISTORICAL', + LOGICAL_STATUS_CODE: '8', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', - BLPU_STATE_CODE: '2', - BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905857', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '24/04/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + BLPU_STATE_CODE: '4', + BLPU_STATE_CODE_DESCRIPTION: 'No longer existing', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158435', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '22/02/2024', + ENTRY_DATE: '19/03/2001', + BLPU_STATE_DATE: '01/10/2013', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1D' + DELIVERY_POINT_SUFFIX: '2G' } }, { DPA: { - UPRN: '200003232008', - UDPRN: '6132435', - ADDRESS: 'ORCHARDS, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'ORCHARDS', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', + UPRN: '100023071949', + UDPRN: '17646242', + ADDRESS: 'SHERLOCK HOLMES MUSEUM, 221B, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'SHERLOCK HOLMES MUSEUM', + BUILDING_NAME: '221B', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', RPC: '2', - X_COORDINATE: 361983.0, - Y_COORDINATE: 371426.0, + X_COORDINATE: 527847.0, + Y_COORDINATE: 182144.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905842', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '29/05/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158436', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '23/09/2018', + ENTRY_DATE: '19/03/2001', + BLPU_STATE_DATE: '19/03/2001', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1G' + DELIVERY_POINT_SUFFIX: '1F' } }, { DPA: { - UPRN: '200003239517', - UDPRN: '6132428', - ADDRESS: 'THE COACH HOUSE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'THE COACH HOUSE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', + UPRN: '10033605426', + UDPRN: '17646231', + ADDRESS: 'LONDON BEATLES STORE, 231-233, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'LONDON BEATLES STORE', + BUILDING_NAME: '231-233', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', RPC: '1', - X_COORDINATE: 361927.0, - Y_COORDINATE: 371397.0, + X_COORDINATE: 527854.0, + Y_COORDINATE: 182123.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD04', - CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905845', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '24/04/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158432', + WARD_CODE: 'E05013805', + PARENT_UPRN: '100023072617', + LAST_UPDATE_DATE: '10/02/2016', + ENTRY_DATE: '25/02/2009', + BLPU_STATE_DATE: '25/02/2009', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1N' + DELIVERY_POINT_SUFFIX: '1A' } }, { DPA: { - UPRN: '200003232007', - UDPRN: '6132429', - ADDRESS: 'THE COTTAGE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'THE COTTAGE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', - RPC: '1', - X_COORDINATE: 361923.0, - Y_COORDINATE: 371375.0, + UPRN: '10033529292', + UDPRN: '17646238', + ADDRESS: 'TOTAL CHI, 241-243, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'TOTAL CHI', + BUILDING_NAME: '241-243', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '2', + X_COORDINATE: 527844.0, + Y_COORDINATE: 182155.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905851', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '24/04/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158438', + WARD_CODE: 'E05013805', + PARENT_UPRN: '100023071621', + LAST_UPDATE_DATE: '23/09/2018', + ENTRY_DATE: '27/04/2003', + BLPU_STATE_DATE: '27/04/2003', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1F' + DELIVERY_POINT_SUFFIX: '1B' } }, { DPA: { - UPRN: '200003232011', - UDPRN: '6132436', - ADDRESS: 'THE SPINNEY, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'THE SPINNEY', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', + UPRN: '10033529295', + UDPRN: '52154612', + ADDRESS: 'FLAT 1-5, 245-247, BAKER STREET, LONDON, NW1 6XE', + SUB_BUILDING_NAME: 'FLAT 1-5', + BUILDING_NAME: '245-247', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', RPC: '1', - X_COORDINATE: 361964.0, - Y_COORDINATE: 371379.0, + X_COORDINATE: 527839.0, + Y_COORDINATE: 182163.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'PP', + CLASSIFICATION_CODE_DESCRIPTION: 'Property Shell', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905843', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '24/04/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158439', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '10/02/2016', + ENTRY_DATE: '27/04/2003', + BLPU_STATE_DATE: '27/04/2003', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1L' + DELIVERY_POINT_SUFFIX: '1E' } }, { DPA: { - UPRN: '200003232005', - UDPRN: '6132437', - ADDRESS: 'WATLING HOUSE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'WATLING HOUSE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', - RPC: '2', - X_COORDINATE: 361888.0, - Y_COORDINATE: 371441.0, + UPRN: '10033625010', + UDPRN: '17646244', + ADDRESS: 'THE VOLUNTEER, 245-247, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'THE VOLUNTEER', + BUILDING_NAME: '245-247', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '1', + X_COORDINATE: 527839.0, + Y_COORDINATE: 182163.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'CR06', + CLASSIFICATION_CODE_DESCRIPTION: 'Public House / Bar / Nightclub', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905838', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '29/05/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158439', + WARD_CODE: 'E05013805', + PARENT_UPRN: '10033529295', + LAST_UPDATE_DATE: '17/05/2019', + ENTRY_DATE: '02/10/2014', + BLPU_STATE_DATE: '02/10/2014', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1E' + DELIVERY_POINT_SUFFIX: '1R' } } ] diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/query.js b/src/server/plugins/postcode-lookup/test/__stubs__/query.js index 1091052cb..3fbfd2383 100644 --- a/src/server/plugins/postcode-lookup/test/__stubs__/query.js +++ b/src/server/plugins/postcode-lookup/test/__stubs__/query.js @@ -1,201 +1,199 @@ export const result = { header: { - uri: 'https://api.os.uk/search/places/v1/find?query=forest%20dene%20northwich', - query: 'query=forest dene northwich', + uri: 'https://api.os.uk/search/places/v1/find?query=baker%20street', + query: 'query=baker street', offset: 0, - totalresults: 109974, + totalresults: 10, format: 'JSON', dataset: 'DPA', lr: 'EN,CY', maxresults: 100, matchprecision: 1, - epoch: '120', - lastupdate: '2025-10-13', + epoch: '121', + lastupdate: '2025-10-14', output_srs: 'EPSG:27700' }, results: [ { DPA: { - UPRN: '200003232010', - UDPRN: '6132431', - ADDRESS: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'FOREST DENE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', + UPRN: '250034655', + UDPRN: '1216958', + ADDRESS: 'BAKER STREET COTTAGE, BAKER STREET, FROME, BA11 3BL', + BUILDING_NAME: 'BAKER STREET COTTAGE', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'FROME', + POSTCODE: 'BA11 3BL', RPC: '1', - X_COORDINATE: 361852.0, - Y_COORDINATE: 371487.0, + X_COORDINATE: 377184.0, + Y_COORDINATE: 148060.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'RD04', + CLASSIFICATION_CODE_DESCRIPTION: 'Terraced', + LOCAL_CUSTODIAN_CODE: 3300, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'SOMERSET', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905836', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '24/04/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000015533989', + WARD_CODE: 'E05014362', + PARISH_CODE: 'E04008560', + LAST_UPDATE_DATE: '31/03/2023', + ENTRY_DATE: '29/03/2000', + BLPU_STATE_DATE: '29/03/2000', LANGUAGE: 'EN', - MATCH: 0.5, + MATCH: 0.4, MATCH_DESCRIPTION: 'NO MATCH', - DELIVERY_POINT_SUFFIX: '1J' + DELIVERY_POINT_SUFFIX: '1L' } }, { DPA: { - UPRN: '10006510253', - UDPRN: '13787372', - ADDRESS: 'FOREST DENE, EAGLE MOOR, LINCOLN, LN6 9DP', - BUILDING_NAME: 'FOREST DENE', - DEPENDENT_LOCALITY: 'EAGLE MOOR', - POST_TOWN: 'LINCOLN', - POSTCODE: 'LN6 9DP', - RPC: '1', - X_COORDINATE: 488846.0, - Y_COORDINATE: 368028.0, + UPRN: '10033336368', + UDPRN: '52500297', + ADDRESS: 'BAKER STREET CINEMA, BAKER STREET, ABERGAVENNY, NP7 5BB', + ORGANISATION_NAME: 'BAKER STREET CINEMA', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'ABERGAVENNY', + POSTCODE: 'NP7 5BB', + RPC: '2', + X_COORDINATE: 329751.36, + Y_COORDINATE: 214353.3, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 2520, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'NORTH KESTEVEN', - COUNTRY_CODE: 'E', - COUNTRY_CODE_DESCRIPTION: 'This record is within England', + CLASSIFICATION_CODE: 'CL07CI', + CLASSIFICATION_CODE_DESCRIPTION: 'Cinema', + LOCAL_CUSTODIAN_CODE: 6840, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'MONMOUTHSHIRE', + COUNTRY_CODE: 'W', + COUNTRY_CODE_DESCRIPTION: 'This record is within Wales', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000026613592', - WARD_CODE: 'E05014441', - PARISH_CODE: 'E04005802', - LAST_UPDATE_DATE: '02/03/2019', - ENTRY_DATE: '16/12/2003', - BLPU_STATE_DATE: '09/07/2009', + TOPOGRAPHY_LAYER_TOID: 'osgb1000021003409', + WARD_CODE: 'W05001775', + PARISH_CODE: 'W04001057', + PARENT_UPRN: '10033345846', + LAST_UPDATE_DATE: '25/02/2025', + ENTRY_DATE: '11/11/2005', + BLPU_STATE_DATE: '07/07/2010', LANGUAGE: 'EN', MATCH: 0.4, MATCH_DESCRIPTION: 'NO MATCH', - DELIVERY_POINT_SUFFIX: '1A' + DELIVERY_POINT_SUFFIX: '2A' } }, { DPA: { - UPRN: '100060115799', - UDPRN: '20267855', - ADDRESS: 'FOREST DENE, WYCH CROSS, FOREST ROW, RH18 5JN', - BUILDING_NAME: 'FOREST DENE', - DEPENDENT_LOCALITY: 'WYCH CROSS', - POST_TOWN: 'FOREST ROW', - POSTCODE: 'RH18 5JN', + UPRN: '10033336368', + UDPRN: '52500297', + ADDRESS: 'BAKER STREET CINEMA, BAKER STREET, Y FENNI, NP7 5BB', + ORGANISATION_NAME: 'BAKER STREET CINEMA', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'Y FENNI', + POSTCODE: 'NP7 5BB', RPC: '2', - X_COORDINATE: 542820.56, - Y_COORDINATE: 131070.66, + X_COORDINATE: 329751.36, + Y_COORDINATE: 214353.3, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 1435, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'WEALDEN', - COUNTRY_CODE: 'E', - COUNTRY_CODE_DESCRIPTION: 'This record is within England', + CLASSIFICATION_CODE: 'CL07CI', + CLASSIFICATION_CODE_DESCRIPTION: 'Cinema', + LOCAL_CUSTODIAN_CODE: 6840, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'MONMOUTHSHIRE', + COUNTRY_CODE: 'W', + COUNTRY_CODE_DESCRIPTION: 'This record is within Wales', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000006967575', - WARD_CODE: 'E05011634', - PARISH_CODE: 'E04003837', - LAST_UPDATE_DATE: '24/04/2021', - ENTRY_DATE: '03/05/2001', - BLPU_STATE_DATE: '07/09/2007', - LANGUAGE: 'EN', + TOPOGRAPHY_LAYER_TOID: 'osgb1000021003409', + WARD_CODE: 'W05001775', + PARISH_CODE: 'W04001057', + PARENT_UPRN: '10033345846', + LAST_UPDATE_DATE: '25/02/2025', + ENTRY_DATE: '11/11/2005', + BLPU_STATE_DATE: '07/07/2010', + LANGUAGE: 'CY', MATCH: 0.4, MATCH_DESCRIPTION: 'NO MATCH', - DELIVERY_POINT_SUFFIX: '1N' + DELIVERY_POINT_SUFFIX: '2A' } }, { DPA: { - UPRN: '100091247570', - UDPRN: '53973575', - ADDRESS: 'FOREST DENE, FOREST TERRACE, HIGH ROAD, CHIGWELL, IG7 5BW', - BUILDING_NAME: 'FOREST DENE', - DEPENDENT_THOROUGHFARE_NAME: 'FOREST TERRACE', - THOROUGHFARE_NAME: 'HIGH ROAD', - POST_TOWN: 'CHIGWELL', - POSTCODE: 'IG7 5BW', - RPC: '1', - X_COORDINATE: 543059.0, - Y_COORDINATE: 192310.0, + UPRN: '100100269282', + UDPRN: '17105375', + ADDRESS: '6, BAKER STREET, Y FENNI, NP7 5BB', + BUILDING_NUMBER: '6', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'Y FENNI', + POSTCODE: 'NP7 5BB', + RPC: '2', + X_COORDINATE: 329754.0, + Y_COORDINATE: 214382.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 1535, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'EPPING FOREST', - COUNTRY_CODE: 'E', - COUNTRY_CODE_DESCRIPTION: 'This record is within England', + CLASSIFICATION_CODE: 'RD03', + CLASSIFICATION_CODE_DESCRIPTION: 'Semi-Detached', + LOCAL_CUSTODIAN_CODE: 6840, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'MONMOUTHSHIRE', + COUNTRY_CODE: 'W', + COUNTRY_CODE_DESCRIPTION: 'This record is within Wales', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000001806748797', - WARD_CODE: 'E05015729', - PARISH_CODE: 'E04004039', - LAST_UPDATE_DATE: '29/06/2022', - ENTRY_DATE: '16/04/2001', - BLPU_STATE_DATE: '16/04/2001', - LANGUAGE: 'EN', + TOPOGRAPHY_LAYER_TOID: 'osgb1000021003407', + WARD_CODE: 'W05001775', + PARISH_CODE: 'W04001057', + LAST_UPDATE_DATE: '17/10/2016', + ENTRY_DATE: '10/05/2001', + BLPU_STATE_DATE: '06/09/2016', + LANGUAGE: 'CY', MATCH: 0.4, MATCH_DESCRIPTION: 'NO MATCH', - DELIVERY_POINT_SUFFIX: '1X' + DELIVERY_POINT_SUFFIX: '1L' } }, { DPA: { - UPRN: '100060107548', - UDPRN: '24605177', - ADDRESS: '9, FOREST DENE, CROWBOROUGH, TN6 2HB', - BUILDING_NUMBER: '9', - THOROUGHFARE_NAME: 'FOREST DENE', - POST_TOWN: 'CROWBOROUGH', - POSTCODE: 'TN6 2HB', - RPC: '2', - X_COORDINATE: 553625.0, - Y_COORDINATE: 130056.0, + UPRN: '100100269283', + UDPRN: '17105376', + ADDRESS: '8, BAKER STREET, Y FENNI, NP7 5BB', + BUILDING_NUMBER: '8', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'Y FENNI', + POSTCODE: 'NP7 5BB', + RPC: '1', + X_COORDINATE: 329753.0, + Y_COORDINATE: 214380.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD06', - CLASSIFICATION_CODE_DESCRIPTION: - 'Self Contained Flat (Includes Maisonette / Apartment)', - LOCAL_CUSTODIAN_CODE: 1435, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'WEALDEN', - COUNTRY_CODE: 'E', - COUNTRY_CODE_DESCRIPTION: 'This record is within England', + CLASSIFICATION_CODE: 'RD03', + CLASSIFICATION_CODE_DESCRIPTION: 'Semi-Detached', + LOCAL_CUSTODIAN_CODE: 6840, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'MONMOUTHSHIRE', + COUNTRY_CODE: 'W', + COUNTRY_CODE_DESCRIPTION: 'This record is within Wales', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000000365578', - WARD_CODE: 'E05011629', - PARISH_CODE: 'E04003835', - LAST_UPDATE_DATE: '24/04/2021', - ENTRY_DATE: '03/05/2001', - BLPU_STATE_DATE: '07/09/2007', - LANGUAGE: 'EN', + TOPOGRAPHY_LAYER_TOID: 'osgb1000021003408', + WARD_CODE: 'W05001775', + PARISH_CODE: 'W04001057', + LAST_UPDATE_DATE: '17/10/2016', + ENTRY_DATE: '10/05/2001', + BLPU_STATE_DATE: '06/09/2016', + LANGUAGE: 'CY', MATCH: 0.4, MATCH_DESCRIPTION: 'NO MATCH', - DELIVERY_POINT_SUFFIX: '2S' + DELIVERY_POINT_SUFFIX: '1N' } } ] diff --git a/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js b/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js index 2af8fe16b..32893812a 100644 --- a/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js +++ b/src/server/plugins/postcode-lookup/test/__stubs__/uprn.js @@ -1,54 +1,52 @@ export const result = { header: { - uri: 'https://api.os.uk/search/places/v1/uprn?uprn=200003232010', - query: 'uprn=200003232010', + uri: 'https://api.os.uk/search/places/v1/uprn?uprn=100023071949', + query: 'uprn=100023071949', offset: 0, totalresults: 1, format: 'JSON', dataset: 'DPA', lr: 'EN,CY', maxresults: 100, - epoch: '120', - lastupdate: '2025-10-13', + epoch: '121', + lastupdate: '2025-10-14', output_srs: 'EPSG:27700' }, results: [ { DPA: { - UPRN: '200003232010', - UDPRN: '6132431', - ADDRESS: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - BUILDING_NAME: 'FOREST DENE', - THOROUGHFARE_NAME: 'FOREST HILL', - DEPENDENT_LOCALITY: 'HARTFORD', - POST_TOWN: 'NORTHWICH', - POSTCODE: 'CW8 2AT', - RPC: '1', - X_COORDINATE: 361852.0, - Y_COORDINATE: 371487.0, + UPRN: '100023071949', + UDPRN: '17646242', + ADDRESS: 'SHERLOCK HOLMES MUSEUM, 221B, BAKER STREET, LONDON, NW1 6XE', + ORGANISATION_NAME: 'SHERLOCK HOLMES MUSEUM', + BUILDING_NAME: '221B', + THOROUGHFARE_NAME: 'BAKER STREET', + POST_TOWN: 'LONDON', + POSTCODE: 'NW1 6XE', + RPC: '2', + X_COORDINATE: 527847.0, + Y_COORDINATE: 182144.0, STATUS: 'APPROVED', LOGICAL_STATUS_CODE: '1', - CLASSIFICATION_CODE: 'RD02', - CLASSIFICATION_CODE_DESCRIPTION: 'Detached', - LOCAL_CUSTODIAN_CODE: 665, - LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CHESHIRE WEST AND CHESTER', + CLASSIFICATION_CODE: 'CR08', + CLASSIFICATION_CODE_DESCRIPTION: 'Shop / Showroom', + LOCAL_CUSTODIAN_CODE: 5990, + LOCAL_CUSTODIAN_CODE_DESCRIPTION: 'CITY OF WESTMINSTER', COUNTRY_CODE: 'E', COUNTRY_CODE_DESCRIPTION: 'This record is within England', POSTAL_ADDRESS_CODE: 'D', POSTAL_ADDRESS_CODE_DESCRIPTION: 'A record which is linked to PAF', BLPU_STATE_CODE: '2', BLPU_STATE_CODE_DESCRIPTION: 'In use', - TOPOGRAPHY_LAYER_TOID: 'osgb1000035905836', - WARD_CODE: 'E05012219', - PARISH_CODE: 'E04012543', - PARENT_UPRN: '10011719253', - LAST_UPDATE_DATE: '24/04/2019', - ENTRY_DATE: '04/09/2002', - BLPU_STATE_DATE: '15/11/2007', + TOPOGRAPHY_LAYER_TOID: 'osgb1000005158436', + WARD_CODE: 'E05013805', + LAST_UPDATE_DATE: '23/09/2018', + ENTRY_DATE: '19/03/2001', + BLPU_STATE_DATE: '19/03/2001', LANGUAGE: 'EN', MATCH: 1.0, MATCH_DESCRIPTION: 'EXACT', - DELIVERY_POINT_SUFFIX: '1J' + DELIVERY_POINT_SUFFIX: '1F' } } ] From 4d006aaef492d6605778befb7506742fa4df5a0e Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 12:36:58 +0100 Subject: [PATCH 56/96] Update postcode lookup tests --- .../plugins/postcode-lookup/service.test.js | 68 ++-- test/form/postcode-lookup.test.js | 343 ++++++++++++++++++ 2 files changed, 377 insertions(+), 34 deletions(-) diff --git a/src/server/plugins/postcode-lookup/service.test.js b/src/server/plugins/postcode-lookup/service.test.js index cd0a475d5..57f301385 100644 --- a/src/server/plugins/postcode-lookup/service.test.js +++ b/src/server/plugins/postcode-lookup/service.test.js @@ -20,18 +20,18 @@ describe('Postcode lookup service', () => { error: undefined }) - const results = await service.searchByPostcode('cw8 2at', 'apikey') + const results = await service.searchByPostcode('NW1 6XE', 'apikey') expect(results).toHaveLength(10) expect(results.at(0)).toEqual({ - address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - addressLine1: 'Forest Dene', - addressLine2: 'Forest Hill, Hartford', + address: "EMILIA'S CRAFTED PASTA, 215, BAKER STREET, LONDON, NW1 6XE", + addressLine1: "Emilia's Crafted Pasta 215", + addressLine2: 'Baker Street', county: '', - formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', - postcode: 'CW8 2AT', - town: 'Northwich', - uprn: '200003232010' + formatted: "Emilia's Crafted Pasta 215, Baker Street, London, NW1 6XE", + postcode: 'NW1 6XE', + town: 'London', + uprn: '10033619968' }) }) @@ -45,7 +45,7 @@ describe('Postcode lookup service', () => { error: new Error('Unknown error') }) - const results = await service.searchByPostcode('cw8 2at', 'apikey') + const results = await service.searchByPostcode('NW1 6XE', 'apikey') expect(results).toHaveLength(0) expect(results).toEqual([]) @@ -80,7 +80,7 @@ describe('Postcode lookup service', () => { error: undefined }) - const results = await service.searchByPostcode('cw8 2at', 'apikey') + const results = await service.searchByPostcode('NW1 6XE', 'apikey') expect(results).toHaveLength(0) expect(results).toEqual([]) @@ -98,18 +98,18 @@ describe('Postcode lookup service', () => { error: undefined }) - const results = await service.searchByUPRN('200003232010', 'apikey') + const results = await service.searchByUPRN('100023071949', 'apikey') expect(results).toHaveLength(1) expect(results.at(0)).toEqual({ - address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - addressLine1: 'Forest Dene', - addressLine2: 'Forest Hill, Hartford', + address: 'SHERLOCK HOLMES MUSEUM, 221B, BAKER STREET, LONDON, NW1 6XE', + addressLine1: 'Sherlock Holmes Museum 221b', + addressLine2: 'Baker Street', county: '', - formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', - postcode: 'CW8 2AT', - town: 'Northwich', - uprn: '200003232010' + formatted: 'Sherlock Holmes Museum 221b, Baker Street, London, NW1 6XE', + postcode: 'NW1 6XE', + town: 'London', + uprn: '100023071949' }) }) }) @@ -126,20 +126,20 @@ describe('Postcode lookup service', () => { }) const results = await service.searchByQuery( - 'forest dene northwich', + 'Prime minister downing', 'apikey' ) expect(results).toHaveLength(5) expect(results.at(0)).toEqual({ - address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - addressLine1: 'Forest Dene', - addressLine2: 'Forest Hill, Hartford', + address: 'BAKER STREET COTTAGE, BAKER STREET, FROME, BA11 3BL', + addressLine1: 'Baker Street Cottage', + addressLine2: 'Baker Street', + town: 'Frome', county: '', - formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', - postcode: 'CW8 2AT', - town: 'Northwich', - uprn: '200003232010' + formatted: 'Baker Street Cottage, Baker Street, Frome, BA11 3BL', + postcode: 'BA11 3BL', + uprn: '250034655' }) }) }) @@ -155,18 +155,18 @@ describe('Postcode lookup service', () => { error: undefined }) - const results = await service.search('cw8 2at', 'Dene', 'apikey') + const results = await service.search('NW1 6XE', 'Emilia', 'apikey') expect(results).toHaveLength(1) expect(results.at(0)).toEqual({ - address: 'FOREST DENE, FOREST HILL, HARTFORD, NORTHWICH, CW8 2AT', - addressLine1: 'Forest Dene', - addressLine2: 'Forest Hill, Hartford', + address: "EMILIA'S CRAFTED PASTA, 215, BAKER STREET, LONDON, NW1 6XE", + addressLine1: "Emilia's Crafted Pasta 215", + addressLine2: 'Baker Street', county: '', - formatted: 'Forest Dene, Forest Hill, Hartford, Northwich, CW8 2AT', - postcode: 'CW8 2AT', - town: 'Northwich', - uprn: '200003232010' + formatted: "Emilia's Crafted Pasta 215, Baker Street, London, NW1 6XE", + postcode: 'NW1 6XE', + town: 'London', + uprn: '10033619968' }) }) }) diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js index a793b7c6c..9cd80fd38 100644 --- a/test/form/postcode-lookup.test.js +++ b/test/form/postcode-lookup.test.js @@ -1,10 +1,15 @@ import { join } from 'node:path' +import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' import { createServer } from '~/src/server/index.js' import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js' +import { + search, + searchByUPRN +} from '~/src/server/plugins/postcode-lookup/service.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' import { getCookie, getCookieHeader } from '~/test/utils/get-cookie.js' @@ -171,6 +176,344 @@ describe('Postcode lookup form pages', () => { expect(response.statusCode).toBe(StatusCodes.OK) }) + + it('should render validation errors after POST when no postcode is provided', async () => { + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + }) + + const { response, container } = await renderResponse(server, { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'details', + postcodeQuery: '', + buildingNameQuery: '', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + const $errorSummary = container.getByRole('alert') + + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + expect($heading).toBeInTheDocument() + }) + + it('should render the select page after POST when multiple addresses are found', async () => { + jest.mocked(search).mockResolvedValueOnce([ + { + address: + 'PRIME MINISTER & FIRST LORD OF THE TREASURY, 10, DOWNING STREET, LONDON, SW1A 2AA', + addressLine1: 'Prime Minister & First Lord Of The Treasury 10', + addressLine2: 'Downing Street', + county: '', + formatted: + 'Prime Minister & First Lord Of The Treasury 10, Downing Street, London, SW1A 2AA', + postcode: 'SW1A 2AA', + town: 'London', + uprn: '100023336956' + }, + { + address: + 'CHANCELLOR & FIRST LORD OF THE TREASURY, 10, DOWNING STREET, LONDON, SW1A 2AA', + addressLine1: 'Chancellor & First Lord Of The Treasury 11', + addressLine2: 'Downing Street', + county: '', + formatted: + 'Chancellor & First Lord Of The Treasury 10, Downing Street, London, SW1A 2AA', + postcode: 'SW1A 2AA', + town: 'London', + uprn: '100023336957' + } + ]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + }) + + const { response: selectResponse, container } = await renderResponse( + server, + { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'details', + postcodeQuery: 'SW1A 2AA', + buildingNameQuery: '', + crumb: csrfToken + } + } + ) + + expect(selectResponse.statusCode).toBe(StatusCodes.OK) + + const $addressSelector = container.getByRole('combobox') + expect($addressSelector).toBeInTheDocument() + + const $addressSelectorOptions = container.getAllByRole('option') + expect($addressSelectorOptions).toHaveLength(3) + + const $useAddressButton = container.getByRole('button', { + name: 'Use this address' + }) + + expect($useAddressButton).toBeInTheDocument() + }) + + it('should render the select page after POST when a single address is found', async () => { + jest.mocked(search).mockResolvedValueOnce([ + { + address: 'SHERLOCK HOLMES MUSEUM, 221B, BAKER STREET, LONDON, NW1 6XE', + addressLine1: 'Sherlock Holmes Museum 221b', + addressLine2: 'Baker Street', + county: '', + formatted: 'Sherlock Holmes Museum 221b, Baker Street, London, NW1 6XE', + postcode: 'NW1 6XE', + town: 'London', + uprn: '100023071949' + } + ]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + }) + + const { response: selectResponse, container } = await renderResponse( + server, + { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'details', + postcodeQuery: 'NW1 6XE', + buildingNameQuery: '221B', + crumb: csrfToken + } + } + ) + + expect(selectResponse.statusCode).toBe(StatusCodes.OK) + + const $addressPostcodeDisplay = container.getByText('NW1 6XE', { + selector: 'strong' + }) + expect($addressPostcodeDisplay).toBeInTheDocument() + + const $addressBuildingNameDisplay = container.getByText('221B', { + selector: 'strong' + }) + expect($addressBuildingNameDisplay).toBeInTheDocument() + + const $addressDisplay = container.getByText( + 'Sherlock Holmes Museum 221b, Baker Street, London, NW1 6XE' + ) + expect($addressDisplay).toBeInTheDocument() + + const $useAddressButton = container.getByRole('button', { + name: 'Use this address' + }) + + expect($useAddressButton).toBeInTheDocument() + }) + + it('should redirect back to the source page after POST when multiple addresses are found', async () => { + jest.mocked(searchByUPRN).mockResolvedValueOnce([ + { + address: + 'PRIME MINISTER & FIRST LORD OF THE TREASURY, 10, DOWNING STREET, LONDON, SW1A 2AA', + addressLine1: 'Prime Minister & First Lord Of The Treasury 10', + addressLine2: 'Downing Street', + county: '', + formatted: + 'Prime Minister & First Lord Of The Treasury 10, Downing Street, London, SW1A 2AA', + postcode: 'SW1A 2AA', + town: 'London', + uprn: '100023336956' + } + ]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + }) + + const { response } = await renderResponse(server, { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'select', + uprn: '100023336956', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toEndWith('/address') + }) + + it('should render validation errors after POST when no address is selected', async () => { + jest.mocked(search).mockResolvedValueOnce([ + { + address: + 'PRIME MINISTER & FIRST LORD OF THE TREASURY, 10, DOWNING STREET, LONDON, SW1A 2AA', + addressLine1: 'Prime Minister & First Lord Of The Treasury 10', + addressLine2: 'Downing Street', + county: '', + formatted: + 'Prime Minister & First Lord Of The Treasury 10, Downing Street, London, SW1A 2AA', + postcode: 'SW1A 2AA', + town: 'London', + uprn: '100023336956' + } + ]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + }) + + const { response, container } = await renderResponse(server, { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'select', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + const $errorSummary = container.getByRole('alert') + + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + expect($heading).toBeInTheDocument() + }) + + it('should render validation errors after POST to manual page when no address lines are provided', async () => { + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + }) + + const { response, container } = await renderResponse(server, { + url: '/postcode-lookup?step=manual', + method: 'POST', + headers, + payload: { + step: 'manual', + addressLine1: '', + addressLine2: '', + town: '', + county: '', + postcode: '', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + const $errorSummary = container.getByRole('alert') + + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + expect($heading).toBeInTheDocument() + }) + + it('should redirect back to the source page after successful POST to manual page', async () => { + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + }) + + const { response } = await renderResponse(server, { + url: '/postcode-lookup?step=manual', + method: 'POST', + headers, + payload: { + step: 'manual', + addressLine1: '1 Street Name', + addressLine2: '', + town: 'Middletown', + county: '', + postcode: 'M15 5TN', + crumb: csrfToken + } + }) + + expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) + expect(response.headers.location).toEndWith('/address') + }) }) /** From 97bfe18fa7e241d2278a5dd89b4a82acf6ebc986 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 12:46:55 +0100 Subject: [PATCH 57/96] Add test for when no addresses are found --- test/form/postcode-lookup.test.js | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js index 9cd80fd38..42b31b383 100644 --- a/test/form/postcode-lookup.test.js +++ b/test/form/postcode-lookup.test.js @@ -350,6 +350,46 @@ describe('Postcode lookup form pages', () => { expect($useAddressButton).toBeInTheDocument() }) + it('should render the select page after POST when a no addresses are found', async () => { + jest.mocked(search).mockResolvedValueOnce([]) + + const { csrfToken, headers } = await initialiseJourney(server) + + // Dispatch to postcode journey + await server.inject({ + url: `${basePath}/address`, + method: 'POST', + headers, + payload: { + action: 'external-postcode-lookup--name:ybMHIv', + crumb: csrfToken + } + }) + + const { response: selectResponse, container } = await renderResponse( + server, + { + url: '/postcode-lookup', + method: 'POST', + headers, + payload: { + step: 'details', + postcodeQuery: 'AA1 1AA', + buildingNameQuery: '100', + crumb: csrfToken + } + } + ) + + expect(selectResponse.statusCode).toBe(StatusCodes.OK) + + const $noAddressesFound = container.getByRole('heading', { + name: 'No address found', + level: 1 + }) + expect($noAddressesFound).toBeInTheDocument() + }) + it('should redirect back to the source page after POST when multiple addresses are found', async () => { jest.mocked(searchByUPRN).mockResolvedValueOnce([ { From 5c17350a2ede7256749c60756833a0d910c28312 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 12:52:08 +0100 Subject: [PATCH 58/96] Sonar issue (unexpected negated condition) --- src/server/plugins/postcode-lookup/models/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index b841b79bb..6f0a6f240 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -444,7 +444,7 @@ export async function selectViewModel(data, payload, err) { // Model buttons const continueButton = { - href: !hasAddresses ? href : undefined, + href: hasAddresses ? undefined : href, text: hasAddresses ? 'Use this address' : 'Search again', classes: GOVUK_MARGIN_RIGHT_1 } From bc5ca1e1fa50ba65a35fe4d1afa0d074a489c297 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 13:02:36 +0100 Subject: [PATCH 59/96] Sonar fixes --- src/server/plugins/postcode-lookup/service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index e6de22aba..16d1b93df 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -73,7 +73,7 @@ export async function searchByQuery(query, apiKey) { */ export async function searchByPostcode(postcode, apiKey) { const endpoint = 'postcode' - const url = `https://api.os.uk/search/places/v1/${endpoint}?postcode=${encodeURIComponent(postcode.replace(/\s/g, ''))}&key=${apiKey}` + const url = `https://api.os.uk/search/places/v1/${endpoint}?postcode=${encodeURIComponent(postcode.replaceAll(/\s/g, ''))}&key=${apiKey}` return getAddressData(url, endpoint) } @@ -118,7 +118,7 @@ function formatAddress(dpa) { const town = titleCase(dpa.POST_TOWN || '') const postcode = dpa.POSTCODE || '' const lines = [addressLine1, addressLine2, town] - const formatted = `${lines.filter((i) => i).join(', ')}, ${postcode}` + const formatted = `${lines.filter((i) => Boolean(i)).join(', ')}, ${postcode}` /** * @type {Address} From 0cdf9100d0eb3e8f043f909fa114590fdc3a3915 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 13:14:11 +0100 Subject: [PATCH 60/96] Sonar fix --- src/server/plugins/postcode-lookup/service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index 16d1b93df..1b5a80b8f 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -118,7 +118,7 @@ function formatAddress(dpa) { const town = titleCase(dpa.POST_TOWN || '') const postcode = dpa.POSTCODE || '' const lines = [addressLine1, addressLine2, town] - const formatted = `${lines.filter((i) => Boolean(i)).join(', ')}, ${postcode}` + const formatted = `${lines.filter((i) => !!i).join(', ')}, ${postcode}` /** * @type {Address} From 13e8909c162adeedbb1361727d4683455dc20834 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 16:08:00 +0100 Subject: [PATCH 61/96] Remove CustomerReferenceField example --- src/server/forms/external-components.json | 942 ------------------ .../CustomerReferenceField.ts | 120 --- .../CustomerReferenceField/routes.ts | 83 -- src/server/plugins/engine/routes/index.ts | 1 + .../engine/services/localFormsService.js | 9 - .../components/customerreferencefield.html | 21 - .../views/components/externalcomponent.html | 43 - 7 files changed, 1 insertion(+), 1218 deletions(-) delete mode 100644 src/server/forms/external-components.json delete mode 100644 src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts delete mode 100644 src/server/plugins/engine/components/CustomerReferenceField/routes.ts delete mode 100644 src/server/plugins/engine/views/components/customerreferencefield.html delete mode 100644 src/server/plugins/engine/views/components/externalcomponent.html diff --git a/src/server/forms/external-components.json b/src/server/forms/external-components.json deleted file mode 100644 index 725b2b1a2..000000000 --- a/src/server/forms/external-components.json +++ /dev/null @@ -1,942 +0,0 @@ -{ - "conditions": [], - "engine": "V2", - "startPage": "/all-components", - "pages": [ - { - "path": "/all-components", - "title": "Details about you", - "components": [ - { - "type": "TextField", - "name": "applicantName", - "title": "What's your name?", - "shortDescription": "Your name", - "hint": "This must match exactly what's written on your passport.", - "options": {}, - "schema": {} - }, - { - "type": "CustomerReferenceField", - "name": "customerReferenceNumber", - "title": "What's your customer reference number?", - "shortDescription": "Your customer reference number", - "hint": "", - "options": {}, - "schema": {} - }, - { - "type": "TextField", - "name": "applicantProfession", - "title": "What's your job title?", - "shortDescription": "Your job title", - "hint": "If you are unemployed, please enter 'unemployed'.", - "options": {}, - "schema": {} - } - ] - }, - { - "title": "Summary", - "path": "/summary", - "controller": "SummaryPageController", - "next": [] - } - ], - "sections": [ - { - "name": "checkBeforeStart", - "title": "Check before you start" - }, - { - "name": "personalDetails", - "title": "Personal details" - }, - { - "name": "companyDetails", - "title": "Company details" - } - ], - "lists": [ - { - "name": "yesNoUnsure", - "title": "Yes/No/Not sure", - "type": "string", - "items": [ - { - "text": "Yes", - "value": "yes" - }, - { - "text": "No", - "value": "no" - }, - { - "text": "Not sure", - "value": "unsure" - } - ] - }, - { - "name": "companyType", - "title": "Company type", - "type": "string", - "items": [ - { - "text": "Sole trader", - "value": "soleTrader" - }, - { - "text": "Private Limited Company", - "value": "privateLimitedCompany" - }, - { - "text": "Public Limited Company", - "value": "publicLimitedCompany" - }, - { - "text": "Limited Liability Partnership", - "value": "limitedLiabilityPartnership" - }, - { - "text": "Charity", - "value": "charity" - }, - { - "text": "Other", - "value": "other" - } - ] - }, - { - "name": "country", - "title": "Country", - "type": "number", - "items": [ - { - "text": "Afghanistan", - "value": 910400000 - }, - { - "text": "Albania", - "value": 910400001 - }, - { - "text": "Algeria", - "value": 910400002 - }, - { - "text": "Andorra", - "value": 910400003 - }, - { - "text": "Angola", - "value": 910400004 - }, - { - "text": "Antigua and Barbuda", - "value": 910400005 - }, - { - "text": "Argentina", - "value": 910400006 - }, - { - "text": "Armenia", - "value": 910400007 - }, - { - "text": "Australia", - "value": 910400008 - }, - { - "text": "Austria", - "value": 910400009 - }, - { - "text": "Azerbaijan", - "value": 910400010 - }, - { - "text": "Bahrain", - "value": 910400011 - }, - { - "text": "Bangladesh", - "value": 910400012 - }, - { - "text": "Barbados", - "value": 910400013 - }, - { - "text": "Belarus", - "value": 910400014 - }, - { - "text": "Belgium", - "value": 910400015 - }, - { - "text": "Belize", - "value": 910400016 - }, - { - "text": "Benin", - "value": 910400017 - }, - { - "text": "Bhutan", - "value": 910400018 - }, - { - "text": "Bolivia", - "value": 910400019 - }, - { - "text": "Bosnia and Herzegovina", - "value": 910400020 - }, - { - "text": "Botswana", - "value": 910400021 - }, - { - "text": "Brazil", - "value": 910400022 - }, - { - "text": "Brunei", - "value": 910400023 - }, - { - "text": "Bulgaria", - "value": 910400024 - }, - { - "text": "Burkina Faso", - "value": 910400025 - }, - { - "text": "Burma", - "value": 910400026 - }, - { - "text": "Burundi", - "value": 910400027 - }, - { - "text": "Cambodia", - "value": 910400028 - }, - { - "text": "Cameroon", - "value": 910400029 - }, - { - "text": "Canada", - "value": 910400030 - }, - { - "text": "Cape Verde", - "value": 910400031 - }, - { - "text": "Central African Republic", - "value": 910400032 - }, - { - "text": "Chad", - "value": 910400033 - }, - { - "text": "Chile", - "value": 910400034 - }, - { - "text": "China", - "value": 910400035 - }, - { - "text": "Colombia", - "value": 910400036 - }, - { - "text": "Comoros", - "value": 910400037 - }, - { - "text": "Congo", - "value": 910400038 - }, - { - "text": "Congo (Democratic Republic)", - "value": 910400039 - }, - { - "text": "Costa Rica", - "value": 910400040 - }, - { - "text": "Croatia", - "value": 910400041 - }, - { - "text": "Cuba", - "value": 910400042 - }, - { - "text": "Cyprus", - "value": 910400043 - }, - { - "text": "Czech Republic", - "value": 910400044 - }, - { - "text": "Denmark", - "value": 910400045 - }, - { - "text": "Djibouti", - "value": 910400046 - }, - { - "text": "Dominica", - "value": 910400047 - }, - { - "text": "Dominican Republic", - "value": 910400048 - }, - { - "text": "East Timor", - "value": 910400049 - }, - { - "text": "Ecuador", - "value": 910400050 - }, - { - "text": "Egypt", - "value": 910400051 - }, - { - "text": "El Salvador", - "value": 910400052 - }, - { - "text": "Equatorial Guinea", - "value": 910400053 - }, - { - "text": "Eritrea", - "value": 910400054 - }, - { - "text": "Estonia", - "value": 910400055 - }, - { - "text": "Ethiopia", - "value": 910400056 - }, - { - "text": "Fiji", - "value": 910400057 - }, - { - "text": "Finland", - "value": 910400058 - }, - { - "text": "France", - "value": 910400059 - }, - { - "text": "Gabon", - "value": 910400060 - }, - { - "text": "Georgia", - "value": 910400061 - }, - { - "text": "Germany", - "value": 910400062 - }, - { - "text": "Ghana", - "value": 910400063 - }, - { - "text": "Greece", - "value": 910400064 - }, - { - "text": "Grenada", - "value": 910400065 - }, - { - "text": "Guatemala", - "value": 910400066 - }, - { - "text": "Guinea", - "value": 910400067 - }, - { - "text": "Guinea-Bissau", - "value": 910400068 - }, - { - "text": "Guyana", - "value": 910400069 - }, - { - "text": "Haiti", - "value": 910400070 - }, - { - "text": "Honduras", - "value": 910400071 - }, - { - "text": "Hungary", - "value": 910400072 - }, - { - "text": "Iceland", - "value": 910400073 - }, - { - "text": "India", - "value": 910400074 - }, - { - "text": "Indonesia", - "value": 910400075 - }, - { - "text": "Iran", - "value": 910400076 - }, - { - "text": "Iraq", - "value": 910400077 - }, - { - "text": "Ireland", - "value": 910400078 - }, - { - "text": "Israel", - "value": 910400079 - }, - { - "text": "Italy", - "value": 910400080 - }, - { - "text": "Ivory Coast", - "value": 910400081 - }, - { - "text": "Jamaica", - "value": 910400082 - }, - { - "text": "Japan", - "value": 910400083 - }, - { - "text": "Jordan", - "value": 910400084 - }, - { - "text": "Kazakhstan", - "value": 910400085 - }, - { - "text": "Kenya", - "value": 910400086 - }, - { - "text": "Kiribati", - "value": 910400087 - }, - { - "text": "Kosovo", - "value": 910400088 - }, - { - "text": "Kuwait", - "value": 910400089 - }, - { - "text": "Kyrgyzstan", - "value": 910400090 - }, - { - "text": "Laos", - "value": 910400091 - }, - { - "text": "Latvia", - "value": 910400092 - }, - { - "text": "Lebanon", - "value": 910400093 - }, - { - "text": "Lesotho", - "value": 910400094 - }, - { - "text": "Liberia", - "value": 910400095 - }, - { - "text": "Libya", - "value": 910400096 - }, - { - "text": "Liechtenstein", - "value": 910400097 - }, - { - "text": "Lithuania", - "value": 910400098 - }, - { - "text": "Luxembourg", - "value": 910400099 - }, - { - "text": "Macedonia", - "value": 910400100 - }, - { - "text": "Madagascar", - "value": 910400101 - }, - { - "text": "Malawi", - "value": 910400102 - }, - { - "text": "Malaysia", - "value": 910400103 - }, - { - "text": "Maldives", - "value": 910400104 - }, - { - "text": "Mali", - "value": 910400105 - }, - { - "text": "Malta", - "value": 910400106 - }, - { - "text": "Marshall Islands", - "value": 910400107 - }, - { - "text": "Mauritania", - "value": 910400108 - }, - { - "text": "Mauritius", - "value": 910400109 - }, - { - "text": "Mexico", - "value": 910400110 - }, - { - "text": "Micronesia", - "value": 910400111 - }, - { - "text": "Moldova", - "value": 910400112 - }, - { - "text": "Monaco", - "value": 910400113 - }, - { - "text": "Mongolia", - "value": 910400114 - }, - { - "text": "Montenegro", - "value": 910400115 - }, - { - "text": "Morocco", - "value": 910400116 - }, - { - "text": "Mozambique", - "value": 910400117 - }, - { - "text": "Namibia", - "value": 910400118 - }, - { - "text": "Nauru", - "value": 910400119 - }, - { - "text": "Nepal", - "value": 910400120 - }, - { - "text": "Netherlands", - "value": 910400121 - }, - { - "text": "New Zealand", - "value": 910400122 - }, - { - "text": "Nicaragua", - "value": 910400123 - }, - { - "text": "Niger", - "value": 910400124 - }, - { - "text": "Nigeria", - "value": 910400125 - }, - { - "text": "North Korea", - "value": 910400126 - }, - { - "text": "Norway", - "value": 910400127 - }, - { - "text": "Oman", - "value": 910400128 - }, - { - "text": "Pakistan", - "value": 910400129 - }, - { - "text": "Palau", - "value": 910400130 - }, - { - "text": "Panama", - "value": 910400131 - }, - { - "text": "Papua New Guinea", - "value": 910400132 - }, - { - "text": "Paraguay", - "value": 910400133 - }, - { - "text": "Peru", - "value": 910400134 - }, - { - "text": "Philippines", - "value": 910400135 - }, - { - "text": "Poland", - "value": 910400136 - }, - { - "text": "Portugal", - "value": 910400137 - }, - { - "text": "Qatar", - "value": 910400138 - }, - { - "text": "Romania", - "value": 910400139 - }, - { - "text": "Russia", - "value": 910400140 - }, - { - "text": "Rwanda", - "value": 910400141 - }, - { - "text": "Samoa", - "value": 910400142 - }, - { - "text": "San Marino", - "value": 910400143 - }, - { - "text": "Sao Tome and Principe", - "value": 910400144 - }, - { - "text": "Saudi Arabia", - "value": 910400145 - }, - { - "text": "Senegal", - "value": 910400146 - }, - { - "text": "Serbia", - "value": 910400147 - }, - { - "text": "Seychelles", - "value": 910400148 - }, - { - "text": "Sierra Leone", - "value": 910400149 - }, - { - "text": "Singapore", - "value": 910400150 - }, - { - "text": "Slovakia", - "value": 910400151 - }, - { - "text": "Slovenia", - "value": 910400152 - }, - { - "text": "Solomon Islands", - "value": 910400153 - }, - { - "text": "Somalia", - "value": 910400154 - }, - { - "text": "South Africa", - "value": 910400155 - }, - { - "text": "South Korea", - "value": 910400156 - }, - { - "text": "South Sudan", - "value": 910400157 - }, - { - "text": "Spain", - "value": 910400158 - }, - { - "text": "Sri Lanka", - "value": 910400159 - }, - { - "text": "St Kitts and Nevis", - "value": 910400160 - }, - { - "text": "St Lucia", - "value": 910400161 - }, - { - "text": "St Vincent", - "value": 910400162 - }, - { - "text": "Sudan", - "value": 910400163 - }, - { - "text": "Suriname", - "value": 910400164 - }, - { - "text": "Swaziland", - "value": 910400165 - }, - { - "text": "Sweden", - "value": 910400166 - }, - { - "text": "Switzerland", - "value": 910400167 - }, - { - "text": "Syria", - "value": 910400168 - }, - { - "text": "Tajikistan", - "value": 910400169 - }, - { - "text": "Tanzania", - "value": 910400170 - }, - { - "text": "Thailand", - "value": 910400171 - }, - { - "text": "The Bahamas", - "value": 910400172 - }, - { - "text": "The Gambia", - "value": 910400173 - }, - { - "text": "Togo", - "value": 910400174 - }, - { - "text": "Tonga", - "value": 910400175 - }, - { - "text": "Trinidad and Tobago", - "value": 910400176 - }, - { - "text": "Tunisia", - "value": 910400177 - }, - { - "text": "Turkey", - "value": 910400178 - }, - { - "text": "Turkmenistan", - "value": 910400179 - }, - { - "text": "Tuvalu", - "value": 910400180 - }, - { - "text": "Uganda", - "value": 910400181 - }, - { - "text": "Ukraine", - "value": 910400182 - }, - { - "text": "United Arab Emirates", - "value": 910400183 - }, - { - "text": "United Kingdom", - "value": 910400184 - }, - { - "text": "United States", - "value": 910400185 - }, - { - "text": "Uruguay", - "value": 910400186 - }, - { - "text": "Uzbekistan", - "value": 910400187 - }, - { - "text": "Vanuatu", - "value": 910400188 - }, - { - "text": "Vatican City", - "value": 910400189 - }, - { - "text": "Venezuela", - "value": 910400190 - }, - { - "text": "Vietnam", - "value": 910400191 - }, - { - "text": "Yemen", - "value": 910400192 - }, - { - "text": "Zambia", - "value": 910400193 - }, - { - "text": "Zimbabwe", - "value": 910400194 - }, - { - "text": "England", - "value": 910400195 - }, - { - "text": "Wales", - "value": 910400196 - }, - { - "text": "Scotland", - "value": 910400197 - }, - { - "text": "Northern Ireland", - "value": 910400198 - } - ] - }, - { - "name": "horseBreed", - "title": "Horse breed", - "type": "string", - "items": [ - { - "text": "Arabian", - "value": "Arabian" - }, - { - "text": "Patomine", - "value": "Patomine" - }, - { - "text": "Shire", - "value": "Shire" - }, - { - "text": "Shetland", - "value": "Shetland" - }, - { - "text": "Race", - "value": "Race" - } - ] - } - ] -} diff --git a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts deleted file mode 100644 index 0c2f5f14e..000000000 --- a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { type FormComponentsDef } from '@defra/forms-model' -import joi, { type ObjectSchema } from 'joi' - -import { getRoutes } from '~/src/server/plugins/engine/components/CustomerReferenceField/routes.js' -import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' -import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' -import { - type ErrorMessageTemplateList, - type FormPayload, - type FormState, - type FormStateValue, - type FormSubmissionError -} from '~/src/server/plugins/engine/types.js' - -export class CustomerReferenceField extends FormComponent { - declare options: CustomerReferenceFieldComponent['options'] - - declare schema: CustomerReferenceFieldComponent['schema'] - - declare formSchema: ObjectSchema - declare stateSchema: ObjectSchema - - constructor( - def: CustomerReferenceFieldComponent, - props: ConstructorParameters[1] - ) { - super(def, props) - - const { options } = def - const schema = 'schema' in def ? def.schema : {} - - this.formSchema = joi - .object() - .keys({ - reference: joi.string().required(), - _id: joi.string().required() - }) - .required() - this.stateSchema = this.formSchema - this.options = options - this.schema = schema - } - - isValue(value?: FormStateValue | FormState): value is CustomerReferenceState { - return CustomerReferenceField.isCustomerReferenceField(value) - } - - isState(value?: FormStateValue | FormState): value is FormState { - return CustomerReferenceField.isCustomerReferenceField(value) - } - - /** - * For error preview page that shows all possible errors on a component - */ - getAllPossibleErrors(): ErrorMessageTemplateList { - return CustomerReferenceField.getAllPossibleErrors() - } - - /** - * Static version of getAllPossibleErrors that doesn't require a component instance. - */ - static getAllPossibleErrors(): ErrorMessageTemplateList { - return { - baseErrors: [{ type: 'required', template: messageTemplate.required }], - advancedSettingsErrors: [ - { type: 'min', template: messageTemplate.min }, - { type: 'max', template: messageTemplate.max } - ] - } - } - - getDisplayStringFromFormValue(value?: FormStateValue | FormState): string { - if (this.isValue(value)) { - return value.reference - } - return '' - } - - getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { - const viewModel = super.getViewModel(payload, errors) - - viewModel.value = this.getDisplayStringFromFormValue(payload[this.name]) - - return viewModel - } - - static isCustomerReferenceField( - value?: FormStateValue | FormState - ): value is CustomerReferenceState { - return ( - value !== null && - typeof value === 'object' && - '_id' in value && - 'reference' in value - ) - } - - static getRoutes() { - return { - routes: getRoutes(), - entrypoint: '/customer-reference-field/confirm' - } - } -} - -export interface CustomerReferenceFieldComponent extends FormComponentsDef { - id?: string - type: 'CustomerReferenceField' - shortDescription?: string - name: string - title: string - hint?: string - options: object - schema: object -} - -interface CustomerReferenceState { - _id: string - reference: string -} diff --git a/src/server/plugins/engine/components/CustomerReferenceField/routes.ts b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts deleted file mode 100644 index 2486546be..000000000 --- a/src/server/plugins/engine/components/CustomerReferenceField/routes.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - type Request, - type ResponseToolkit, - type ServerRoute -} from '@hapi/hapi' -import Joi from 'joi' - -function randomReference() { - // Example: 3 groups of 3 digits - return `${Math.floor(100 + Math.random() * 900)}-${Math.floor(100 + Math.random() * 900)}-${Math.floor(100 + Math.random() * 900)}` -} - -function randomId() { - // Example: 32 hex chars - return Array.from({ length: 32 }, () => - Math.floor(Math.random() * 16).toString(16) - ).join('') -} - -export function initiateHandler(request: Request, h: ResponseToolkit) { - const returnUrl = request.query.returnUrl - const component = request.query.component - - const data = { - reference: randomReference(), - _id: randomId() - } - - request.yar.set('returnUrl', returnUrl) - request.yar.set('component', component) - request.yar.set('data', data) - - return h.response( - ` -

Simulated external service page

- -

You have been generated a reference number: ${data.reference}.

- -
- -
- ` - ) -} - -export function confirmHandler(request: Request, h: ResponseToolkit) { - const component = request.yar.get('component') - const data = request.yar.get('data') - const returnUrl = request.yar.get('returnUrl') - - request.yar.flash( - 'externalStateAppendage', - JSON.stringify({ - component, - data - }) - ) - - return h.redirect(returnUrl) -} - -export function getRoutes(): ServerRoute[] { - return [ - { - method: 'get', - path: '/customer-reference-field/confirm', - handler: initiateHandler, - options: { - validate: { - query: Joi.object().keys({ - component: Joi.string().required(), - returnUrl: Joi.string().uri().required() - }) - } - } - }, - { - method: 'post', - path: '/customer-reference-field/confirm', - handler: confirmHandler - } - ] -} diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index d4a6dbb9e..4e850d3e7 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -80,6 +80,7 @@ export async function redirectOrMakeHandler( return dispatchExternalHandler(request, h, opts) } + state = await importExternalComponentState(request, page, state) const flash = cacheService.getFlash(request) diff --git a/src/server/plugins/engine/services/localFormsService.js b/src/server/plugins/engine/services/localFormsService.js index afc6d70ef..c824bad34 100644 --- a/src/server/plugins/engine/services/localFormsService.js +++ b/src/server/plugins/engine/services/localFormsService.js @@ -51,14 +51,5 @@ export const formsService = async () => { slug: 'components' }) - // external-components - - await loader.addForm('src/server/forms/external-components.json', { - ...metadata, - id: 'z6a872d3b-13f9e-804ce3e-4830-5c45fb32', - title: 'external-components', - slug: 'external-components' - }) - return loader.toFormsService() } diff --git a/src/server/plugins/engine/views/components/customerreferencefield.html b/src/server/plugins/engine/views/components/customerreferencefield.html deleted file mode 100644 index d5846ba74..000000000 --- a/src/server/plugins/engine/views/components/customerreferencefield.html +++ /dev/null @@ -1,21 +0,0 @@ - -{% from "govuk/components/button/macro.njk" import govukButton %} -{% from "govuk/components/hint/macro.njk" import govukHint %} -{% from "components/externalcomponent.html" import ExternalComponent %} - - -{% macro CustomerReferenceField(component) %} - {% if component.model.value %} - {% set bodyHtml %} -

Your reference number: {{ component.model.value }}

- {% endset %} - {% else %} - {% set bodyHtml %} -
- Use the button below to look up your reference number from name of external system. -
- {% endset %} - {% endif %} - - {{ ExternalComponent(component, bodyHtml) }} -{% endmacro %} diff --git a/src/server/plugins/engine/views/components/externalcomponent.html b/src/server/plugins/engine/views/components/externalcomponent.html deleted file mode 100644 index 87a22eeab..000000000 --- a/src/server/plugins/engine/views/components/externalcomponent.html +++ /dev/null @@ -1,43 +0,0 @@ -{% from "govuk/components/button/macro.njk" import govukButton %} -{% from "govuk/components/hint/macro.njk" import govukHint %} - -{% macro ExternalComponent(component, bodyHtml) %} -
-

{{ component.model.label.text }}

- - {% if component.model.hint %} - {{ govukHint({ - id: component.model.name + "-hint", - text: component.model.hint.text - }) }} - {% endif %} - - {% if component.model.errors | length %} -

- Error: {{ component.model.errors[0].text }} -

- {% endif %} - - {{ bodyHtml | safe }} - - {% if component.model.value %} - {{ govukButton({ - text: "Edit", - type: "submit", - name: "action", - value: "external-component-edit-" + component.model.name, - preventDoubleClick: true, - classes: "govuk-button--secondary" - }) }} - {% else %} - {{ govukButton({ - text: "Select", - type: "submit", - name: "action", - value: "external-component-edit-" + component.model.name, - preventDoubleClick: true, - classes: "govuk-button--secondary" - }) }} - {% endif %} -
-{% endmacro %} From 7102a57ce09612b4e1c8aa190313d8d03f2b2aa7 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 09:24:22 +0100 Subject: [PATCH 62/96] Remove external getRoutes --- src/server/plugins/engine/plugin.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/server/plugins/engine/plugin.ts b/src/server/plugins/engine/plugin.ts index 39e4eaf62..cde8ed853 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -14,7 +14,6 @@ import { getRoutes as getQuestionRoutes } from '~/src/server/plugins/engine/rout import { getRoutes as getRepeaterItemDeleteRoutes } from '~/src/server/plugins/engine/routes/repeaters/item-delete.js' import { getRoutes as getRepeaterSummaryRoutes } from '~/src/server/plugins/engine/routes/repeaters/summary.js' import { type PluginOptions } from '~/src/server/plugins/engine/types.js' -import { getComponentsByType } from '~/src/server/plugins/engine/validationHelpers.js' import { registerVision } from '~/src/server/plugins/engine/vision.js' import { postcodeLookupPlugin } from '~/src/server/plugins/postcode-lookup/index.js' import { @@ -92,13 +91,6 @@ export const plugin = { ] } - // Collect routes from components with a static getRoutes method using getComponentsByType - const { externalComponents } = getComponentsByType() - - const componentRoutes = Array.from(externalComponents.values()).flatMap( - (comp) => comp.getRoutes().routes - ) - const routes = [ ...getQuestionRoutes( getRouteOptions, @@ -107,8 +99,7 @@ export const plugin = { ), ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions), ...getRepeaterItemDeleteRoutes(getRouteOptions, postRouteOptions), - ...getFileUploadStatusRoutes(), - ...componentRoutes + ...getFileUploadStatusRoutes() ] server.route(routes as unknown as ServerRoute[]) // TODO From 522922505c03940650cc013a1cfc3319663c1217 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:35:18 +0100 Subject: [PATCH 63/96] Add External types --- src/server/constants.js | 2 ++ src/server/plugins/engine/types.ts | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/server/constants.js b/src/server/constants.js index 42785542d..fd659a09e 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -1,2 +1,4 @@ export const PREVIEW_PATH_PREFIX = '/preview' export const FORM_PREFIX = '' +export const EXTERNAL_STATE_PAYLOAD = 'EXTERNAL_STATE_PAYLOAD' +export const EXTERNAL_STATE_APPENDAGE = 'EXTERNAL_STATE_APPENDAGE' diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 3673daf39..7f6df1111 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -6,7 +6,8 @@ import { type FormVersionMetadata, type Item, type List, - type Page + type Page, + type UkAddressFieldComponent } from '@defra/forms-model' import { type PluginProperties, @@ -30,6 +31,7 @@ import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' +import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/index.js' import { type FileStatus, type FormAdapterSubmissionSchemaVersion, @@ -379,6 +381,23 @@ export type SaveAndExitHandler = ( context: FormContext ) => ResponseObject +export interface ExternalArgs { + component: ComponentDef + controller: QuestionPageController + sourceUrl: string + actionArgs: Record +} + +export interface PostcodeLookupExternalArgs extends ExternalArgs { + component: UkAddressFieldComponent + actionArgs: { step: string } +} + +export interface ExternalStateAppendage { + component: string + data: FormStateValue | FormState +} + export interface PluginOptions { model?: FormModel services?: Services From f1c6265fea6c1f95289697bb765543f4ab16dad6 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:35:40 +0100 Subject: [PATCH 64/96] Replace getRoutes with dispatcher --- src/server/plugins/engine/validationHelpers.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/server/plugins/engine/validationHelpers.ts b/src/server/plugins/engine/validationHelpers.ts index c2c1d8930..268cf5792 100644 --- a/src/server/plugins/engine/validationHelpers.ts +++ b/src/server/plugins/engine/validationHelpers.ts @@ -1,20 +1,30 @@ +import { type ResponseObject } from '@hapi/hapi' + import * as Components from '~/src/server/plugins/engine/components/index.js' +import { + type FormRequestPayload, + type FormResponseToolkit +} from '~/src/server/plugins/engine/types/index.js' +import { type ExternalArgs } from '~/src/server/plugins/engine/types.js' // Type guard for ExternalComponent export function isExternalComponent( component: unknown ): component is ExternalComponent { - return typeof (component as ExternalComponent).getRoutes === 'function' + return typeof (component as ExternalComponent).dispatcher === 'function' } -// External components are guaranteed to have getRoutes +// External components are guaranteed to have a dispatcher method export interface ExternalComponent { - getRoutes(): { routes: unknown[]; entrypoint: string } + dispatcher( + request: FormRequestPayload, + h: FormResponseToolkit, + args: ExternalArgs + ): ResponseObject } /** * Returns internal and external components from a componentMap, regardless of error state. - * @param componentMap - Map of component names to component instances * @returns An object containing internalComponents and externalComponents arrays */ export function getComponentsByType(): { From d40706fccffdcc86d6b5afedca7fbd375de7af22 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:35:51 +0100 Subject: [PATCH 65/96] Remove CustomerReferenceField --- src/server/plugins/engine/components/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/plugins/engine/components/index.ts b/src/server/plugins/engine/components/index.ts index 611105632..8b6d001b7 100644 --- a/src/server/plugins/engine/components/index.ts +++ b/src/server/plugins/engine/components/index.ts @@ -23,4 +23,3 @@ 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 { CustomerReferenceField } from '~/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.js' From 53af5ab4e6a5e04184f2e02439f6fd5d9c1ac04f Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:36:11 +0100 Subject: [PATCH 66/96] Add dispatcher to UKAddressField --- .../engine/components/UkAddressField.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 34153a2ff..53bc15274 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -8,14 +8,20 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' +import { + type FormRequestPayload, + type FormResponseToolkit +} from '~/src/server/plugins/engine/types/index.js' import { type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, - type FormSubmissionState + type FormSubmissionState, + type PostcodeLookupExternalArgs } from '~/src/server/plugins/engine/types.js' +import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' export class UkAddressField extends FormComponent { declare options: UkAddressFieldComponent['options'] @@ -249,6 +255,23 @@ export class UkAddressField extends FormComponent { TextField.isText(value.postcode) ) } + + static dispatcher( + request: FormRequestPayload, + h: FormResponseToolkit, + args: PostcodeLookupExternalArgs + ) { + const { controller, component } = args + + return dispatch(request, h, { + formName: controller.model.name, + componentName: component.name, + componentHint: component.hint, + componentTitle: component.title || controller.title, + step: args.actionArgs.step, + sourceUrl: args.sourceUrl + }) + } } export interface UkAddressState extends Record { From 5a2d3cfdd6b1c6a73b21837967286bc895a886b7 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:37:04 +0100 Subject: [PATCH 67/96] Remove CustomerReferenceField --- src/server/plugins/engine/components/helpers/components.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index b5d0dd926..8fa55556d 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -29,7 +29,6 @@ export type Field = InstanceType< | typeof Components.TextField | typeof Components.UkAddressField | typeof Components.FileUploadField - | typeof Components.CustomerReferenceField > // Guidance component instances only From 06aae72da9395e9fb9331da6f4b71527e1851538 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:37:30 +0100 Subject: [PATCH 68/96] Update external action attribute name --- .../plugins/engine/views/components/ukaddressfield.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index 41f473d75..7e2e45176 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -42,7 +42,7 @@

+ value="external-{{component.model.name}}">Use a different address

{% endset %} @@ -56,11 +56,11 @@ text: "Find an address", attributes: { name: "action", - value: "external-postcode-lookup--name:" + component.model.name + value: "external-" + component.model.name }, classes: "govuk-button--secondary govuk-!-margin-right-1" }) }} -

or

+

or

{% endif %} {% endif %} From 2e35d3abc660d09de40c84cb1aff353bb2a2b407 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:37:49 +0100 Subject: [PATCH 69/96] Remove payload from PostcodeLookupDispatchData --- src/server/plugins/postcode-lookup/types.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index ae876b1f8..49ca24a11 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -18,8 +18,7 @@ * componentName: string * componentTitle: string, * componentHint?: string - * step?: string, - * payload: FormPayload + * step?: string * }} PostcodeLookupDispatchData */ From c6533af0a674bf911a6994d4072e752416ca10ba Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:38:21 +0100 Subject: [PATCH 70/96] Flash external component state --- .../plugins/postcode-lookup/routes/index.js | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 683051763..0315faa10 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -2,7 +2,7 @@ import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' -import { getCacheService } from '~/src/server/plugins/engine/helpers.js' +import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js' import { JOURNEY_BASE_URL, detailsPayloadSchema, @@ -36,34 +36,30 @@ function getSessionState(request) { } /** - * Update form component state + * Flash form component state * @param {PostcodeLookupRequest} request - the request * @param {string} componentName - the component name * @param {Address | PostcodeLookupManualPayload} address - the address from ordnance survey or manually entered */ -async function updateComponentState(request, componentName, address) { - // TODO: Set state another way +function flashComponentState(request, componentName, address) { const addressState = { - [`${componentName}__addressLine1`]: address.addressLine1, - [`${componentName}__addressLine2`]: address.addressLine2, - [`${componentName}__town`]: address.town, - [`${componentName}__county`]: address.county, - [`${componentName}__postcode`]: address.postcode + addressLine1: address.addressLine1, + addressLine2: address.addressLine2, + town: address.town, + county: address.county, + postcode: address.postcode, + uprn: 'uprn' in address && address.uprn ? address.uprn : undefined } - // Assign UPRN if available - if ('uprn' in address && address.uprn) { - addressState[`${componentName}__uprn`] = address.uprn + /** + * @type {ExternalStateAppendage} + */ + const appendage = { + component: componentName, + data: addressState } - const cacheService = getCacheService(request.server) - // @ts-expect-error - Request typing - const state = await cacheService.getState(request) - // @ts-expect-error - Request typing - await cacheService.setState(request, { - ...state, - ...addressState - }) + request.yar.flash(EXTERNAL_STATE_APPENDAGE, appendage, true) } /** @@ -222,7 +218,7 @@ async function selectPostHandler(request, h, options) { } const { componentName, sourceUrl } = session.initial - await updateComponentState(request, componentName, property) + flashComponentState(request, componentName, property) // Redirect back to the source form page return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER) @@ -233,7 +229,7 @@ async function selectPostHandler(request, h, options) { * @param {PostcodeLookupPostRequest} request * @param {ResponseToolkit} h */ -async function manualPostHandler(request, h) { +function manualPostHandler(request, h) { const { payload } = request const session = getSessionState(request) @@ -248,7 +244,7 @@ async function manualPostHandler(request, h) { } const { componentName, sourceUrl } = session.initial - await updateComponentState(request, componentName, manual) + flashComponentState(request, componentName, manual) // Redirect back to the source form page return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER) @@ -258,4 +254,5 @@ async function manualPostHandler(request, h) { * @import { ResponseToolkit, ServerRoute } from '@hapi/hapi' * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupPostRequest, PostcodeLookupConfiguration, PostcodeLookupDispatchData, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js' * @import { FormRequestPayload, FormResponseToolkit } from '~/src/server/routes/types.js' + * @import { ExternalStateAppendage } from '~/src/server/plugins/engine/types.js' */ From b7422e1c930cc15298550738c8805fc013fd8f79 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:40:20 +0100 Subject: [PATCH 71/96] Update to use importExternalComponentState --- src/server/plugins/engine/routes/index.ts | 124 +++++++--------------- 1 file changed, 36 insertions(+), 88 deletions(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 4e850d3e7..bba28c870 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -1,4 +1,3 @@ -import { ComponentType } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, @@ -7,8 +6,15 @@ import { } from '@hapi/hapi' import { isEqual } from 'date-fns' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' -import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { + EXTERNAL_STATE_APPENDAGE, + EXTERNAL_STATE_PAYLOAD, + PREVIEW_PATH_PREFIX +} from '~/src/server/constants.js' +import { + FormComponent, + isFormState +} from '~/src/server/plugins/engine/components/FormComponent.js' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -24,18 +30,14 @@ import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNu import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { type AnyFormRequest, + type ExternalStateAppendage, type FormContext, type FormPayload, type FormSubmissionState, type PluginOptions } from '~/src/server/plugins/engine/types.js' -import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' -import { type PostcodeLookupDispatchArgs } from '~/src/server/plugins/postcode-lookup/types.js' import { - ExternalActions, - FormAction, type FormRequest, - type FormRequestPayload, type FormResponseToolkit } from '~/src/server/routes/types.js' @@ -47,7 +49,7 @@ export async function redirectOrMakeHandler( context: FormContext ) => ResponseObject | Promise ) { - const { app, params, payload } = request + const { app, params } = request const { model } = app if (!model) { @@ -73,14 +75,6 @@ export async function redirectOrMakeHandler( }) } - // External journey redirect - const { action = '' } = page.getFormParams(request) - if (payload && action.startsWith(FormAction.External)) { - const opts = { action, model, payload, page } - - return dispatchExternalHandler(request, h, opts) - } - state = await importExternalComponentState(request, page, state) const flash = cacheService.getFlash(request) @@ -104,72 +98,14 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } -function dispatchExternalHandler( - request: AnyFormRequest, - h: FormResponseToolkit, - options: { - action: string - model: FormModel - payload: FormPayload - page: PageControllerClass - } -) { - const { action, model, payload, page } = options - - // Find the external action and arguments - // `external-{externalAction}--{argname1}:{argvalue1}--{argname2}:{argvalue2}` - // E.g. external-postcode-lookup--name:wDFtgf--step:manual - const externalActionsWithArgs = action - .slice(`${FormAction.External}-`.length) - .split('--') - const externalAction = externalActionsWithArgs[0] as ExternalActions - const externalActionArgs = externalActionsWithArgs - .slice(1) - .map((arg) => arg.split(':')) - - switch (externalAction) { - case ExternalActions.PostcodeLookup: { - const args = Object.fromEntries( - externalActionArgs - ) as PostcodeLookupDispatchArgs - const componentName = args.name - const component = model.componentDefMap.get(componentName) - - if (!component) { - throw Boom.notFound(`No component found for ${componentName}`) - } - - if (component.type !== ComponentType.UkAddressField) { - throw Boom.internal( - `Invalid component type, expected UkAddressFieldComponent got ${component.type}` - ) - } - - return dispatch(request as FormRequestPayload, h, { - payload, - formName: model.name, - componentName, - componentHint: component.hint, - componentTitle: component.title || page.title, - step: args.step, - sourceUrl: request.url.toString() - }) - } - default: - throw Boom.internal( - `Invalid external action, expected one of '${Object.values(ExternalActions).join('|')}' got '${externalAction}'` - ) - } -} - -function importExternalComponentState( +async function importExternalComponentState( request: AnyFormRequest, page: PageControllerClass, state: FormSubmissionState ): Promise { - const externalComponentData = request.yar.flash('externalStateAppendage')[0] + const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE) - if (!externalComponentData) { + if (Array.isArray(externalComponentData)) { return Promise.resolve(state) } @@ -177,16 +113,14 @@ function importExternalComponentState( let stateAppendage try { - const parsedStateAppendage = JSON.parse(externalComponentData) + const parsedStateAppendage = externalComponentData as ExternalStateAppendage componentName = parsedStateAppendage.component stateAppendage = parsedStateAppendage.data - } catch (e) { - request.server.logger.error( - e, - 'Error parsing external component state JSON' - ) - throw new Error('Error parsing external component state JSON') + } catch (err) { + request.logger.error(err, 'Error parsing external component state JSON') + + throw new Error('Error parsing external component state') } const component = request.app.model?.componentMap.get(componentName) @@ -207,9 +141,23 @@ function importExternalComponentState( throw new Error(`State for component ${componentName} is invalid`) } - return page.mergeState(request, state, { - ...(stateAppendage ? { [componentName]: stateAppendage } : {}) - }) + // TODO: A better way? + // const componentState = component.getStateFromValidForm(stateAppendage) + const componentState = isFormState(stateAppendage) + ? Object.fromEntries( + Object.entries(stateAppendage).map(([key, value]) => [ + `${componentName}__${key}`, + value + ]) + ) + : { [componentName]: stateAppendage } + + // Save the component state + const updatedState = await page.mergeState(request, state, componentState) + const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD) + const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload) + + return { ...updatedState, ...stashedPayload } } export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { From 5793ec6d0132818c5a52c7b113680efed8bc453f Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 10:44:47 +0100 Subject: [PATCH 72/96] Dispatch to external journey --- .../pageControllers/QuestionPageController.ts | 86 ++++++++++++------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 30fa49d44..77d0dbd3e 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -12,6 +12,7 @@ import Boom from '@hapi/boom' import { type RouteOptions } from '@hapi/hapi' import { type ValidationErrorItem } from 'joi' +import { EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' @@ -496,14 +497,15 @@ export class QuestionPageController extends PageController { const action = request.payload.action ?? '' + if (action && action.startsWith(FormAction.External)) { + return this.dispatchExternal(request, h) + } + /** * If there are any errors, render the page with the parsed errors * @todo Refactor to match POST REDIRECT GET pattern */ - if ( - (!action.startsWith('external-component-edit') && context.errors) || // ensure that normal components still pass - isForceAccess - ) { + if (context.errors || isForceAccess) { const viewModel = this.getViewModel(request, context) viewModel.errors = collection.getViewErrors(viewModel.errors) @@ -520,32 +522,6 @@ export class QuestionPageController extends PageController { // Save state await this.setState(request, state) - if (action && action.startsWith('external-component-edit-')) { - const { externalComponents } = getComponentsByType() - - const componentName = action.split('external-component-edit-')[1] - - const component = model.componentDefMap.get(componentName) - const componentType = component?.type - - if (!componentType) { - throw Boom.internal( - `External component of type ${componentType} not found` - ) - } - - const selectedComponent = externalComponents.get(componentType) - - if (!selectedComponent) { - throw Boom.internal(`External component ${componentName} not found`) - } - - const { entrypoint } = selectedComponent.getRoutes() - return h.redirect( - `${entrypoint}?component=${componentName}&returnUrl=${encodeURI(`${request.url.origin}${request.url.pathname}`)}` - ) - } - // Check if this is a save-and-exit action if (action === FormAction.SaveAndExit) { return this.handleSaveAndExit(request, context, h) @@ -556,6 +532,56 @@ export class QuestionPageController extends PageController { } } + private dispatchExternal( + request: FormRequestPayload, + h: FormResponseToolkit + ) { + const { externalComponents } = getComponentsByType() + const action = request.payload.action ?? '' + + // Find the external action and arguments + // `external-{componentName}--{argname1}:{argvalue1}--{argname2}:{argvalue2}` + // E.g. external-abcdef--amount:10--step:manual + const externalActionsWithArgs = action + .slice(`${FormAction.External}-`.length) + .split('--') + + const externalActionArgs = externalActionsWithArgs + .slice(1) + .map((arg) => arg.split(':')) + + const args = Object.fromEntries(externalActionArgs) as Record< + string, + string + > + + const componentName = externalActionsWithArgs[0] + const component = this.model.componentDefMap.get(componentName) + const componentType = component?.type + + if (!componentType) { + throw Boom.internal( + `External component of type ${componentType} not found` + ) + } + + const selectedComponent = externalComponents.get(componentType) + + if (!selectedComponent) { + throw Boom.internal(`External component ${componentName} not found`) + } + + // Stash payload + request.yar.flash(EXTERNAL_STATE_PAYLOAD, request.payload, true) + + return selectedComponent.dispatcher(request, h, { + component, + controller: this, + sourceUrl: request.url.toString(), + actionArgs: args + }) + } + proceed( request: FormContextRequest, h: FormResponseToolkit, From 5f1f320e597eb488a4c253cd45666899bee68d7d Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 11:09:09 +0100 Subject: [PATCH 73/96] Sonar fixes (Remove unnecessary Promise.resolve) --- src/server/plugins/engine/routes/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index bba28c870..e6f96e2b9 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -106,7 +106,7 @@ async function importExternalComponentState( const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE) if (Array.isArray(externalComponentData)) { - return Promise.resolve(state) + return state } let componentName From 3e1211e89a8b4e897287ce49407dda23c5632bfa Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 11:10:56 +0100 Subject: [PATCH 74/96] Sonar fixes (Prefer using an optional chaining) --- .../plugins/engine/pageControllers/QuestionPageController.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 77d0dbd3e..f60d61f8c 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -494,10 +494,9 @@ export class QuestionPageController extends PageController { ) => { const { collection, viewName, model } = this const { isForceAccess, state, evaluationState } = context + const action = request.payload.action - const action = request.payload.action ?? '' - - if (action && action.startsWith(FormAction.External)) { + if (action?.startsWith(FormAction.External)) { return this.dispatchExternal(request, h) } From 6731643b5f6dd2d422cc3ad8937b35cad7deb58e Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 11:18:29 +0100 Subject: [PATCH 75/96] Fix postcode tests --- test/form/postcode-lookup.test.js | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js index 42b31b383..8de00272a 100644 --- a/test/form/postcode-lookup.test.js +++ b/test/form/postcode-lookup.test.js @@ -64,9 +64,7 @@ describe('Postcode lookup form pages', () => { expect($actionButton).toBeInTheDocument() expect($actionButton.getAttribute('name')).toBe('action') - expect($actionButton.getAttribute('value')).toBe( - 'external-postcode-lookup--name:ybMHIv' - ) + expect($actionButton.getAttribute('value')).toBe('external-ybMHIv') const $manualButton = container.getByRole('button', { name: 'enter address manually' @@ -75,7 +73,7 @@ describe('Postcode lookup form pages', () => { expect($manualButton).toBeInTheDocument() expect($manualButton.getAttribute('name')).toBe('action') expect($manualButton.getAttribute('value')).toBe( - 'external-postcode-lookup--name:ybMHIv--step:manual' + 'external-ybMHIv--step:manual' ) }) @@ -83,7 +81,7 @@ describe('Postcode lookup form pages', () => { let { csrfToken, response, headers } = await initialiseJourney(server) const payload = { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } @@ -102,7 +100,7 @@ describe('Postcode lookup form pages', () => { let { csrfToken, response, headers } = await initialiseJourney(server) const payload = { - action: 'external-postcode-lookup--name:ybMHIv--step:manual', + action: 'external-ybMHIv--step:manual', crumb: csrfToken } @@ -122,7 +120,7 @@ describe('Postcode lookup form pages', () => { // Dispatch to postcode journey const payload = { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } @@ -152,7 +150,7 @@ describe('Postcode lookup form pages', () => { // Dispatch to postcode journey const payload = { - action: 'external-postcode-lookup--name:ybMHIv--step:manual', + action: 'external-ybMHIv--step:manual', crumb: csrfToken } @@ -186,7 +184,7 @@ describe('Postcode lookup form pages', () => { method: 'POST', headers, payload: { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } }) @@ -249,7 +247,7 @@ describe('Postcode lookup form pages', () => { method: 'POST', headers, payload: { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } }) @@ -306,7 +304,7 @@ describe('Postcode lookup form pages', () => { method: 'POST', headers, payload: { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } }) @@ -361,7 +359,7 @@ describe('Postcode lookup form pages', () => { method: 'POST', headers, payload: { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } }) @@ -414,7 +412,7 @@ describe('Postcode lookup form pages', () => { method: 'POST', headers, payload: { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } }) @@ -458,7 +456,7 @@ describe('Postcode lookup form pages', () => { method: 'POST', headers, payload: { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } }) @@ -492,7 +490,7 @@ describe('Postcode lookup form pages', () => { method: 'POST', headers, payload: { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } }) @@ -531,7 +529,7 @@ describe('Postcode lookup form pages', () => { method: 'POST', headers, payload: { - action: 'external-postcode-lookup--name:ybMHIv', + action: 'external-ybMHIv', crumb: csrfToken } }) From ab0c861bba2d4340a17b352d218ffde77438bb75 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 11:40:40 +0100 Subject: [PATCH 76/96] Update unit tests --- test/form/postcode-lookup.test.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js index 8de00272a..2eb1e43bd 100644 --- a/test/form/postcode-lookup.test.js +++ b/test/form/postcode-lookup.test.js @@ -417,7 +417,7 @@ describe('Postcode lookup form pages', () => { } }) - const { response } = await renderResponse(server, { + let { response } = await renderResponse(server, { url: '/postcode-lookup', method: 'POST', headers, @@ -430,6 +430,13 @@ describe('Postcode lookup form pages', () => { expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) expect(response.headers.location).toEndWith('/address') + + // Follow the redirect back to the source + // page to exercise `importExternalComponentState` + response = await server.inject({ + url: `${basePath}/address`, + headers + }) }) it('should render validation errors after POST when no address is selected', async () => { @@ -534,7 +541,7 @@ describe('Postcode lookup form pages', () => { } }) - const { response } = await renderResponse(server, { + let { response } = await renderResponse(server, { url: '/postcode-lookup?step=manual', method: 'POST', headers, @@ -551,6 +558,13 @@ describe('Postcode lookup form pages', () => { expect(response.statusCode).toBe(StatusCodes.SEE_OTHER) expect(response.headers.location).toEndWith('/address') + + // Follow the redirect back to the source + // page to exercise `importExternalComponentState` + response = await server.inject({ + url: `${basePath}/address`, + headers + }) }) }) From 025916575a17c08b3d4a36349aa34a0beb009a8e Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 11:41:48 +0100 Subject: [PATCH 77/96] Add assertion in postocde test --- test/form/postcode-lookup.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js index 2eb1e43bd..c62c2c79b 100644 --- a/test/form/postcode-lookup.test.js +++ b/test/form/postcode-lookup.test.js @@ -437,6 +437,8 @@ describe('Postcode lookup form pages', () => { url: `${basePath}/address`, headers }) + + expect(response.statusCode).toBe(StatusCodes.OK) }) it('should render validation errors after POST when no address is selected', async () => { @@ -565,6 +567,8 @@ describe('Postcode lookup form pages', () => { url: `${basePath}/address`, headers }) + + expect(response.statusCode).toBe(StatusCodes.OK) }) }) From 87a48c31c4c760f2a9fc37b80e44283cd35c76ed Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 11:56:29 +0100 Subject: [PATCH 78/96] Stash payload without crumb --- .../plugins/engine/pageControllers/QuestionPageController.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index f60d61f8c..dd8f5c04a 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -570,8 +570,9 @@ export class QuestionPageController extends PageController { throw Boom.internal(`External component ${componentName} not found`) } - // Stash payload - request.yar.flash(EXTERNAL_STATE_PAYLOAD, request.payload, true) + // Stash payload without crumb + const stashedPayload = { ...request.payload, crumb: undefined } + request.yar.flash(EXTERNAL_STATE_PAYLOAD, stashedPayload, true) return selectedComponent.dispatcher(request, h, { component, From 16a2b7f05817fcf98b6f98b737f298a9491ac638 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 12:34:19 +0100 Subject: [PATCH 79/96] Stash the payload from context not the request --- .../pageControllers/QuestionPageController.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index dd8f5c04a..20ef943ae 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -12,7 +12,10 @@ import Boom from '@hapi/boom' import { type RouteOptions } from '@hapi/hapi' import { type ValidationErrorItem } from 'joi' -import { EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' +import { + EXTERNAL_STATE_APPENDAGE, + EXTERNAL_STATE_PAYLOAD +} from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' @@ -497,7 +500,7 @@ export class QuestionPageController extends PageController { const action = request.payload.action if (action?.startsWith(FormAction.External)) { - return this.dispatchExternal(request, h) + return this.dispatchExternal(request, h, context) } /** @@ -533,7 +536,8 @@ export class QuestionPageController extends PageController { private dispatchExternal( request: FormRequestPayload, - h: FormResponseToolkit + h: FormResponseToolkit, + context: FormContext ) { const { externalComponents } = getComponentsByType() const action = request.payload.action ?? '' @@ -570,10 +574,17 @@ export class QuestionPageController extends PageController { throw Boom.internal(`External component ${componentName} not found`) } - // Stash payload without crumb - const stashedPayload = { ...request.payload, crumb: undefined } + // Stash payload without crumb and action + const stashedPayload = { + ...context.payload, + crumb: undefined, + action: undefined + } request.yar.flash(EXTERNAL_STATE_PAYLOAD, stashedPayload, true) + // Clear any previous state appendage + request.yar.clear(EXTERNAL_STATE_APPENDAGE) + return selectedComponent.dispatcher(request, h, { component, controller: this, From 00611491e3f531acbbe7fb0895689ca11251aeb3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 12:34:40 +0100 Subject: [PATCH 80/96] Remove unnecessary try-catch --- src/server/plugins/engine/routes/index.ts | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index e6f96e2b9..e9d82d5ea 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -109,20 +109,9 @@ async function importExternalComponentState( return state } - let componentName - let stateAppendage - - try { - const parsedStateAppendage = externalComponentData as ExternalStateAppendage - - componentName = parsedStateAppendage.component - stateAppendage = parsedStateAppendage.data - } catch (err) { - request.logger.error(err, 'Error parsing external component state JSON') - - throw new Error('Error parsing external component state') - } - + const typedStateAppendage = externalComponentData as ExternalStateAppendage + const componentName = typedStateAppendage.component + const stateAppendage = typedStateAppendage.data const component = request.app.model?.componentMap.get(componentName) if (!component) { @@ -157,7 +146,7 @@ async function importExternalComponentState( const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD) const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload) - return { ...updatedState, ...stashedPayload } + return { ...stashedPayload, ...updatedState } } export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { From f268521d7a0b3f8136367266ea2fd806e96d511f Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 13:02:13 +0100 Subject: [PATCH 81/96] Update docs --- src/server/plugins/engine/routes/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index e9d82d5ea..99a567332 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -141,8 +141,10 @@ async function importExternalComponentState( ) : { [componentName]: stateAppendage } - // Save the component state + // Save the external component state immediately const updatedState = await page.mergeState(request, state, componentState) + + // Merge the stashed payload into the local state const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD) const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload) From b46b9121b74cfc905513e5e2615291e5cdca6adf Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 14:01:57 +0100 Subject: [PATCH 82/96] Form group errors for UKAddress when using postcode lookup --- .../views/components/ukaddressfield.html | 72 ++++++++++--------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index 7e2e45176..a39c96fc1 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -25,43 +25,45 @@ {% set addressFieldHtml = addressHintHtml + addressFieldHtml %} {% endif %} - {{ govukFieldset({ - legend: fieldset.legend, - attributes: fieldset.attributes, - html: addressFieldHtml - }) if fieldset else addressFieldHtml }} +
+ {{ govukFieldset({ + legend: fieldset.legend, + attributes: fieldset.attributes, + html: addressFieldHtml + }) if fieldset else addressFieldHtml }} - {% if usePostcodeLookup %} - {% set value = component.model.value %} + {% if usePostcodeLookup %} + {% set value = component.model.value %} - {% if value %} - {% set insetHtml %} - Selected address: -

- {{ value }} -

-

- -

- {% endset %} + {% if value %} + {% set insetHtml %} + Selected address: +

+ {{ value }} +

+

+ +

+ {% endset %} - {{ govukInsetText({ - html: insetHtml, - classes: "govuk-!-margin-top-2" - }) }} - {% else %} -
- {{ govukButton({ - text: "Find an address", - attributes: { - name: "action", - value: "external-" + component.model.name - }, - classes: "govuk-button--secondary govuk-!-margin-right-1" - }) }} -

or

-
+ {{ govukInsetText({ + html: insetHtml, + classes: "govuk-!-margin-top-2" + }) }} + {% else %} +
+ {{ govukButton({ + text: "Find an address", + attributes: { + name: "action", + value: "external-" + component.model.name + }, + classes: "govuk-button--secondary govuk-!-margin-right-1 govuk-!-margin-bottom-0" + }) }} +

or

+
+ {% endif %} {% endif %} - {% endif %} +
{% endmacro %} From ca9ce60c5332ab946972d4d2f693d47ef8f8a679 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 14:11:42 +0100 Subject: [PATCH 83/96] Allow ordnanceSurveyApiKey config to be undefined --- src/config/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 302e224f5..96fe12dac 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -258,9 +258,9 @@ export const config = convict({ doc: 'The ordnance survey api key use by the postcode lookup plugin', format: String, nullable: true, - default: '', + default: undefined, env: 'ORDNANCE_SURVEY_API_KEY' - } as SchemaObj + } as SchemaObj }) config.validate({ allowed: 'strict' }) From 54b3f7eb538ad3401de272cc88b0c2c782dc8517 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 14:12:22 +0100 Subject: [PATCH 84/96] Change Boom type --- src/server/plugins/postcode-lookup/routes/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 0315faa10..9dc29236f 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -29,7 +29,7 @@ function getSessionState(request) { const data = request.yar.get(JOURNEY_BASE_URL) if (!data) { - throw Boom.notFound(`No data found for ${JOURNEY_BASE_URL}`) + throw Boom.internal(`No postcode lookup data found for ${JOURNEY_BASE_URL}`) } return data From be7eca3fdbd8b85686337ea6b31db4492bf1771b Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 14:12:42 +0100 Subject: [PATCH 85/96] Rename variable --- src/server/plugins/postcode-lookup/routes/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 9dc29236f..7252e5fec 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -26,13 +26,13 @@ function getSessionState(request) { /** * @type {PostcodeLookupSessionData | undefined} */ - const data = request.yar.get(JOURNEY_BASE_URL) + const state = request.yar.get(JOURNEY_BASE_URL) - if (!data) { + if (!state) { throw Boom.internal(`No postcode lookup data found for ${JOURNEY_BASE_URL}`) } - return data + return state } /** From f2dfbe8026a6d006427c9e98ab708cd30d6903d9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 14:18:43 +0100 Subject: [PATCH 86/96] Sonar fixes (use TypeError) --- src/server/plugins/engine/routes/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 99a567332..6194b40c5 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -119,7 +119,7 @@ async function importExternalComponentState( } if (!(component instanceof FormComponent)) { - throw new Error( + throw new TypeError( `Component ${componentName} is not a FormComponent and does not support isState` ) } From 34f0fce1087e5b501a176253433d4f635e92b82a Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 16:44:25 +0100 Subject: [PATCH 87/96] Remove TODO --- src/server/plugins/engine/routes/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 6194b40c5..5cbd27bde 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -130,8 +130,6 @@ async function importExternalComponentState( throw new Error(`State for component ${componentName} is invalid`) } - // TODO: A better way? - // const componentState = component.getStateFromValidForm(stateAppendage) const componentState = isFormState(stateAppendage) ? Object.fromEntries( Object.entries(stateAppendage).map(([key, value]) => [ From aae6b68fab2011adb1a90d4fd299e107f045683c Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 16:46:17 +0100 Subject: [PATCH 88/96] Document address formatting --- src/server/plugins/postcode-lookup/service.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index 1b5a80b8f..d8614bcd1 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -110,6 +110,7 @@ export async function search(postcodeQuery, buildingNameQuery, apiKey) { /** * Converts a delivery point address to an address + * Taken from http://github.com/dwp/find-an-address-plugin/blob/main/utils/getData.js * @param {DeliveryPointAddress} dpa */ function formatAddress(dpa) { From 72e1869783f1dbb4c7769052c45fb87cc736521a Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 16 Oct 2025 16:58:45 +0100 Subject: [PATCH 89/96] Update register unicorn address page --- .../forms/register-as-a-unicorn-breeder.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index e09138f23..e60c78c79 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -60,21 +60,21 @@ pages: type: UkAddressField title: What is your billing address schema: {} - hint: This is a UK address. Users must enter address line 1, town and a postcode - - name: uvBxTz - options: - required: true - type: EmailAddressField - title: What is your email adress + hint: This is a UK billing address. Users must enter address line 1, town and a postcode + - name: dfTGhD + options: {} schema: {} - hint: This is an email address. An email address must contain an at sign @ + type: MultilineTextField + title: Delivery notes + hint: + Enter some instructions for the delivery person - name: drGHuj options: required: true type: UkAddressField title: What is your delivery address schema: {} - hint: This is a UK address. Users must enter address line 1, town and a postcode + hint: This is a UK delivery address. Users must enter address line 1, town and a postcode next: - path: '/do-you-want-your-unicorn-breeder-certificate-sent-to-this-address' section: section From 9221aa2ebf695da1d90b266c27a9c1fbb2236154 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 17 Oct 2025 12:03:44 +0100 Subject: [PATCH 90/96] Remove SASS warning --- src/client/stylesheets/application.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/stylesheets/application.scss b/src/client/stylesheets/application.scss index de7abe1d0..822869f24 100644 --- a/src/client/stylesheets/application.scss +++ b/src/client/stylesheets/application.scss @@ -20,9 +20,9 @@ .govuk-button--link { @extend %govuk-link; - @include govuk-font($size: 19); color: $govuk-link-colour; border: none; cursor: pointer; background-color: transparent; + @include govuk-font($size: 19); } From 36c4def50669130d69c5edac04a21231236f3cf1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 17 Oct 2025 13:02:58 +0100 Subject: [PATCH 91/96] Reduce errors to a single validation message when using postcode lookup --- .../forms/register-as-a-unicorn-breeder.yaml | 1 + .../engine/components/UkAddressField.ts | 40 ++++++++++++++++--- .../views/components/ukaddressfield.html | 2 +- test/form/postcode-lookup.test.js | 28 +++++++++++++ 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index e60c78c79..3a7a74760 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -59,6 +59,7 @@ pages: usePostcodeLookup: true type: UkAddressField title: What is your billing address + shortDescription: Billing address schema: {} hint: This is a UK billing address. Users must enter address line 1, town and a postcode - name: dfTGhD diff --git a/src/server/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 53bc15274..66eb2aebf 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -1,5 +1,10 @@ -import { ComponentType, type UkAddressFieldComponent } from '@defra/forms-model' +import { + ComponentType, + type FormComponentsDef, + type UkAddressFieldComponent +} from '@defra/forms-model' import { type ObjectSchema } from 'joi' +import lowerFirst from 'lodash/lowerFirst.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { @@ -29,13 +34,15 @@ export class UkAddressField extends FormComponent { declare stateSchema: ObjectSchema declare collection: ComponentCollection + shortDescription: FormComponentsDef['shortDescription'] + constructor( def: UkAddressFieldComponent, props: ConstructorParameters[1] ) { super(def, props) - const { name, options } = def + const { name, options, shortDescription } = def const isRequired = options.required !== false const hideOptional = !!options.optionalText @@ -119,6 +126,7 @@ export class UkAddressField extends FormComponent { this.options = options this.formSchema = this.collection.formSchema this.stateSchema = this.collection.stateSchema + this.shortDescription = shortDescription } getFormValueFromState(state: FormSubmissionState) { @@ -158,10 +166,27 @@ export class UkAddressField extends FormComponent { getViewErrors( errors?: FormSubmissionError[] ): FormSubmissionError[] | undefined { - return this.getErrors(errors)?.filter( + const uniqueErrors = this.getErrors(errors)?.filter( (error, index, self) => index === self.findIndex((err) => err.name === error.name) ) + + // When using postcode lookup, the address fields are hidden + // so we replace any individual validation messages with a single one + if (this.shouldUsePostcodeLookup() && uniqueErrors?.length) { + const { name, shortDescription } = this + + return [ + { + name, + path: [name], + href: `#${name}`, + text: `Enter ${lowerFirst(shortDescription)}` + } + ] + } + + return uniqueErrors } getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { @@ -203,9 +228,8 @@ export class UkAddressField extends FormComponent { uprn.model.formGroup = { classes: 'app-hidden' } // Postcode lookup - const usePostcodeLookup = !!( - this.options.usePostcodeLookup && this.model.ordnanceSurveyApiKey - ) + const usePostcodeLookup = this.shouldUsePostcodeLookup() + const value = usePostcodeLookup ? this.getDisplayStringFromState(payload) : undefined @@ -230,6 +254,10 @@ export class UkAddressField extends FormComponent { return UkAddressField.getAllPossibleErrors() } + private shouldUsePostcodeLookup() { + return !!(this.options.usePostcodeLookup && this.model.ordnanceSurveyApiKey) + } + /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ diff --git a/src/server/plugins/engine/views/components/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index a39c96fc1..e2aabbe7e 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -25,7 +25,7 @@ {% set addressFieldHtml = addressHintHtml + addressFieldHtml %} {% endif %} -
+
{{ govukFieldset({ legend: fieldset.legend, attributes: fieldset.attributes, diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js index c62c2c79b..f6b433099 100644 --- a/test/form/postcode-lookup.test.js +++ b/test/form/postcode-lookup.test.js @@ -77,6 +77,34 @@ describe('Postcode lookup form pages', () => { ) }) + it('should return a single validation message', async () => { + const { csrfToken, headers } = await initialiseJourney(server) + + const payload = { + crumb: csrfToken + } + + const { response, container } = await renderResponse(server, { + url: `${basePath}/address`, + method: 'POST', + headers, + payload + }) + + expect(response.statusCode).toBe(StatusCodes.OK) + const $errorSummary = container.getByRole('alert') + + const $heading = within($errorSummary).getByRole('heading', { + name: 'There is a problem', + level: 2 + }) + expect($heading).toBeInTheDocument() + + const $errorItems = within($errorSummary).getAllByRole('listitem') + expect($errorItems).toHaveLength(1) + expect($errorItems[0]).toHaveTextContent('Enter address') + }) + it('should dispatch to details page on POST', async () => { let { csrfToken, response, headers } = await initialiseJourney(server) From 31745bbdb8c03430c4e16f73761edf31e6e89837 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 17 Oct 2025 13:03:38 +0100 Subject: [PATCH 92/96] Formatting --- test/form/postcode-lookup.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/form/postcode-lookup.test.js b/test/form/postcode-lookup.test.js index f6b433099..cb1570564 100644 --- a/test/form/postcode-lookup.test.js +++ b/test/form/postcode-lookup.test.js @@ -92,8 +92,8 @@ describe('Postcode lookup form pages', () => { }) expect(response.statusCode).toBe(StatusCodes.OK) - const $errorSummary = container.getByRole('alert') + const $errorSummary = container.getByRole('alert') const $heading = within($errorSummary).getByRole('heading', { name: 'There is a problem', level: 2 From 8bf6d8cea230d97b5afe9ea9e79cbd60cf702764 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 17 Oct 2025 13:36:52 +0100 Subject: [PATCH 93/96] Remove CustomerReferenceField from components.json --- src/server/forms/components.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/server/forms/components.json b/src/server/forms/components.json index 8a1c5dcaa..ee9bbe185 100644 --- a/src/server/forms/components.json +++ b/src/server/forms/components.json @@ -14,14 +14,6 @@ "options": {}, "schema": {} }, - { - "type": "CustomerReferenceField", - "name": "customerReferenceNumber", - "title": "Customer reference number", - "hint": "Help text", - "options": {}, - "schema": {} - }, { "type": "MultilineTextField", "name": "multilineTextField", From 53f02613b6825dd167958843b5605ba3ff3f6276 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 20 Oct 2025 12:23:17 +0100 Subject: [PATCH 94/96] Ensure full valid postcode query --- src/server/plugins/postcode-lookup/models/index.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/postcode-lookup/models/index.js b/src/server/plugins/postcode-lookup/models/index.js index 6f0a6f240..34593c18a 100644 --- a/src/server/plugins/postcode-lookup/models/index.js +++ b/src/server/plugins/postcode-lookup/models/index.js @@ -298,9 +298,15 @@ const sharedPayloadSchemaKeys = { export const detailsPayloadSchema = Joi.object() .keys({ ...sharedPayloadSchemaKeys, - [postcodeQueryFieldName]: Joi.string().trim().required().messages({ - '*': 'Enter a postcode' - }), + [postcodeQueryFieldName]: Joi.string() + .pattern(/^[a-zA-Z]{1,2}\d[a-zA-Z\d]?\s?\d[a-zA-Z]{2}$/) + .trim() + .required() + .messages({ + 'string.pattern.base': + 'Enter a valid postcode or enter an address manually', + '*': 'Enter a postcode' + }), [buildingNameQueryFieldName]: Joi.string() .trim() .required() From fe5cc301fead25b626ca8ad465f6ff8b9a5b7759 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 20 Oct 2025 12:31:24 +0100 Subject: [PATCH 95/96] Fix linting --- src/server/plugins/engine/routes/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index d2b4e0176..e4e765706 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -32,6 +32,9 @@ import { type AnyFormRequest, type ExternalStateAppendage, type FormContext, + type FormPayload, + type FormSubmissionState, + type OnRequestCallback, type PluginOptions } from '~/src/server/plugins/engine/types.js' import { From e868eacf517d5112dfc8ca4a28f0ffe3a0fa1143 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 20 Oct 2025 12:45:56 +0100 Subject: [PATCH 96/96] Fix tests --- src/server/plugins/engine/routes/index.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/plugins/engine/routes/index.test.ts b/src/server/plugins/engine/routes/index.test.ts index 826ccef48..45cb49128 100644 --- a/src/server/plugins/engine/routes/index.test.ts +++ b/src/server/plugins/engine/routes/index.test.ts @@ -25,6 +25,7 @@ describe('redirectOrMakeHandler', () => { const mockRequest: AnyFormRequest = { server: mockServer, app: {}, + yar: { flash: () => [] }, params: { path: 'test-path' }, query: {} } as unknown as AnyFormRequest