@defra/forms-engine-plugin 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.server/server/plugins/engine/index.js +1 -1
  2. package/.server/server/plugins/engine/index.js.map +1 -1
  3. package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +1 -0
  4. package/.server/server/plugins/engine/models/SummaryViewModel.js +1 -0
  5. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  6. package/.server/server/plugins/engine/options.js +5 -1
  7. package/.server/server/plugins/engine/options.js.map +1 -1
  8. package/.server/server/plugins/engine/options.test.js +20 -0
  9. package/.server/server/plugins/engine/options.test.js.map +1 -1
  10. package/.server/server/plugins/engine/pageControllers/QuestionPageController.d.ts +5 -0
  11. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +27 -1
  12. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  13. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
  14. package/.server/server/plugins/engine/pageControllers/StartPageController.js +3 -0
  15. package/.server/server/plugins/engine/pageControllers/StartPageController.js.map +1 -1
  16. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -0
  17. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +4 -0
  18. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  19. package/.server/server/plugins/engine/plugin.js +5 -2
  20. package/.server/server/plugins/engine/plugin.js.map +1 -1
  21. package/.server/server/plugins/engine/routes/exit.d.ts +46 -0
  22. package/.server/server/plugins/engine/routes/exit.js +36 -0
  23. package/.server/server/plugins/engine/routes/exit.js.map +1 -0
  24. package/.server/server/plugins/engine/types.d.ts +6 -2
  25. package/.server/server/plugins/engine/types.js.map +1 -1
  26. package/.server/server/plugins/engine/views/exit.html +31 -0
  27. package/.server/server/plugins/engine/views/partials/form.html +17 -6
  28. package/.server/server/routes/types.d.ts +2 -1
  29. package/.server/server/routes/types.js +1 -0
  30. package/.server/server/routes/types.js.map +1 -1
  31. package/.server/server/schemas/index.js +1 -1
  32. package/.server/server/schemas/index.js.map +1 -1
  33. package/.server/server/services/cacheService.d.ts +2 -0
  34. package/.server/server/services/cacheService.js +9 -5
  35. package/.server/server/services/cacheService.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/server/index.test.ts +39 -0
  38. package/src/server/plugins/engine/components/helpers.test.ts +31 -0
  39. package/src/server/plugins/engine/index.ts +1 -3
  40. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +59 -0
  41. package/src/server/plugins/engine/models/SummaryViewModel.ts +1 -0
  42. package/src/server/plugins/engine/options.js +5 -1
  43. package/src/server/plugins/engine/options.test.js +20 -0
  44. package/src/server/plugins/engine/pageControllers/PageController.test.ts +25 -0
  45. package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +178 -1
  46. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +28 -1
  47. package/src/server/plugins/engine/pageControllers/StartPageController.ts +4 -0
  48. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +5 -0
  49. package/src/server/plugins/engine/plugin.ts +5 -1
  50. package/src/server/plugins/engine/routes/exit.ts +47 -0
  51. package/src/server/plugins/engine/types.ts +10 -4
  52. package/src/server/plugins/engine/views/exit.html +31 -0
  53. package/src/server/plugins/engine/views/partials/form.html +17 -6
  54. package/src/server/routes/types.ts +2 -1
  55. package/src/server/schemas/index.ts +2 -1
  56. package/src/server/services/cacheService.test.ts +45 -0
  57. package/src/server/services/cacheService.ts +20 -9
@@ -51,6 +51,7 @@ export class SummaryViewModel {
51
51
  serviceUrl: string
52
52
  hasMissingNotificationEmail?: boolean
53
53
  components?: ComponentViewModel[]
54
+ allowSaveAndReturn?: boolean
54
55
 
55
56
  constructor(
56
57
  request: FormContextRequest,
@@ -9,6 +9,7 @@ const pluginRegistrationOptionsSchema = Joi.object({
9
9
  services: Joi.object().optional(),
10
10
  controllers: Joi.object().pattern(Joi.string(), Joi.any()).optional(),
11
11
  cacheName: Joi.string().optional(),
12
+ globals: Joi.object().pattern(Joi.string(), Joi.any()).optional(),
12
13
  filters: Joi.object().pattern(Joi.string(), Joi.any()).optional(),
13
14
  pluginPath: Joi.string().optional(),
14
15
  nunjucks: Joi.object({
@@ -18,7 +19,10 @@ const pluginRegistrationOptionsSchema = Joi.object({
18
19
  viewContext: Joi.function().required(),
19
20
  preparePageEventRequestOptions: Joi.function().optional(),
20
21
  onRequest: Joi.function().optional(),
21
- baseUrl: Joi.string().uri().required()
22
+ baseUrl: Joi.string().uri().required(),
23
+ keyGenerator: Joi.function().optional(),
24
+ sessionHydrator: Joi.function().optional(),
25
+ sessionPersister: Joi.function().optional()
22
26
  })
23
27
 
24
28
  /**
@@ -16,6 +16,26 @@ describe('validatePluginOptions', () => {
16
16
  expect(validatePluginOptions(validOptions)).toEqual(validOptions)
17
17
  })
18
18
 
19
+ it('accepts optional properties keyGenerator, sessionHydrator, and sessionPersister', () => {
20
+ const validOptionsWithOptionals = {
21
+ nunjucks: {
22
+ baseLayoutPath: 'dxt-devtool-baselayout.html',
23
+ paths: ['src/server/devserver']
24
+ },
25
+ viewContext: () => {
26
+ return { hello: 'world' }
27
+ },
28
+ baseUrl: 'http://localhost:3009',
29
+ keyGenerator: () => 'test-key',
30
+ sessionHydrator: () => Promise.resolve({ someState: 'value' }),
31
+ sessionPersister: () => Promise.resolve(undefined)
32
+ }
33
+
34
+ expect(validatePluginOptions(validOptionsWithOptionals)).toEqual(
35
+ validOptionsWithOptionals
36
+ )
37
+ })
38
+
19
39
  /**
20
40
  * tsc would usually check compliance with the type, but given a user might be using plain JS we still want a test
21
41
  */
@@ -204,5 +204,30 @@ describe('PageController', () => {
204
204
  'Unsupported POST route handler for this page'
205
205
  )
206
206
  })
207
+
208
+ it('supports save and return functionality', async () => {
209
+ const mockRequest = {
210
+ ...request,
211
+ payload: { saveAndReturn: true }
212
+ } as FormRequest
213
+
214
+ const mockResponse = {
215
+ redirect: jest.fn(),
216
+ view: jest.fn()
217
+ } as unknown as ResponseToolkit
218
+
219
+ await controller1.makeGetRouteHandler()(
220
+ mockRequest,
221
+ model.getFormContext(mockRequest, { $$__referenceNumber: 'test-ref' }),
222
+ mockResponse
223
+ )
224
+
225
+ expect(mockResponse.view).toHaveBeenCalledWith(
226
+ controller1.viewName,
227
+ expect.objectContaining({
228
+ pageTitle: 'Buy a rod fishing licence'
229
+ })
230
+ )
231
+ })
207
232
  })
208
233
  })
@@ -10,7 +10,10 @@ import {
10
10
  type FormState,
11
11
  type FormSubmissionState
12
12
  } from '~/src/server/plugins/engine/types.js'
13
- import { type FormRequest } from '~/src/server/routes/types.js'
13
+ import {
14
+ type FormRequest,
15
+ type FormRequestPayload
16
+ } from '~/src/server/routes/types.js'
14
17
  import { CacheService } from '~/src/server/services/cacheService.js'
15
18
  import conditionalReveal from '~/test/form/definitions/conditional-reveal.js'
16
19
  import definitionConditionsBasic, {
@@ -1274,3 +1277,177 @@ describe('QuestionPageController V2', () => {
1274
1277
  })
1275
1278
  })
1276
1279
  })
1280
+
1281
+ describe('Save and Return functionality', () => {
1282
+ let model: FormModel
1283
+ let controller1: QuestionPageController
1284
+ let requestPage1: FormRequest
1285
+
1286
+ beforeEach(() => {
1287
+ const { pages } = definitionConditionsBasic
1288
+
1289
+ model = new FormModel(definitionConditionsBasic, {
1290
+ basePath: 'test'
1291
+ })
1292
+
1293
+ controller1 = new QuestionPageController(model, pages[0])
1294
+
1295
+ requestPage1 = {
1296
+ method: 'get',
1297
+ url: new URL('http://example.com/test/first-page'),
1298
+ path: '/test/first-page',
1299
+ params: {
1300
+ path: 'first-page',
1301
+ slug: 'test'
1302
+ },
1303
+ query: {},
1304
+ app: { model }
1305
+ } as FormRequest
1306
+ })
1307
+
1308
+ const response = {
1309
+ code: jest.fn().mockImplementation(() => response)
1310
+ }
1311
+
1312
+ const h: Pick<ResponseToolkit, 'redirect' | 'view'> = {
1313
+ redirect: jest.fn().mockReturnValue(response),
1314
+ view: jest.fn()
1315
+ }
1316
+
1317
+ beforeEach(() => {
1318
+ jest.clearAllMocks()
1319
+ jest.spyOn(CacheService.prototype, 'setState')
1320
+ })
1321
+
1322
+ describe('shouldShowSaveAndReturn', () => {
1323
+ it('should return true by default', () => {
1324
+ expect(controller1.shouldShowSaveAndReturn()).toBe(true)
1325
+ })
1326
+ })
1327
+
1328
+ describe('handleSaveAndReturn', () => {
1329
+ it('should save state and redirect to exit page', async () => {
1330
+ const state: FormSubmissionState = {
1331
+ $$__referenceNumber: 'foobar',
1332
+ yesNoField: true
1333
+ }
1334
+ const request = {
1335
+ ...requestPage1,
1336
+ method: 'post',
1337
+ payload: { yesNoField: true, action: 'save-and-return' }
1338
+ } as unknown as FormRequestPayload
1339
+
1340
+ const context = model.getFormContext(request, state)
1341
+
1342
+ jest.spyOn(controller1, 'setState').mockResolvedValue(state)
1343
+
1344
+ await controller1.handleSaveAndReturn(request, context, h)
1345
+
1346
+ expect(controller1.setState).toHaveBeenCalledWith(request, state)
1347
+ expect(h.redirect).toHaveBeenCalledWith('/test/exit')
1348
+ })
1349
+
1350
+ it('should handle save-and-return with incomplete data', async () => {
1351
+ const state: FormSubmissionState = {
1352
+ $$__referenceNumber: 'foobar',
1353
+ yesNoField: null
1354
+ }
1355
+ const request = {
1356
+ ...requestPage1,
1357
+ method: 'post',
1358
+ payload: { yesNoField: '', action: 'save-and-return' }
1359
+ } as unknown as FormRequestPayload
1360
+
1361
+ const context = model.getFormContext(request, state)
1362
+
1363
+ jest.spyOn(controller1, 'setState').mockResolvedValue(state)
1364
+
1365
+ await controller1.handleSaveAndReturn(request, context, h)
1366
+
1367
+ expect(controller1.setState).toHaveBeenCalledWith(request, state)
1368
+ expect(h.redirect).toHaveBeenCalledWith('/test/exit')
1369
+ })
1370
+
1371
+ it('should handle save-and-return with validation errors', async () => {
1372
+ const state: FormSubmissionState = { $$__referenceNumber: 'foobar' }
1373
+ const request = {
1374
+ ...requestPage1,
1375
+ method: 'post',
1376
+ payload: { action: 'save-and-return' }
1377
+ } as unknown as FormRequestPayload
1378
+
1379
+ const context = model.getFormContext(request, state)
1380
+
1381
+ jest.spyOn(controller1, 'setState').mockResolvedValue(state)
1382
+
1383
+ await controller1.handleSaveAndReturn(request, context, h)
1384
+
1385
+ expect(controller1.setState).toHaveBeenCalledWith(request, state)
1386
+ expect(h.redirect).toHaveBeenCalledWith('/test/exit')
1387
+ })
1388
+ })
1389
+
1390
+ describe('POST handler with save-and-return action', () => {
1391
+ it('should handle FormAction.SaveAndReturn', async () => {
1392
+ const state: FormSubmissionState = {
1393
+ $$__referenceNumber: 'foobar',
1394
+ yesNoField: true
1395
+ }
1396
+ const request = {
1397
+ ...requestPage1,
1398
+ method: 'post',
1399
+ payload: { yesNoField: true, action: 'save-and-return' }
1400
+ } as unknown as FormRequestPayload
1401
+
1402
+ const context = model.getFormContext(request, state)
1403
+
1404
+ jest.spyOn(controller1, 'getState').mockResolvedValue({})
1405
+ jest
1406
+ .spyOn(controller1, 'handleSaveAndReturn')
1407
+ .mockResolvedValue(h.redirect('/test/exit'))
1408
+
1409
+ const postHandler = controller1.makePostRouteHandler()
1410
+ await postHandler(request, context, h)
1411
+
1412
+ expect(controller1.handleSaveAndReturn).toHaveBeenCalledWith(
1413
+ request,
1414
+ context,
1415
+ h
1416
+ )
1417
+ })
1418
+
1419
+ it('should not call handleSaveAndReturn for continue action', async () => {
1420
+ const state: FormSubmissionState = {
1421
+ $$__referenceNumber: 'foobar',
1422
+ yesNoField: true
1423
+ }
1424
+ const request = {
1425
+ ...requestPage1,
1426
+ method: 'post',
1427
+ payload: { yesNoField: true, action: 'continue' }
1428
+ } as unknown as FormRequestPayload
1429
+
1430
+ const context = model.getFormContext(request, state)
1431
+
1432
+ jest.spyOn(controller1, 'getState').mockResolvedValue({})
1433
+ jest
1434
+ .spyOn(controller1, 'handleSaveAndReturn')
1435
+ .mockResolvedValue(h.redirect('/test/exit'))
1436
+ jest.spyOn(controller1, 'setState').mockResolvedValue(state)
1437
+
1438
+ const mockResponse = {
1439
+ code: jest.fn().mockReturnValue({ redirect: jest.fn() })
1440
+ }
1441
+
1442
+ const mockH = {
1443
+ redirect: jest.fn().mockReturnValue(mockResponse),
1444
+ view: jest.fn()
1445
+ }
1446
+
1447
+ const postHandler = controller1.makePostRouteHandler()
1448
+ await postHandler(request, context, mockH)
1449
+
1450
+ expect(controller1.handleSaveAndReturn).not.toHaveBeenCalled()
1451
+ })
1452
+ })
1453
+ })
@@ -33,6 +33,7 @@ import {
33
33
  type FormSubmissionState
34
34
  } from '~/src/server/plugins/engine/types.js'
35
35
  import {
36
+ FormAction,
36
37
  type FormRequest,
37
38
  type FormRequestPayload,
38
39
  type FormRequestPayloadRefs,
@@ -172,7 +173,8 @@ export class QuestionPageController extends PageController {
172
173
  context,
173
174
  showTitle,
174
175
  components,
175
- errors
176
+ errors,
177
+ allowSaveAndReturn: this.shouldShowSaveAndReturn()
176
178
  }
177
179
  }
178
180
 
@@ -510,6 +512,12 @@ export class QuestionPageController extends PageController {
510
512
  return h.view(viewName, viewModel)
511
513
  }
512
514
 
515
+ // Check if this is a save-and-return action
516
+ const { action } = request.payload
517
+ if (action === FormAction.SaveAndReturn) {
518
+ return this.handleSaveAndReturn(request, context, h)
519
+ }
520
+
513
521
  // Save and proceed
514
522
  await this.setState(request, state)
515
523
  return this.proceed(request, h, this.getNextPath(context))
@@ -528,6 +536,25 @@ export class QuestionPageController extends PageController {
528
536
  return proceed(request, h, nextUrl)
529
537
  }
530
538
 
539
+ shouldShowSaveAndReturn(): boolean {
540
+ return true
541
+ }
542
+
543
+ /**
544
+ * Handle save-and-return action by processing form data and redirecting to exit page
545
+ */
546
+ async handleSaveAndReturn(
547
+ request: FormRequestPayload,
548
+ context: FormContext,
549
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
550
+ ) {
551
+ const { state } = context
552
+
553
+ // Save the current state and redirect to exit page
554
+ await this.setState(request, state)
555
+ return h.redirect(this.getHref('/exit'))
556
+ }
557
+
531
558
  /**
532
559
  * {@link https://hapi.dev/api/?v=20.1.2#route-options}
533
560
  */
@@ -15,4 +15,8 @@ export class StartPageController extends QuestionPageController {
15
15
  isStartPage: true
16
16
  }
17
17
  }
18
+
19
+ shouldShowSaveAndReturn(): boolean {
20
+ return false
21
+ }
18
22
  }
@@ -68,6 +68,7 @@ export class SummaryPageController extends QuestionPageController {
68
68
  viewModel.feedbackLink = this.feedbackLink
69
69
  viewModel.phaseTag = this.phaseTag
70
70
  viewModel.components = components
71
+ viewModel.allowSaveAndReturn = this.shouldShowSaveAndReturn()
71
72
 
72
73
  return viewModel
73
74
  }
@@ -143,6 +144,10 @@ export class SummaryPageController extends QuestionPageController {
143
144
  }
144
145
  }
145
146
  }
147
+
148
+ shouldShowSaveAndReturn(): boolean {
149
+ return true
150
+ }
146
151
  }
147
152
 
148
153
  async function submitForm(
@@ -8,6 +8,7 @@ import {
8
8
 
9
9
  import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
10
10
  import { validatePluginOptions } from '~/src/server/plugins/engine/options.js'
11
+ import { getRoutes as getSaveAndReturnExitRoutes } from '~/src/server/plugins/engine/routes/exit.js'
11
12
  import { getRoutes as getFileUploadStatusRoutes } from '~/src/server/plugins/engine/routes/file-upload.js'
12
13
  import { makeLoadFormPreHandler } from '~/src/server/plugins/engine/routes/index.js'
13
14
  import { getRoutes as getQuestionRoutes } from '~/src/server/plugins/engine/routes/questions.js'
@@ -33,6 +34,7 @@ export const plugin = {
33
34
  cacheName,
34
35
  keyGenerator,
35
36
  sessionHydrator,
37
+ sessionPersister,
36
38
  nunjucks: nunjucksOptions,
37
39
  viewContext,
38
40
  preparePageEventRequestOptions
@@ -42,7 +44,8 @@ export const plugin = {
42
44
  cacheName,
43
45
  options: {
44
46
  keyGenerator,
45
- sessionHydrator
47
+ sessionHydrator,
48
+ sessionPersister
46
49
  }
47
50
  })
48
51
 
@@ -90,6 +93,7 @@ export const plugin = {
90
93
  ),
91
94
  ...getRepeaterSummaryRoutes(getRouteOptions, postRouteOptions),
92
95
  ...getRepeaterItemDeleteRoutes(getRouteOptions, postRouteOptions),
96
+ ...getSaveAndReturnExitRoutes(getRouteOptions),
93
97
  ...getFileUploadStatusRoutes()
94
98
  ]
95
99
 
@@ -0,0 +1,47 @@
1
+ import { slugSchema } from '@defra/forms-model'
2
+ import Boom from '@hapi/boom'
3
+ import { type ResponseToolkit, type RouteOptions } from '@hapi/hapi'
4
+ import Joi from 'joi'
5
+
6
+ import {
7
+ type FormRequest,
8
+ type FormRequestRefs
9
+ } from '~/src/server/routes/types.js'
10
+
11
+ export function getRoutes(getRouteOptions: RouteOptions<FormRequestRefs>) {
12
+ return [
13
+ {
14
+ method: 'get',
15
+ path: '/{slug}/exit',
16
+ handler: (
17
+ request: FormRequest,
18
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
19
+ ) => {
20
+ const { app } = request
21
+ const { model } = app
22
+
23
+ if (!model) {
24
+ throw Boom.notFound('No model found for exit page')
25
+ }
26
+
27
+ const returnUrl = request.query.returnUrl
28
+
29
+ const exitViewModel = {
30
+ pageTitle: 'Your progress has been saved',
31
+ phaseTag: model.def.phaseBanner?.phase,
32
+ returnUrl
33
+ }
34
+
35
+ return h.view('exit', exitViewModel)
36
+ },
37
+ options: {
38
+ ...getRouteOptions,
39
+ validate: {
40
+ params: Joi.object().keys({
41
+ slug: slugSchema
42
+ })
43
+ }
44
+ }
45
+ }
46
+ ]
47
+ }
@@ -30,6 +30,8 @@ import {
30
30
  import { type RequestOptions } from '~/src/server/services/httpService.js'
31
31
  import { type Services } from '~/src/server/types.js'
32
32
 
33
+ type RequestType = Request | FormRequest | FormRequestPayload
34
+
33
35
  /**
34
36
  * Form submission state stores the following in Redis:
35
37
  * Props containing user's submitted values as `{ [inputId]: value }` or as `{ [sectionName]: { [inputName]: value } }`
@@ -303,6 +305,7 @@ export interface FormPageViewModel extends PageViewModelBase {
303
305
  context: FormContext
304
306
  errors?: FormSubmissionError[]
305
307
  hasMissingNotificationEmail?: boolean
308
+ allowSaveAndReturn?: boolean
306
309
  }
307
310
 
308
311
  export interface RepeaterSummaryPageViewModel extends PageViewModelBase {
@@ -360,10 +363,13 @@ export interface PluginOptions {
360
363
  cacheName?: string
361
364
  globals?: Record<string, GlobalFunction>
362
365
  filters?: Record<string, FilterFunction>
363
- keyGenerator?: (request: Request | FormRequest | FormRequestPayload) => string
364
- sessionHydrator?: (
365
- request: Request | FormRequest | FormRequestPayload
366
- ) => Promise<FormSubmissionState>
366
+ keyGenerator?: (request: RequestType) => string
367
+ sessionHydrator?: (request: RequestType) => Promise<FormSubmissionState>
368
+ sessionPersister?: (
369
+ key: string,
370
+ state: FormSubmissionState,
371
+ request: RequestType
372
+ ) => Promise<void>
367
373
  pluginPath?: string
368
374
  nunjucks: {
369
375
  baseLayoutPath: string
@@ -0,0 +1,31 @@
1
+ {% extends baseLayoutPath %}
2
+
3
+ {% from "govuk/components/panel/macro.njk" import govukPanel %}
4
+ {% from "govuk/components/button/macro.njk" import govukButton %}
5
+
6
+ {% set mainClasses = "govuk-main-wrapper--l" %}
7
+
8
+ {% block content %}
9
+ <div class="govuk-grid-row">
10
+ <div class="govuk-grid-column-two-thirds">
11
+ {{ govukPanel({
12
+ titleText: pageTitle or "Your progress has been saved"
13
+ }) }}
14
+
15
+ <h2 class="govuk-heading-m">What happens next</h2>
16
+ <div class="app-prose-scope">
17
+ <p class="govuk-body">Your form progress has been saved. You can return to complete your application at any time using the link provided.</p>
18
+
19
+ {% if returnUrl %}
20
+ <p class="govuk-body">
21
+ {{ govukButton({
22
+ text: "Return to application",
23
+ href: returnUrl,
24
+ classes: "govuk-button--secondary"
25
+ }) }}
26
+ </p>
27
+ {% endif %}
28
+ </div>
29
+ </div>
30
+ </div>
31
+ {% endblock %}
@@ -3,13 +3,24 @@
3
3
 
4
4
  <form method="post" novalidate>
5
5
  <input type="hidden" name="crumb" value="{{ crumb }}">
6
- <input type="hidden" name="action" value="validate">
7
6
 
8
7
  {{ componentList(components) }}
9
8
 
10
- {{ govukButton({
11
- text: "Start now" if isStartPage else "Continue",
12
- isStartButton: isStartPage,
13
- preventDoubleClick: true
14
- }) }}
9
+ <div class="govuk-button-group">
10
+ {{ govukButton({
11
+ text: "Start now" if isStartPage else "Continue",
12
+ isStartButton: isStartPage,
13
+ preventDoubleClick: true
14
+ }) }}
15
+
16
+ {% if allowSaveAndReturn %}
17
+ {{ govukButton({
18
+ text: "Save and return",
19
+ classes: "govuk-button--secondary",
20
+ name: "action",
21
+ value: "save-and-return",
22
+ preventDoubleClick: true
23
+ }) }}
24
+ {% endif %}
25
+ </div>
15
26
  </form>
@@ -39,7 +39,8 @@ export enum FormAction {
39
39
  Validate = 'validate',
40
40
  Delete = 'delete',
41
41
  AddAnother = 'add-another',
42
- Send = 'send'
42
+ Send = 'send',
43
+ SaveAndReturn = 'save-and-return'
43
44
  }
44
45
 
45
46
  export enum FormStatus {
@@ -13,7 +13,8 @@ export const actionSchema = Joi.string<FormAction>()
13
13
  FormAction.Validate,
14
14
  FormAction.Delete,
15
15
  FormAction.AddAnother,
16
- FormAction.Send
16
+ FormAction.Send,
17
+ FormAction.SaveAndReturn
17
18
  )
18
19
  .default(FormAction.Validate)
19
20
  .optional()
@@ -110,6 +110,29 @@ describe('CacheService', () => {
110
110
  )
111
111
  expect(result).toEqual(rehydratedState)
112
112
  })
113
+
114
+ it('should return empty object when custom fetcher returns null', async () => {
115
+ const customFetcher = jest.fn().mockResolvedValue(null)
116
+
117
+ cacheService = new CacheService({
118
+ server: mockServer as Server,
119
+ cacheName: 'test-cache',
120
+ options: { sessionHydrator: customFetcher }
121
+ })
122
+
123
+ const mockRequest = {
124
+ yar: { id: 'session-id' },
125
+ params: { state: 's', slug: 'p' }
126
+ } as unknown as FormRequest
127
+
128
+ mockCache.get.mockResolvedValue(null)
129
+
130
+ const result = await cacheService.getState(mockRequest)
131
+
132
+ expect(customFetcher).toHaveBeenCalledWith(mockRequest)
133
+ expect(mockCache.set).not.toHaveBeenCalled()
134
+ expect(result).toEqual({})
135
+ })
113
136
  })
114
137
 
115
138
  describe('setState', () => {
@@ -312,6 +335,28 @@ describe('CacheService', () => {
312
335
  id: 'some-session:form1:page1:'
313
336
  })
314
337
  })
338
+
339
+ it('should not clear state when session ID is undefined', async () => {
340
+ const mockRequest = {
341
+ yar: { id: undefined },
342
+ params: { state: 'form1', slug: 'page1' }
343
+ } as unknown as FormRequest
344
+
345
+ await cacheService.clearState(mockRequest)
346
+
347
+ expect(mockCache.drop).not.toHaveBeenCalled()
348
+ })
349
+
350
+ it('should not clear state when session ID is null', async () => {
351
+ const mockRequest = {
352
+ yar: { id: null },
353
+ params: { state: 'form1', slug: 'page1' }
354
+ } as unknown as FormRequest
355
+
356
+ await cacheService.clearState(mockRequest)
357
+
358
+ expect(mockCache.drop).not.toHaveBeenCalled()
359
+ })
315
360
  })
316
361
 
317
362
  describe('getConfirmationState', () => {