@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.
- 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/FormModel.js +2 -2
- package/.server/server/plugins/engine/models/FormModel.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/FormModel.test.ts +85 -11
- package/src/server/plugins/engine/models/FormModel.ts +5 -2
- 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
|
@@ -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', () => {
|
|
@@ -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
|
-
|
|
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
|
|