@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.
Files changed (78) 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/index.d.ts +2 -0
  18. package/.server/server/plugins/engine/index.js +2 -0
  19. package/.server/server/plugins/engine/index.js.map +1 -1
  20. package/.server/server/plugins/engine/models/FormModel.js +4 -0
  21. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  22. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +6 -2
  23. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
  24. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +8 -3
  25. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  26. package/.server/server/plugins/engine/pageControllers/StartPageController.d.ts +4 -0
  27. package/.server/server/plugins/engine/pageControllers/SummaryPageController.d.ts +1 -1
  28. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +33 -35
  29. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  30. package/.server/server/plugins/engine/pageControllers/__stubs__/request.d.ts +2 -2
  31. package/.server/server/plugins/engine/pageControllers/__stubs__/request.js +9 -0
  32. package/.server/server/plugins/engine/pageControllers/__stubs__/request.js.map +1 -1
  33. package/.server/server/plugins/engine/pageControllers/errors.d.ts +15 -0
  34. package/.server/server/plugins/engine/pageControllers/errors.js +25 -0
  35. package/.server/server/plugins/engine/pageControllers/errors.js.map +1 -0
  36. package/.server/server/plugins/engine/pageControllers/helpers/state.d.ts +13 -1
  37. package/.server/server/plugins/engine/pageControllers/helpers/state.js +33 -0
  38. package/.server/server/plugins/engine/pageControllers/helpers/state.js.map +1 -1
  39. package/.server/server/plugins/engine/services/localFormsService.js +6 -0
  40. package/.server/server/plugins/engine/services/localFormsService.js.map +1 -1
  41. package/.server/server/plugins/engine/views/index.html +1 -1
  42. package/.server/server/plugins/nunjucks/context.test.js +9 -1
  43. package/.server/server/plugins/nunjucks/context.test.js.map +1 -1
  44. package/.server/server/plugins/nunjucks/types.d.ts +4 -0
  45. package/.server/server/plugins/nunjucks/types.js +1 -0
  46. package/.server/server/plugins/nunjucks/types.js.map +1 -1
  47. package/.server/server/services/cacheService.d.ts +1 -0
  48. package/.server/server/services/cacheService.js +10 -0
  49. package/.server/server/services/cacheService.js.map +1 -1
  50. package/.server/typings/hapi/index.d.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/server/constants.js +1 -0
  53. package/src/server/forms/register-as-a-unicorn-breeder.yaml +0 -1
  54. package/src/server/forms/simple-form.yaml +64 -0
  55. package/src/server/plugins/engine/beta/form-context.test.ts +4 -3
  56. package/src/server/plugins/engine/beta/form-context.ts +4 -3
  57. package/src/server/plugins/engine/components/FileUploadField.test.ts +203 -2
  58. package/src/server/plugins/engine/components/FileUploadField.ts +61 -2
  59. package/src/server/plugins/engine/components/FormComponent.ts +17 -1
  60. package/src/server/plugins/engine/helpers.ts +8 -0
  61. package/src/server/plugins/engine/index.ts +3 -0
  62. package/src/server/plugins/engine/models/FormModel.ts +4 -0
  63. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +9 -0
  64. package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +11 -4
  65. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +12 -2
  66. package/src/server/plugins/engine/pageControllers/SummaryPageController.test.ts +3 -0
  67. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +55 -46
  68. package/src/server/plugins/engine/pageControllers/__stubs__/request.ts +14 -4
  69. package/src/server/plugins/engine/pageControllers/errors.test.ts +63 -0
  70. package/src/server/plugins/engine/pageControllers/errors.ts +30 -0
  71. package/src/server/plugins/engine/pageControllers/helpers/state.test.ts +75 -1
  72. package/src/server/plugins/engine/pageControllers/helpers/state.ts +50 -1
  73. package/src/server/plugins/engine/services/localFormsService.js +7 -0
  74. package/src/server/plugins/engine/views/index.html +1 -1
  75. package/src/server/plugins/nunjucks/context.test.js +10 -2
  76. package/src/server/plugins/nunjucks/types.js +1 -0
  77. package/src/server/services/cacheService.ts +16 -0
  78. package/src/typings/hapi/index.d.ts +2 -0
@@ -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
@@ -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 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'
@@ -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 { prefillStateFromQueryParameters } from '~/src/server/plugins/engine/pageControllers/helpers/state.js'
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
- return cacheService.setState(request, state)
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(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
  }
@@ -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 { type AnyFormRequest } from '~/src/server/plugins/engine/types.js'
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
  })