Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/server/constants.js
Original file line number Diff line number Diff line change
@@ -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'
15 changes: 15 additions & 0 deletions src/server/forms/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
"options": {},
"schema": {}
},
{
"type": "CustomerReferenceField",
"name": "customerReferenceNumber",
"title": "Customer reference number",
"hint": "Help text",
"options": {},
"schema": {}
},
{
"type": "MultilineTextField",
"name": "multilineTextField",
Expand Down Expand Up @@ -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": []
}
]
}
Expand Down
25 changes: 24 additions & 1 deletion src/server/plugins/engine/components/UkAddressField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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<string, string> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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,
Expand Down
134 changes: 68 additions & 66 deletions src/server/plugins/engine/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ComponentType } from '@defra/forms-model'
import Boom from '@hapi/boom'
import {
type ResponseObject,
Expand All @@ -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,
Expand All @@ -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'

Expand All @@ -45,7 +49,7 @@ export async function redirectOrMakeHandler(
context: FormContext
) => ResponseObject | Promise<ResponseObject>
) {
const { app, params, payload } = request
const { app, params } = request
const { model } = app

if (!model) {
Expand All @@ -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)
Expand All @@ -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<FormSubmissionState> {
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) {
Expand Down
21 changes: 20 additions & 1 deletion src/server/plugins/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
type FormVersionMetadata,
type Item,
type List,
type Page
type Page,
type UkAddressFieldComponent
} from '@defra/forms-model'
import {
type PluginProperties,
Expand All @@ -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,
Expand Down Expand Up @@ -379,6 +381,23 @@ export type SaveAndExitHandler = (
context: FormContext
) => ResponseObject

export interface ExternalArgs {
component: ComponentDef
controller: QuestionPageController
sourceUrl: string
actionArgs: Record<string, string>
}

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
Expand Down
Loading
Loading