Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ac3c204
Stash
jbarnsley10 Sep 30, 2025
cc2797a
Stash
jbarnsley10 Oct 3, 2025
28c5991
Reworked and added unit tests
jbarnsley10 Oct 3, 2025
d7ccfca
Uses updated forms-model version
jbarnsley10 Oct 3, 2025
7160dd7
Sonar fix
jbarnsley10 Oct 3, 2025
cafc7dc
Changed field name
jbarnsley10 Oct 3, 2025
3a19b5d
Added const
jbarnsley10 Oct 6, 2025
4b3b1f3
Merge branch 'main' into feat/DF-330-citizen-email
jbarnsley10 Oct 6, 2025
13c5701
Reworked to avoid framework restrictions
jbarnsley10 Oct 6, 2025
e2006b3
Reverted method
jbarnsley10 Oct 6, 2025
ef146c2
Fixed tests
jbarnsley10 Oct 6, 2025
a956778
Fixed test
jbarnsley10 Oct 7, 2025
334b2bb
Reverted
jbarnsley10 Oct 7, 2025
661bb7f
Changed to 'options'
jbarnsley10 Oct 7, 2025
288edfc
Upversioned forms model
jbarnsley10 Oct 7, 2025
d967450
Passes confirmation email address
jbarnsley10 Oct 7, 2025
a05a90d
Remove confirmation email from plugin, now delegated to Runner
alexluckett Oct 8, 2025
bc8f22c
Don't skip summary submit
alexluckett Oct 8, 2025
e0bd9c8
Support custom page content on summary pages
alexluckett Oct 8, 2025
6ecde8d
Removed confirmation email to prevent bleed from runner
jbarnsley10 Oct 8, 2025
5c01fa0
Reverted variable name
jbarnsley10 Oct 8, 2025
a7b1ed3
Tidy up
jbarnsley10 Oct 8, 2025
8846475
Allow custom key/values in meta
jbarnsley10 Oct 8, 2025
a4959fc
Changed to use custom property in meta
jbarnsley10 Oct 8, 2025
d7bb184
Corrected custom property schema
jbarnsley10 Oct 9, 2025
ade367c
chore(release): #minor
jbarnsley10 Oct 9, 2025
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
},
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@defra/forms-model": "^3.0.555",
"@defra/forms-model": "^3.0.559",
"@defra/hapi-tracing": "^1.26.0",
"@elastic/ecs-pino-format": "^1.5.0",
"@hapi/boom": "^10.0.1",
Expand Down
1 change: 1 addition & 0 deletions src/server/forms/register-as-a-unicorn-breeder.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
name: Register as a unicorn breeder
declaration: "<p class=\"govuk-body\">All the answers you have provided are true to the best of your knowledge.</p>"
pages:
- path: '/whats-your-name'
title: What's your name?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
import { SummaryPageController } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
import { buildFormRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js'
import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js'
import {
type FormRequest,
type FormRequestPayload,
type FormResponseToolkit
} from '~/src/server/routes/types.js'
import { type CacheService } from '~/src/server/services/cacheService.js'
import definition from '~/test/form/definitions/basic.js'

describe('SummaryPageController', () => {
let model: FormModel
let controller: SummaryPageController
let requestPage: FormRequest

const response = {
code: jest.fn().mockImplementation(() => response)
}
const h: FormResponseToolkit = {
redirect: jest.fn().mockReturnValue(response),
view: jest.fn()
}

beforeEach(() => {
model = new FormModel(definition, {
basePath: 'test'
})

// Create a mock page for SummaryPageController
const mockPage = {
...definition.pages[0],
controller: 'summary'
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
controller = new SummaryPageController(model, mockPage as any)

requestPage = buildFormRequest({
method: 'get',
url: new URL('http://example.com/test/summary'),
path: '/test/summary',
params: {
path: 'summary',
slug: 'test'
},
query: {},
app: { model }
} as FormRequest)
})

describe('handleSaveAndExit', () => {
it('should invoke saveAndExit plugin option', async () => {
const saveAndExitMock = jest.fn(() => ({}))
const state: FormSubmissionState = {
$$__referenceNumber: 'foobar',
licenceLength: 365,
fullName: 'John Smith'
}
const request = {
...requestPage,
server: {
plugins: {
'forms-engine-plugin': {
saveAndExit: saveAndExitMock,
cacheService: {
clearState: jest.fn()
} as unknown as CacheService
}
}
},
method: 'post',
payload: { fullName: 'John Smith', action: 'save-and-exit' }
} as unknown as FormRequestPayload

const context = model.getFormContext(request, state)

const postHandler = controller.makePostRouteHandler()
await postHandler(request, context, h)

expect(saveAndExitMock).toHaveBeenCalledWith(request, h, context)
})
})
})
75 changes: 42 additions & 33 deletions src/server/plugins/engine/pageControllers/SummaryPageController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class SummaryPageController extends QuestionPageController {
viewModel.phaseTag = this.phaseTag
viewModel.components = components
viewModel.allowSaveAndExit = this.shouldShowSaveAndExit(request.server)
viewModel.errors = errors

return viewModel
}
Expand Down Expand Up @@ -107,48 +108,56 @@ export class SummaryPageController extends QuestionPageController {
context: FormContext,
h: FormResponseToolkit
) => {
const { model } = this
const { params } = request

// Check if this is a save-and-exit action
const { action } = request.payload
if (action === FormAction.SaveAndExit) {
return this.handleSaveAndExit(request, context, h)
}

const cacheService = getCacheService(request.server)

const { formsService } = this.model.services
const { getFormMetadata } = formsService

// Get the form metadata using the `slug` param
const formMetadata = await getFormMetadata(params.slug)
const { notificationEmail } = formMetadata
const { isPreview } = checkFormStatus(request.params)
const emailAddress = notificationEmail ?? this.model.def.outputEmail

checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)

// Send submission email
if (emailAddress) {
const viewModel = this.getSummaryViewModel(request, context)
await submitForm(
context,
request,
viewModel,
model,
emailAddress,
formMetadata
)
}
return this.handleFormSubmit(request, context, h)
}
}

async handleFormSubmit(
request: FormRequestPayload,
context: FormContext,
h: FormResponseToolkit
) {
const { model } = this
const { params } = request

const cacheService = getCacheService(request.server)

await cacheService.setConfirmationState(request, { confirmed: true })
const { formsService } = this.model.services
const { getFormMetadata } = formsService

// Clear all form data
await cacheService.clearState(request)
// Get the form metadata using the `slug` param
const formMetadata = await getFormMetadata(params.slug)
const { notificationEmail } = formMetadata
const { isPreview } = checkFormStatus(request.params)
const emailAddress = notificationEmail ?? this.model.def.outputEmail

return this.proceed(request, h, this.getStatusPath())
checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)

// Send submission email
if (emailAddress) {
const viewModel = this.getSummaryViewModel(request, context)
await submitForm(
context,
request,
viewModel,
model,
emailAddress,
formMetadata
)
}

await cacheService.setConfirmationState(request, { confirmed: true })

// Clear all form data
await cacheService.clearState(request)

return this.proceed(request, h, this.getStatusPath())
}

get postRouteOptions(): RouteOptions<FormRequestPayloadRefs> {
Expand All @@ -164,7 +173,7 @@ export class SummaryPageController extends QuestionPageController {
}
}

async function submitForm(
export async function submitForm(
context: FormContext,
request: FormRequestPayload,
summaryViewModel: SummaryViewModel,
Expand Down
1 change: 1 addition & 0 deletions src/server/plugins/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ export interface FormAdapterSubmissionMessageMeta {
isPreview: boolean
notificationEmail: string
versionMetadata?: FormVersionMetadata
custom?: Record<string, unknown>
}

export type FormAdapterSubmissionMessageMetaSerialised = Omit<
Expand Down
34 changes: 34 additions & 0 deletions src/server/plugins/engine/types/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,29 @@ describe('Schema validation', () => {
expect(error).toBeUndefined()
})

it('should validate valid meta object with valid custom properties', () => {
const validMetaWithCustom = {
...validMeta,
custom: {
property1: 'value 1',
property2: 'value2'
}
}
const { error } =
formAdapterSubmissionMessageMetaSchema.validate(validMetaWithCustom)
expect(error).toBeUndefined()
})

it('should validate valid meta object with empty custom properties', () => {
const validMetaWithCustom = {
...validMeta,
custom: {}
}
const { error } =
formAdapterSubmissionMessageMetaSchema.validate(validMetaWithCustom)
expect(error).toBeUndefined()
})

it('should reject invalid schema version', () => {
const invalidMeta = { ...validMeta, schemaVersion: 'invalid' }
const { error } =
Expand All @@ -92,6 +115,17 @@ describe('Schema validation', () => {
formAdapterSubmissionMessageMetaSchema.validate(metaWithoutTimestamp)
expect(error).toBeDefined()
})

it('should reject invalid custom structure', () => {
const validMetaWithInvalidCustom = {
...validMeta,
custom: 'invalid'
}
const { error } = formAdapterSubmissionMessageMetaSchema.validate(
validMetaWithInvalidCustom
)
expect(error).toBeDefined()
})
})

describe('formAdapterSubmissionMessageDataSchema', () => {
Expand Down
7 changes: 6 additions & 1 deletion src/server/plugins/engine/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export const formAdapterSubmissionMessageMetaSchema =
.required(),
isPreview: Joi.boolean().required(),
notificationEmail: notificationEmailAddressSchema.required(),
versionMetadata: formVersionMetadataSchema.optional()
versionMetadata: formVersionMetadataSchema.optional(),
custom: Joi.object()
.pattern(/^/, Joi.any())
.unknown()
.optional()
.description('Custom properties for the message')
})

export const formAdapterSubmissionMessageDataSchema =
Expand Down
14 changes: 12 additions & 2 deletions src/server/plugins/engine/views/summary.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %}
{% from "govuk/components/button/macro.njk" import govukButton %}
{% from "partials/components.html" import componentList with context %}
{% from "govuk/components/input/macro.njk" import govukInput %}

{% block content %}
<div class="govuk-grid-row">
Expand All @@ -12,6 +13,13 @@
{% include "partials/preview-banner.html" %}
{% endif %}

{% if errors %}
{{ govukErrorSummary({
titleText: "There is a problem",
errorList: checkErrorTemplates(errors)
}) }}
{% endif %}

{% if hasMissingNotificationEmail %}
{% include "partials/warn-missing-notification-email.html" %}
{% endif %}
Expand All @@ -33,15 +41,17 @@ <h2 class="govuk-heading-m">
<form method="post" novalidate>
<input type="hidden" name="crumb" value="{{ crumb }}">

{{ componentList(components) }}

{% block customPageContent %}{% endblock %}

{% if declaration %}
<h2 class="govuk-heading-m" id="declaration">Declaration</h2>
<div class="govuk-body">
{{ declaration | safe }}
</div>
{% endif %}

{{ componentList(components) }}

<div class="govuk-button-group">
{% set isDeclaration = declaration or components | length %}

Expand Down
Loading