From 9b5e0c153aeb1dc1ec429db14912971bc38f0ac1 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Mon, 6 Oct 2025 16:46:58 +0100 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 bfa5e2f65850b517715ec866da2b07abed874160 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 15 Oct 2025 14:12:02 +0100 Subject: [PATCH 7/8] Refactor to support component collections --- src/server/forms/external-components.json | 16 ++- .../CustomerReferenceField.ts | 100 ++++++++++++------ .../CustomerReferenceField/routes.ts | 10 +- src/server/plugins/engine/routes/index.ts | 47 +++++--- 4 files changed, 121 insertions(+), 52 deletions(-) diff --git a/src/server/forms/external-components.json b/src/server/forms/external-components.json index 725b2b1a2..5cb986a19 100644 --- a/src/server/forms/external-components.json +++ b/src/server/forms/external-components.json @@ -1,8 +1,22 @@ { "conditions": [], "engine": "V2", - "startPage": "/all-components", "pages": [ + { + "path": "/address", + "title": "Your address", + "components": [ + { + "type": "UkAddressField", + "name": "applicantAddress", + "title": "What's your address?", + "shortDescription": "Your address", + "hint": "", + "options": {}, + "schema": {} + } + ] + }, { "path": "/all-components", "title": "Details about you", diff --git a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts index 0c2f5f14e..ca152da3c 100644 --- a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts +++ b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts @@ -1,15 +1,22 @@ -import { type FormComponentsDef } from '@defra/forms-model' +import { ComponentType, type FormComponentsDef } from '@defra/forms-model' import joi, { type ObjectSchema } from 'joi' +import { ComponentCollection } from '../ComponentCollection.js' +import { TextField } from '../TextField.js' + import { getRoutes } from '~/src/server/plugins/engine/components/CustomerReferenceField/routes.js' -import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' +import { + FormComponent, + isFormState +} 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 + type FormSubmissionError, + type FormSubmissionState } from '~/src/server/plugins/engine/types.js' export class CustomerReferenceField extends FormComponent { @@ -26,24 +33,46 @@ export class CustomerReferenceField extends FormComponent { ) { 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 + // 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 + const { name } = def + this.collection = new ComponentCollection( + [ + { + type: ComponentType.TextField, + name: `${name}__reference`, + title: 'Reference', + schema: {}, + options: {} + }, + { + type: ComponentType.TextField, + name: `${name}___id`, + title: 'ID', + schema: {}, + options: {} + } + ], + { ...props, parent: this } + ) + this.formSchema = this.collection.formSchema + this.stateSchema = this.collection.stateSchema } - isValue(value?: FormStateValue | FormState): value is CustomerReferenceState { - return CustomerReferenceField.isCustomerReferenceField(value) - } + // isValue(value?: FormStateValue | FormState): value is CustomerReferenceState { + // return CustomerReferenceField.isCustomerReferenceField(value) + // } isState(value?: FormStateValue | FormState): value is FormState { return CustomerReferenceField.isCustomerReferenceField(value) @@ -70,28 +99,33 @@ export class CustomerReferenceField extends FormComponent { } getDisplayStringFromFormValue(value?: FormStateValue | FormState): string { - if (this.isValue(value)) { - return value.reference + if (!value) { + return '' } - return '' + + return value[`${this.name}__reference`] // todo value.reference similarly to UkAddressField value.addessline1 } getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const viewModel = super.getViewModel(payload, errors) - viewModel.value = this.getDisplayStringFromFormValue(payload[this.name]) + viewModel.value = this.getDisplayStringFromFormValue(payload) return viewModel } + getFormValueFromState(state: FormSubmissionState) { + const value = super.getFormValueFromState(state) + return this.isState(value) ? value : undefined + } + static isCustomerReferenceField( value?: FormStateValue | FormState ): value is CustomerReferenceState { return ( - value !== null && - typeof value === 'object' && - '_id' in value && - 'reference' in value + isFormState(value) && + TextField.isText(value.reference) && + TextField.isText(value._id) ) } @@ -103,6 +137,13 @@ export class CustomerReferenceField extends FormComponent { } } +// TODO move this to model + +interface CustomerReferenceState extends Record { + _id: string + reference: string +} + export interface CustomerReferenceFieldComponent extends FormComponentsDef { id?: string type: 'CustomerReferenceField' @@ -113,8 +154,3 @@ export interface CustomerReferenceFieldComponent extends FormComponentsDef { 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 index 2486546be..0ca37e004 100644 --- a/src/server/plugins/engine/components/CustomerReferenceField/routes.ts +++ b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts @@ -18,12 +18,12 @@ function randomId() { } export function initiateHandler(request: Request, h: ResponseToolkit) { - const returnUrl = request.query.returnUrl - const component = request.query.component + const returnUrl: string = request.query.returnUrl + const component: string = request.query.component const data = { - reference: randomReference(), - _id: randomId() + [`${component}__reference`]: randomReference(), + [`${component}__id`]: randomId() } request.yar.set('returnUrl', returnUrl) @@ -34,7 +34,7 @@ export function initiateHandler(request: Request, h: ResponseToolkit) { `

Simulated external service page

-

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

+

You have been generated a reference number: ${data[`${component}__reference`]}.

diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index 358a32e0c..f84c7e7d7 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -5,6 +5,7 @@ import { type Server } from '@hapi/hapi' import { isEqual } from 'date-fns' +import { object } from 'joi' import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' @@ -24,6 +25,8 @@ import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { type AnyFormRequest, type FormContext, + type FormState, + type FormStateValue, type FormSubmissionState, type PluginOptions } from '~/src/server/plugins/engine/types.js' @@ -94,26 +97,32 @@ function importExternalComponentState( page: PageControllerClass, state: FormSubmissionState ): Promise { - const externalComponentData = request.yar.flash('externalStateAppendage')[0] + const externalComponentData: string = request.yar.flash( + 'externalStateAppendage' + )[0] if (!externalComponentData) { return Promise.resolve(state) } - let componentName - let stateAppendage + let componentName: string | undefined + let stateAppendage: FormState | undefined try { - const parsedStateAppendage = JSON.parse(externalComponentData) - - componentName = parsedStateAppendage.component - stateAppendage = parsedStateAppendage.data + const externalComponentPayload = JSON.parse(externalComponentData) as { + component: string + data: FormState + } + componentName = externalComponentPayload.component + stateAppendage = externalComponentPayload.data } catch (e) { request.server.logger.error( e, - 'Error parsing external component state JSON' + 'Error parsing external component state JSON or type cast failed' + ) + throw new Error( + 'Error parsing external component state JSON or type cast failed' ) - throw new Error('Error parsing external component state JSON') } const component = request.app.model?.componentMap.get(componentName) @@ -128,14 +137,24 @@ function importExternalComponentState( ) } - const isStateValid = component.isState(stateAppendage) - - if (!isStateValid) { - throw new Error(`State for component ${componentName} is invalid`) + if (!stateAppendage) { + throw new Error('No state appendage found') } + // Filter stateAppendage keys to only those starting with componentName + const filteredStateAppendage = Object.keys(stateAppendage) + .filter((key) => key.startsWith(componentName)) + .reduce((acc: Record, key) => { + acc[key] = stateAppendage[key] + return acc + }, {}) + + // if (!component.isState(filteredStateAppendage as FormState)) { + // throw new Error(`State for component ${componentName} is invalid`) + // } + return page.mergeState(request, state, { - ...(stateAppendage ? { [componentName]: stateAppendage } : {}) + ...filteredStateAppendage }) } From beae08c4d1224215418ce016c288cc96f72294b3 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 16 Oct 2025 16:17:41 +0100 Subject: [PATCH 8/8] fix --- .../CustomerReferenceField.ts | 13 ++++++------- .../components/CustomerReferenceField/routes.ts | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts index ca152da3c..678884dda 100644 --- a/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts +++ b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts @@ -15,8 +15,7 @@ import { type FormPayload, type FormState, type FormStateValue, - type FormSubmissionError, - type FormSubmissionState + type FormSubmissionError } from '~/src/server/plugins/engine/types.js' export class CustomerReferenceField extends FormComponent { @@ -78,6 +77,11 @@ export class CustomerReferenceField extends FormComponent { return CustomerReferenceField.isCustomerReferenceField(value) } + getFormValueFromState(state: FormSubmissionState) { + const value = super.getFormValueFromState(state) + return this.isState(value) ? value : undefined + } + /** * For error preview page that shows all possible errors on a component */ @@ -114,11 +118,6 @@ export class CustomerReferenceField extends FormComponent { return viewModel } - getFormValueFromState(state: FormSubmissionState) { - const value = super.getFormValueFromState(state) - return this.isState(value) ? value : undefined - } - static isCustomerReferenceField( value?: FormStateValue | FormState ): value is CustomerReferenceState { diff --git a/src/server/plugins/engine/components/CustomerReferenceField/routes.ts b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts index 0ca37e004..ae02085c3 100644 --- a/src/server/plugins/engine/components/CustomerReferenceField/routes.ts +++ b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts @@ -23,7 +23,7 @@ export function initiateHandler(request: Request, h: ResponseToolkit) { const data = { [`${component}__reference`]: randomReference(), - [`${component}__id`]: randomId() + [`${component}___id`]: randomId() } request.yar.set('returnUrl', returnUrl)