@defra/forms-engine-plugin 4.0.33 → 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.
Files changed (66) hide show
  1. package/.server/server/constants.d.ts +1 -0
  2. package/.server/server/constants.js +1 -0
  3. package/.server/server/constants.js.map +1 -1
  4. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +0 -1
  5. package/.server/server/forms/simple-form.yaml +64 -0
  6. package/.server/server/plugins/engine/beta/form-context.js +1 -2
  7. package/.server/server/plugins/engine/beta/form-context.js.map +1 -1
  8. package/.server/server/plugins/engine/components/FileUploadField.d.ts +4 -3
  9. package/.server/server/plugins/engine/components/FileUploadField.js +38 -0
  10. package/.server/server/plugins/engine/components/FileUploadField.js.map +1 -1
  11. package/.server/server/plugins/engine/components/FormComponent.d.ts +9 -7
  12. package/.server/server/plugins/engine/components/FormComponent.js +3 -0
  13. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  14. package/.server/server/plugins/engine/helpers.d.ts +5 -0
  15. package/.server/server/plugins/engine/helpers.js +7 -0
  16. package/.server/server/plugins/engine/helpers.js.map +1 -1
  17. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +6 -2
  18. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
  19. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +4 -1
  20. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  21. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +4 -0
  22. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -1
  23. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +33 -35
  24. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  25. package/.server/server/plugins/engine/pageControllers/__stubs__/request.d.ts +2 -2
  26. package/.server/server/plugins/engine/pageControllers/__stubs__/request.js +9 -0
  27. package/.server/server/plugins/engine/pageControllers/__stubs__/request.js.map +1 -1
  28. package/.server/server/plugins/engine/pageControllers/errors.d.ts +15 -0
  29. package/.server/server/plugins/engine/pageControllers/errors.js +25 -0
  30. package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -0
  31. package/.server/server/plugins/engine/services/localFormsService.js +6 -0
  32. package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
  33. package/.server/server/plugins/engine/views/index.html +1 -1
  34. package/.server/server/plugins/nunjucks/context.test.js +9 -1
  35. package/.server/server/plugins/nunjucks/context.test.js.map +1 -1
  36. package/.server/server/plugins/nunjucks/types.d.ts +4 -0
  37. package/.server/server/plugins/nunjucks/types.js +1 -0
  38. package/.server/server/plugins/nunjucks/types.js.map +1 -1
  39. package/.server/server/services/cacheService.d.ts +1 -0
  40. package/.server/server/services/cacheService.js +10 -0
  41. package/.server/server/services/cacheService.js.map +1 -1
  42. package/.server/typings/hapi/index.d.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/server/constants.js +1 -0
  45. package/src/server/forms/register-as-a-unicorn-breeder.yaml +0 -1
  46. package/src/server/forms/simple-form.yaml +64 -0
  47. package/src/server/plugins/engine/beta/form-context.test.ts +4 -3
  48. package/src/server/plugins/engine/beta/form-context.ts +4 -3
  49. package/src/server/plugins/engine/components/FileUploadField.test.ts +203 -2
  50. package/src/server/plugins/engine/components/FileUploadField.ts +61 -2
  51. package/src/server/plugins/engine/components/FormComponent.ts +17 -1
  52. package/src/server/plugins/engine/helpers.ts +8 -0
  53. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +9 -0
  54. package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +11 -4
  55. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +6 -0
  56. package/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +3 -0
  57. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +55 -46
  58. package/src/server/plugins/engine/pageControllers/__stubs__/request.ts +14 -4
  59. package/src/server/plugins/engine/pageControllers/errors.test.ts +63 -0
  60. package/src/server/plugins/engine/pageControllers/errors.ts +30 -0
  61. package/src/server/plugins/engine/services/localFormsService.js +7 -0
  62. package/src/server/plugins/engine/views/index.html +1 -1
  63. package/src/server/plugins/nunjucks/context.test.js +10 -2
  64. package/src/server/plugins/nunjucks/types.js +1 -0
  65. package/src/server/services/cacheService.ts +16 -0
  66. package/src/typings/hapi/index.d.ts +2 -0
@@ -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 { tempItemSchema } from '~/src/server/plugins/engine/components/FileUploadField.js'
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 { type FileUploadFieldComponent } from '@defra/forms-model'
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 { type FormQuery } from '~/src/server/routes/types.js'
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 { type FormComponentsDef, type Item } from '@defra/forms-model'
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 outputEmail =
437
- this.model.def.outputEmail ?? 'defraforms@defra.gov.uk'
438
-
439
- const newUpload = await initiateUpload(href, outputEmail, options.accept)
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(emailAddress, isPreview)
141
+ checkEmailAddressForLiveFormSubmission(notificationEmail, isPreview)
142
142
 
143
143
  // Send submission email
144
- if (emailAddress) {
144
+ if (notificationEmail) {
145
145
  const viewModel = this.getSummaryViewModel(request, context)
146
- await submitForm(
147
- context,
148
- request,
149
- viewModel,
150
- model,
151
- emailAddress,
152
- formMetadata
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 extendFileRetention(model, context.state, emailAddress)
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
- async function extendFileRetention(
226
- model: FormModel,
227
- state: FormSubmissionState,
228
- updatedRetrievalKey: string
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 { formSubmissionService } = model.services
231
- const { persistFiles } = formSubmissionService
232
- const files: { fileId: string; initiatedRetrievalKey: string }[] = []
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
- if (files.length) {
257
- return persistFiles(files, updatedRetrievalKey)
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
  }