@defra/forms-engine-plugin 1.3.1 → 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.
- package/.server/server/plugins/engine/index.js +1 -1
- package/.server/server/plugins/engine/index.js.map +1 -1
- package/.server/server/plugins/engine/models/SummaryViewModel.d.ts +1 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.js +1 -0
- package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
- package/.server/server/plugins/engine/options.js +4 -1
- package/.server/server/plugins/engine/options.js.map +1 -1
- package/.server/server/plugins/engine/options.test.js +20 -0
- package/.server/server/plugins/engine/options.test.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.d.ts +5 -0
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +27 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +2 -0
- package/.server/server/plugins/engine/pageControllers/StartPageController.js +3 -0
- package/.server/server/plugins/engine/pageControllers/StartPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -0
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +4 -0
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
- package/.server/server/plugins/engine/plugin.js +5 -2
- package/.server/server/plugins/engine/plugin.js.map +1 -1
- package/.server/server/plugins/engine/routes/exit.d.ts +46 -0
- package/.server/server/plugins/engine/routes/exit.js +36 -0
- package/.server/server/plugins/engine/routes/exit.js.map +1 -0
- package/.server/server/plugins/engine/types.d.ts +6 -2
- package/.server/server/plugins/engine/types.js.map +1 -1
- package/.server/server/plugins/engine/views/exit.html +31 -0
- package/.server/server/plugins/engine/views/partials/form.html +17 -6
- package/.server/server/routes/types.d.ts +2 -1
- package/.server/server/routes/types.js +1 -0
- package/.server/server/routes/types.js.map +1 -1
- package/.server/server/schemas/index.js +1 -1
- package/.server/server/schemas/index.js.map +1 -1
- package/.server/server/services/cacheService.d.ts +2 -0
- package/.server/server/services/cacheService.js +9 -5
- package/.server/server/services/cacheService.js.map +1 -1
- package/package.json +1 -1
- package/src/server/index.test.ts +39 -0
- package/src/server/plugins/engine/components/helpers.test.ts +31 -0
- package/src/server/plugins/engine/index.ts +1 -3
- package/src/server/plugins/engine/models/SummaryViewModel.test.ts +59 -0
- package/src/server/plugins/engine/models/SummaryViewModel.ts +1 -0
- package/src/server/plugins/engine/options.js +4 -1
- package/src/server/plugins/engine/options.test.js +20 -0
- package/src/server/plugins/engine/pageControllers/PageController.test.ts +25 -0
- package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +178 -1
- package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +28 -1
- package/src/server/plugins/engine/pageControllers/StartPageController.ts +4 -0
- package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +5 -0
- package/src/server/plugins/engine/plugin.ts +5 -1
- package/src/server/plugins/engine/routes/exit.ts +47 -0
- package/src/server/plugins/engine/types.ts +10 -4
- package/src/server/plugins/engine/views/exit.html +31 -0
- package/src/server/plugins/engine/views/partials/form.html +17 -6
- package/src/server/routes/types.ts +2 -1
- package/src/server/schemas/index.ts +2 -1
- package/src/server/services/cacheService.test.ts +45 -0
- package/src/server/services/cacheService.ts +20 -9
|
@@ -19,7 +19,10 @@ const pluginRegistrationOptionsSchema = Joi.object({
|
|
|
19
19
|
viewContext: Joi.function().required(),
|
|
20
20
|
preparePageEventRequestOptions: Joi.function().optional(),
|
|
21
21
|
onRequest: Joi.function().optional(),
|
|
22
|
-
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()
|
|
23
26
|
})
|
|
24
27
|
|
|
25
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 {
|
|
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
|
*/
|
|
@@ -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:
|
|
364
|
-
sessionHydrator?: (
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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>
|
|
@@ -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', () => {
|