diff --git a/src/server/constants.js b/src/server/constants.js index 42785542d..fd659a09e 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -1,2 +1,4 @@ export const PREVIEW_PATH_PREFIX = '/preview' export const FORM_PREFIX = '' +export const EXTERNAL_STATE_PAYLOAD = 'EXTERNAL_STATE_PAYLOAD' +export const EXTERNAL_STATE_APPENDAGE = 'EXTERNAL_STATE_APPENDAGE' diff --git a/src/server/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/plugins/engine/components/UkAddressField.ts b/src/server/plugins/engine/components/UkAddressField.ts index 34153a2ff..53bc15274 100644 --- a/src/server/plugins/engine/components/UkAddressField.ts +++ b/src/server/plugins/engine/components/UkAddressField.ts @@ -8,14 +8,20 @@ import { } from '~/src/server/plugins/engine/components/FormComponent.js' import { TextField } from '~/src/server/plugins/engine/components/TextField.js' import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js' +import { + type FormRequestPayload, + type FormResponseToolkit +} from '~/src/server/plugins/engine/types/index.js' import { type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, - type FormSubmissionState + type FormSubmissionState, + type PostcodeLookupExternalArgs } from '~/src/server/plugins/engine/types.js' +import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' export class UkAddressField extends FormComponent { declare options: UkAddressFieldComponent['options'] @@ -249,6 +255,23 @@ export class UkAddressField extends FormComponent { TextField.isText(value.postcode) ) } + + static dispatcher( + request: FormRequestPayload, + h: FormResponseToolkit, + args: PostcodeLookupExternalArgs + ) { + const { controller, component } = args + + return dispatch(request, h, { + formName: controller.model.name, + componentName: component.name, + componentHint: component.hint, + componentTitle: component.title || controller.title, + step: args.actionArgs.step, + sourceUrl: args.sourceUrl + }) + } } export interface UkAddressState extends Record { diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index b912a6449..77d0dbd3e 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -12,6 +12,7 @@ import Boom from '@hapi/boom' import { type RouteOptions } from '@hapi/hapi' import { type ValidationErrorItem } from 'joi' +import { EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' @@ -35,6 +36,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,6 +495,12 @@ export class QuestionPageController extends PageController { const { collection, viewName, model } = this const { isForceAccess, state, evaluationState } = context + const action = request.payload.action ?? '' + + if (action && action.startsWith(FormAction.External)) { + return this.dispatchExternal(request, h) + } + /** * If there are any errors, render the page with the parsed errors * @todo Refactor to match POST REDIRECT GET pattern @@ -515,7 +523,6 @@ export class QuestionPageController extends PageController { await this.setState(request, state) // Check if this is a save-and-exit action - const { action } = request.payload if (action === FormAction.SaveAndExit) { return this.handleSaveAndExit(request, context, h) } @@ -525,6 +532,56 @@ export class QuestionPageController extends PageController { } } + private dispatchExternal( + request: FormRequestPayload, + h: FormResponseToolkit + ) { + const { externalComponents } = getComponentsByType() + const action = request.payload.action ?? '' + + // Find the external action and arguments + // `external-{componentName}--{argname1}:{argvalue1}--{argname2}:{argvalue2}` + // E.g. external-abcdef--amount:10--step:manual + const externalActionsWithArgs = action + .slice(`${FormAction.External}-`.length) + .split('--') + + const externalActionArgs = externalActionsWithArgs + .slice(1) + .map((arg) => arg.split(':')) + + const args = Object.fromEntries(externalActionArgs) as Record< + string, + string + > + + const componentName = externalActionsWithArgs[0] + const component = this.model.componentDefMap.get(componentName) + const componentType = component?.type + + if (!componentType) { + throw Boom.internal( + `External component of type ${componentType} not found` + ) + } + + const selectedComponent = externalComponents.get(componentType) + + if (!selectedComponent) { + throw Boom.internal(`External component ${componentName} not found`) + } + + // Stash payload + request.yar.flash(EXTERNAL_STATE_PAYLOAD, request.payload, true) + + return selectedComponent.dispatcher(request, h, { + component, + controller: this, + sourceUrl: request.url.toString(), + actionArgs: args + }) + } + proceed( request: FormContextRequest, h: FormResponseToolkit, diff --git a/src/server/plugins/engine/routes/index.ts b/src/server/plugins/engine/routes/index.ts index b0f600da5..bba28c870 100644 --- a/src/server/plugins/engine/routes/index.ts +++ b/src/server/plugins/engine/routes/index.ts @@ -1,4 +1,3 @@ -import { ComponentType } from '@defra/forms-model' import Boom from '@hapi/boom' import { type ResponseObject, @@ -7,7 +6,15 @@ import { } from '@hapi/hapi' import { isEqual } from 'date-fns' -import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js' +import { + EXTERNAL_STATE_APPENDAGE, + EXTERNAL_STATE_PAYLOAD, + PREVIEW_PATH_PREFIX +} from '~/src/server/constants.js' +import { + FormComponent, + isFormState +} from '~/src/server/plugins/engine/components/FormComponent.js' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -23,17 +30,14 @@ import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNu import * as defaultServices from '~/src/server/plugins/engine/services/index.js' import { type AnyFormRequest, + type ExternalStateAppendage, type FormContext, type FormPayload, + type FormSubmissionState, type PluginOptions } from '~/src/server/plugins/engine/types.js' -import { dispatch } from '~/src/server/plugins/postcode-lookup/routes/index.js' -import { type PostcodeLookupDispatchArgs } from '~/src/server/plugins/postcode-lookup/types.js' import { - ExternalActions, - FormAction, type FormRequest, - type FormRequestPayload, type FormResponseToolkit } from '~/src/server/routes/types.js' @@ -45,7 +49,7 @@ export async function redirectOrMakeHandler( context: FormContext ) => ResponseObject | Promise ) { - const { app, params, payload } = request + const { app, params } = request const { model } = app if (!model) { @@ -71,13 +75,7 @@ export async function redirectOrMakeHandler( }) } - // External journey redirect - const { action = '' } = page.getFormParams(request) - if (payload && action.startsWith(FormAction.External)) { - const opts = { action, model, payload, page } - - return dispatchExternalHandler(request, h, opts) - } + state = await importExternalComponentState(request, page, state) const flash = cacheService.getFlash(request) const context = model.getFormContext(request, state, flash?.errors) @@ -100,62 +98,66 @@ export async function redirectOrMakeHandler( return proceed(request, h, page.getHref(relevantPath)) } -function dispatchExternalHandler( +async function importExternalComponentState( request: AnyFormRequest, - h: FormResponseToolkit, - options: { - action: string - model: FormModel - payload: FormPayload - page: PageControllerClass + page: PageControllerClass, + state: FormSubmissionState +): Promise { + const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE) + + if (Array.isArray(externalComponentData)) { + return Promise.resolve(state) } -) { - const { action, model, payload, page } = options - - // Find the external action and arguments - // `external-{externalAction}--{argname1}:{argvalue1}--{argname2}:{argvalue2}` - // E.g. external-postcode-lookup--name:wDFtgf--step:manual - const externalActionsWithArgs = action - .slice(`${FormAction.External}-`.length) - .split('--') - const externalAction = externalActionsWithArgs[0] as ExternalActions - const externalActionArgs = externalActionsWithArgs - .slice(1) - .map((arg) => arg.split(':')) - - switch (externalAction) { - case ExternalActions.PostcodeLookup: { - const args = Object.fromEntries( - externalActionArgs - ) as PostcodeLookupDispatchArgs - const componentName = args.name - const component = model.componentDefMap.get(componentName) - - if (!component) { - throw Boom.notFound(`No component found for ${componentName}`) - } - if (component.type !== ComponentType.UkAddressField) { - throw Boom.internal( - `Invalid component type, expected UkAddressFieldComponent got ${component.type}` - ) - } + let componentName + let stateAppendage - return dispatch(request as FormRequestPayload, h, { - payload, - formName: model.name, - componentName, - componentHint: component.hint, - componentTitle: component.title || page.title, - step: args.step, - sourceUrl: request.url.toString() - }) - } - default: - throw Boom.internal( - `Invalid external action, expected one of '${Object.values(ExternalActions).join('|')}' got '${externalAction}'` - ) + try { + const parsedStateAppendage = externalComponentData as ExternalStateAppendage + + componentName = parsedStateAppendage.component + stateAppendage = parsedStateAppendage.data + } catch (err) { + request.logger.error(err, 'Error parsing external component state JSON') + + throw new Error('Error parsing external component state') + } + + const 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`) + } + + // TODO: A better way? + // const componentState = component.getStateFromValidForm(stateAppendage) + const componentState = isFormState(stateAppendage) + ? Object.fromEntries( + Object.entries(stateAppendage).map(([key, value]) => [ + `${componentName}__${key}`, + value + ]) + ) + : { [componentName]: stateAppendage } + + // Save the component state + const updatedState = await page.mergeState(request, state, componentState) + const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD) + const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload) + + return { ...updatedState, ...stashedPayload } } export function makeLoadFormPreHandler(server: Server, options: PluginOptions) { diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 3673daf39..7f6df1111 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -6,7 +6,8 @@ import { type FormVersionMetadata, type Item, type List, - type Page + type Page, + type UkAddressFieldComponent } from '@defra/forms-model' import { type PluginProperties, @@ -30,6 +31,7 @@ import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type DetailItemField } from '~/src/server/plugins/engine/models/types.js' import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' +import { type QuestionPageController } from '~/src/server/plugins/engine/pageControllers/index.js' import { type FileStatus, type FormAdapterSubmissionSchemaVersion, @@ -379,6 +381,23 @@ export type SaveAndExitHandler = ( context: FormContext ) => ResponseObject +export interface ExternalArgs { + component: ComponentDef + controller: QuestionPageController + sourceUrl: string + actionArgs: Record +} + +export interface PostcodeLookupExternalArgs extends ExternalArgs { + component: UkAddressFieldComponent + actionArgs: { step: string } +} + +export interface ExternalStateAppendage { + component: string + data: FormStateValue | FormState +} + export interface PluginOptions { model?: FormModel services?: Services diff --git a/src/server/plugins/engine/validationHelpers.ts b/src/server/plugins/engine/validationHelpers.ts new file mode 100644 index 000000000..268cf5792 --- /dev/null +++ b/src/server/plugins/engine/validationHelpers.ts @@ -0,0 +1,48 @@ +import { type ResponseObject } from '@hapi/hapi' + +import * as Components from '~/src/server/plugins/engine/components/index.js' +import { + type FormRequestPayload, + type FormResponseToolkit +} from '~/src/server/plugins/engine/types/index.js' +import { type ExternalArgs } from '~/src/server/plugins/engine/types.js' + +// Type guard for ExternalComponent +export function isExternalComponent( + component: unknown +): component is ExternalComponent { + return typeof (component as ExternalComponent).dispatcher === 'function' +} + +// External components are guaranteed to have a dispatcher method +export interface ExternalComponent { + dispatcher( + request: FormRequestPayload, + h: FormResponseToolkit, + args: ExternalArgs + ): ResponseObject +} + +/** + * Returns internal and external components from a componentMap, regardless of error state. + * @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/ukaddressfield.html b/src/server/plugins/engine/views/components/ukaddressfield.html index 41f473d75..7e2e45176 100644 --- a/src/server/plugins/engine/views/components/ukaddressfield.html +++ b/src/server/plugins/engine/views/components/ukaddressfield.html @@ -42,7 +42,7 @@

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

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

or

+

or

{% endif %} {% endif %} diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 683051763..0315faa10 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -2,7 +2,7 @@ import Boom from '@hapi/boom' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' -import { getCacheService } from '~/src/server/plugins/engine/helpers.js' +import { EXTERNAL_STATE_APPENDAGE } from '~/src/server/constants.js' import { JOURNEY_BASE_URL, detailsPayloadSchema, @@ -36,34 +36,30 @@ function getSessionState(request) { } /** - * Update form component state + * Flash form component state * @param {PostcodeLookupRequest} request - the request * @param {string} componentName - the component name * @param {Address | PostcodeLookupManualPayload} address - the address from ordnance survey or manually entered */ -async function updateComponentState(request, componentName, address) { - // TODO: Set state another way +function flashComponentState(request, componentName, address) { const addressState = { - [`${componentName}__addressLine1`]: address.addressLine1, - [`${componentName}__addressLine2`]: address.addressLine2, - [`${componentName}__town`]: address.town, - [`${componentName}__county`]: address.county, - [`${componentName}__postcode`]: address.postcode + addressLine1: address.addressLine1, + addressLine2: address.addressLine2, + town: address.town, + county: address.county, + postcode: address.postcode, + uprn: 'uprn' in address && address.uprn ? address.uprn : undefined } - // Assign UPRN if available - if ('uprn' in address && address.uprn) { - addressState[`${componentName}__uprn`] = address.uprn + /** + * @type {ExternalStateAppendage} + */ + const appendage = { + component: componentName, + data: addressState } - const cacheService = getCacheService(request.server) - // @ts-expect-error - Request typing - const state = await cacheService.getState(request) - // @ts-expect-error - Request typing - await cacheService.setState(request, { - ...state, - ...addressState - }) + request.yar.flash(EXTERNAL_STATE_APPENDAGE, appendage, true) } /** @@ -222,7 +218,7 @@ async function selectPostHandler(request, h, options) { } const { componentName, sourceUrl } = session.initial - await updateComponentState(request, componentName, property) + flashComponentState(request, componentName, property) // Redirect back to the source form page return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER) @@ -233,7 +229,7 @@ async function selectPostHandler(request, h, options) { * @param {PostcodeLookupPostRequest} request * @param {ResponseToolkit} h */ -async function manualPostHandler(request, h) { +function manualPostHandler(request, h) { const { payload } = request const session = getSessionState(request) @@ -248,7 +244,7 @@ async function manualPostHandler(request, h) { } const { componentName, sourceUrl } = session.initial - await updateComponentState(request, componentName, manual) + flashComponentState(request, componentName, manual) // Redirect back to the source form page return h.redirect(sourceUrl).code(StatusCodes.SEE_OTHER) @@ -258,4 +254,5 @@ async function manualPostHandler(request, h) { * @import { ResponseToolkit, ServerRoute } from '@hapi/hapi' * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupPostRequest, PostcodeLookupConfiguration, PostcodeLookupDispatchData, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js' * @import { FormRequestPayload, FormResponseToolkit } from '~/src/server/routes/types.js' + * @import { ExternalStateAppendage } from '~/src/server/plugins/engine/types.js' */ diff --git a/src/server/plugins/postcode-lookup/types.js b/src/server/plugins/postcode-lookup/types.js index ae876b1f8..49ca24a11 100644 --- a/src/server/plugins/postcode-lookup/types.js +++ b/src/server/plugins/postcode-lookup/types.js @@ -18,8 +18,7 @@ * componentName: string * componentTitle: string, * componentHint?: string - * step?: string, - * payload: FormPayload + * step?: string * }} PostcodeLookupDispatchData */