@defra/forms-engine-plugin 4.0.32 → 4.0.34
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/CheckboxesField.d.ts +1 -1
- package/.server/server/plugins/engine/components/CheckboxesField.js +3 -0
- package/.server/server/plugins/engine/components/CheckboxesField.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/pageControllers/FileUploadPageController.js +6 -2
- package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +4 -1
- 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/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/CheckboxesField.test.ts +38 -18
- package/src/server/plugins/engine/components/CheckboxesField.ts +7 -1
- 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/pageControllers/FileUploadPageController.test.ts +9 -0
- package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +11 -4
- package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +6 -0
- 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/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
|
@@ -65,9 +65,15 @@ export class CheckboxesField extends SelectionControlField {
|
|
|
65
65
|
return this.isValue(value) ? value : undefined
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
getDisplayStringFromFormValue(
|
|
68
|
+
getDisplayStringFromFormValue(
|
|
69
|
+
selected: (string | number | boolean)[] | undefined
|
|
70
|
+
) {
|
|
69
71
|
const { items } = this
|
|
70
72
|
|
|
73
|
+
if (!selected) {
|
|
74
|
+
return ''
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
// Map selected values to text
|
|
72
78
|
return items
|
|
73
79
|
.filter((item) => selected.includes(item.value))
|
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ComponentType,
|
|
3
|
-
type FileUploadFieldComponent
|
|
3
|
+
type FileUploadFieldComponent,
|
|
4
|
+
type FormMetadata
|
|
4
5
|
} from '@defra/forms-model'
|
|
6
|
+
import Boom from '@hapi/boom'
|
|
5
7
|
|
|
6
8
|
import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
|
|
7
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
FileUploadField,
|
|
11
|
+
tempItemSchema
|
|
12
|
+
} from '~/src/server/plugins/engine/components/FileUploadField.js'
|
|
8
13
|
import {
|
|
9
14
|
getAnswer,
|
|
10
15
|
type Field
|
|
11
16
|
} from '~/src/server/plugins/engine/components/helpers/components.js'
|
|
12
17
|
import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
|
|
18
|
+
import { InvalidComponentStateError } from '~/src/server/plugins/engine/pageControllers/errors.js'
|
|
13
19
|
import {
|
|
14
20
|
createPage,
|
|
15
21
|
type PageControllerClass
|
|
16
22
|
} from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
|
|
17
23
|
import { validationOptions as opts } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
|
|
24
|
+
import { type Services } from '~/src/server/plugins/engine/types/index.js'
|
|
18
25
|
import {
|
|
19
26
|
FileStatus,
|
|
20
27
|
UploadStatus,
|
|
28
|
+
type FormContext,
|
|
21
29
|
type UploadState
|
|
22
30
|
} from '~/src/server/plugins/engine/types.js'
|
|
31
|
+
import { type FormRequestPayload } from '~/src/server/routes/types.js'
|
|
23
32
|
import definition from '~/test/form/definitions/file-upload-basic.js'
|
|
24
33
|
import { getFormData, getFormState } from '~/test/helpers/component-helpers.js'
|
|
25
34
|
|
|
@@ -828,4 +837,196 @@ describe('FileUploadField', () => {
|
|
|
828
837
|
)
|
|
829
838
|
})
|
|
830
839
|
})
|
|
840
|
+
|
|
841
|
+
describe('onSubmit', () => {
|
|
842
|
+
let fileUploadField: FileUploadField
|
|
843
|
+
let mockRequest: FormRequestPayload
|
|
844
|
+
let mockMetadata: FormMetadata
|
|
845
|
+
let mockContext: FormContext
|
|
846
|
+
let mockPersistFiles: jest.Mock
|
|
847
|
+
|
|
848
|
+
beforeEach(() => {
|
|
849
|
+
// Create a FileUploadField instance
|
|
850
|
+
const componentDef: FileUploadFieldComponent = {
|
|
851
|
+
name: 'fileUpload',
|
|
852
|
+
title: 'Upload something',
|
|
853
|
+
type: ComponentType.FileUploadField,
|
|
854
|
+
options: {},
|
|
855
|
+
schema: {}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const page = model.pages.find((p) => p.path === '/file-upload-component')
|
|
859
|
+
fileUploadField = new FileUploadField(componentDef, {
|
|
860
|
+
model,
|
|
861
|
+
page
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
// Mock persistFiles
|
|
865
|
+
mockPersistFiles = jest.fn().mockResolvedValue(undefined)
|
|
866
|
+
|
|
867
|
+
// Mock request
|
|
868
|
+
mockRequest = {
|
|
869
|
+
app: {
|
|
870
|
+
model: {
|
|
871
|
+
services: {
|
|
872
|
+
formSubmissionService: {
|
|
873
|
+
persistFiles: mockPersistFiles
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
} as unknown as FormRequestPayload
|
|
879
|
+
|
|
880
|
+
// Mock metadata
|
|
881
|
+
mockMetadata = {
|
|
882
|
+
notificationEmail: 'test@example.com'
|
|
883
|
+
} as FormMetadata
|
|
884
|
+
|
|
885
|
+
// Mock context with state
|
|
886
|
+
mockContext = {
|
|
887
|
+
state: {
|
|
888
|
+
fileUpload: validState
|
|
889
|
+
}
|
|
890
|
+
} as unknown as FormContext
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
afterEach(() => {
|
|
894
|
+
jest.clearAllMocks()
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
it('should successfully persist files', async () => {
|
|
898
|
+
await fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
899
|
+
|
|
900
|
+
expect(mockPersistFiles).toHaveBeenCalledTimes(1)
|
|
901
|
+
expect(mockPersistFiles).toHaveBeenCalledWith(
|
|
902
|
+
[
|
|
903
|
+
{
|
|
904
|
+
fileId: 'fcb4f0f8-6862-4836-86dc-f56ff900b0ff',
|
|
905
|
+
initiatedRetrievalKey: 'enrique.chase@defra.gov.uk'
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
fileId: 'e1d6cf98-35a7-4f97-8a28-cdd2b115d8fa',
|
|
909
|
+
initiatedRetrievalKey: 'enrique.chase@defra.gov.uk'
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
fileId: '71fb359c-dee7-4c2e-8701-239eb892765a',
|
|
913
|
+
initiatedRetrievalKey: 'enrique.chase@defra.gov.uk'
|
|
914
|
+
}
|
|
915
|
+
],
|
|
916
|
+
'test@example.com'
|
|
917
|
+
)
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
it('should fail when notificationEmail is not set', async () => {
|
|
921
|
+
mockMetadata.notificationEmail = undefined
|
|
922
|
+
|
|
923
|
+
await expect(
|
|
924
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
925
|
+
).rejects.toThrow('Unexpected missing notificationEmail in metadata')
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it('should fail when notificationEmail is empty string', async () => {
|
|
929
|
+
mockMetadata.notificationEmail = ''
|
|
930
|
+
|
|
931
|
+
await expect(
|
|
932
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
933
|
+
).rejects.toThrow('Unexpected missing notificationEmail in metadata')
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('should not call persistFiles when no files in state', async () => {
|
|
937
|
+
mockContext.state = {}
|
|
938
|
+
|
|
939
|
+
await fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
940
|
+
|
|
941
|
+
expect(mockPersistFiles).not.toHaveBeenCalled()
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
it('should not call persistFiles when empty array in state', async () => {
|
|
945
|
+
mockContext.state = { fileUpload: [] }
|
|
946
|
+
|
|
947
|
+
await fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
948
|
+
|
|
949
|
+
expect(mockPersistFiles).not.toHaveBeenCalled()
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
it('should throw Error when formSubmissionService is not available', async () => {
|
|
953
|
+
if (!mockRequest.app.model) {
|
|
954
|
+
throw new Error('Invalid test setup')
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
mockRequest.app.model.services = {} as unknown as Services
|
|
958
|
+
|
|
959
|
+
await expect(
|
|
960
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
961
|
+
).rejects.toThrow('No form submission service available in app model')
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
it('should throw InvalidComponentStateError when persistFiles throws 403 Forbidden', async () => {
|
|
965
|
+
const forbiddenError = Boom.forbidden('Invalid retrieval key')
|
|
966
|
+
mockPersistFiles.mockRejectedValue(forbiddenError)
|
|
967
|
+
|
|
968
|
+
await expect(
|
|
969
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
970
|
+
).rejects.toThrow(InvalidComponentStateError)
|
|
971
|
+
|
|
972
|
+
const error = await fileUploadField
|
|
973
|
+
.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
974
|
+
.catch((e: unknown) => e)
|
|
975
|
+
|
|
976
|
+
expect(error).toBeInstanceOf(InvalidComponentStateError)
|
|
977
|
+
expect((error as InvalidComponentStateError).component).toBe(
|
|
978
|
+
fileUploadField
|
|
979
|
+
)
|
|
980
|
+
expect((error as InvalidComponentStateError).userMessage).toBe(
|
|
981
|
+
'There was a problem with your uploaded files. Re-upload them before submitting the form again.'
|
|
982
|
+
)
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
it('should throw InvalidComponentStateError when persistFiles throws 410 Gone', async () => {
|
|
986
|
+
const goneError = Boom.resourceGone('File has expired')
|
|
987
|
+
mockPersistFiles.mockRejectedValue(goneError)
|
|
988
|
+
|
|
989
|
+
await expect(
|
|
990
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
991
|
+
).rejects.toThrow(InvalidComponentStateError)
|
|
992
|
+
|
|
993
|
+
const error = await fileUploadField
|
|
994
|
+
.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
995
|
+
.catch((e: unknown) => e)
|
|
996
|
+
|
|
997
|
+
expect(error).toBeInstanceOf(InvalidComponentStateError)
|
|
998
|
+
expect((error as InvalidComponentStateError).component).toBe(
|
|
999
|
+
fileUploadField
|
|
1000
|
+
)
|
|
1001
|
+
expect((error as InvalidComponentStateError).userMessage).toBe(
|
|
1002
|
+
'There was a problem with your uploaded files. Re-upload them before submitting the form again.'
|
|
1003
|
+
)
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('should re-throw other Boom errors without wrapping', async () => {
|
|
1007
|
+
const serverError = Boom.internal('Internal server error')
|
|
1008
|
+
mockPersistFiles.mockRejectedValue(serverError)
|
|
1009
|
+
|
|
1010
|
+
await expect(
|
|
1011
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
1012
|
+
).rejects.toThrow(serverError)
|
|
1013
|
+
|
|
1014
|
+
await expect(
|
|
1015
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
1016
|
+
).rejects.not.toThrow(InvalidComponentStateError)
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
it('should re-throw non-Boom errors without wrapping', async () => {
|
|
1020
|
+
const genericError = new Error('Something went wrong')
|
|
1021
|
+
mockPersistFiles.mockRejectedValue(genericError)
|
|
1022
|
+
|
|
1023
|
+
await expect(
|
|
1024
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
1025
|
+
).rejects.toThrow(genericError)
|
|
1026
|
+
|
|
1027
|
+
await expect(
|
|
1028
|
+
fileUploadField.onSubmit(mockRequest, mockMetadata, mockContext)
|
|
1029
|
+
).rejects.not.toThrow(InvalidComponentStateError)
|
|
1030
|
+
})
|
|
1031
|
+
})
|
|
831
1032
|
})
|
|
@@ -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
|
|
@@ -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'
|
|
@@ -413,6 +414,11 @@ export class QuestionPageController extends PageController {
|
|
|
413
414
|
const viewModel = this.getViewModel(request, context)
|
|
414
415
|
viewModel.errors = collection.getViewErrors(viewModel.errors)
|
|
415
416
|
|
|
417
|
+
const flashedError = request.yar.flash(COMPONENT_STATE_ERROR)
|
|
418
|
+
const flashedErrors = !Array.isArray(flashedError) ? [flashedError] : []
|
|
419
|
+
|
|
420
|
+
viewModel.errors = (viewModel.errors ?? []).concat(flashedErrors)
|
|
421
|
+
|
|
416
422
|
/**
|
|
417
423
|
* 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
424
|
*/
|
|
@@ -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
|
}
|