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..5cb986a19 --- /dev/null +++ b/src/server/forms/external-components.json @@ -0,0 +1,956 @@ +{ + "conditions": [], + "engine": "V2", + "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", + "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..678884dda --- /dev/null +++ b/src/server/plugins/engine/components/CustomerReferenceField/CustomerReferenceField.ts @@ -0,0 +1,155 @@ +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, + 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 +} 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 + 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) + // } + + isState(value?: FormStateValue | FormState): value is FormState { + 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 + */ + 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 (!value) { + 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) + + return viewModel + } + + static isCustomerReferenceField( + value?: FormStateValue | FormState + ): value is CustomerReferenceState { + return ( + isFormState(value) && + TextField.isText(value.reference) && + TextField.isText(value._id) + ) + } + + static getRoutes() { + return { + routes: getRoutes(), + entrypoint: '/customer-reference-field/confirm' + } + } +} + +// TODO move this to model + +interface CustomerReferenceState extends Record { + _id: string + reference: string +} + +export interface CustomerReferenceFieldComponent extends FormComponentsDef { + id?: string + type: 'CustomerReferenceField' + shortDescription?: string + name: string + title: string + hint?: string + options: object + schema: object +} 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..ae02085c3 --- /dev/null +++ b/src/server/plugins/engine/components/CustomerReferenceField/routes.ts @@ -0,0 +1,83 @@ +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: string = request.query.returnUrl + const component: string = request.query.component + + const data = { + [`${component}__reference`]: randomReference(), + [`${component}___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[`${component}__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/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..8e8f4fd3a 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 } diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index b912a6449..30fa49d44 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -35,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, @@ -493,11 +494,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 +520,33 @@ 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 - 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..a3159b37e 100644 --- a/src/server/plugins/engine/plugin.ts +++ b/src/server/plugins/engine/plugin.ts @@ -14,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, @@ -79,6 +80,13 @@ 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, @@ -87,7 +95,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..f84c7e7d7 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -5,8 +5,10 @@ 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' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -23,6 +25,9 @@ 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' import { @@ -64,6 +69,8 @@ export async function redirectOrMakeHandler( }) } + state = await importExternalComponentState(request, page, state) + const flash = cacheService.getFlash(request) const context = model.getFormContext(request, state, flash?.errors) const relevantPath = page.getRelevantPath(request, context) @@ -85,6 +92,72 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } +function importExternalComponentState( + request: AnyFormRequest, + page: PageControllerClass, + state: FormSubmissionState +): Promise { + const externalComponentData: string = request.yar.flash( + 'externalStateAppendage' + )[0] + + if (!externalComponentData) { + return Promise.resolve(state) + } + + let componentName: string | undefined + let stateAppendage: FormState | undefined + + try { + 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 or type cast failed' + ) + throw new Error( + 'Error parsing external component state JSON or type cast failed' + ) + } + + 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` + ) + } + + 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, { + ...filteredStateAppendage + }) +} + 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 ?? '' 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/validationHelpers.ts b/src/server/plugins/engine/validationHelpers.ts new file mode 100644 index 000000000..c2c1d8930 --- /dev/null +++ b/src/server/plugins/engine/validationHelpers.ts @@ -0,0 +1,38 @@ +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(): { + internalComponents: Map + externalComponents: Map +} { + const internalComponents = new Map() + const externalComponents = new Map() + + const componentMap = new Map(Object.entries(Components)) + + for (const [name, component] of componentMap.entries()) { + if (isExternalComponent(component)) { + externalComponents.set(name, component) + } else { + internalComponents.set(name, component) + } + } + + return { internalComponents, externalComponents } +} 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..d5846ba74 --- /dev/null +++ b/src/server/plugins/engine/views/components/customerreferencefield.html @@ -0,0 +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) %} + {% 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 new file mode 100644 index 000000000..87a22eeab --- /dev/null +++ b/src/server/plugins/engine/views/components/externalcomponent.html @@ -0,0 +1,43 @@ +{% 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 %} 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()