@defra/forms-engine-plugin 0.1.11 → 0.1.13

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 (147) hide show
  1. package/.public/javascripts/file-upload.min.js +1 -1
  2. package/.public/javascripts/file-upload.min.js.map +1 -1
  3. package/.server/client/javascripts/file-upload.js +45 -4
  4. package/.server/client/javascripts/file-upload.js.map +1 -1
  5. package/.server/server/constants.js +2 -0
  6. package/.server/server/constants.js.map +1 -1
  7. package/.server/server/index.js +1 -1
  8. package/.server/server/index.js.map +1 -1
  9. package/.server/server/plugins/engine/components/AutocompleteField.js +2 -0
  10. package/.server/server/plugins/engine/components/AutocompleteField.js.map +1 -1
  11. package/.server/server/plugins/engine/components/CheckboxesField.js +3 -4
  12. package/.server/server/plugins/engine/components/CheckboxesField.js.map +1 -1
  13. package/.server/server/plugins/engine/components/ComponentCollection.js +37 -16
  14. package/.server/server/plugins/engine/components/ComponentCollection.js.map +1 -1
  15. package/.server/server/plugins/engine/components/DatePartsField.js +36 -2
  16. package/.server/server/plugins/engine/components/DatePartsField.js.map +1 -1
  17. package/.server/server/plugins/engine/components/EmailAddressField.js +19 -3
  18. package/.server/server/plugins/engine/components/EmailAddressField.js.map +1 -1
  19. package/.server/server/plugins/engine/components/FileUploadField.js +44 -4
  20. package/.server/server/plugins/engine/components/FileUploadField.js.map +1 -1
  21. package/.server/server/plugins/engine/components/FormComponent.js +14 -2
  22. package/.server/server/plugins/engine/components/FormComponent.js.map +1 -1
  23. package/.server/server/plugins/engine/components/ListFormComponent.js +16 -3
  24. package/.server/server/plugins/engine/components/ListFormComponent.js.map +1 -1
  25. package/.server/server/plugins/engine/components/Markdown.js +24 -0
  26. package/.server/server/plugins/engine/components/Markdown.js.map +1 -0
  27. package/.server/server/plugins/engine/components/MonthYearField.js +30 -2
  28. package/.server/server/plugins/engine/components/MonthYearField.js.map +1 -1
  29. package/.server/server/plugins/engine/components/MultilineTextField.js +32 -3
  30. package/.server/server/plugins/engine/components/MultilineTextField.js.map +1 -1
  31. package/.server/server/plugins/engine/components/NumberField.js +28 -3
  32. package/.server/server/plugins/engine/components/NumberField.js.map +1 -1
  33. package/.server/server/plugins/engine/components/SelectionControlField.js +14 -0
  34. package/.server/server/plugins/engine/components/SelectionControlField.js.map +1 -1
  35. package/.server/server/plugins/engine/components/TelephoneNumberField.js +19 -3
  36. package/.server/server/plugins/engine/components/TelephoneNumberField.js.map +1 -1
  37. package/.server/server/plugins/engine/components/TextField.js +22 -3
  38. package/.server/server/plugins/engine/components/TextField.js.map +1 -1
  39. package/.server/server/plugins/engine/components/UkAddressField.js +29 -0
  40. package/.server/server/plugins/engine/components/UkAddressField.js.map +1 -1
  41. package/.server/server/plugins/engine/components/YesNoField.js +18 -0
  42. package/.server/server/plugins/engine/components/YesNoField.js.map +1 -1
  43. package/.server/server/plugins/engine/components/helpers.js +16 -0
  44. package/.server/server/plugins/engine/components/helpers.js.map +1 -1
  45. package/.server/server/plugins/engine/components/index.js +1 -0
  46. package/.server/server/plugins/engine/components/index.js.map +1 -1
  47. package/.server/server/plugins/engine/configureEnginePlugin.js +3 -1
  48. package/.server/server/plugins/engine/configureEnginePlugin.js.map +1 -1
  49. package/.server/server/plugins/engine/helpers.js +38 -18
  50. package/.server/server/plugins/engine/helpers.js.map +1 -1
  51. package/.server/server/plugins/engine/models/FormModel.js +60 -2
  52. package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
  53. package/.server/server/plugins/engine/models/SummaryViewModel.js +3 -2
  54. package/.server/server/plugins/engine/models/SummaryViewModel.js.map +1 -1
  55. package/.server/server/plugins/engine/outputFormatters/human/v1.js +1 -1
  56. package/.server/server/plugins/engine/outputFormatters/human/v1.js.map +1 -1
  57. package/.server/server/plugins/engine/pageControllers/PageController.js +13 -5
  58. package/.server/server/plugins/engine/pageControllers/PageController.js.map +1 -1
  59. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +2 -2
  60. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  61. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +19 -5
  62. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  63. package/.server/server/plugins/engine/pageControllers/validationOptions.js +6 -11
  64. package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
  65. package/.server/server/plugins/engine/plugin.js +5 -4
  66. package/.server/server/plugins/engine/plugin.js.map +1 -1
  67. package/.server/server/plugins/engine/services/notifyService.js +1 -4
  68. package/.server/server/plugins/engine/services/notifyService.js.map +1 -1
  69. package/.server/server/plugins/engine/services/uploadService.js +5 -3
  70. package/.server/server/plugins/engine/services/uploadService.js.map +1 -1
  71. package/.server/server/plugins/engine/types.js.map +1 -1
  72. package/.server/server/plugins/engine/views/components/html.html +1 -1
  73. package/.server/server/plugins/engine/views/components/markdown.html +5 -0
  74. package/.server/server/plugins/engine/views/summary.html +7 -1
  75. package/.server/server/plugins/nunjucks/context.js +7 -6
  76. package/.server/server/plugins/nunjucks/context.js.map +1 -1
  77. package/.server/server/plugins/nunjucks/enviroment.test.js +6 -3
  78. package/.server/server/plugins/nunjucks/enviroment.test.js.map +1 -1
  79. package/.server/server/utils/type-utils.js +8 -0
  80. package/.server/server/utils/type-utils.js.map +1 -0
  81. package/.server/typings/joi/index.d.js.map +1 -1
  82. package/package.json +3 -3
  83. package/src/client/javascripts/file-upload.js +60 -4
  84. package/src/server/constants.js +2 -0
  85. package/src/server/index.test.ts +34 -29
  86. package/src/server/index.ts +2 -1
  87. package/src/server/plugins/engine/components/AutocompleteField.test.ts +71 -3
  88. package/src/server/plugins/engine/components/AutocompleteField.ts +6 -2
  89. package/src/server/plugins/engine/components/CheckboxesField.test.ts +40 -8
  90. package/src/server/plugins/engine/components/CheckboxesField.ts +7 -3
  91. package/src/server/plugins/engine/components/ComponentCollection.ts +45 -18
  92. package/src/server/plugins/engine/components/DatePartsField.test.ts +13 -4
  93. package/src/server/plugins/engine/components/DatePartsField.ts +29 -8
  94. package/src/server/plugins/engine/components/EmailAddressField.test.ts +51 -1
  95. package/src/server/plugins/engine/components/EmailAddressField.ts +17 -2
  96. package/src/server/plugins/engine/components/FileUploadField.test.ts +53 -0
  97. package/src/server/plugins/engine/components/FileUploadField.ts +52 -3
  98. package/src/server/plugins/engine/components/FormComponent.ts +24 -2
  99. package/src/server/plugins/engine/components/ListFormComponent.ts +16 -2
  100. package/src/server/plugins/engine/components/Markdown.test.ts +48 -0
  101. package/src/server/plugins/engine/components/Markdown.ts +29 -0
  102. package/src/server/plugins/engine/components/MonthYearField.test.ts +35 -0
  103. package/src/server/plugins/engine/components/MonthYearField.ts +34 -9
  104. package/src/server/plugins/engine/components/MultilineTextField.test.ts +83 -5
  105. package/src/server/plugins/engine/components/MultilineTextField.ts +37 -2
  106. package/src/server/plugins/engine/components/NumberField.test.ts +24 -2
  107. package/src/server/plugins/engine/components/NumberField.ts +23 -3
  108. package/src/server/plugins/engine/components/RadiosField.test.ts +10 -1
  109. package/src/server/plugins/engine/components/SelectField.test.ts +2 -1
  110. package/src/server/plugins/engine/components/SelectionControlField.ts +14 -0
  111. package/src/server/plugins/engine/components/TelephoneNumberField.test.ts +30 -2
  112. package/src/server/plugins/engine/components/TelephoneNumberField.ts +17 -2
  113. package/src/server/plugins/engine/components/TextField.test.ts +33 -1
  114. package/src/server/plugins/engine/components/TextField.ts +17 -2
  115. package/src/server/plugins/engine/components/UkAddressField.test.ts +46 -3
  116. package/src/server/plugins/engine/components/UkAddressField.ts +28 -0
  117. package/src/server/plugins/engine/components/YesNoField.test.ts +9 -1
  118. package/src/server/plugins/engine/components/YesNoField.ts +24 -0
  119. package/src/server/plugins/engine/components/helpers.test.ts +24 -0
  120. package/src/server/plugins/engine/components/helpers.ts +39 -0
  121. package/src/server/plugins/engine/components/index.ts +1 -0
  122. package/src/server/plugins/engine/configureEnginePlugin.ts +13 -3
  123. package/src/server/plugins/engine/helpers.test.ts +71 -20
  124. package/src/server/plugins/engine/helpers.ts +46 -19
  125. package/src/server/plugins/engine/models/FormModel.test.ts +91 -1
  126. package/src/server/plugins/engine/models/FormModel.ts +86 -3
  127. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +46 -7
  128. package/src/server/plugins/engine/models/SummaryViewModel.ts +7 -3
  129. package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +1 -2
  130. package/src/server/plugins/engine/outputFormatters/human/v1.ts +1 -1
  131. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +1 -0
  132. package/src/server/plugins/engine/pageControllers/PageController.test.ts +9 -6
  133. package/src/server/plugins/engine/pageControllers/PageController.ts +15 -5
  134. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +2 -2
  135. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +21 -6
  136. package/src/server/plugins/engine/pageControllers/validationOptions.ts +31 -17
  137. package/src/server/plugins/engine/plugin.ts +9 -5
  138. package/src/server/plugins/engine/services/notifyService.ts +1 -2
  139. package/src/server/plugins/engine/services/uploadService.js +10 -6
  140. package/src/server/plugins/engine/types.ts +10 -1
  141. package/src/server/plugins/engine/views/components/html.html +1 -1
  142. package/src/server/plugins/engine/views/components/markdown.html +5 -0
  143. package/src/server/plugins/engine/views/summary.html +7 -1
  144. package/src/server/plugins/nunjucks/context.js +5 -5
  145. package/src/server/plugins/nunjucks/enviroment.test.js +9 -3
  146. package/src/server/utils/type-utils.ts +15 -0
  147. package/src/typings/joi/index.d.ts +8 -0
@@ -3,7 +3,6 @@ import { type ResponseObject, type ResponseToolkit } from '@hapi/hapi'
3
3
  import { StatusCodes } from 'http-status-codes'
4
4
  import { ValidationError } from 'joi'
5
5
 
6
- import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js'
7
6
  import {
8
7
  checkEmailAddressForLiveFormSubmission,
9
8
  checkFormStatus,
@@ -17,6 +16,7 @@ import {
17
16
  safeGenerateCrumb,
18
17
  type GlobalScope
19
18
  } from '~/src/server/plugins/engine/helpers.js'
19
+ import { handleLegacyRedirect } from '~/src/server/plugins/engine/helpers.js'
20
20
  import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
21
21
  import {
22
22
  createPage,
@@ -311,35 +311,42 @@ describe('Helpers', () => {
311
311
  })
312
312
 
313
313
  describe('checkFormStatus', () => {
314
- it('should return true/live for paths starting with PREVIEW_PATH_PREFIX and form is live', () => {
315
- const path = `${PREVIEW_PATH_PREFIX}/live/another/segment`
316
- expect(checkFormStatus(path)).toStrictEqual({
314
+ it('should return true/live for params that include live state segment', () => {
315
+ expect(
316
+ checkFormStatus({
317
+ state: FormStatus.Live,
318
+ slug: 'another',
319
+ path: 'segment'
320
+ })
321
+ ).toStrictEqual({
317
322
  state: FormStatus.Live,
318
323
  isPreview: true
319
324
  })
320
325
  })
321
326
 
322
- it('should return false for paths not starting with PREVIEW_PATH_PREFIX', () => {
323
- const path = '/some/other/path'
324
- expect(checkFormStatus(path)).toStrictEqual({
325
- state: FormStatus.Live,
326
- isPreview: false
327
- })
328
- })
329
-
330
- it('should be case insensitive and return draft when form is draft', () => {
331
- const path = `${PREVIEW_PATH_PREFIX.toUpperCase()}/draft/path`
332
- expect(checkFormStatus(path)).toStrictEqual({
327
+ it('should return true/draft for params that include draft state segment', () => {
328
+ expect(
329
+ checkFormStatus({
330
+ state: FormStatus.Draft,
331
+ slug: 'another',
332
+ path: 'segment'
333
+ })
334
+ ).toStrictEqual({
333
335
  state: FormStatus.Draft,
334
336
  isPreview: true
335
337
  })
336
338
  })
337
339
 
338
- it('should throw an error for invalid form state', () => {
339
- const path = `${PREVIEW_PATH_PREFIX}/invalid-state`
340
- expect(() => checkFormStatus(path)).toThrow(
341
- 'Invalid form state: invalid-state'
342
- )
340
+ it('should return false/live for paths without a state segment', () => {
341
+ expect(
342
+ checkFormStatus({
343
+ slug: 'some',
344
+ path: 'other'
345
+ })
346
+ ).toStrictEqual({
347
+ state: FormStatus.Live,
348
+ isPreview: false
349
+ })
343
350
  })
344
351
  })
345
352
 
@@ -789,4 +796,48 @@ describe('Helpers', () => {
789
796
  })
790
797
  })
791
798
  })
799
+
800
+ describe('handleLegacyRedirect', () => {
801
+ let mockH: jest.Mocked<Pick<ResponseToolkit, 'redirect'>>
802
+ let mockRedirectResponse: jest.Mocked<
803
+ ReturnType<ResponseToolkit['redirect']>
804
+ >
805
+
806
+ beforeEach(() => {
807
+ mockRedirectResponse = {
808
+ permanent: jest.fn().mockReturnThis(),
809
+ takeover: jest.fn().mockReturnThis()
810
+ } as unknown as jest.Mocked<ReturnType<ResponseToolkit['redirect']>>
811
+
812
+ mockH = {
813
+ redirect: jest.fn().mockReturnValue(mockRedirectResponse)
814
+ }
815
+ })
816
+
817
+ it('should call h.redirect with the target URL', () => {
818
+ const targetUrl = '/another/target'
819
+ handleLegacyRedirect(mockH as unknown as ResponseToolkit, targetUrl)
820
+
821
+ expect(mockH.redirect).toHaveBeenCalledTimes(1)
822
+ expect(mockH.redirect).toHaveBeenCalledWith(targetUrl)
823
+ })
824
+
825
+ it('should call permanent() and takeover() on the redirect response', () => {
826
+ const targetUrl = '/final/destination'
827
+ handleLegacyRedirect(mockH as unknown as ResponseToolkit, targetUrl)
828
+
829
+ expect(mockRedirectResponse.permanent).toHaveBeenCalledTimes(1)
830
+ expect(mockRedirectResponse.takeover).toHaveBeenCalledTimes(1)
831
+ })
832
+
833
+ it('should return the final response object from takeover()', () => {
834
+ const targetUrl = '/the/end'
835
+ const response = handleLegacyRedirect(
836
+ mockH as unknown as ResponseToolkit,
837
+ targetUrl
838
+ )
839
+
840
+ expect(response).toBe(mockRedirectResponse)
841
+ })
842
+ })
792
843
  })
@@ -1,7 +1,10 @@
1
1
  import {
2
2
  ControllerPath,
3
3
  Engine,
4
+ hasComponents,
5
+ isFormType,
4
6
  type ComponentDef,
7
+ type FormDefinition,
5
8
  type Page
6
9
  } from '@defra/forms-model'
7
10
  import Boom from '@hapi/boom'
@@ -12,7 +15,6 @@ import { type Schema, type ValidationErrorItem } from 'joi'
12
15
  import { Liquid } from 'liquidjs'
13
16
 
14
17
  import { createLogger } from '~/src/server/common/helpers/logging/logger.js'
15
- import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js'
16
18
  import {
17
19
  getAnswer,
18
20
  type Field
@@ -27,6 +29,7 @@ import {
27
29
  import {
28
30
  FormAction,
29
31
  FormStatus,
32
+ type FormParams,
30
33
  type FormQuery,
31
34
  type FormRequest,
32
35
  type FormRequestPayload
@@ -253,29 +256,18 @@ export function getStartPath(model?: FormModel) {
253
256
  return startPath ? `/${startPath}` : ControllerPath.Start
254
257
  }
255
258
 
256
- export function checkFormStatus(path: string) {
257
- const isPreview = path.toLowerCase().startsWith(PREVIEW_PATH_PREFIX)
259
+ export function checkFormStatus(params?: FormParams) {
260
+ const isPreview = !!params?.state
258
261
 
259
- let state: FormStatus | undefined
262
+ let state = FormStatus.Live
260
263
 
261
- if (isPreview) {
262
- const previewState = path.split('/')[2]
263
-
264
- for (const formState of Object.values(FormStatus)) {
265
- if (previewState === formState.toString()) {
266
- state = formState
267
- break
268
- }
269
- }
270
-
271
- if (!state) {
272
- throw new Error(`Invalid form state: ${previewState}`)
273
- }
264
+ if (isPreview && params.state === FormStatus.Draft) {
265
+ state = FormStatus.Draft
274
266
  }
275
267
 
276
268
  return {
277
269
  isPreview,
278
- state: state ?? FormStatus.Live
270
+ state
279
271
  }
280
272
  }
281
273
 
@@ -337,7 +329,7 @@ export function safeGenerateCrumb(
337
329
  return undefined
338
330
  }
339
331
 
340
- // crumb plugin or its generate method doesnt exist
332
+ // crumb plugin or its generate method doesn't exist
341
333
  if (!request.server.plugins.crumb.generate) {
342
334
  return undefined
343
335
  }
@@ -382,3 +374,38 @@ export function evaluateTemplate(
382
374
  export function getCacheService(server: Server) {
383
375
  return server.plugins['forms-engine-plugin'].cacheService
384
376
  }
377
+
378
+ /**
379
+ * Handles logging and issuing a permanent redirect for legacy routes.
380
+ * @param h - The Hapi response toolkit.
381
+ * @param targetUrl - The URL to redirect to.
382
+ * @returns The Hapi response object configured for permanent redirect.
383
+ */
384
+ export function handleLegacyRedirect(h: ResponseToolkit, targetUrl: string) {
385
+ return h.redirect(targetUrl).permanent().takeover()
386
+ }
387
+
388
+ /**
389
+ * If the page doesn't have a title, set it from the title of the first form component
390
+ * @param def - the form definition
391
+ */
392
+ export function setPageTitles(def: FormDefinition) {
393
+ def.pages.forEach((page) => {
394
+ if (!page.title) {
395
+ if (hasComponents(page)) {
396
+ // Set the page title from the first form component
397
+ const firstFormComponent = page.components.find((component) =>
398
+ isFormType(component.type)
399
+ )
400
+
401
+ page.title = firstFormComponent?.title ?? ''
402
+ }
403
+
404
+ if (!page.title) {
405
+ const formNameMsg = def.name ? ` in form '${def.name}'` : ''
406
+
407
+ logger.warn(`Page '${page.path}' has no title${formNameMsg}`)
408
+ }
409
+ }
410
+ })
411
+ }
@@ -1,6 +1,8 @@
1
1
  import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
2
2
  import { type FormContextRequest } from '~/src/server/plugins/engine/types.js'
3
+ import { V2 as definitionV2 } from '~/test/form/definitions/conditions-basic.js'
3
4
  import definition from '~/test/form/definitions/conditions-escaping.js'
5
+ import conditionsListDefinition from '~/test/form/definitions/conditions-list.js'
4
6
  import fieldsRequiredDefinition from '~/test/form/definitions/fields-required.js'
5
7
 
6
8
  describe('FormModel', () => {
@@ -10,6 +12,20 @@ describe('FormModel', () => {
10
12
  () => new FormModel(definition, { basePath: 'test' })
11
13
  ).not.toThrow()
12
14
  })
15
+
16
+ it('Sets the page title from first form component when empty (V2 only)', () => {
17
+ const noTitlesDefinition = {
18
+ ...definitionV2,
19
+ pages: definitionV2.pages.map((page) => ({ ...page, title: '' }))
20
+ }
21
+
22
+ const model = new FormModel(noTitlesDefinition, { basePath: 'test' })
23
+
24
+ expect(model.def.pages.at(0)?.title).toBe(
25
+ 'Have you previously been married?'
26
+ )
27
+ expect(model.def.pages.at(1)?.title).toBe('Date of marriage')
28
+ })
13
29
  })
14
30
 
15
31
  describe('getFormContext', () => {
@@ -74,7 +90,7 @@ describe('FormModel', () => {
74
90
  })
75
91
 
76
92
  const state = {
77
- $$__referenceNumber: 1232456,
93
+ $$__referenceNumber: 123456789,
78
94
  checkboxesSingle: ['Arabian', 'Shetland']
79
95
  }
80
96
  const pageUrl = new URL('http://example.com/components/fields-required')
@@ -93,5 +109,79 @@ describe('FormModel', () => {
93
109
  'Reference number not found in form state'
94
110
  )
95
111
  })
112
+
113
+ it('redirects to the page if the list field (radio) is invalidated due to list item conditions', () => {
114
+ const formModel = new FormModel(conditionsListDefinition, {
115
+ basePath: '/conditional-list-items'
116
+ })
117
+
118
+ const state = {
119
+ $$__referenceNumber: 'foobar',
120
+ gXsqLq: true,
121
+ QwcNsc: 'meat',
122
+ zeQDES: ['peppers', 'cheese', 'ham']
123
+ }
124
+ const pageUrl = new URL(
125
+ 'http://example.com/conditional-list-items/summary'
126
+ )
127
+
128
+ const request: FormContextRequest = {
129
+ method: 'get',
130
+ query: {},
131
+ path: pageUrl.pathname,
132
+ params: { path: 'summary', slug: 'conditional-list-items' },
133
+ url: pageUrl,
134
+ app: { model: formModel }
135
+ }
136
+
137
+ const context = formModel.getFormContext(request, state)
138
+
139
+ expect(context.errors).toHaveLength(1)
140
+ expect(context.errors?.at(0)?.text).toBe(
141
+ 'Options are different because you changed a previous answer'
142
+ )
143
+ expect(context.relevantPages).toHaveLength(2)
144
+ expect(context.paths).toHaveLength(2)
145
+ expect(context.relevantState).toEqual({ gXsqLq: true, QwcNsc: 'meat' })
146
+ })
147
+
148
+ it('redirects to the page if the list field (check) is invalidated due to list item conditions', () => {
149
+ const formModel = new FormModel(conditionsListDefinition, {
150
+ basePath: '/conditional-list-items'
151
+ })
152
+
153
+ const state = {
154
+ $$__referenceNumber: 'foobar',
155
+ gXsqLq: true,
156
+ QwcNsc: 'vegan',
157
+ zeQDES: ['peppers', 'cheese', 'ham']
158
+ }
159
+ const pageUrl = new URL(
160
+ 'http://example.com/conditional-list-items/summary'
161
+ )
162
+
163
+ const request: FormContextRequest = {
164
+ method: 'get',
165
+ query: {},
166
+ path: pageUrl.pathname,
167
+ params: { path: 'summary', slug: 'conditional-list-items' },
168
+ url: pageUrl,
169
+ app: { model: formModel }
170
+ }
171
+
172
+ const context = formModel.getFormContext(request, state)
173
+
174
+ expect(context.errors).toHaveLength(1)
175
+ expect(context.errors?.at(0)?.text).toBe(
176
+ 'Options are different because you changed a previous answer'
177
+ )
178
+ expect(context.relevantPages).toHaveLength(3)
179
+ expect(context.paths).toHaveLength(3)
180
+ expect(context.relevantState).toEqual({
181
+ gXsqLq: true,
182
+ QwcNsc: 'vegan',
183
+ zeQDES: ['peppers', 'cheese', 'ham']
184
+ })
185
+ })
96
186
  })
97
187
  })
@@ -19,11 +19,16 @@ import { add } from 'date-fns'
19
19
  import { Parser, type Value } from 'expr-eval'
20
20
  import joi from 'joi'
21
21
 
22
- import { type Component } from '~/src/server/plugins/engine/components/helpers.js'
22
+ import { type ListFormComponent } from '~/src/server/plugins/engine/components/ListFormComponent.js'
23
+ import {
24
+ hasListFormField,
25
+ type Component
26
+ } from '~/src/server/plugins/engine/components/helpers.js'
23
27
  import {
24
28
  findPage,
25
29
  getError,
26
- getPage
30
+ getPage,
31
+ setPageTitles
27
32
  } from '~/src/server/plugins/engine/helpers.js'
28
33
  import { type ExecutableCondition } from '~/src/server/plugins/engine/models/types.js'
29
34
  import { type PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
@@ -100,6 +105,9 @@ export class FormModel {
100
105
  ]
101
106
  })
102
107
 
108
+ // Fix up page titles
109
+ setPageTitles(def)
110
+
103
111
  this.engine = def.engine
104
112
  this.def = def
105
113
  this.lists = def.lists
@@ -297,7 +305,10 @@ export class FormModel {
297
305
  this.assignRelevantState(context, nextPage)
298
306
 
299
307
  // Stop at current page
300
- if (nextPage.path === currentPath) {
308
+ if (
309
+ this.pageStateIsInvalid(context, nextPage) ||
310
+ nextPage.path === currentPath
311
+ ) {
301
312
  break
302
313
  }
303
314
 
@@ -351,6 +362,78 @@ export class FormModel {
351
362
  }
352
363
  }
353
364
 
365
+ private pageStateIsInvalid(context: FormContext, page: PageControllerClass) {
366
+ // Get any list-bound fields on the page
367
+ const listFields = page.collection.fields.filter(hasListFormField)
368
+
369
+ // For each list field that is bound to a list that contains any conditional items,
370
+ // we need to check any answers are still valid. Do this by evaluating the conditions
371
+ // and ensuring any current answers are all included in the set of valid answers
372
+ for (const field of listFields) {
373
+ const list = field.list
374
+
375
+ // Filter out YesNo as they can't be conditional
376
+ if (list !== undefined && field.type !== ComponentType.YesNoField) {
377
+ const hasOptionalItems =
378
+ list.items.filter((item) => item.condition).length > 0
379
+
380
+ if (hasOptionalItems) {
381
+ return this.fieldStateIsInvalid(context, field, list)
382
+ }
383
+ }
384
+ }
385
+ }
386
+
387
+ private fieldStateIsInvalid(
388
+ context: FormContext,
389
+ field: ListFormComponent,
390
+ list: List
391
+ ) {
392
+ const { evaluationState, state } = context
393
+
394
+ const validValues = list.items
395
+ .filter((item) =>
396
+ item.condition
397
+ ? this.conditions[item.condition]?.fn(evaluationState)
398
+ : true
399
+ )
400
+ .map((item) => item.value)
401
+
402
+ // Get the field state
403
+ const fieldState = field.getFormValueFromState(state)
404
+
405
+ if (fieldState !== undefined) {
406
+ let isInvalid = false
407
+ const isArray = Array.isArray(fieldState)
408
+
409
+ // Check if any saved state value(s) are still valid
410
+ // and return true if any are invalid
411
+ if (isArray) {
412
+ isInvalid = !fieldState.every((item) => validValues.includes(item))
413
+ } else {
414
+ isInvalid = !validValues.includes(fieldState)
415
+ }
416
+
417
+ if (isInvalid) {
418
+ if (!context.errors) {
419
+ context.errors = []
420
+ }
421
+
422
+ const text =
423
+ 'Options are different because you changed a previous answer'
424
+
425
+ context.errors.push({
426
+ text,
427
+ name: field.name,
428
+ href: `#${field.name}`,
429
+ path: [`#${field.name}`]
430
+ })
431
+ }
432
+
433
+ return isInvalid
434
+ }
435
+ }
436
+
354
437
  private assignPaths(context: FormContext) {
355
438
  for (const { keys, path } of context.relevantPages) {
356
439
  context.paths.push(path)
@@ -1,3 +1,4 @@
1
+ import { FORM_PREFIX } from '~/src/server/constants.js'
1
2
  import {
2
3
  FormModel,
3
4
  SummaryViewModel
@@ -13,7 +14,7 @@ import {
13
14
  } from '~/src/server/plugins/engine/types.js'
14
15
  import definition from '~/test/form/definitions/repeat-mixed.js'
15
16
 
16
- const basePath = '/test'
17
+ const basePath = `${FORM_PREFIX}/test`
17
18
 
18
19
  describe('SummaryViewModel', () => {
19
20
  const itemId1 = 'abc-123'
@@ -28,7 +29,7 @@ describe('SummaryViewModel', () => {
28
29
 
29
30
  beforeEach(() => {
30
31
  model = new FormModel(definition, {
31
- basePath: 'test'
32
+ basePath: `${FORM_PREFIX}/test`
32
33
  })
33
34
 
34
35
  page = createPage(model, definition.pages[2])
@@ -55,7 +56,13 @@ describe('SummaryViewModel', () => {
55
56
  orderType: 'collection',
56
57
  pizza: []
57
58
  } satisfies FormState,
58
- keys: ['How would you like to receive your pizza?', 'Pizzas'],
59
+ keys: [
60
+ 'How would you like to receive your pizza?',
61
+ 'Pizzas',
62
+ 'How you would like to receive your pizza',
63
+ 'Pizzas',
64
+ 'Pizza'
65
+ ],
59
66
  values: ['Collection', 'Not supplied']
60
67
  },
61
68
  {
@@ -71,7 +78,13 @@ describe('SummaryViewModel', () => {
71
78
  }
72
79
  ]
73
80
  } satisfies FormState,
74
- keys: ['How would you like to receive your pizza?', 'Pizza added'],
81
+ keys: [
82
+ 'How would you like to receive your pizza?',
83
+ 'Pizza added',
84
+ 'How you would like to receive your pizza',
85
+ 'Pizzas',
86
+ 'Pizza'
87
+ ],
75
88
  values: ['Delivery', 'You added 1 Pizza']
76
89
  },
77
90
  {
@@ -92,7 +105,13 @@ describe('SummaryViewModel', () => {
92
105
  }
93
106
  ]
94
107
  } satisfies FormState,
95
- keys: ['How would you like to receive your pizza?', 'Pizzas added'],
108
+ keys: [
109
+ 'How would you like to receive your pizza?',
110
+ 'Pizzas added',
111
+ 'How you would like to receive your pizza',
112
+ 'Pizzas',
113
+ 'Pizza'
114
+ ],
96
115
  values: ['Delivery', 'You added 2 Pizzas']
97
116
  }
98
117
  ])('Check answers ($description)', ({ state, keys, values }) => {
@@ -124,7 +143,7 @@ describe('SummaryViewModel', () => {
124
143
  expect(summaryList1).toHaveProperty('rows', [
125
144
  {
126
145
  key: {
127
- text: keys[0]
146
+ text: keys[2]
128
147
  },
129
148
  value: {
130
149
  classes: 'app-prose-scope',
@@ -181,7 +200,7 @@ describe('SummaryViewModel', () => {
181
200
  expect(summaryList1).toHaveProperty('rows', [
182
201
  {
183
202
  key: {
184
- text: keys[0]
203
+ text: keys[2]
185
204
  },
186
205
  value: {
187
206
  classes: 'app-prose-scope',
@@ -208,5 +227,25 @@ describe('SummaryViewModel', () => {
208
227
  }
209
228
  ])
210
229
  })
230
+
231
+ it('should use correct summary labels', () => {
232
+ request.query.force = '' // Preview URL '?force'
233
+ context = model.getFormContext(request, state)
234
+ summaryViewModel = new SummaryViewModel(request, page, context)
235
+
236
+ expect(summaryViewModel.details).toHaveLength(2)
237
+
238
+ const [details1, details2] = summaryViewModel.details
239
+
240
+ expect(details1.items[0]).toMatchObject({
241
+ title: keys[2],
242
+ label: keys[0]
243
+ })
244
+
245
+ expect(details2.items[0]).toMatchObject({
246
+ title: keys[1],
247
+ label: keys[4]
248
+ })
249
+ })
211
250
  })
212
251
  })
@@ -4,7 +4,10 @@ import {
4
4
  getAnswer,
5
5
  type Field
6
6
  } from '~/src/server/plugins/engine/components/helpers.js'
7
- import { type BackLink } from '~/src/server/plugins/engine/components/types.js'
7
+ import {
8
+ type BackLink,
9
+ type ComponentViewModel
10
+ } from '~/src/server/plugins/engine/components/types.js'
8
11
  import {
9
12
  evaluateTemplate,
10
13
  getError,
@@ -47,6 +50,7 @@ export class SummaryViewModel {
47
50
  errors?: FormSubmissionError[]
48
51
  serviceUrl: string
49
52
  hasMissingNotificationEmail?: boolean
53
+ components?: ComponentViewModel[]
50
54
 
51
55
  constructor(
52
56
  request: FormContextRequest,
@@ -207,8 +211,8 @@ function ItemField(
207
211
  return {
208
212
  name: field.name,
209
213
  label: field.title,
210
- title: field.title,
211
- error: field.getError(options.errors),
214
+ title: field.label,
215
+ error: field.getFirstError(options.errors),
212
216
  value: getAnswer(field, state),
213
217
  href: getPageHref(page, options.path, {
214
218
  returnUrl: getPageHref(page, page.getSummaryPath())
@@ -93,10 +93,9 @@ describe('getPersonalisation', () => {
93
93
  `^ For security reasons, the links in this email expire at ${dateFormat(dateExpiry, 'h:mmaaa')} on ${dateFormat(dateExpiry, 'eeee d MMMM yyyy')}`
94
94
  )
95
95
 
96
- // Check for form answers
97
96
  expect(body).toContain(
98
97
  outdent`
99
- Form submitted at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
98
+ ${definition.name} form received at ${dateFormat(dateNow, 'h:mmaaa')} on ${dateFormat(dateNow, 'd MMMM yyyy')}.
100
99
 
101
100
  ---
102
101
 
@@ -42,7 +42,7 @@ export function format(
42
42
  lines.push(`This is a test of the ${formName} ${formStatus.state} form.\n`)
43
43
  }
44
44
 
45
- lines.push(`Form submitted at ${escapeMarkdown(formattedNow)}.\n`)
45
+ lines.push(`${formName} form received at ${escapeMarkdown(formattedNow)}.\n`)
46
46
  lines.push('---\n')
47
47
 
48
48
  items.forEach((item) => {
@@ -210,6 +210,7 @@ describe('FileUploadPageController', () => {
210
210
  expect(getUploadStatusSpy).toHaveBeenCalledTimes(2)
211
211
  expect(request.logger.info).toHaveBeenCalled()
212
212
 
213
+ /* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */
213
214
  const logMsg = (request.logger.info as jest.Mock).mock.calls[0][0]
214
215
  expect(logMsg).toEqual(expect.stringContaining('Waiting'))
215
216
  expect(logMsg).toEqual(expect.stringContaining('some-id'))