@defra/forms-engine-plugin 4.0.33 → 4.0.35
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/constants.d.ts +1 -0
- package/.server/server/constants.js +1 -0
- package/.server/server/constants.js.map +1 -1
- package/.server/server/forms/register-as-a-unicorn-breeder.yaml +0 -1
- package/.server/server/forms/simple-form.yaml +64 -0
- package/.server/server/plugins/engine/beta/form-context.js +1 -2
- package/.server/server/plugins/engine/beta/form-context.js.map +1 -1
- package/.server/server/plugins/engine/components/FileUploadField.d.ts +4 -3
- package/.server/server/plugins/engine/components/FileUploadField.js +38 -0
- package/.server/server/plugins/engine/components/FileUploadField.js.map +1 -1
- package/.server/server/plugins/engine/components/FormComponent.d.ts +9 -7
- package/.server/server/plugins/engine/components/FormComponent.js +3 -0
- package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
- package/.server/server/plugins/engine/helpers.d.ts +5 -0
- package/.server/server/plugins/engine/helpers.js +7 -0
- package/.server/server/plugins/engine/helpers.js.map +1 -1
- package/.server/server/plugins/engine/index.d.ts +2 -0
- package/.server/server/plugins/engine/index.js +2 -0
- package/.server/server/plugins/engine/index.js.map +1 -1
- package/.server/server/plugins/engine/models/FormModel.js +4 -0
- package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +6 -2
- package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +8 -3
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +4 -0
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -1
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +33 -35
- package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/__stubs__/request.d.ts +2 -2
- package/.server/server/plugins/engine/pageControllers/__stubs__/request.js +9 -0
- package/.server/server/plugins/engine/pageControllers/__stubs__/request.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/errors.d.ts +15 -0
- package/.server/server/plugins/engine/pageControllers/errors.js +25 -0
- package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -0
- package/.server/server/plugins/engine/pageControllers/helpers/state.d.ts +13 -1
- package/.server/server/plugins/engine/pageControllers/helpers/state.js +33 -0
- package/.server/server/plugins/engine/pageControllers/helpers/state.js.map +1 -1
- package/.server/server/plugins/engine/services/localFormsService.js +6 -0
- package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
- package/.server/server/plugins/engine/views/index.html +1 -1
- package/.server/server/plugins/nunjucks/context.test.js +9 -1
- package/.server/server/plugins/nunjucks/context.test.js.map +1 -1
- package/.server/server/plugins/nunjucks/types.d.ts +4 -0
- package/.server/server/plugins/nunjucks/types.js +1 -0
- package/.server/server/plugins/nunjucks/types.js.map +1 -1
- package/.server/server/services/cacheService.d.ts +1 -0
- package/.server/server/services/cacheService.js +10 -0
- package/.server/server/services/cacheService.js.map +1 -1
- package/.server/typings/hapi/index.d.js.map +1 -1
- package/package.json +1 -1
- package/src/server/constants.js +1 -0
- package/src/server/forms/register-as-a-unicorn-breeder.yaml +0 -1
- package/src/server/forms/simple-form.yaml +64 -0
- package/src/server/plugins/engine/beta/form-context.test.ts +4 -3
- package/src/server/plugins/engine/beta/form-context.ts +4 -3
- package/src/server/plugins/engine/components/FileUploadField.test.ts +203 -2
- package/src/server/plugins/engine/components/FileUploadField.ts +61 -2
- package/src/server/plugins/engine/components/FormComponent.ts +17 -1
- package/src/server/plugins/engine/helpers.ts +8 -0
- package/src/server/plugins/engine/index.ts +3 -0
- package/src/server/plugins/engine/models/FormModel.ts +4 -0
- package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +9 -0
- package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +11 -4
- package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +12 -2
- package/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +3 -0
- package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +55 -46
- package/src/server/plugins/engine/pageControllers/__stubs__/request.ts +14 -4
- package/src/server/plugins/engine/pageControllers/errors.test.ts +63 -0
- package/src/server/plugins/engine/pageControllers/errors.ts +30 -0
- package/src/server/plugins/engine/pageControllers/helpers/state.test.ts +75 -1
- package/src/server/plugins/engine/pageControllers/helpers/state.ts +50 -1
- package/src/server/plugins/engine/services/localFormsService.js +7 -0
- package/src/server/plugins/engine/views/index.html +1 -1
- package/src/server/plugins/nunjucks/context.test.js +10 -2
- package/src/server/plugins/nunjucks/types.js +1 -0
- package/src/server/services/cacheService.ts +16 -0
- package/src/typings/hapi/index.d.ts +2 -0
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type FileUploadFieldComponent,
|
|
3
|
+
type FormMetadata
|
|
4
|
+
} from '@defra/forms-model'
|
|
5
|
+
import Boom from '@hapi/boom'
|
|
2
6
|
import joi, { type ArraySchema } from 'joi'
|
|
3
7
|
|
|
4
8
|
import {
|
|
5
9
|
FormComponent,
|
|
6
10
|
isUploadState
|
|
7
11
|
} from '~/src/server/plugins/engine/components/FormComponent.js'
|
|
12
|
+
import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
|
|
8
13
|
import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
|
|
9
14
|
import {
|
|
10
15
|
FileStatus,
|
|
@@ -13,6 +18,7 @@ import {
|
|
|
13
18
|
type FileState,
|
|
14
19
|
type FileUpload,
|
|
15
20
|
type FileUploadMetadata,
|
|
21
|
+
type FormContext,
|
|
16
22
|
type FormPayload,
|
|
17
23
|
type FormState,
|
|
18
24
|
type FormStateValue,
|
|
@@ -26,7 +32,10 @@ import {
|
|
|
26
32
|
type UploadStatusResponse
|
|
27
33
|
} from '~/src/server/plugins/engine/types.js'
|
|
28
34
|
import { render } from '~/src/server/plugins/nunjucks/index.js'
|
|
29
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
type FormQuery,
|
|
37
|
+
type FormRequestPayload
|
|
38
|
+
} from '~/src/server/routes/types.js'
|
|
30
39
|
|
|
31
40
|
export const uploadIdSchema = joi.string().uuid().required()
|
|
32
41
|
|
|
@@ -284,6 +293,56 @@ export class FileUploadField extends FormComponent {
|
|
|
284
293
|
return FileUploadField.getAllPossibleErrors()
|
|
285
294
|
}
|
|
286
295
|
|
|
296
|
+
async onSubmit(
|
|
297
|
+
request: FormRequestPayload,
|
|
298
|
+
metadata: FormMetadata,
|
|
299
|
+
context: FormContext
|
|
300
|
+
) {
|
|
301
|
+
const notificationEmail = metadata.notificationEmail
|
|
302
|
+
|
|
303
|
+
if (!notificationEmail) {
|
|
304
|
+
// this should not happen because notificationEmail is checked further up
|
|
305
|
+
// the chain in SummaryPageController before submitForm is called.
|
|
306
|
+
throw new Error('Unexpected missing notificationEmail in metadata')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!request.app.model?.services.formSubmissionService) {
|
|
310
|
+
throw new Error('No form submission service available in app model')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const { formSubmissionService } = request.app.model.services
|
|
314
|
+
const values = this.getFormValueFromState(context.state) ?? []
|
|
315
|
+
|
|
316
|
+
const files = values.map((value) => ({
|
|
317
|
+
fileId: value.status.form.file.fileId,
|
|
318
|
+
initiatedRetrievalKey: value.status.metadata.retrievalKey
|
|
319
|
+
}))
|
|
320
|
+
|
|
321
|
+
if (!files.length) {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
await formSubmissionService.persistFiles(files, notificationEmail)
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (
|
|
329
|
+
Boom.isBoom(error) &&
|
|
330
|
+
(error.output.statusCode === 403 || // Forbidden - retrieval key invalid
|
|
331
|
+
error.output.statusCode === 410) // Gone - file expired (took to long to submit, etc)
|
|
332
|
+
) {
|
|
333
|
+
// Failed to persist files. We can't recover from this, the only real way we can recover the submissions is
|
|
334
|
+
// by resetting the problematic components and letting the user re-try.
|
|
335
|
+
// Scenarios: file missing from S3, invalid retrieval key (timing problem), etc.
|
|
336
|
+
throw new InvalidComponentStateError(
|
|
337
|
+
this,
|
|
338
|
+
'There was a problem with your uploaded files. Re-upload them before submitting the form again.'
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
throw error
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
287
346
|
/**
|
|
288
347
|
* Static version of getAllPossibleErrors that doesn't require a component instance.
|
|
289
348
|
*/
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type FormComponentsDef,
|
|
3
|
+
type FormMetadata,
|
|
4
|
+
type Item
|
|
5
|
+
} from '@defra/forms-model'
|
|
2
6
|
|
|
3
7
|
import { ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js'
|
|
4
8
|
import { optionalText } from '~/src/server/plugins/engine/components/constants.js'
|
|
9
|
+
import {
|
|
10
|
+
type FormContext,
|
|
11
|
+
type FormRequestPayload
|
|
12
|
+
} from '~/src/server/plugins/engine/types/index.js'
|
|
5
13
|
import {
|
|
6
14
|
type ErrorMessageTemplateList,
|
|
7
15
|
type FileState,
|
|
@@ -220,6 +228,14 @@ export class FormComponent extends ComponentBase {
|
|
|
220
228
|
advancedSettingsErrors: []
|
|
221
229
|
}
|
|
222
230
|
}
|
|
231
|
+
|
|
232
|
+
onSubmit(
|
|
233
|
+
_request: FormRequestPayload,
|
|
234
|
+
_metadata: FormMetadata,
|
|
235
|
+
_context: FormContext
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
return Promise.resolve()
|
|
238
|
+
}
|
|
223
239
|
}
|
|
224
240
|
|
|
225
241
|
/**
|
|
@@ -321,6 +321,14 @@ export function getError(detail: ValidationErrorItem): FormSubmissionError {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
export function createError(componentName: string, message: string) {
|
|
325
|
+
return {
|
|
326
|
+
href: `#${componentName}`,
|
|
327
|
+
name: componentName,
|
|
328
|
+
text: message
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
324
332
|
/**
|
|
325
333
|
* A small helper to safely generate a crumb token.
|
|
326
334
|
* Checks that the crumb plugin is available, that crumb
|
|
@@ -31,6 +31,9 @@ const globals = {
|
|
|
31
31
|
export const VIEW_PATH = 'src/server/plugins/engine/views'
|
|
32
32
|
export const PLUGIN_PATH = 'node_modules/@defra/forms-engine-plugin'
|
|
33
33
|
|
|
34
|
+
export const STATE_NOT_YET_VALIDATED = '__stateNotYetValidated'
|
|
35
|
+
export const CURRENT_PAGE_PATH_KEY = '__currentPagePath'
|
|
36
|
+
|
|
34
37
|
export const prepareNunjucksEnvironment = function (
|
|
35
38
|
env: Environment,
|
|
36
39
|
pluginOptions: PluginOptions
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
createPage,
|
|
49
49
|
type PageControllerClass
|
|
50
50
|
} from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
|
|
51
|
+
import { copyNotYetValidatedState } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
|
|
51
52
|
import { validationOptions as opts } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
|
|
52
53
|
import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
|
|
53
54
|
import {
|
|
@@ -401,6 +402,9 @@ export class FormModel {
|
|
|
401
402
|
// Add paths for navigation
|
|
402
403
|
this.assignPaths(context)
|
|
403
404
|
|
|
405
|
+
// Handle restoration of payload from say a 'save-and-exit' request
|
|
406
|
+
copyNotYetValidatedState(request, context)
|
|
407
|
+
|
|
404
408
|
return context
|
|
405
409
|
}
|
|
406
410
|
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
|
|
16
16
|
import { serverWithSaveAndExit } from '~/src/server/plugins/engine/pageControllers/__stubs__/server.js'
|
|
17
17
|
import * as pageHelpers from '~/src/server/plugins/engine/pageControllers/helpers/index.js'
|
|
18
|
+
import { getFormMetadata } from '~/src/server/plugins/engine/services/formsService.js'
|
|
18
19
|
import * as uploadService from '~/src/server/plugins/engine/services/uploadService.js'
|
|
19
20
|
import {
|
|
20
21
|
FileStatus,
|
|
@@ -33,8 +34,11 @@ import {
|
|
|
33
34
|
type FormResponseToolkit
|
|
34
35
|
} from '~/src/server/routes/types.js'
|
|
35
36
|
import { type CacheService } from '~/src/server/services/index.js'
|
|
37
|
+
import * as fixtures from '~/test/fixtures/index.js'
|
|
36
38
|
import definition from '~/test/form/definitions/file-upload-basic.js'
|
|
37
39
|
|
|
40
|
+
jest.mock('~/src/server/plugins/engine/services/formsService.js')
|
|
41
|
+
|
|
38
42
|
type TestableFileUploadPageController = FileUploadPageController & {
|
|
39
43
|
initiateAndStoreNewUpload(
|
|
40
44
|
req: FormRequest,
|
|
@@ -65,8 +69,13 @@ describe('FileUploadPageController', () => {
|
|
|
65
69
|
basePath: 'test'
|
|
66
70
|
})
|
|
67
71
|
|
|
72
|
+
jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata)
|
|
73
|
+
|
|
68
74
|
controller = new FileUploadPageController(model, pages[0])
|
|
69
75
|
request = {
|
|
76
|
+
params: {
|
|
77
|
+
slug: 'test-form'
|
|
78
|
+
},
|
|
70
79
|
logger: {
|
|
71
80
|
info: jest.fn(),
|
|
72
81
|
error: jest.fn(),
|
|
@@ -371,6 +371,7 @@ export class FileUploadPageController extends QuestionPageController {
|
|
|
371
371
|
// Flash the error message.
|
|
372
372
|
const { fileUpload } = this
|
|
373
373
|
const cacheService = getCacheService(request.server)
|
|
374
|
+
|
|
374
375
|
const name = fileUpload.name
|
|
375
376
|
const text = file.errorMessage ?? 'Unknown error'
|
|
376
377
|
const errors: FormSubmissionError[] = [
|
|
@@ -423,6 +424,7 @@ export class FileUploadPageController extends QuestionPageController {
|
|
|
423
424
|
) {
|
|
424
425
|
const { fileUpload, href, path } = this
|
|
425
426
|
const { options, schema } = fileUpload
|
|
427
|
+
const { getFormMetadata } = this.model.services.formsService
|
|
426
428
|
|
|
427
429
|
const files = this.getFilesFromState(state)
|
|
428
430
|
|
|
@@ -433,10 +435,15 @@ export class FileUploadPageController extends QuestionPageController {
|
|
|
433
435
|
const max = Math.min(schema.max ?? MAX_UPLOADS, MAX_UPLOADS)
|
|
434
436
|
|
|
435
437
|
if (files.length < max) {
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
438
|
+
const formMetadata = await getFormMetadata(request.params.slug)
|
|
439
|
+
const notificationEmail =
|
|
440
|
+
formMetadata.notificationEmail ?? 'defraforms@defra.gov.uk'
|
|
441
|
+
|
|
442
|
+
const newUpload = await initiateUpload(
|
|
443
|
+
href,
|
|
444
|
+
notificationEmail,
|
|
445
|
+
options.accept
|
|
446
|
+
)
|
|
440
447
|
|
|
441
448
|
if (newUpload === undefined) {
|
|
442
449
|
throw Boom.badRequest('Unexpected empty response from initiateUpload')
|
|
@@ -13,6 +13,7 @@ import { type RouteOptions } from '@hapi/hapi'
|
|
|
13
13
|
import { type ValidationErrorItem } from 'joi'
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
+
COMPONENT_STATE_ERROR,
|
|
16
17
|
EXTERNAL_STATE_APPENDAGE,
|
|
17
18
|
EXTERNAL_STATE_PAYLOAD
|
|
18
19
|
} from '~/src/server/constants.js'
|
|
@@ -28,7 +29,10 @@ import {
|
|
|
28
29
|
} from '~/src/server/plugins/engine/helpers.js'
|
|
29
30
|
import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
|
|
30
31
|
import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
|
|
31
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
clearNotYetValidatedState,
|
|
34
|
+
prefillStateFromQueryParameters
|
|
35
|
+
} from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
|
|
32
36
|
import {
|
|
33
37
|
type AnyFormRequest,
|
|
34
38
|
type FormContext,
|
|
@@ -324,7 +328,8 @@ export class QuestionPageController extends PageController {
|
|
|
324
328
|
|
|
325
329
|
const cacheService = getCacheService(request.server)
|
|
326
330
|
|
|
327
|
-
|
|
331
|
+
// Clear any 'not yet validated' state before saving to cache
|
|
332
|
+
return cacheService.setState(request, clearNotYetValidatedState(state))
|
|
328
333
|
}
|
|
329
334
|
|
|
330
335
|
async mergeState(
|
|
@@ -413,6 +418,11 @@ export class QuestionPageController extends PageController {
|
|
|
413
418
|
const viewModel = this.getViewModel(request, context)
|
|
414
419
|
viewModel.errors = collection.getViewErrors(viewModel.errors)
|
|
415
420
|
|
|
421
|
+
const flashedError = request.yar.flash(COMPONENT_STATE_ERROR)
|
|
422
|
+
const flashedErrors = !Array.isArray(flashedError) ? [flashedError] : []
|
|
423
|
+
|
|
424
|
+
viewModel.errors = (viewModel.errors ?? []).concat(flashedErrors)
|
|
425
|
+
|
|
416
426
|
/**
|
|
417
427
|
* Content components can be hidden based on a condition. If the condition evaluates to true, it is safe to be kept, otherwise discard it
|
|
418
428
|
*/
|
|
@@ -83,4 +83,7 @@ describe('SummaryPageController', () => {
|
|
|
83
83
|
expect(saveAndExitMock).toHaveBeenCalledWith(request, h, context)
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
|
+
|
|
87
|
+
// Note: InvalidComponentStateError handling is comprehensively tested
|
|
88
|
+
// in the integration test: test/form/component-state-errors.test.js
|
|
86
89
|
})
|
|
@@ -7,12 +7,13 @@ import {
|
|
|
7
7
|
import Boom from '@hapi/boom'
|
|
8
8
|
import { type RouteOptions } from '@hapi/hapi'
|
|
9
9
|
|
|
10
|
+
import { COMPONENT_STATE_ERROR } from '~/src/server/constants.js'
|
|
10
11
|
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
|
|
11
|
-
import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js'
|
|
12
12
|
import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js'
|
|
13
13
|
import {
|
|
14
14
|
checkEmailAddressForLiveFormSubmission,
|
|
15
15
|
checkFormStatus,
|
|
16
|
+
createError,
|
|
16
17
|
getCacheService
|
|
17
18
|
} from '~/src/server/plugins/engine/helpers.js'
|
|
18
19
|
import {
|
|
@@ -24,11 +25,11 @@ import {
|
|
|
24
25
|
type DetailItem
|
|
25
26
|
} from '~/src/server/plugins/engine/models/types.js'
|
|
26
27
|
import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
|
|
28
|
+
import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
|
|
27
29
|
import {
|
|
28
30
|
type FormConfirmationState,
|
|
29
31
|
type FormContext,
|
|
30
|
-
type FormContextRequest
|
|
31
|
-
type FormSubmissionState
|
|
32
|
+
type FormContextRequest
|
|
32
33
|
} from '~/src/server/plugins/engine/types.js'
|
|
33
34
|
import {
|
|
34
35
|
FormAction,
|
|
@@ -136,21 +137,39 @@ export class SummaryPageController extends QuestionPageController {
|
|
|
136
137
|
const formMetadata = await getFormMetadata(params.slug)
|
|
137
138
|
const { notificationEmail } = formMetadata
|
|
138
139
|
const { isPreview } = checkFormStatus(request.params)
|
|
139
|
-
const emailAddress = notificationEmail ?? this.model.def.outputEmail
|
|
140
140
|
|
|
141
|
-
checkEmailAddressForLiveFormSubmission(
|
|
141
|
+
checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview)
|
|
142
142
|
|
|
143
143
|
// Send submission email
|
|
144
|
-
if (
|
|
144
|
+
if (notificationEmail) {
|
|
145
145
|
const viewModel = this.getSummaryViewModel(request, context)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await submitForm(
|
|
149
|
+
context,
|
|
150
|
+
formMetadata,
|
|
151
|
+
request,
|
|
152
|
+
viewModel,
|
|
153
|
+
model,
|
|
154
|
+
notificationEmail,
|
|
155
|
+
formMetadata
|
|
156
|
+
)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error instanceof InvalidComponentStateError) {
|
|
159
|
+
const govukError = createError(
|
|
160
|
+
error.component.name,
|
|
161
|
+
error.userMessage
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
request.yar.flash(COMPONENT_STATE_ERROR, govukError, true)
|
|
165
|
+
|
|
166
|
+
await cacheService.resetComponentStates(request, error.getStateKeys())
|
|
167
|
+
|
|
168
|
+
return this.proceed(request, h, error.component.page?.path)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
154
173
|
}
|
|
155
174
|
|
|
156
175
|
await cacheService.setConfirmationState(request, {
|
|
@@ -179,13 +198,14 @@ export class SummaryPageController extends QuestionPageController {
|
|
|
179
198
|
|
|
180
199
|
export async function submitForm(
|
|
181
200
|
context: FormContext,
|
|
201
|
+
metadata: FormMetadata,
|
|
182
202
|
request: FormRequestPayload,
|
|
183
203
|
summaryViewModel: SummaryViewModel,
|
|
184
204
|
model: FormModel,
|
|
185
205
|
emailAddress: string,
|
|
186
206
|
formMetadata: FormMetadata
|
|
187
207
|
) {
|
|
188
|
-
await
|
|
208
|
+
await finaliseComponents(request, metadata, context)
|
|
189
209
|
|
|
190
210
|
const formStatus = checkFormStatus(request.params)
|
|
191
211
|
const logTags = ['submit', 'submissionApi']
|
|
@@ -222,39 +242,28 @@ export async function submitForm(
|
|
|
222
242
|
)
|
|
223
243
|
}
|
|
224
244
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Finalises any components that need post-processing before form submission. Candidates usually involve
|
|
247
|
+
* those that have external state.
|
|
248
|
+
* Examples include:
|
|
249
|
+
* - file uploads which are 'persisted' before submission
|
|
250
|
+
* - payments which are 'captured' before submission
|
|
251
|
+
*/
|
|
252
|
+
async function finaliseComponents(
|
|
253
|
+
request: FormRequestPayload,
|
|
254
|
+
metadata: FormMetadata,
|
|
255
|
+
context: FormContext
|
|
229
256
|
) {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
// For each file upload component with files in
|
|
235
|
-
// state, add the files to the batch getting persisted
|
|
236
|
-
model.pages.forEach((page) => {
|
|
237
|
-
const fileUploadComponents = page.collection.fields.filter(
|
|
238
|
-
(component) => component instanceof FileUploadField
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
fileUploadComponents.forEach((component) => {
|
|
242
|
-
const values = component.getFormValueFromState(state)
|
|
243
|
-
if (!values?.length) {
|
|
244
|
-
return
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
files.push(
|
|
248
|
-
...values.map(({ status }) => ({
|
|
249
|
-
fileId: status.form.file.fileId,
|
|
250
|
-
initiatedRetrievalKey: status.metadata.retrievalKey
|
|
251
|
-
}))
|
|
252
|
-
)
|
|
253
|
-
})
|
|
254
|
-
})
|
|
257
|
+
const relevantFields = context.relevantPages.flatMap(
|
|
258
|
+
(page) => page.collection.fields
|
|
259
|
+
)
|
|
255
260
|
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
for (const component of relevantFields) {
|
|
262
|
+
/*
|
|
263
|
+
Each component will throw InvalidComponent if its state is invalid, which is handled
|
|
264
|
+
by handleFormSubmit
|
|
265
|
+
*/
|
|
266
|
+
await component.onSubmit(request, metadata, context)
|
|
258
267
|
}
|
|
259
268
|
}
|
|
260
269
|
|
|
@@ -2,20 +2,30 @@ import { server } from '~/src/server/plugins/engine/pageControllers/__stubs__/se
|
|
|
2
2
|
import { type FormContextRequest } from '~/src/server/plugins/engine/types.js'
|
|
3
3
|
import { type FormRequest } from '~/src/server/routes/types.js'
|
|
4
4
|
|
|
5
|
+
const mockYar = {
|
|
6
|
+
flash: jest.fn().mockReturnValue([]),
|
|
7
|
+
clear: jest.fn(),
|
|
8
|
+
get: jest.fn(),
|
|
9
|
+
set: jest.fn(),
|
|
10
|
+
reset: jest.fn()
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export function buildFormRequest(
|
|
6
|
-
request: Omit<FormRequest, 'server'>
|
|
14
|
+
request: Omit<FormRequest, 'server' | 'yar'>
|
|
7
15
|
): FormRequest {
|
|
8
16
|
return {
|
|
9
17
|
...request,
|
|
18
|
+
yar: mockYar,
|
|
10
19
|
server
|
|
11
|
-
} as FormRequest
|
|
20
|
+
} as unknown as FormRequest
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
export function buildFormContextRequest(
|
|
15
|
-
request: Omit<FormContextRequest, 'server'>
|
|
24
|
+
request: Omit<FormContextRequest, 'server' | 'yar'>
|
|
16
25
|
): FormContextRequest {
|
|
17
26
|
return {
|
|
18
27
|
...request,
|
|
28
|
+
yar: mockYar,
|
|
19
29
|
server
|
|
20
|
-
} as FormContextRequest
|
|
30
|
+
} as unknown as FormContextRequest
|
|
21
31
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ComponentType } from '@defra/forms-model'
|
|
2
|
+
|
|
3
|
+
import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js'
|
|
4
|
+
import { TextField } from '~/src/server/plugins/engine/components/TextField.js'
|
|
5
|
+
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
|
|
6
|
+
import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
|
|
7
|
+
import definition from '~/test/form/definitions/file-upload-basic.js'
|
|
8
|
+
|
|
9
|
+
describe('InvalidComponentStateError', () => {
|
|
10
|
+
let model: FormModel
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
model = new FormModel(definition, {
|
|
14
|
+
basePath: 'test'
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('getStateKeys', () => {
|
|
19
|
+
it('should return component name and upload for FileUploadField', () => {
|
|
20
|
+
const page = model.pages.find((p) => p.path === '/file-upload-component')
|
|
21
|
+
const component = new FileUploadField(
|
|
22
|
+
{
|
|
23
|
+
name: 'fileUpload',
|
|
24
|
+
title: 'Upload something',
|
|
25
|
+
type: ComponentType.FileUploadField,
|
|
26
|
+
options: {},
|
|
27
|
+
schema: {}
|
|
28
|
+
},
|
|
29
|
+
{ model, page }
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const error = new InvalidComponentStateError(
|
|
33
|
+
component,
|
|
34
|
+
'Test error message'
|
|
35
|
+
)
|
|
36
|
+
const stateKeys = error.getStateKeys()
|
|
37
|
+
|
|
38
|
+
expect(stateKeys).toEqual(['fileUpload', 'upload'])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should return only component name for non-FileUploadField components', () => {
|
|
42
|
+
const page = model.pages.find((p) => p.path === '/file-upload-component')
|
|
43
|
+
const component = new TextField(
|
|
44
|
+
{
|
|
45
|
+
name: 'textField',
|
|
46
|
+
title: 'Text field',
|
|
47
|
+
type: ComponentType.TextField,
|
|
48
|
+
options: {},
|
|
49
|
+
schema: {}
|
|
50
|
+
},
|
|
51
|
+
{ model, page }
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const error = new InvalidComponentStateError(
|
|
55
|
+
component,
|
|
56
|
+
'Test error message'
|
|
57
|
+
)
|
|
58
|
+
const stateKeys = error.getStateKeys()
|
|
59
|
+
|
|
60
|
+
expect(stateKeys).toEqual(['textField'])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js'
|
|
2
|
+
import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when a component has an invalid state. This is typically only required where state needs
|
|
6
|
+
* to be checked against an external source upon submission of a form. For example: file upload
|
|
7
|
+
* has internal state (file upload IDs) but also external state (files in S3). The internal state
|
|
8
|
+
* is always validated by the engine, but the external state needs validating too.
|
|
9
|
+
*
|
|
10
|
+
* This should be used within a formComponent.onSubmit(...).
|
|
11
|
+
*/
|
|
12
|
+
export class InvalidComponentStateError extends Error {
|
|
13
|
+
public readonly component: FormComponent
|
|
14
|
+
public readonly userMessage: string
|
|
15
|
+
|
|
16
|
+
constructor(component: FormComponent, userMessage: string) {
|
|
17
|
+
const message = `Invalid component state for: ${component.name}`
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = 'InvalidComponentStateError'
|
|
20
|
+
this.component = component
|
|
21
|
+
this.userMessage = userMessage
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getStateKeys() {
|
|
25
|
+
const extraStateKeys =
|
|
26
|
+
this.component instanceof FileUploadField ? ['upload'] : []
|
|
27
|
+
|
|
28
|
+
return [this.component.name].concat(extraStateKeys)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -3,10 +3,15 @@ import { ComponentType, type Page } from '@defra/forms-model'
|
|
|
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
|
+
copyNotYetValidatedState,
|
|
6
7
|
prefillStateFromQueryParameters,
|
|
7
8
|
stripParam
|
|
8
9
|
} from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type AnyFormRequest,
|
|
12
|
+
type FormContext,
|
|
13
|
+
type FormContextRequest
|
|
14
|
+
} from '~/src/server/plugins/engine/types.js'
|
|
10
15
|
import { type FormsService, type Services } from '~/src/server/types.js'
|
|
11
16
|
|
|
12
17
|
function buildMockPage(
|
|
@@ -218,4 +223,73 @@ describe('State helpers', () => {
|
|
|
218
223
|
expect(stripParam(params, 'anyParam')).toBeUndefined()
|
|
219
224
|
})
|
|
220
225
|
})
|
|
226
|
+
|
|
227
|
+
describe('copyNotYetValidatedState', () => {
|
|
228
|
+
it('should ignore if no invalid state', () => {
|
|
229
|
+
const mockRequest = {} as FormContextRequest
|
|
230
|
+
const mockContext = {
|
|
231
|
+
state: { abc: '123' },
|
|
232
|
+
payload: {}
|
|
233
|
+
} as unknown as FormContext
|
|
234
|
+
copyNotYetValidatedState(mockRequest, mockContext)
|
|
235
|
+
expect(mockContext.state).toEqual({ abc: '123' })
|
|
236
|
+
expect(mockContext.payload).toEqual({})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('should ignore if wrong path', () => {
|
|
240
|
+
const mockRequest = {
|
|
241
|
+
url: {
|
|
242
|
+
pathname: '/form-page1'
|
|
243
|
+
}
|
|
244
|
+
} as unknown as FormContextRequest
|
|
245
|
+
const mockContext = {
|
|
246
|
+
state: {
|
|
247
|
+
abc: '123',
|
|
248
|
+
__stateNotYetValidated: {
|
|
249
|
+
def: '456',
|
|
250
|
+
__currentPagePath: '/root'
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
payload: {}
|
|
254
|
+
} as unknown as FormContext
|
|
255
|
+
copyNotYetValidatedState(mockRequest, mockContext)
|
|
256
|
+
expect(mockContext.state).toEqual({
|
|
257
|
+
abc: '123',
|
|
258
|
+
__stateNotYetValidated: {
|
|
259
|
+
def: '456',
|
|
260
|
+
__currentPagePath: '/root'
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
expect(mockContext.payload).toEqual({})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should apply if correct path', () => {
|
|
267
|
+
const mockRequest = {
|
|
268
|
+
url: {
|
|
269
|
+
pathname: '/form-page1'
|
|
270
|
+
}
|
|
271
|
+
} as unknown as FormContextRequest
|
|
272
|
+
const mockContext = {
|
|
273
|
+
state: {
|
|
274
|
+
abc: '123',
|
|
275
|
+
__stateNotYetValidated: {
|
|
276
|
+
def: '456',
|
|
277
|
+
__currentPagePath: '/form-page1'
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
payload: {}
|
|
281
|
+
} as unknown as FormContext
|
|
282
|
+
copyNotYetValidatedState(mockRequest, mockContext)
|
|
283
|
+
expect(mockContext.state).toEqual({
|
|
284
|
+
abc: '123',
|
|
285
|
+
__stateNotYetValidated: {
|
|
286
|
+
def: '456',
|
|
287
|
+
__currentPagePath: '/form-page1'
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
expect(mockContext.payload).toEqual({
|
|
291
|
+
def: '456'
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
})
|
|
221
295
|
})
|