Skip to content

Commit 3bf4d44

Browse files
authored
Merge branch 'main' into feat/df-922-version-metadata-refactor
2 parents 3c8465f + 9c27495 commit 3bf4d44

6 files changed

Lines changed: 156 additions & 46 deletions

File tree

src/server/plugins/engine/models/FormModel.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ import {
4848
createPage,
4949
type PageControllerClass
5050
} from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
51-
import { copyNotYetValidatedState } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
5251
import { validationOptions as opts } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
5352
import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
5453
import {
@@ -402,9 +401,6 @@ export class FormModel {
402401
// Add paths for navigation
403402
this.assignPaths(context)
404403

405-
// Handle restoration of payload from say a 'save-and-exit' request
406-
copyNotYetValidatedState(request, context)
407-
408404
return context
409405
}
410406

src/server/plugins/engine/pageControllers/QuestionPageController.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,7 @@ import {
3131
} from '~/src/server/plugins/engine/helpers.js'
3232
import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
3333
import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
34-
import {
35-
clearNotYetValidatedState,
36-
prefillStateFromQueryParameters
37-
} from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
34+
import { prefillStateFromQueryParameters } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
3835
import {
3936
type AnyFormRequest,
4037
type FormContext,
@@ -342,8 +339,7 @@ export class QuestionPageController extends PageController {
342339

343340
const cacheService = getCacheService(request.server)
344341

345-
// Clear any 'not yet validated' state before saving to cache
346-
return cacheService.setState(request, clearNotYetValidatedState(state))
342+
return cacheService.setState(request, state)
347343
}
348344

349345
async mergeState(

src/server/plugins/engine/pageControllers/helpers/state.test.ts

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1-
import { ComponentType, type Page } from '@defra/forms-model'
1+
import { ComponentType, ControllerType, type Page } from '@defra/forms-model'
22

33
import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
44
import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
55
import {
6+
checkSaveAndExitRepeater,
67
copyNotYetValidatedState,
78
prefillStateFromQueryParameters,
89
stripParam
910
} from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
1011
import {
1112
type AnyFormRequest,
12-
type FormContext,
13-
type FormContextRequest
13+
type FormContext
1414
} from '~/src/server/plugins/engine/types.js'
1515
import { type FormsService, type Services } from '~/src/server/types.js'
1616

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+
1725
function buildMockPage(
1826
pagesOverride = {},
1927
stateOverride = {},
@@ -225,23 +233,26 @@ describe('State helpers', () => {
225233
})
226234

227235
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
230241
const mockContext = {
231242
state: { abc: '123' },
232243
payload: {}
233244
} as unknown as FormContext
234-
copyNotYetValidatedState(mockRequest, mockContext)
245+
await copyNotYetValidatedState(mockRequest, mockContext)
235246
expect(mockContext.state).toEqual({ abc: '123' })
236247
expect(mockContext.payload).toEqual({})
237248
})
238249

239-
it('should ignore if wrong path', () => {
250+
it('should ignore if wrong path', async () => {
240251
const mockRequest = {
241252
url: {
242253
pathname: '/form-page1'
243254
}
244-
} as unknown as FormContextRequest
255+
} as unknown as AnyFormRequest
245256
const mockContext = {
246257
state: {
247258
abc: '123',
@@ -252,7 +263,7 @@ describe('State helpers', () => {
252263
},
253264
payload: {}
254265
} as unknown as FormContext
255-
copyNotYetValidatedState(mockRequest, mockContext)
266+
await copyNotYetValidatedState(mockRequest, mockContext)
256267
expect(mockContext.state).toEqual({
257268
abc: '123',
258269
__stateNotYetValidated: {
@@ -263,12 +274,12 @@ describe('State helpers', () => {
263274
expect(mockContext.payload).toEqual({})
264275
})
265276

266-
it('should apply if correct path', () => {
277+
it('should apply if correct path', async () => {
267278
const mockRequest = {
268279
url: {
269280
pathname: '/form-page1'
270281
}
271-
} as unknown as FormContextRequest
282+
} as unknown as AnyFormRequest
272283
const mockContext = {
273284
state: {
274285
abc: '123',
@@ -279,17 +290,70 @@ describe('State helpers', () => {
279290
},
280291
payload: {}
281292
} as unknown as FormContext
282-
copyNotYetValidatedState(mockRequest, mockContext)
293+
await copyNotYetValidatedState(mockRequest, mockContext)
283294
expect(mockContext.state).toEqual({
284295
abc: '123',
285-
__stateNotYetValidated: {
286-
def: '456',
287-
__currentPagePath: '/form-page1'
288-
}
296+
__stateNotYetValidated: undefined
289297
})
290298
expect(mockContext.payload).toEqual({
291299
def: '456'
292300
})
293301
})
294302
})
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+
})
295359
})

src/server/plugins/engine/pageControllers/helpers/state.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
23

4+
import { getCacheService } from '~/src/server/plugins/engine/helpers.js'
35
import {
46
CURRENT_PAGE_PATH_KEY,
57
STATE_NOT_YET_VALIDATED
68
} from '~/src/server/plugins/engine/index.js'
9+
import { type FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
710
import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
811
import {
912
type AnyFormRequest,
1013
type FormContext,
11-
type FormContextRequest,
1214
type FormStateValue,
13-
type FormSubmissionState,
1415
type FormValue
1516
} from '~/src/server/plugins/engine/types.js'
1617
import { type FormQuery } from '~/src/server/routes/types.js'
1718
import { type Services } from '~/src/server/types.js'
1819

20+
const GUID_LENGTH = 36
21+
1922
/**
2023
* A series of functions that can transform a pre-fill input parameter e.g lookup a form title based on form id
2124
*/
@@ -100,14 +103,56 @@ export async function prefillStateFromQueryParameters(
100103
return true
101104
}
102105

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+
103148
/**
104149
* Copies any potentially invalid state into the payload, and removes those values from state
105150
* NOTE - this method has a side-effect on 'context.state' and 'context.payload'
106151
* @param request - the form request
107152
* @param context - the form context
108153
*/
109-
export function copyNotYetValidatedState(
110-
request: FormContextRequest,
154+
export async function copyNotYetValidatedState(
155+
request: AnyFormRequest,
111156
context: FormContext
112157
) {
113158
const potentiallyInvalidState = context.state[STATE_NOT_YET_VALIDATED] as
@@ -125,18 +170,13 @@ export function copyNotYetValidatedState(
125170
...potentiallyInvalidState,
126171
[CURRENT_PAGE_PATH_KEY]: undefined
127172
}
128-
}
129-
}
130173

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)
140181
}
141-
return state
142182
}

src/server/plugins/engine/routes/index.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ describe('redirectOrMakeHandler', () => {
8686
// Reset mock model
8787
mockModel.getFormContext = jest.fn().mockReturnValue({
8888
isForceAccess: false,
89-
data: {}
89+
data: {},
90+
state: {}
9091
})
9192

9293
// Setup mocks
@@ -225,7 +226,8 @@ describe('redirectOrMakeHandler', () => {
225226
it('should call makeHandler when context has force access', async () => {
226227
mockModel.getFormContext = jest.fn().mockReturnValue({
227228
isForceAccess: true,
228-
data: {}
229+
data: {},
230+
state: {}
229231
})
230232

231233
await redirectOrMakeHandler(

src/server/plugins/engine/routes/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
proceed
2424
} from '~/src/server/plugins/engine/helpers.js'
2525
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'
2630
import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js'
2731
import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
2832
import {
@@ -78,6 +82,9 @@ export async function redirectOrMakeHandler(
7882

7983
const flash = cacheService.getFlash(request)
8084
const context = model.getFormContext(request, state, flash?.errors)
85+
86+
await copyNotYetValidatedState(request, context)
87+
8188
const relevantPath = page.getRelevantPath(request, context)
8289
const summaryPath = page.getSummaryPath()
8390

@@ -89,14 +96,19 @@ export async function redirectOrMakeHandler(
8996
}
9097
}
9198

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+
92105
// Return handler for relevant pages or preview URL direct access
93106
if (relevantPath.startsWith(page.path) || context.isForceAccess) {
94107
return makeHandler(page, context)
95108
}
96109

97110
// Redirect back to last relevant page
98111
const redirectTo = findPage(model, relevantPath)
99-
100112
// Set the return URL unless an exit page
101113
if (redirectTo?.next.length) {
102114
request.query.returnUrl = page.getHref(summaryPath)

0 commit comments

Comments
 (0)