@defra/forms-engine-plugin 1.3.1 → 1.4.1

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 (61) 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/FormModel.js +2 -2
  4. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  5. package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +1 -0
  6. package/.server/server/plugins/engine/models/SummaryViewModel.js +1 -0
  7. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  8. package/.server/server/plugins/engine/options.js +4 -1
  9. package/.server/server/plugins/engine/options.js.map +1 -1
  10. package/.server/server/plugins/engine/options.test.js +20 -0
  11. package/.server/server/plugins/engine/options.test.js.map +1 -1
  12. package/.server/server/plugins/engine/pageControllers/QuestionPageController.d.ts +5 -0
  13. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +27 -1
  14. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  15. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
  16. package/.server/server/plugins/engine/pageControllers/StartPageController.js +3 -0
  17. package/.server/server/plugins/engine/pageControllers/StartPageController.js.map +1 -1
  18. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -0
  19. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +4 -0
  20. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  21. package/.server/server/plugins/engine/plugin.js +5 -2
  22. package/.server/server/plugins/engine/plugin.js.map +1 -1
  23. package/.server/server/plugins/engine/routes/exit.d.ts +46 -0
  24. package/.server/server/plugins/engine/routes/exit.js +36 -0
  25. package/.server/server/plugins/engine/routes/exit.js.map +1 -0
  26. package/.server/server/plugins/engine/types.d.ts +6 -2
  27. package/.server/server/plugins/engine/types.js.map +1 -1
  28. package/.server/server/plugins/engine/views/exit.html +31 -0
  29. package/.server/server/plugins/engine/views/partials/form.html +17 -6
  30. package/.server/server/routes/types.d.ts +2 -1
  31. package/.server/server/routes/types.js +1 -0
  32. package/.server/server/routes/types.js.map +1 -1
  33. package/.server/server/schemas/index.js +1 -1
  34. package/.server/server/schemas/index.js.map +1 -1
  35. package/.server/server/services/cacheService.d.ts +2 -0
  36. package/.server/server/services/cacheService.js +9 -5
  37. package/.server/server/services/cacheService.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/server/index.test.ts +39 -0
  40. package/src/server/plugins/engine/components/helpers.test.ts +31 -0
  41. package/src/server/plugins/engine/index.ts +1 -3
  42. package/src/server/plugins/engine/models/FormModel.test.ts +85 -11
  43. package/src/server/plugins/engine/models/FormModel.ts +5 -2
  44. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +59 -0
  45. package/src/server/plugins/engine/models/SummaryViewModel.ts +1 -0
  46. package/src/server/plugins/engine/options.js +4 -1
  47. package/src/server/plugins/engine/options.test.js +20 -0
  48. package/src/server/plugins/engine/pageControllers/PageController.test.ts +25 -0
  49. package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +178 -1
  50. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +28 -1
  51. package/src/server/plugins/engine/pageControllers/StartPageController.ts +4 -0
  52. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +5 -0
  53. package/src/server/plugins/engine/plugin.ts +5 -1
  54. package/src/server/plugins/engine/routes/exit.ts +47 -0
  55. package/src/server/plugins/engine/types.ts +10 -4
  56. package/src/server/plugins/engine/views/exit.html +31 -0
  57. package/src/server/plugins/engine/views/partials/form.html +17 -6
  58. package/src/server/routes/types.ts +2 -1
  59. package/src/server/schemas/index.ts +2 -1
  60. package/src/server/services/cacheService.test.ts +45 -0
  61. package/src/server/services/cacheService.ts +20 -9
@@ -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', () => {
@@ -30,6 +30,12 @@ export class CacheService {
30
30
  request: Request | FormRequest | FormRequestPayload
31
31
  ) => Promise<FormSubmissionState | null>
32
32
 
33
+ customPersister?: (
34
+ key: string,
35
+ state: FormSubmissionState,
36
+ request: Request | FormRequest | FormRequestPayload
37
+ ) => Promise<void>
38
+
33
39
  logger: Server['logger']
34
40
 
35
41
  constructor({
@@ -46,9 +52,14 @@ export class CacheService {
46
52
  sessionHydrator?: (
47
53
  request: Request | FormRequest | FormRequestPayload
48
54
  ) => Promise<FormSubmissionState | null>
55
+ sessionPersister?: (
56
+ key: string,
57
+ state: FormSubmissionState,
58
+ request: Request | FormRequest | FormRequestPayload
59
+ ) => Promise<void>
49
60
  }
50
61
  }) {
51
- const { keyGenerator, sessionHydrator } = options ?? {}
62
+ const { keyGenerator, sessionHydrator, sessionPersister } = options ?? {}
52
63
  if (!cacheName) {
53
64
  server.log(
54
65
  'warn',
@@ -57,6 +68,7 @@ export class CacheService {
57
68
  }
58
69
  this.generateKey = keyGenerator ?? this.defaultKeyGenerator.bind(this)
59
70
  this.customFetcher = sessionHydrator ?? undefined
71
+ this.customPersister = sessionPersister ?? undefined
60
72
  this.cache = server.cache({ cache: cacheName, segment: 'formSubmission' })
61
73
  this.logger = server.logger
62
74
  }
@@ -64,18 +76,16 @@ export class CacheService {
64
76
  async getState(
65
77
  request: Request | FormRequest | FormRequestPayload
66
78
  ): Promise<FormSubmissionState> {
67
- let cached = await this.cache.get(this.Key(request))
79
+ const key = this.Key(request)
80
+
81
+ let cached = await this.cache.get(key)
68
82
 
69
83
  // If nothing in Redis, attempt to rehydrate from backend DB
70
84
  if (!cached && this.customFetcher) {
71
85
  const rehydrated = await this.customFetcher(request)
72
86
 
73
87
  if (rehydrated != null) {
74
- await this.cache.set(
75
- this.Key(request),
76
- rehydrated,
77
- config.get('sessionTimeout')
78
- )
88
+ await this.cache.set(key, rehydrated, config.get('sessionTimeout'))
79
89
  cached = await this.getState(request)
80
90
  }
81
91
  }
@@ -91,6 +101,7 @@ export class CacheService {
91
101
  const ttl = config.get('sessionTimeout')
92
102
 
93
103
  await this.cache.set(key, state, ttl)
104
+
94
105
  return this.getState(request)
95
106
  }
96
107
 
@@ -146,8 +157,8 @@ export class CacheService {
146
157
  throw new Error('No session ID found')
147
158
  }
148
159
 
149
- const state = request.params.state ?? ''
150
- const slug = request.params.slug ?? ''
160
+ const state = (request.params.state as string) || ''
161
+ const slug = (request.params.slug as string) || ''
151
162
  return `${request.yar.id}:${state}:${slug}:`
152
163
  }
153
164