From a12acaac81cae37c0957e61cad0b2e85acf6c49b Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 18 Dec 2025 14:12:38 +0000 Subject: [PATCH 01/12] Handles partial page when clicking 'Save and exit' --- src/server/constants.js | 1 + src/server/index.ts | 14 ++++++++++++-- src/server/routes/save-and-exit.js | 16 ++++++++++++++++ src/typings/hapi/index.d.ts | 7 +++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/server/constants.js b/src/server/constants.js index 39af41be1..c687dfdc1 100644 --- a/src/server/constants.js +++ b/src/server/constants.js @@ -1,3 +1,4 @@ export const PREVIEW_PATH_PREFIX = '/preview' export const ERROR_PREVIEW_PATH_PREFIX = '/error-preview' export const FORM_PREFIX = '/form' +export const SAVE_AND_EXIT_PAYLOAD = 'SAVE_AND_EXIT_PAYLOAD' diff --git a/src/server/index.ts b/src/server/index.ts index b00bffb8c..15b61d685 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -29,7 +29,7 @@ import forwardLogs from '~/src/server/common/helpers/logging/forward-logs.js' import { requestLogger } from '~/src/server/common/helpers/logging/request-logger.js' import { requestTracing } from '~/src/server/common/helpers/logging/request-tracing.js' import { buildRedisClient } from '~/src/server/common/helpers/redis-client.js' -import { FORM_PREFIX } from '~/src/server/constants.js' +import { FORM_PREFIX, SAVE_AND_EXIT_PAYLOAD } from '~/src/server/constants.js' import { FeedbackPageController } from '~/src/server/plugins/FeedbackPageController.js' import { SummaryPageWithConfirmationEmailController } from '~/src/server/plugins/SummaryPageWithConfirmationEmailController.js' import { configureBlankiePlugin } from '~/src/server/plugins/blankie.js' @@ -159,10 +159,20 @@ export const configureEnginePlugin = async ({ h: FormResponseToolkit, _context: FormContext ) => { - const { params } = request + const { params, yar } = request const { slug } = params const { isPreview, state } = checkFormStatus(params) + // Payload from current page without crumb and action + // (in case current page is not saved to state yet i.e. only partially completed) + const pagePayload = { + ...request.payload, + crumb: undefined, + action: undefined + } + + yar.flash(SAVE_AND_EXIT_PAYLOAD, pagePayload, true) + return h.redirect( !isPreview ? `/save-and-exit/${slug}` diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 50a7bec9c..35ef1f064 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -2,10 +2,12 @@ import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' import { stateSchema } from '@defra/forms-engine-plugin/schema.js' import { slugSchema } from '@defra/forms-model' import Boom from '@hapi/boom' +import * as Hoek from '@hapi/hoek' import { StatusCodes } from 'http-status-codes' import Joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' +import { SAVE_AND_EXIT_PAYLOAD } from '~/src/server/constants.js' import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' import { confirmationViewModel, @@ -59,6 +61,19 @@ export default [ const metadata = await getFormMetadata(slug) const model = detailsViewModel(metadata, status) + // Merge current form state with any outstanding data from the current page + // (in case the current page wasn't yet validated and saved) + const cacheService = getCacheService(request.server) + const formState = await cacheService.getState(request) + const pagePayload = request.yar.flash(SAVE_AND_EXIT_PAYLOAD) + const stashedPayload = Array.isArray(pagePayload) + ? {} + : /** @type {FormPayload} */ (pagePayload) + const combinedState = Hoek.merge(formState, stashedPayload, { + mergeArrays: false + }) + await cacheService.setState(request, combinedState) + // Clear any previous save and exit session state request.yar.clear(getKey(slug, status)) @@ -408,5 +423,6 @@ export default [ /** * @import { ServerRoute } from '@hapi/hapi' + * @import { FormPayload } from '@defra/forms-engine-plugin/engine/types.js' * @import { SaveAndExitParams, SaveAndExitPayload, SaveAndExitResumePasswordPayload, SaveAndExitResumePasswordParams } from '~/src/server/models/save-and-exit.js' */ diff --git a/src/typings/hapi/index.d.ts b/src/typings/hapi/index.d.ts index 4b976d1a7..0b3dc82b9 100644 --- a/src/typings/hapi/index.d.ts +++ b/src/typings/hapi/index.d.ts @@ -5,6 +5,7 @@ import { type Plugin } from '@hapi/hapi' import { type ServerYar, type Yar } from '@hapi/yar' import { type Logger } from 'pino' +import { type SAVE_AND_EXIT_PAYLOAD } from '~/src/server/constants.js' import { type CacheService } from '~/src/server/services/index.js' declare module '@hapi/hapi' { @@ -89,3 +90,9 @@ declare module 'hapi-pulse' { export = hapiPulse } + +declare module '@hapi/yar' { + interface YarFlashes { + [SAVE_AND_EXIT_PAYLOAD]: object + } +} From 13d33ab5825c7ce36c79dfbec6dd2cbb1d23a6ae Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 18 Dec 2025 14:55:49 +0000 Subject: [PATCH 02/12] Extra coverage --- src/server/index.test.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 491077a66..f0e32437a 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -3,7 +3,7 @@ import { type Server } from '@hapi/hapi' import { StatusCodes } from 'http-status-codes' import { FORM_PREFIX } from '~/src/server/constants.js' -import { createServer } from '~/src/server/index.js' +import { configureEnginePlugin, createServer } from '~/src/server/index.js' import { getFormDefinition, getFormMetadata @@ -488,4 +488,33 @@ describe('Model cache', () => { expect(res.statusCode).toBe(StatusCodes.OK) }) }) + + describe('configureEnginePlugin', () => { + const mockFlash = jest.fn() + const mockYar = { + flash: mockFlash + } + const mockRequest = /** @type {any} */ { + params: { + slug: 'test-form' + }, + yar: mockYar + } + const mockH = { + redirect: jest.fn() + } + test('should handle save and exit', async () => { + const [pluginObject] = await configureEnginePlugin({}) + expect(pluginObject).toBeDefined() + const saveAndExitFunc = pluginObject.options.saveAndExit + expect(saveAndExitFunc).toBeDefined() + saveAndExitFunc(mockRequest, mockH, undefined) + expect(mockFlash).toHaveBeenCalledWith( + 'SAVE_AND_EXIT_PAYLOAD', + { action: undefined, crumb: undefined }, + true + ) + expect(mockH.redirect).toHaveBeenCalledWith('/save-and-exit/test-form') + }) + }) }) From 8a91e84a2e86a1007c8ae7729ad080c94b5903ff Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 18 Dec 2025 15:08:05 +0000 Subject: [PATCH 03/12] Updated plugin version --- package-lock.json | 35 +++++++++++++++++++++++++++++++---- package.json | 2 +- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 083cee8e7..c44061edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.934.0", - "@defra/forms-engine-plugin": "^4.0.30", + "@defra/forms-engine-plugin": "^4.0.32", "@defra/forms-model": "^3.0.592", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", @@ -861,6 +861,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2717,6 +2718,7 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2830,6 +2832,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2853,6 +2856,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2905,9 +2909,9 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "4.0.30", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-4.0.30.tgz", - "integrity": "sha512-iBczTTFYEZTSgPSLzDYoLi2BT+6zMKrNnEDCIXkXYRlznwSh3XFw0BppqOC9q2PTjARQj34VTRxFG+I2YcK4mg==", + "version": "4.0.32", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-4.0.32.tgz", + "integrity": "sha512-/yY28YR8x0vEk7zlhj+Ym30glXgFGQsYwK1IHHwXkUHHPJm+g1dOTWIWtqULlrWWhI3qF9jeQBzb5SyeOo+sfQ==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -6606,6 +6610,7 @@ "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.47.0", @@ -6636,6 +6641,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -7420,6 +7426,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7885,6 +7892,7 @@ "integrity": "sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/sinon": "^17.0.3", "sinon": "^18.0.1", @@ -8300,6 +8308,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -10056,6 +10065,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -10306,6 +10316,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10479,6 +10490,7 @@ "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "builtins": "^5.0.1", @@ -10542,6 +10554,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -12618,6 +12631,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -13705,6 +13719,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13714,6 +13729,7 @@ "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", @@ -13782,6 +13798,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -15398,6 +15415,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15961,6 +15979,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -17209,6 +17228,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -18003,6 +18023,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -18738,6 +18759,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18903,6 +18925,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -19037,6 +19060,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19122,6 +19146,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -19322,6 +19347,7 @@ "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -19390,6 +19416,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/package.json b/package.json index 507206f92..cff8e6e44 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.934.0", - "@defra/forms-engine-plugin": "^4.0.30", + "@defra/forms-engine-plugin": "^4.0.32", "@defra/forms-model": "^3.0.592", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", From 145c2d6fcb4ede5dcb91945b5723319a6527bff9 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 19 Dec 2025 12:26:11 +0000 Subject: [PATCH 04/12] Saves path and invalid state --- src/server/routes/save-and-exit.js | 28 ++++++++++++++++++++++------ src/server/utils/utils.js | 13 +++++++++++++ src/server/utils/utils.test.js | 8 ++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 35ef1f064..2c7df83dc 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -1,3 +1,7 @@ +import { + CURRENT_PAGE_PATH, + STATE_POTENTIALLY_INVALID +} from '@defra/forms-engine-plugin' import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' import { stateSchema } from '@defra/forms-engine-plugin/schema.js' import { slugSchema } from '@defra/forms-model' @@ -29,6 +33,7 @@ import { getSaveAndExitDetails, validateSaveAndExitCredentials } from '~/src/server/services/formsService.js' +import { getCallingPath } from '~/src/server/utils/utils.js' const logger = createLogger() const maxInvalidPasswordAttempts = 5 @@ -61,17 +66,28 @@ export default [ const metadata = await getFormMetadata(slug) const model = detailsViewModel(metadata, status) - // Merge current form state with any outstanding data from the current page - // (in case the current page wasn't yet validated and saved) + // Store any outstanding data from the current page in a special attribute + // (in case the current page wasn't yet validated and saved). + // The current page state may be invalid so we don't want to push into the cache as normal properties. const cacheService = getCacheService(request.server) const formState = await cacheService.getState(request) const pagePayload = request.yar.flash(SAVE_AND_EXIT_PAYLOAD) - const stashedPayload = Array.isArray(pagePayload) + const currentPagePayload = Array.isArray(pagePayload) ? {} : /** @type {FormPayload} */ (pagePayload) - const combinedState = Hoek.merge(formState, stashedPayload, { - mergeArrays: false - }) + + const combinedState = Hoek.merge( + formState, + { + [STATE_POTENTIALLY_INVALID]: { + ...currentPagePayload, + [CURRENT_PAGE_PATH]: getCallingPath(request) + } + }, + { + mergeArrays: false + } + ) await cacheService.setState(request, combinedState) // Clear any previous save and exit session state diff --git a/src/server/utils/utils.js b/src/server/utils/utils.js index 8133bab8c..914f25d51 100644 --- a/src/server/utils/utils.js +++ b/src/server/utils/utils.js @@ -30,3 +30,16 @@ export function applyTraceHeaders( export function getFeedbackFormLink(formId) { return { feedbackLink: `/form/feedback?formId=${formId}` } } + +/** + * Extracts the path of the calling page + * @param {AnyFormRequest} request + */ +export function getCallingPath(request) { + const url = new URL(request.headers.referer) + return url.pathname +} + +/** + * @import { AnyFormRequest } from '@defra/forms-engine-plugin/engine/types.js' + */ diff --git a/src/server/utils/utils.test.js b/src/server/utils/utils.test.js index 23d528c56..2951043cd 100644 --- a/src/server/utils/utils.test.js +++ b/src/server/utils/utils.test.js @@ -3,6 +3,7 @@ import { getTraceId } from '@defra/hapi-tracing' import { config } from '~/src/config/index.js' import { applyTraceHeaders, + getCallingPath, getFeedbackFormLink } from '~/src/server/utils/utils.js' @@ -64,4 +65,11 @@ describe('utils', () => { }) }) }) + + describe('getCallingPath', () => { + it('should return path', () => { + const mockRequest = { headers: { referer: 'http:/my-site.com/my-path' } } + expect(getCallingPath(mockRequest)).toBe('/my-path') + }) + }) }) From 287d7255a157b939a0b5690b1c1e941ef8fa71af Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 19 Dec 2025 12:35:30 +0000 Subject: [PATCH 05/12] Fixed test --- src/server/utils/utils.js | 2 +- src/server/utils/utils.test.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/utils/utils.js b/src/server/utils/utils.js index 914f25d51..1ed62108e 100644 --- a/src/server/utils/utils.js +++ b/src/server/utils/utils.js @@ -36,7 +36,7 @@ export function getFeedbackFormLink(formId) { * @param {AnyFormRequest} request */ export function getCallingPath(request) { - const url = new URL(request.headers.referer) + const url = new URL(request.headers?.referer ?? request.url) return url.pathname } diff --git a/src/server/utils/utils.test.js b/src/server/utils/utils.test.js index 2951043cd..1d4ca54bd 100644 --- a/src/server/utils/utils.test.js +++ b/src/server/utils/utils.test.js @@ -71,5 +71,9 @@ describe('utils', () => { const mockRequest = { headers: { referer: 'http:/my-site.com/my-path' } } expect(getCallingPath(mockRequest)).toBe('/my-path') }) + it('should fallback to current url if no referer', () => { + const mockRequest = { url: 'http:/my-site.com/my-path' } + expect(getCallingPath(mockRequest)).toBe('/my-path') + }) }) }) From 5bdb969c1fbe245e8e02fa4c2ede44e2976bf425 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 19 Dec 2025 14:04:10 +0000 Subject: [PATCH 06/12] Change of property name --- src/server/routes/save-and-exit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 2c7df83dc..46cb939bc 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -1,6 +1,6 @@ import { CURRENT_PAGE_PATH, - STATE_POTENTIALLY_INVALID + STATE_NOT_YET_VALIDATED } from '@defra/forms-engine-plugin' import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' import { stateSchema } from '@defra/forms-engine-plugin/schema.js' @@ -79,7 +79,7 @@ export default [ const combinedState = Hoek.merge( formState, { - [STATE_POTENTIALLY_INVALID]: { + [STATE_NOT_YET_VALIDATED]: { ...currentPagePayload, [CURRENT_PAGE_PATH]: getCallingPath(request) } From 3a85c5681286cdb2960caa9b4e4d62fe8dd2e6d3 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 8 Jan 2026 14:20:38 +0000 Subject: [PATCH 07/12] Saves URL at point of saving payload to flash --- src/server/index.test.ts | 7 +++++-- src/server/index.ts | 5 +++-- src/server/routes/save-and-exit.js | 5 +++-- src/server/utils/utils.js | 9 --------- src/server/utils/utils.test.js | 12 ------------ 5 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src/server/index.test.ts b/src/server/index.test.ts index f0e32437a..aaa2ad100 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -498,7 +498,10 @@ describe('Model cache', () => { params: { slug: 'test-form' }, - yar: mockYar + yar: mockYar, + url: { + pathname: '/my-path' + } } const mockH = { redirect: jest.fn() @@ -511,7 +514,7 @@ describe('Model cache', () => { saveAndExitFunc(mockRequest, mockH, undefined) expect(mockFlash).toHaveBeenCalledWith( 'SAVE_AND_EXIT_PAYLOAD', - { action: undefined, crumb: undefined }, + { action: undefined, crumb: undefined, __currentPagePath: '/my-path' }, true ) expect(mockH.redirect).toHaveBeenCalledWith('/save-and-exit/test-form') diff --git a/src/server/index.ts b/src/server/index.ts index 15b61d685..efa742918 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,6 @@ import { join, parse } from 'path' -import plugin from '@defra/forms-engine-plugin' +import plugin, { CURRENT_PAGE_PATH } from '@defra/forms-engine-plugin' import { checkFormStatus } from '@defra/forms-engine-plugin/engine/helpers.js' import { FormModel } from '@defra/forms-engine-plugin/engine/models/FormModel.js' import { formSubmissionService } from '@defra/forms-engine-plugin/services/index.js' @@ -168,7 +168,8 @@ export const configureEnginePlugin = async ({ const pagePayload = { ...request.payload, crumb: undefined, - action: undefined + action: undefined, + [CURRENT_PAGE_PATH]: request.url.pathname } yar.flash(SAVE_AND_EXIT_PAYLOAD, pagePayload, true) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 46cb939bc..0a9830cbd 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -33,7 +33,6 @@ import { getSaveAndExitDetails, validateSaveAndExitCredentials } from '~/src/server/services/formsService.js' -import { getCallingPath } from '~/src/server/utils/utils.js' const logger = createLogger() const maxInvalidPasswordAttempts = 5 @@ -75,13 +74,15 @@ export default [ const currentPagePayload = Array.isArray(pagePayload) ? {} : /** @type {FormPayload} */ (pagePayload) + const currentPagePath = + CURRENT_PAGE_PATH in pagePayload ? pagePayload[CURRENT_PAGE_PATH] : '/' const combinedState = Hoek.merge( formState, { [STATE_NOT_YET_VALIDATED]: { ...currentPagePayload, - [CURRENT_PAGE_PATH]: getCallingPath(request) + [CURRENT_PAGE_PATH]: currentPagePath } }, { diff --git a/src/server/utils/utils.js b/src/server/utils/utils.js index 1ed62108e..7159b4f4d 100644 --- a/src/server/utils/utils.js +++ b/src/server/utils/utils.js @@ -31,15 +31,6 @@ export function getFeedbackFormLink(formId) { return { feedbackLink: `/form/feedback?formId=${formId}` } } -/** - * Extracts the path of the calling page - * @param {AnyFormRequest} request - */ -export function getCallingPath(request) { - const url = new URL(request.headers?.referer ?? request.url) - return url.pathname -} - /** * @import { AnyFormRequest } from '@defra/forms-engine-plugin/engine/types.js' */ diff --git a/src/server/utils/utils.test.js b/src/server/utils/utils.test.js index 1d4ca54bd..23d528c56 100644 --- a/src/server/utils/utils.test.js +++ b/src/server/utils/utils.test.js @@ -3,7 +3,6 @@ import { getTraceId } from '@defra/hapi-tracing' import { config } from '~/src/config/index.js' import { applyTraceHeaders, - getCallingPath, getFeedbackFormLink } from '~/src/server/utils/utils.js' @@ -65,15 +64,4 @@ describe('utils', () => { }) }) }) - - describe('getCallingPath', () => { - it('should return path', () => { - const mockRequest = { headers: { referer: 'http:/my-site.com/my-path' } } - expect(getCallingPath(mockRequest)).toBe('/my-path') - }) - it('should fallback to current url if no referer', () => { - const mockRequest = { url: 'http:/my-site.com/my-path' } - expect(getCallingPath(mockRequest)).toBe('/my-path') - }) - }) }) From 35f92850ec19b8fa2e4fe2aacaa056e099974aff Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 8 Jan 2026 14:58:47 +0000 Subject: [PATCH 08/12] Removed fallback '/' --- src/server/routes/save-and-exit.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 0a9830cbd..adec41628 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -75,7 +75,9 @@ export default [ ? {} : /** @type {FormPayload} */ (pagePayload) const currentPagePath = - CURRENT_PAGE_PATH in pagePayload ? pagePayload[CURRENT_PAGE_PATH] : '/' + CURRENT_PAGE_PATH in pagePayload + ? pagePayload[CURRENT_PAGE_PATH] + : undefined const combinedState = Hoek.merge( formState, From 40bd340a9db14373fa594c4eaa501da7572eeb79 Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 8 Jan 2026 15:47:13 +0000 Subject: [PATCH 09/12] Bypass save if not path stored --- src/server/routes/save-and-exit.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index adec41628..7d1645921 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -79,19 +79,21 @@ export default [ ? pagePayload[CURRENT_PAGE_PATH] : undefined - const combinedState = Hoek.merge( - formState, - { - [STATE_NOT_YET_VALIDATED]: { - ...currentPagePayload, - [CURRENT_PAGE_PATH]: currentPagePath + if (currentPagePath) { + const combinedState = Hoek.merge( + formState, + { + [STATE_NOT_YET_VALIDATED]: { + ...currentPagePayload, + [CURRENT_PAGE_PATH]: currentPagePath + } + }, + { + mergeArrays: false } - }, - { - mergeArrays: false - } - ) - await cacheService.setState(request, combinedState) + ) + await cacheService.setState(request, combinedState) + } // Clear any previous save and exit session state request.yar.clear(getKey(slug, status)) From 719c9a9f664ed33e81ef3b23b175446caffe6aee Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Thu, 8 Jan 2026 16:03:23 +0000 Subject: [PATCH 10/12] Stash --- package.json | 2 +- src/server/index.ts | 4 ++-- src/server/routes/save-and-exit.js | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index cff8e6e44..a7e464780 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.934.0", - "@defra/forms-engine-plugin": "^4.0.32", + "@defra/forms-engine-plugin": "^4.0.35", "@defra/forms-model": "^3.0.592", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", diff --git a/src/server/index.ts b/src/server/index.ts index efa742918..21164468b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,6 @@ import { join, parse } from 'path' -import plugin, { CURRENT_PAGE_PATH } from '@defra/forms-engine-plugin' +import plugin, { CURRENT_PAGE_PATH_KEY } from '@defra/forms-engine-plugin' import { checkFormStatus } from '@defra/forms-engine-plugin/engine/helpers.js' import { FormModel } from '@defra/forms-engine-plugin/engine/models/FormModel.js' import { formSubmissionService } from '@defra/forms-engine-plugin/services/index.js' @@ -169,7 +169,7 @@ export const configureEnginePlugin = async ({ ...request.payload, crumb: undefined, action: undefined, - [CURRENT_PAGE_PATH]: request.url.pathname + [CURRENT_PAGE_PATH_KEY]: request.url.pathname } yar.flash(SAVE_AND_EXIT_PAYLOAD, pagePayload, true) diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 7d1645921..e121b08a3 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -1,5 +1,5 @@ import { - CURRENT_PAGE_PATH, + CURRENT_PAGE_PATH_KEY, STATE_NOT_YET_VALIDATED } from '@defra/forms-engine-plugin' import { getCacheService } from '@defra/forms-engine-plugin/engine/helpers.js' @@ -75,8 +75,8 @@ export default [ ? {} : /** @type {FormPayload} */ (pagePayload) const currentPagePath = - CURRENT_PAGE_PATH in pagePayload - ? pagePayload[CURRENT_PAGE_PATH] + CURRENT_PAGE_PATH_KEY in pagePayload + ? pagePayload[CURRENT_PAGE_PATH_KEY] : undefined if (currentPagePath) { @@ -85,7 +85,7 @@ export default [ { [STATE_NOT_YET_VALIDATED]: { ...currentPagePayload, - [CURRENT_PAGE_PATH]: currentPagePath + [CURRENT_PAGE_PATH_KEY]: currentPagePath } }, { From 08048d2b0f8dee526d1e0d79b87f5be96d202cce Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 9 Jan 2026 09:23:29 +0000 Subject: [PATCH 11/12] Extra coverage Extracted method to new file for easy mock --- src/server/routes/save-and-exit-helper.js | 14 +++++++++ src/server/routes/save-and-exit.js | 10 +++--- test/form/save-and-exit.test.js | 38 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 src/server/routes/save-and-exit-helper.js diff --git a/src/server/routes/save-and-exit-helper.js b/src/server/routes/save-and-exit-helper.js new file mode 100644 index 000000000..df5d846f3 --- /dev/null +++ b/src/server/routes/save-and-exit-helper.js @@ -0,0 +1,14 @@ +import { SAVE_AND_EXIT_PAYLOAD } from '~/src/server/constants.js' + +/** + * Get Save and Exit payload - extracted here to a separate file so it can easily be mocked + * @param {Request<{ Params: SaveAndExitParams }>} request + */ +export function getPayloadFromFlash(request) { + return request.yar.flash(SAVE_AND_EXIT_PAYLOAD) +} + +/** + * @import { Request } from '@hapi/hapi' + * @import { SaveAndExitParams } from '~/src/server/models/save-and-exit.js' + */ diff --git a/src/server/routes/save-and-exit.js b/src/server/routes/save-and-exit.js index 948ae12aa..181e66f82 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -11,7 +11,6 @@ import { StatusCodes } from 'http-status-codes' import Joi from 'joi' import { createLogger } from '~/src/server/common/helpers/logging/logger.js' -import { SAVE_AND_EXIT_PAYLOAD } from '~/src/server/constants.js' import { publishSaveAndExitEvent } from '~/src/server/messaging/publish.js' import { confirmationViewModel, @@ -27,6 +26,7 @@ import { resumeSuccessViewModel, validatePayloadSchema } from '~/src/server/models/save-and-exit.js' +import { getPayloadFromFlash } from '~/src/server/routes/save-and-exit-helper.js' import { getFormMetadata, getFormMetadataById, @@ -70,13 +70,13 @@ export default [ // The current page state may be invalid so we don't want to push into the cache as normal properties. const cacheService = getCacheService(request.server) const formState = await cacheService.getState(request) - const pagePayload = request.yar.flash(SAVE_AND_EXIT_PAYLOAD) + const pagePayload = getPayloadFromFlash(request) const currentPagePayload = Array.isArray(pagePayload) ? {} - : /** @type {FormPayload} */ (pagePayload) + : /** @type { FormPayload | undefined } */ (pagePayload) const currentPagePath = - CURRENT_PAGE_PATH_KEY in pagePayload - ? pagePayload[CURRENT_PAGE_PATH_KEY] + currentPagePayload && CURRENT_PAGE_PATH_KEY in currentPagePayload + ? currentPagePayload[CURRENT_PAGE_PATH_KEY] : undefined if (currentPagePath) { diff --git a/test/form/save-and-exit.test.js b/test/form/save-and-exit.test.js index 360faea7d..a7dce66bc 100644 --- a/test/form/save-and-exit.test.js +++ b/test/form/save-and-exit.test.js @@ -4,6 +4,7 @@ import { within } from '@testing-library/dom' import { StatusCodes } from 'http-status-codes' import { createServer } from '~/src/server/index.js' +import { getPayloadFromFlash } from '~/src/server/routes/save-and-exit-helper.js' import { getFormMetadata } from '~/src/server/services/formsService.js' import * as fixtures from '~/test/fixtures/index.js' import { renderResponse } from '~/test/helpers/component-helpers.js' @@ -11,6 +12,7 @@ import { getCookieHeader } from '~/test/utils/get-cookie.js' jest.mock('~/src/server/services/formsService.js') jest.mock('~/src/server/messaging/publish.js') +jest.mock('~/src/server/routes/save-and-exit-helper.js') describe('Save and exit', () => { /** @type {Server} */ @@ -66,6 +68,42 @@ describe('Save and exit', () => { expect($securityAnswerLabel).toBeInTheDocument() }) + it('shows the details page when current path is stored', async () => { + const options = { + method: 'GET', + url: '/save-and-exit/basic' + } + + // @ts-expect-error - partial mock of payload + jest + .mocked(getPayloadFromFlash) + .mockReturnValueOnce({ __currentPagePath: '/the-current-path' }) + + const { container } = await renderResponse(server, options) + + const $heading = container.getByRole('heading', { + name: 'Save your progress for later', + level: 1 + }) + + const $emailLabel = container.getByLabelText('Your email address') + const $emailConfirmationLabel = container.getByLabelText( + 'Confirm your email address' + ) + const $securityQuestionLegend = container.getByRole('group', { + name: 'Choose a security question to answer' + }) + const $securityAnswerLabel = container.getByLabelText( + 'Your answer to the security question' + ) + + expect($heading).toBeInTheDocument() + expect($emailLabel).toBeInTheDocument() + expect($emailConfirmationLabel).toBeInTheDocument() + expect($securityQuestionLegend).toBeInTheDocument() + expect($securityAnswerLabel).toBeInTheDocument() + }) + it('shows the details page with errors', async () => { const options = { method: 'POST', From ba1d001a92779dbf471a036f06b402a2fbf4542e Mon Sep 17 00:00:00 2001 From: Jez Barnsley Date: Fri, 9 Jan 2026 09:25:12 +0000 Subject: [PATCH 12/12] Lint fix --- test/form/save-and-exit.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/form/save-and-exit.test.js b/test/form/save-and-exit.test.js index a7dce66bc..aeb7d7bf4 100644 --- a/test/form/save-and-exit.test.js +++ b/test/form/save-and-exit.test.js @@ -74,9 +74,9 @@ describe('Save and exit', () => { url: '/save-and-exit/basic' } - // @ts-expect-error - partial mock of payload jest .mocked(getPayloadFromFlash) + // @ts-expect-error - partial mock of payload .mockReturnValueOnce({ __currentPagePath: '/the-current-path' }) const { container } = await renderResponse(server, options)