@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.
- package/.server/server/plugins/engine/index.d.ts +1 -0
- package/.server/server/plugins/engine/index.js +1 -0
- package/.server/server/plugins/engine/index.js.map +1 -1
- package/.server/server/plugins/engine/models/FormModel.js +0 -4
- package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +2 -4
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -1
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +3 -3
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/helpers/state.d.ts +8 -7
- package/.server/server/plugins/engine/pageControllers/helpers/state.js +39 -12
- package/.server/server/plugins/engine/pageControllers/helpers/state.js.map +1 -1
- package/.server/server/plugins/engine/routes/index.js +8 -1
- package/.server/server/plugins/engine/routes/index.js.map +1 -1
- package/package.json +1 -1
- package/src/server/plugins/engine/index.ts +1 -0
- package/src/server/plugins/engine/models/FormModel.ts +0 -4
- package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +2 -6
- package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +4 -6
- package/src/server/plugins/engine/pageControllers/helpers/state.test.ts +80 -16
- package/src/server/plugins/engine/pageControllers/helpers/state.ts +57 -17
- package/src/server/plugins/engine/routes/index.test.ts +4 -2
- 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
|
-
|
|
229
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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)
|