diff --git a/package-lock.json b/package-lock.json index de40ff8fb..5020fca90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.965.0", - "@defra/forms-engine-plugin": "^4.0.34", + "@defra/forms-engine-plugin": "^4.0.35", "@defra/forms-model": "^3.0.601", "@defra/hapi-tracing": "^1.30.0", "@elastic/ecs-pino-format": "^1.5.0", @@ -881,6 +881,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", @@ -2737,6 +2738,7 @@ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2850,6 +2852,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2893,6 +2896,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2945,9 +2949,9 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "4.0.34", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-4.0.34.tgz", - "integrity": "sha512-Irc2rSbN9NkZml/6V1+3dhGvCEde/Q7Q92hx0X3q13V1Pg4cDdj4vdM3jO2MMzb6S+AADk1sZ2+2v3Q0XExwWA==", + "version": "4.0.35", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-4.0.35.tgz", + "integrity": "sha512-E060jnGEppHfImesRcLoF27vNxJVFQBlaYwdzTjzUE91KBlmgPtpwa6A0EXiTAIUPge6n+sDJUl7nHXwxaBAlg==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { @@ -6646,6 +6650,7 @@ "integrity": "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", @@ -6675,6 +6680,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -7458,6 +7464,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7922,6 +7929,7 @@ "integrity": "sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/sinon": "^17.0.3", "sinon": "^18.0.1", @@ -8337,6 +8345,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10097,6 +10106,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", @@ -10347,6 +10357,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10520,6 +10531,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", @@ -10583,6 +10595,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" }, @@ -12659,6 +12672,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -13746,6 +13760,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13755,6 +13770,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", @@ -13823,6 +13839,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -15428,6 +15445,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15991,6 +16009,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -17261,6 +17280,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", @@ -18055,6 +18075,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -18784,6 +18805,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18949,6 +18971,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -19083,6 +19106,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19168,6 +19192,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -19368,6 +19393,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -19436,6 +19462,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 89c994a85..e66b40118 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.965.0", - "@defra/forms-engine-plugin": "^4.0.34", + "@defra/forms-engine-plugin": "^4.0.35", "@defra/forms-model": "^3.0.601", "@defra/hapi-tracing": "^1.30.0", "@elastic/ecs-pino-format": "^1.5.0", 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.test.ts b/src/server/index.test.ts index 491077a66..aaa2ad100 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,36 @@ 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, + url: { + pathname: '/my-path' + } + } + 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, __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 b00bffb8c..21164468b 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_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' @@ -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,21 @@ 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, + [CURRENT_PAGE_PATH_KEY]: request.url.pathname + } + + 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-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 504a9a6f6..181e66f82 100644 --- a/src/server/routes/save-and-exit.js +++ b/src/server/routes/save-and-exit.js @@ -1,7 +1,12 @@ +import { + CURRENT_PAGE_PATH_KEY, + 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' 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' @@ -21,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, @@ -59,6 +65,36 @@ export default [ const metadata = await getFormMetadata(slug) const model = detailsViewModel(metadata, status) + // 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 = getPayloadFromFlash(request) + const currentPagePayload = Array.isArray(pagePayload) + ? {} + : /** @type { FormPayload | undefined } */ (pagePayload) + const currentPagePath = + currentPagePayload && CURRENT_PAGE_PATH_KEY in currentPagePayload + ? currentPagePayload[CURRENT_PAGE_PATH_KEY] + : undefined + + if (currentPagePath) { + const combinedState = Hoek.merge( + formState, + { + [STATE_NOT_YET_VALIDATED]: { + ...currentPagePayload, + [CURRENT_PAGE_PATH_KEY]: currentPagePath + } + }, + { + mergeArrays: false + } + ) + await cacheService.setState(request, combinedState) + } + // Clear any previous save and exit session state request.yar.clear(getKey(slug, status)) @@ -414,5 +450,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/server/utils/utils.js b/src/server/utils/utils.js index 8133bab8c..7159b4f4d 100644 --- a/src/server/utils/utils.js +++ b/src/server/utils/utils.js @@ -30,3 +30,7 @@ export function applyTraceHeaders( export function getFeedbackFormLink(formId) { return { feedbackLink: `/form/feedback?formId=${formId}` } } + +/** + * @import { AnyFormRequest } from '@defra/forms-engine-plugin/engine/types.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 + } +} diff --git a/test/form/save-and-exit.test.js b/test/form/save-and-exit.test.js index 360faea7d..aeb7d7bf4 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' + } + + jest + .mocked(getPayloadFromFlash) + // @ts-expect-error - partial mock of payload + .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',