From 9b5e0c153aeb1dc1ec429db14912971bc38f0ac1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 6 Oct 2025 16:46:58 +0100 Subject: [PATCH 01/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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 13e8909c162adeedbb1361727d4683455dc20834 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 15 Oct 2025 16:08:00 +0100 Subject: [PATCH 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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 16/18] 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 17/18] 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 18/18] 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,