@defra/forms-engine-plugin 4.1.4 → 4.3.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 (24) hide show
  1. package/.server/server/plugins/engine/index.d.ts +1 -0
  2. package/.server/server/plugins/engine/index.js +1 -0
  3. package/.server/server/plugins/engine/index.js.map +1 -1
  4. package/.server/server/plugins/engine/models/FormModel.js +0 -4
  5. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  6. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +2 -4
  7. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  8. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -1
  9. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +3 -3
  10. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  11. package/.server/server/plugins/engine/pageControllers/helpers/state.d.ts +8 -7
  12. package/.server/server/plugins/engine/pageControllers/helpers/state.js +39 -12
  13. package/.server/server/plugins/engine/pageControllers/helpers/state.js.map +1 -1
  14. package/.server/server/plugins/engine/routes/index.js +8 -1
  15. package/.server/server/plugins/engine/routes/index.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/server/plugins/engine/index.ts +1 -0
  18. package/src/server/plugins/engine/models/FormModel.ts +0 -4
  19. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +2 -6
  20. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +4 -6
  21. package/src/server/plugins/engine/pageControllers/helpers/state.test.ts +80 -16
  22. package/src/server/plugins/engine/pageControllers/helpers/state.ts +57 -17
  23. package/src/server/plugins/engine/routes/index.test.ts +4 -2
  24. package/src/server/plugins/engine/routes/index.ts +13 -1
@@ -1,19 +1,27 @@
1
- import { ComponentType, type Page } from '@defra/forms-model'
1
+ import { ComponentType, ControllerType, type Page } from '@defra/forms-model'
2
2
 
3
3
  import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
4
4
  import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
5
5
  import {
6
+ checkSaveAndExitRepeater,
6
7
  copyNotYetValidatedState,
7
8
  prefillStateFromQueryParameters,
8
9
  stripParam
9
10
  } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
10
11
  import {
11
12
  type AnyFormRequest,
12
- type FormContext,
13
- type FormContextRequest
13
+ type FormContext
14
14
  } from '~/src/server/plugins/engine/types.js'
15
15
  import { type FormsService, type Services } from '~/src/server/types.js'
16
16
 
17
+ const mockGetCacheService = jest.fn()
18
+ const mockCacheService = { setState: jest.fn() }
19
+
20
+ jest.mock('~/src/server/plugins/engine/helpers.ts', () => ({
21
+ __esModule: true,
22
+ getCacheService: (...args: unknown[]) => mockGetCacheService(...args)
23
+ }))
24
+
17
25
  function buildMockPage(
18
26
  pagesOverride = {},
19
27
  stateOverride = {},
@@ -225,23 +233,26 @@ describe('State helpers', () => {
225
233
  })
226
234
 
227
235
  describe('copyNotYetValidatedState', () => {
228
- it('should ignore if no invalid state', () => {
229
- const mockRequest = {} as FormContextRequest
236
+ beforeEach(() => {
237
+ mockGetCacheService.mockReturnValue(mockCacheService)
238
+ })
239
+ it('should ignore if no invalid state', async () => {
240
+ const mockRequest = {} as AnyFormRequest
230
241
  const mockContext = {
231
242
  state: { abc: '123' },
232
243
  payload: {}
233
244
  } as unknown as FormContext
234
- copyNotYetValidatedState(mockRequest, mockContext)
245
+ await copyNotYetValidatedState(mockRequest, mockContext)
235
246
  expect(mockContext.state).toEqual({ abc: '123' })
236
247
  expect(mockContext.payload).toEqual({})
237
248
  })
238
249
 
239
- it('should ignore if wrong path', () => {
250
+ it('should ignore if wrong path', async () => {
240
251
  const mockRequest = {
241
252
  url: {
242
253
  pathname: '/form-page1'
243
254
  }
244
- } as unknown as FormContextRequest
255
+ } as unknown as AnyFormRequest
245
256
  const mockContext = {
246
257
  state: {
247
258
  abc: '123',
@@ -252,7 +263,7 @@ describe('State helpers', () => {
252
263
  },
253
264
  payload: {}
254
265
  } as unknown as FormContext
255
- copyNotYetValidatedState(mockRequest, mockContext)
266
+ await copyNotYetValidatedState(mockRequest, mockContext)
256
267
  expect(mockContext.state).toEqual({
257
268
  abc: '123',
258
269
  __stateNotYetValidated: {
@@ -263,12 +274,12 @@ describe('State helpers', () => {
263
274
  expect(mockContext.payload).toEqual({})
264
275
  })
265
276
 
266
- it('should apply if correct path', () => {
277
+ it('should apply if correct path', async () => {
267
278
  const mockRequest = {
268
279
  url: {
269
280
  pathname: '/form-page1'
270
281
  }
271
- } as unknown as FormContextRequest
282
+ } as unknown as AnyFormRequest
272
283
  const mockContext = {
273
284
  state: {
274
285
  abc: '123',
@@ -279,17 +290,70 @@ describe('State helpers', () => {
279
290
  },
280
291
  payload: {}
281
292
  } as unknown as FormContext
282
- copyNotYetValidatedState(mockRequest, mockContext)
293
+ await copyNotYetValidatedState(mockRequest, mockContext)
283
294
  expect(mockContext.state).toEqual({
284
295
  abc: '123',
285
- __stateNotYetValidated: {
286
- def: '456',
287
- __currentPagePath: '/form-page1'
288
- }
296
+ __stateNotYetValidated: undefined
289
297
  })
290
298
  expect(mockContext.payload).toEqual({
291
299
  def: '456'
292
300
  })
293
301
  })
294
302
  })
303
+
304
+ describe('checkSaveAndExitRepeater', () => {
305
+ function createMockContextWithPath(path: string) {
306
+ return {
307
+ state: {
308
+ abc: '123',
309
+ __stateNotYetValidated: {
310
+ def: '456',
311
+ __currentPagePath: path
312
+ }
313
+ },
314
+ payload: {}
315
+ } as unknown as FormContext
316
+ }
317
+
318
+ const mockModel = {
319
+ def: {
320
+ pages: [
321
+ {
322
+ controller: ControllerType.Repeat,
323
+ path: '/personal_details'
324
+ }
325
+ ]
326
+ },
327
+ basePath: 'form/preview/draft/repeater-test'
328
+ } as unknown as FormModel
329
+
330
+ it('should return undefined if url does not end in a guid', () => {
331
+ const mockContext = createMockContextWithPath(
332
+ '/form/preview/draft/repeater-test/personal_details'
333
+ )
334
+ expect(checkSaveAndExitRepeater(mockContext, mockModel)).toBeUndefined()
335
+ })
336
+
337
+ it('should return undefined if url ends in a guid but not a repeater path', () => {
338
+ const mockContext = createMockContextWithPath(
339
+ '/form/preview/draft/repeater-test/wrong_page/7d27fe6e-73e8-4265-84bd-1e118c92470b'
340
+ )
341
+ expect(checkSaveAndExitRepeater(mockContext, mockModel)).toBeUndefined()
342
+ })
343
+
344
+ it('should return undefined if url is not a string', () => {
345
+ // @ts-expect-error - invalid dataype on purpose for this test
346
+ const mockContext = createMockContextWithPath({})
347
+ expect(checkSaveAndExitRepeater(mockContext, mockModel)).toBeUndefined()
348
+ })
349
+
350
+ it('should return correct urls if url ends in a guid and is a repeater path', () => {
351
+ const mockContext = createMockContextWithPath(
352
+ '/form/preview/draft/repeater-test/personal_details/7d27fe6e-73e8-4265-84bd-1e118c92470b'
353
+ )
354
+ expect(checkSaveAndExitRepeater(mockContext, mockModel)).toBe(
355
+ '/form/preview/draft/repeater-test/personal_details/7d27fe6e-73e8-4265-84bd-1e118c92470b'
356
+ )
357
+ })
358
+ })
295
359
  })
@@ -1,21 +1,24 @@
1
- import { getHiddenFields } from '@defra/forms-model'
1
+ import { ControllerType, getHiddenFields } from '@defra/forms-model'
2
+ import { validate as isValidUUID } from 'uuid'
2
3
 
4
+ import { getCacheService } from '~/src/server/plugins/engine/helpers.js'
3
5
  import {
4
6
  CURRENT_PAGE_PATH_KEY,
5
7
  STATE_NOT_YET_VALIDATED
6
8
  } from '~/src/server/plugins/engine/index.js'
9
+ import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
7
10
  import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
8
11
  import {
9
12
  type AnyFormRequest,
10
13
  type FormContext,
11
- type FormContextRequest,
12
14
  type FormStateValue,
13
- type FormSubmissionState,
14
15
  type FormValue
15
16
  } from '~/src/server/plugins/engine/types.js'
16
17
  import { type FormQuery } from '~/src/server/routes/types.js'
17
18
  import { type Services } from '~/src/server/types.js'
18
19
 
20
+ const GUID_LENGTH = 36
21
+
19
22
  /**
20
23
  * A series of functions that can transform a pre-fill input parameter e.g lookup a form title based on form id
21
24
  */
@@ -100,14 +103,56 @@ export async function prefillStateFromQueryParameters(
100
103
  return true
101
104
  }
102
105
 
106
+ /**
107
+ * Checks whether the save-and-exit finished on a repeater with partial state
108
+ * @param context - the form context
109
+ */
110
+ export function checkSaveAndExitRepeater(
111
+ context: FormContext,
112
+ model: FormModel
113
+ ) {
114
+ const potentiallyInvalidState = context.state[STATE_NOT_YET_VALIDATED] as
115
+ | Record<string, FormValue>
116
+ | undefined
117
+ if (!potentiallyInvalidState) {
118
+ return
119
+ }
120
+
121
+ const originalPath = potentiallyInvalidState[CURRENT_PAGE_PATH_KEY]
122
+
123
+ const repeaterPaths = model.def.pages
124
+ .filter((page) => page.controller === ControllerType.Repeat)
125
+ .map((p) => `/${model.basePath}${p.path}/`)
126
+
127
+ if (typeof originalPath !== 'string') {
128
+ return undefined
129
+ }
130
+
131
+ const segments = originalPath.split('/')
132
+ const lastSegment = segments.at(-1) ?? ''
133
+
134
+ if (!isValidUUID(lastSegment)) {
135
+ return undefined
136
+ }
137
+
138
+ const guidStartIndex = originalPath.length - GUID_LENGTH
139
+ const originalPathWithoutGuid = originalPath.substring(0, guidStartIndex)
140
+
141
+ if (!repeaterPaths.includes(originalPathWithoutGuid)) {
142
+ return undefined
143
+ }
144
+
145
+ return originalPath
146
+ }
147
+
103
148
  /**
104
149
  * Copies any potentially invalid state into the payload, and removes those values from state
105
150
  * NOTE - this method has a side-effect on 'context.state' and 'context.payload'
106
151
  * @param request - the form request
107
152
  * @param context - the form context
108
153
  */
109
- export function copyNotYetValidatedState(
110
- request: FormContextRequest,
154
+ export async function copyNotYetValidatedState(
155
+ request: AnyFormRequest,
111
156
  context: FormContext
112
157
  ) {
113
158
  const potentiallyInvalidState = context.state[STATE_NOT_YET_VALIDATED] as
@@ -125,18 +170,13 @@ export function copyNotYetValidatedState(
125
170
  ...potentiallyInvalidState,
126
171
  [CURRENT_PAGE_PATH_KEY]: undefined
127
172
  }
128
- }
129
- }
130
173
 
131
- /**
132
- * Remove any temporary 'not yet validated' state now that it's been validated
133
- * @param state - the form state
134
- */
135
- export function clearNotYetValidatedState(
136
- state: FormSubmissionState
137
- ): FormSubmissionState {
138
- if (state[STATE_NOT_YET_VALIDATED]) {
139
- state[STATE_NOT_YET_VALIDATED] = undefined
174
+ // Remove any temporary 'not yet validated' state now it's been copied to the payload
175
+ if (context.state[STATE_NOT_YET_VALIDATED]) {
176
+ context.state[STATE_NOT_YET_VALIDATED] = undefined
177
+ }
178
+
179
+ const cacheService = getCacheService(request.server)
180
+ await cacheService.setState(request, context.state)
140
181
  }
141
- return state
142
182
  }
@@ -86,7 +86,8 @@ describe('redirectOrMakeHandler', () => {
86
86
  // Reset mock model
87
87
  mockModel.getFormContext = jest.fn().mockReturnValue({
88
88
  isForceAccess: false,
89
- data: {}
89
+ data: {},
90
+ state: {}
90
91
  })
91
92
 
92
93
  // Setup mocks
@@ -225,7 +226,8 @@ describe('redirectOrMakeHandler', () => {
225
226
  it('should call makeHandler when context has force access', async () => {
226
227
  mockModel.getFormContext = jest.fn().mockReturnValue({
227
228
  isForceAccess: true,
228
- data: {}
229
+ data: {},
230
+ state: {}
229
231
  })
230
232
 
231
233
  await redirectOrMakeHandler(
@@ -23,6 +23,10 @@ import {
23
23
  proceed
24
24
  } from '~/src/server/plugins/engine/helpers.js'
25
25
  import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
26
+ import {
27
+ checkSaveAndExitRepeater,
28
+ copyNotYetValidatedState
29
+ } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
26
30
  import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js'
27
31
  import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
28
32
  import {
@@ -78,6 +82,9 @@ export async function redirectOrMakeHandler(
78
82
 
79
83
  const flash = cacheService.getFlash(request)
80
84
  const context = model.getFormContext(request, state, flash?.errors)
85
+
86
+ await copyNotYetValidatedState(request, context)
87
+
81
88
  const relevantPath = page.getRelevantPath(request, context)
82
89
  const summaryPath = page.getSummaryPath()
83
90
 
@@ -89,6 +96,12 @@ export async function redirectOrMakeHandler(
89
96
  }
90
97
  }
91
98
 
99
+ // Check whether save-and-exit should resume from within a repeater
100
+ const resumeInRepeaterUrl = checkSaveAndExitRepeater(context, model)
101
+ if (resumeInRepeaterUrl) {
102
+ return proceed(request, h, resumeInRepeaterUrl)
103
+ }
104
+
92
105
  // Return handler for relevant pages or preview URL direct access
93
106
  if (relevantPath.startsWith(page.path) || context.isForceAccess) {
94
107
  return makeHandler(page, context)
@@ -96,7 +109,6 @@ export async function redirectOrMakeHandler(
96
109
 
97
110
  // Redirect back to last relevant page
98
111
  const redirectTo = findPage(model, relevantPath)
99
-
100
112
  // Set the return URL unless an exit page
101
113
  if (redirectTo?.next.length) {
102
114
  request.query.returnUrl = page.getHref(summaryPath)