diff --git a/package-lock.json b/package-lock.json index 3b7b9ad6e..1daec55bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "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", @@ -2272,9 +2272,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.555", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.555.tgz", - "integrity": "sha512-RgPYOiwdzxI3WSNp+WD/GKZam72GUlZEpUDbciejQBdkpWMbMcKQkPbyOLuHjHp+/ZyR3T7xySlGEkUtPc9Gzw==", + "version": "3.0.559", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.559.tgz", + "integrity": "sha512-dSMrTnhUXnapflHKdeQLMGDwK2QlFhp/08XwzLNHzLHmgx7pqHAgelzVeRsyHtzYDu7B7tF4r5cyR+SxI4UmXw==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index 57150f31e..18a94e715 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/server/forms/register-as-a-unicorn-breeder.yaml b/src/server/forms/register-as-a-unicorn-breeder.yaml index 345dd4930..3c20764c8 100644 --- a/src/server/forms/register-as-a-unicorn-breeder.yaml +++ b/src/server/forms/register-as-a-unicorn-breeder.yaml @@ -1,5 +1,6 @@ --- name: Register as a unicorn breeder +declaration: "

All the answers you have provided are true to the best of your knowledge.

" pages: - path: '/whats-your-name' title: What's your name? diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts new file mode 100644 index 000000000..3e3a367ef --- /dev/null +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts @@ -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) + }) + }) +}) diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 14d4ecae4..aac9d3b55 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -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 } @@ -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 { @@ -164,7 +173,7 @@ export class SummaryPageController extends QuestionPageController { } } -async function submitForm( +export async function submitForm( context: FormContext, request: FormRequestPayload, summaryViewModel: SummaryViewModel, diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 993fa148e..1ff21d013 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -409,6 +409,7 @@ export interface FormAdapterSubmissionMessageMeta { isPreview: boolean notificationEmail: string versionMetadata?: FormVersionMetadata + custom?: Record } export type FormAdapterSubmissionMessageMetaSerialised = Omit< diff --git a/src/server/plugins/engine/types/schema.test.ts b/src/server/plugins/engine/types/schema.test.ts index b07a48392..a77c9257a 100644 --- a/src/server/plugins/engine/types/schema.test.ts +++ b/src/server/plugins/engine/types/schema.test.ts @@ -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 } = @@ -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', () => { diff --git a/src/server/plugins/engine/types/schema.ts b/src/server/plugins/engine/types/schema.ts index 396d965cb..203c4f1ec 100644 --- a/src/server/plugins/engine/types/schema.ts +++ b/src/server/plugins/engine/types/schema.ts @@ -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 = diff --git a/src/server/plugins/engine/views/summary.html b/src/server/plugins/engine/views/summary.html index 22a112dc9..1ce7a5e83 100644 --- a/src/server/plugins/engine/views/summary.html +++ b/src/server/plugins/engine/views/summary.html @@ -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 %}
@@ -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 %} @@ -33,6 +41,10 @@

+ {{ componentList(components) }} + + {% block customPageContent %}{% endblock %} + {% if declaration %}

Declaration

@@ -40,8 +52,6 @@

Declaration

{% endif %} - {{ componentList(components) }} -
{% set isDeclaration = declaration or components | length %}