@defra/forms-engine-plugin 0.0.4 → 0.0.6

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 (260) hide show
  1. package/.server/server/index.js +0 -4
  2. package/.server/server/index.js.map +1 -1
  3. package/.server/server/plugins/engine/helpers.js +3 -0
  4. package/.server/server/plugins/engine/helpers.js.map +1 -1
  5. package/.server/server/plugins/engine/index.js +27 -1
  6. package/.server/server/plugins/engine/index.js.map +1 -1
  7. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js +2 -4
  8. package/.server/server/plugins/engine/pageControllers/FileUploadPageController.js.map +1 -1
  9. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js +4 -10
  10. package/.server/server/plugins/engine/pageControllers/QuestionPageController.js.map +1 -1
  11. package/.server/server/plugins/engine/pageControllers/StatusPageController.js +2 -3
  12. package/.server/server/plugins/engine/pageControllers/StatusPageController.js.map +1 -1
  13. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js +2 -4
  14. package/.server/server/plugins/engine/pageControllers/SummaryPageController.js.map +1 -1
  15. package/.server/server/plugins/engine/plugin.js +65 -6
  16. package/.server/server/plugins/engine/plugin.js.map +1 -1
  17. package/.server/server/plugins/engine/types.js.map +1 -1
  18. package/.server/server/{views → plugins/engine/views}/components/service-banner/template.test.js +1 -1
  19. package/.server/server/plugins/engine/views/components/service-banner/template.test.js.map +1 -0
  20. package/.server/server/{views → plugins/engine/views}/components/tag-env/template.test.js +1 -1
  21. package/.server/server/plugins/engine/views/components/tag-env/template.test.js.map +1 -0
  22. package/.server/server/services/cacheService.js +5 -2
  23. package/.server/server/services/cacheService.js.map +1 -1
  24. package/.server/typings/hapi/index.d.js.map +1 -1
  25. package/README.md +215 -4
  26. package/package.json +3 -3
  27. package/src/client/javascripts/application.js +87 -0
  28. package/src/client/javascripts/file-upload.js +386 -0
  29. package/src/client/stylesheets/_code.scss +33 -0
  30. package/src/client/stylesheets/_govuk-frontend.scss +4 -0
  31. package/src/client/stylesheets/_prose.scss +56 -0
  32. package/src/client/stylesheets/_service-banner.scss +24 -0
  33. package/src/client/stylesheets/_summary-list.scss +28 -0
  34. package/src/client/stylesheets/_tag-env.scss +24 -0
  35. package/src/client/stylesheets/application.scss +14 -0
  36. package/src/common/cookies.js +58 -0
  37. package/src/common/cookies.test.js +23 -0
  38. package/src/common/types.js +5 -0
  39. package/src/config/index.ts +271 -0
  40. package/src/index.ts +31 -0
  41. package/src/server/common/helpers/logging/logger-options.test.ts +50 -0
  42. package/src/server/common/helpers/logging/logger-options.ts +46 -0
  43. package/src/server/common/helpers/logging/logger.ts +7 -0
  44. package/src/server/common/helpers/logging/request-logger.ts +9 -0
  45. package/src/server/common/helpers/logging/request-tracing.js +10 -0
  46. package/src/server/common/helpers/redis-client.js +70 -0
  47. package/src/server/constants.js +1 -0
  48. package/src/server/forms/README.md +10 -0
  49. package/src/server/forms/components.json +1015 -0
  50. package/src/server/forms/report-a-terrorist.json +270 -0
  51. package/src/server/forms/runner-components-test.json +365 -0
  52. package/src/server/forms/test.json +581 -0
  53. package/src/server/index.test.ts +582 -0
  54. package/src/server/index.ts +135 -0
  55. package/src/server/plugins/blankie.test.ts +73 -0
  56. package/src/server/plugins/blankie.ts +48 -0
  57. package/src/server/plugins/crumb.ts +20 -0
  58. package/src/server/plugins/engine/README.md +87 -0
  59. package/src/server/plugins/engine/components/AutocompleteField.test.ts +294 -0
  60. package/src/server/plugins/engine/components/AutocompleteField.ts +49 -0
  61. package/src/server/plugins/engine/components/CheckboxesField.test.ts +379 -0
  62. package/src/server/plugins/engine/components/CheckboxesField.ts +106 -0
  63. package/src/server/plugins/engine/components/ComponentBase.ts +97 -0
  64. package/src/server/plugins/engine/components/ComponentCollection.ts +278 -0
  65. package/src/server/plugins/engine/components/DatePartsField.test.ts +822 -0
  66. package/src/server/plugins/engine/components/DatePartsField.ts +264 -0
  67. package/src/server/plugins/engine/components/Details.test.ts +49 -0
  68. package/src/server/plugins/engine/components/Details.ts +30 -0
  69. package/src/server/plugins/engine/components/EmailAddressField.test.ts +395 -0
  70. package/src/server/plugins/engine/components/EmailAddressField.ts +55 -0
  71. package/src/server/plugins/engine/components/FileUploadField.test.ts +778 -0
  72. package/src/server/plugins/engine/components/FileUploadField.ts +262 -0
  73. package/src/server/plugins/engine/components/FormComponent.ts +249 -0
  74. package/src/server/plugins/engine/components/Html.test.ts +48 -0
  75. package/src/server/plugins/engine/components/Html.ts +29 -0
  76. package/src/server/plugins/engine/components/InsetText.test.ts +48 -0
  77. package/src/server/plugins/engine/components/InsetText.ts +27 -0
  78. package/src/server/plugins/engine/components/List.test.ts +76 -0
  79. package/src/server/plugins/engine/components/List.ts +72 -0
  80. package/src/server/plugins/engine/components/ListFormComponent.ts +140 -0
  81. package/src/server/plugins/engine/components/MonthYearField.test.ts +567 -0
  82. package/src/server/plugins/engine/components/MonthYearField.ts +222 -0
  83. package/src/server/plugins/engine/components/MultilineTextField.test.ts +558 -0
  84. package/src/server/plugins/engine/components/MultilineTextField.ts +138 -0
  85. package/src/server/plugins/engine/components/NumberField.test.ts +701 -0
  86. package/src/server/plugins/engine/components/NumberField.ts +163 -0
  87. package/src/server/plugins/engine/components/RadiosField.test.ts +288 -0
  88. package/src/server/plugins/engine/components/RadiosField.ts +24 -0
  89. package/src/server/plugins/engine/components/SelectField.test.ts +288 -0
  90. package/src/server/plugins/engine/components/SelectField.ts +47 -0
  91. package/src/server/plugins/engine/components/SelectionControlField.ts +43 -0
  92. package/src/server/plugins/engine/components/TelephoneNumberField.test.ts +356 -0
  93. package/src/server/plugins/engine/components/TelephoneNumberField.ts +67 -0
  94. package/src/server/plugins/engine/components/TextField.test.ts +489 -0
  95. package/src/server/plugins/engine/components/TextField.ts +96 -0
  96. package/src/server/plugins/engine/components/UkAddressField.test.ts +623 -0
  97. package/src/server/plugins/engine/components/UkAddressField.ts +172 -0
  98. package/src/server/plugins/engine/components/YesNoField.test.ts +248 -0
  99. package/src/server/plugins/engine/components/YesNoField.ts +31 -0
  100. package/src/server/plugins/engine/components/constants.ts +1 -0
  101. package/src/server/plugins/engine/components/helpers.ts +330 -0
  102. package/src/server/plugins/engine/components/index.ts +24 -0
  103. package/src/server/plugins/engine/components/types.ts +117 -0
  104. package/src/server/plugins/engine/configureEnginePlugin.ts +47 -0
  105. package/src/server/plugins/engine/helpers.test.ts +791 -0
  106. package/src/server/plugins/engine/helpers.ts +384 -0
  107. package/src/server/plugins/engine/index.ts +47 -0
  108. package/src/server/plugins/engine/models/FormModel.test.ts +42 -0
  109. package/src/server/plugins/engine/models/FormModel.ts +443 -0
  110. package/src/server/plugins/engine/models/RepeatingSummaryViewModel.ts +0 -0
  111. package/src/server/plugins/engine/models/Section.ts +0 -0
  112. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +209 -0
  113. package/src/server/plugins/engine/models/SummaryViewModel.ts +220 -0
  114. package/src/server/plugins/engine/models/index.ts +2 -0
  115. package/src/server/plugins/engine/models/types.ts +114 -0
  116. package/src/server/plugins/engine/outputFormatters/human/v1.test.ts +143 -0
  117. package/src/server/plugins/engine/outputFormatters/human/v1.ts +73 -0
  118. package/src/server/plugins/engine/outputFormatters/index.test.ts +17 -0
  119. package/src/server/plugins/engine/outputFormatters/index.ts +44 -0
  120. package/src/server/plugins/engine/outputFormatters/machine/v1.test.ts +229 -0
  121. package/src/server/plugins/engine/outputFormatters/machine/v1.ts +140 -0
  122. package/src/server/plugins/engine/outputFormatters/machine/v2.test.ts +229 -0
  123. package/src/server/plugins/engine/outputFormatters/machine/v2.ts +153 -0
  124. package/src/server/plugins/engine/pageControllers/FileUploadPageController.test.ts +1116 -0
  125. package/src/server/plugins/engine/pageControllers/FileUploadPageController.ts +447 -0
  126. package/src/server/plugins/engine/pageControllers/PageController.test.ts +205 -0
  127. package/src/server/plugins/engine/pageControllers/PageController.ts +176 -0
  128. package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +1264 -0
  129. package/src/server/plugins/engine/pageControllers/QuestionPageController.ts +565 -0
  130. package/src/server/plugins/engine/pageControllers/README.md +28 -0
  131. package/src/server/plugins/engine/pageControllers/RepeatPageController.test.ts +264 -0
  132. package/src/server/plugins/engine/pageControllers/RepeatPageController.ts +458 -0
  133. package/src/server/plugins/engine/pageControllers/StartPageController.ts +18 -0
  134. package/src/server/plugins/engine/pageControllers/StatusPageController.ts +51 -0
  135. package/src/server/plugins/engine/pageControllers/SummaryPageController.ts +262 -0
  136. package/src/server/plugins/engine/pageControllers/TerminalController.test.ts +28 -0
  137. package/src/server/plugins/engine/pageControllers/TerminalPageController.ts +19 -0
  138. package/src/server/plugins/engine/pageControllers/helpers.test.ts +198 -0
  139. package/src/server/plugins/engine/pageControllers/helpers.ts +101 -0
  140. package/src/server/plugins/engine/pageControllers/index.ts +10 -0
  141. package/src/server/plugins/engine/pageControllers/validationOptions.ts +89 -0
  142. package/src/server/plugins/engine/plugin.ts +753 -0
  143. package/src/server/plugins/engine/services/formSubmissionService.js +46 -0
  144. package/src/server/plugins/engine/services/formsService.js +46 -0
  145. package/src/server/plugins/engine/services/formsService.test.js +90 -0
  146. package/src/server/plugins/engine/services/index.js +3 -0
  147. package/src/server/plugins/engine/services/notifyService.test.ts +132 -0
  148. package/src/server/plugins/engine/services/notifyService.ts +64 -0
  149. package/src/server/plugins/engine/services/uploadService.js +60 -0
  150. package/src/server/plugins/engine/types.ts +317 -0
  151. package/src/server/plugins/engine/views/components/autocompletefield.html +5 -0
  152. package/src/server/plugins/engine/views/components/checkboxesfield.html +5 -0
  153. package/src/server/plugins/engine/views/components/datepartsfield.html +5 -0
  154. package/src/server/plugins/engine/views/components/debug/macro.njk +3 -0
  155. package/src/server/plugins/engine/views/components/debug/template.njk +13 -0
  156. package/src/server/plugins/engine/views/components/details.html +6 -0
  157. package/src/server/plugins/engine/views/components/emailaddressfield.html +5 -0
  158. package/src/server/plugins/engine/views/components/fileuploadfield-key.html +8 -0
  159. package/src/server/plugins/engine/views/components/fileuploadfield-value.html +3 -0
  160. package/src/server/plugins/engine/views/components/fileuploadfield.html +24 -0
  161. package/src/server/plugins/engine/views/components/html.html +3 -0
  162. package/src/server/plugins/engine/views/components/insettext.html +7 -0
  163. package/src/server/plugins/engine/views/components/list.html +36 -0
  164. package/src/server/plugins/engine/views/components/monthyearfield.html +5 -0
  165. package/src/server/plugins/engine/views/components/multilinetextfield.html +10 -0
  166. package/src/server/plugins/engine/views/components/numberfield.html +5 -0
  167. package/src/server/plugins/engine/views/components/radiosfield.html +5 -0
  168. package/src/server/plugins/engine/views/components/selectfield.html +5 -0
  169. package/src/server/plugins/engine/views/components/service-banner/macro.njk +3 -0
  170. package/src/server/plugins/engine/views/components/service-banner/template.njk +20 -0
  171. package/src/server/plugins/engine/views/components/service-banner/template.test.js +43 -0
  172. package/src/server/plugins/engine/views/components/tag-env/macro.njk +3 -0
  173. package/src/server/plugins/engine/views/components/tag-env/template.njk +30 -0
  174. package/src/server/plugins/engine/views/components/tag-env/template.test.js +66 -0
  175. package/src/server/plugins/engine/views/components/telephonenumberfield.html +5 -0
  176. package/src/server/plugins/engine/views/components/textfield.html +5 -0
  177. package/src/server/plugins/engine/views/components/ukaddressfield.html +25 -0
  178. package/src/server/plugins/engine/views/components/yesnofield.html +5 -0
  179. package/src/server/plugins/engine/views/confirmation.html +19 -0
  180. package/src/server/plugins/engine/views/file-upload.html +45 -0
  181. package/src/server/plugins/engine/views/index.html +39 -0
  182. package/src/server/plugins/engine/views/item-delete.html +56 -0
  183. package/src/server/plugins/engine/views/layout.html +199 -0
  184. package/src/server/plugins/engine/views/partials/components.html +6 -0
  185. package/src/server/plugins/engine/views/partials/conditional-components.html +3 -0
  186. package/src/server/plugins/engine/views/partials/debug.html +44 -0
  187. package/src/server/plugins/engine/views/partials/form.html +15 -0
  188. package/src/server/plugins/engine/views/partials/heading.html +16 -0
  189. package/src/server/plugins/engine/views/partials/preview-banner.html +32 -0
  190. package/src/server/plugins/engine/views/partials/preview-banner.test.js +122 -0
  191. package/src/server/plugins/engine/views/partials/warn-missing-notification-email.html +10 -0
  192. package/src/server/plugins/engine/views/repeat-list-summary.html +53 -0
  193. package/src/server/plugins/engine/views/summary.html +50 -0
  194. package/src/server/plugins/errorPages.ts +58 -0
  195. package/src/server/plugins/nunjucks/context.js +88 -0
  196. package/src/server/plugins/nunjucks/context.test.js +142 -0
  197. package/src/server/plugins/nunjucks/enviroment.test.js +201 -0
  198. package/src/server/plugins/nunjucks/environment.js +116 -0
  199. package/src/server/plugins/nunjucks/filters/answer.js +27 -0
  200. package/src/server/plugins/nunjucks/filters/answer.test.js +89 -0
  201. package/src/server/plugins/nunjucks/filters/evaluate.js +21 -0
  202. package/src/server/plugins/nunjucks/filters/field.js +28 -0
  203. package/src/server/plugins/nunjucks/filters/field.test.js +75 -0
  204. package/src/server/plugins/nunjucks/filters/highlight.js +11 -0
  205. package/src/server/plugins/nunjucks/filters/href.js +30 -0
  206. package/src/server/plugins/nunjucks/filters/href.test.js +80 -0
  207. package/src/server/plugins/nunjucks/filters/index.js +8 -0
  208. package/src/server/plugins/nunjucks/filters/inspect.js +15 -0
  209. package/src/server/plugins/nunjucks/filters/page.js +24 -0
  210. package/src/server/plugins/nunjucks/filters/page.test.js +65 -0
  211. package/src/server/plugins/nunjucks/index.js +3 -0
  212. package/src/server/plugins/nunjucks/plugin.js +40 -0
  213. package/src/server/plugins/nunjucks/render.js +42 -0
  214. package/src/server/plugins/nunjucks/types.js +40 -0
  215. package/src/server/plugins/pulse.ts +11 -0
  216. package/src/server/plugins/router.ts +201 -0
  217. package/src/server/plugins/session.ts +28 -0
  218. package/src/server/routes/health.js +13 -0
  219. package/src/server/routes/health.test.js +35 -0
  220. package/src/server/routes/index.test.ts +125 -0
  221. package/src/server/routes/index.ts +2 -0
  222. package/src/server/routes/public.ts +47 -0
  223. package/src/server/routes/types.ts +48 -0
  224. package/src/server/schemas/index.ts +34 -0
  225. package/src/server/secure-context.js +43 -0
  226. package/src/server/services/cacheService.test.ts +277 -0
  227. package/src/server/services/cacheService.ts +138 -0
  228. package/src/server/services/httpService.test.js +491 -0
  229. package/src/server/services/httpService.ts +50 -0
  230. package/src/server/services/index.ts +1 -0
  231. package/src/server/types.ts +54 -0
  232. package/src/server/utils/notify.test.ts +37 -0
  233. package/src/server/utils/notify.ts +50 -0
  234. package/src/server/utils/secure-context/get-trust-store-certs.js +11 -0
  235. package/src/server/utils/secure-context/get-trust-store-certs.test.js +19 -0
  236. package/src/server/utils/utils.js +24 -0
  237. package/src/server/utils/utils.test.js +54 -0
  238. package/src/server/views/404.html +16 -0
  239. package/src/server/views/500.html +19 -0
  240. package/src/server/views/help/accessibility-statement.html +58 -0
  241. package/src/server/views/help/cookie-preferences.html +57 -0
  242. package/src/server/views/help/cookies.html +71 -0
  243. package/src/server/views/help/get-support.html +37 -0
  244. package/src/server/views/help/privacy-notice.html +68 -0
  245. package/src/server/views/help/terms-and-conditions.html +83 -0
  246. package/src/typings/hapi/index.d.ts +87 -0
  247. package/src/typings/hapi-tracing/index.d.ts +6 -0
  248. package/src/typings/index.d.ts +3 -0
  249. package/src/typings/joi/index.d.ts +22 -0
  250. package/.server/server/views/components/service-banner/template.test.js.map +0 -1
  251. package/.server/server/views/components/tag-env/template.test.js.map +0 -1
  252. /package/.server/server/{views → plugins/engine/views}/components/debug/macro.njk +0 -0
  253. /package/.server/server/{views → plugins/engine/views}/components/debug/template.njk +0 -0
  254. /package/.server/server/{views → plugins/engine/views}/components/service-banner/macro.njk +0 -0
  255. /package/.server/server/{views → plugins/engine/views}/components/service-banner/template.njk +0 -0
  256. /package/.server/server/{views → plugins/engine/views}/components/tag-env/macro.njk +0 -0
  257. /package/.server/server/{views → plugins/engine/views}/components/tag-env/template.njk +0 -0
  258. /package/.server/server/{views → plugins/engine/views}/confirmation.html +0 -0
  259. /package/.server/server/{views → plugins/engine/views}/layout.html +0 -0
  260. /package/.server/server/{views → plugins/engine/views}/summary.html +0 -0
@@ -0,0 +1,264 @@
1
+ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
2
+ import { RepeatPageController } from '~/src/server/plugins/engine/pageControllers/RepeatPageController.js'
3
+ import {
4
+ type FormContextRequest,
5
+ type FormPageViewModel,
6
+ type FormSubmissionError,
7
+ type RepeatListState,
8
+ type RepeaterSummaryPageViewModel
9
+ } from '~/src/server/plugins/engine/types.js'
10
+ import definition from '~/test/form/definitions/repeat.js'
11
+
12
+ describe('RepeatPageController', () => {
13
+ const itemId1 = 'abc-123'
14
+ const itemId2 = 'xyz-987'
15
+
16
+ let pageUrl: URL
17
+ let pageItemUrl: URL
18
+ let pageSummaryUrl: URL
19
+
20
+ let model: FormModel
21
+ let controller: RepeatPageController
22
+ let requestPage: FormContextRequest
23
+ let requestPageItem: FormContextRequest
24
+ let requestPageSummary: FormContextRequest
25
+
26
+ beforeEach(() => {
27
+ const { pages } = definition
28
+
29
+ pageUrl = new URL('/repeat/pizza-order', 'http://example.com')
30
+
31
+ pageItemUrl = new URL(
32
+ `${pageUrl.pathname}/${itemId1}`,
33
+ 'http://example.com'
34
+ )
35
+
36
+ pageSummaryUrl = new URL(
37
+ `${pageUrl.pathname}/summary`,
38
+ 'http://example.com'
39
+ )
40
+
41
+ model = new FormModel(definition, {
42
+ basePath: 'test'
43
+ })
44
+
45
+ controller = new RepeatPageController(model, pages[0])
46
+
47
+ requestPage = {
48
+ method: 'get',
49
+ url: pageUrl,
50
+ path: pageUrl.pathname,
51
+ params: {
52
+ path: 'pizza-order',
53
+ slug: 'repeat'
54
+ },
55
+ query: {},
56
+ app: { model }
57
+ }
58
+
59
+ requestPageItem = {
60
+ method: 'get',
61
+ url: pageItemUrl,
62
+ path: pageItemUrl.pathname,
63
+ params: {
64
+ path: 'pizza-order',
65
+ slug: 'repeat',
66
+ itemId: itemId1
67
+ },
68
+ query: {},
69
+ app: { model }
70
+ }
71
+
72
+ requestPageSummary = {
73
+ method: 'get',
74
+ url: pageSummaryUrl,
75
+ path: pageSummaryUrl.pathname,
76
+ params: {
77
+ path: 'pizza-order',
78
+ slug: 'repeat'
79
+ },
80
+ query: {},
81
+ app: { model }
82
+ }
83
+ })
84
+
85
+ describe('Properties', () => {
86
+ it('returns summary view name', () => {
87
+ expect(controller).toHaveProperty(
88
+ 'listSummaryViewName',
89
+ 'repeat-list-summary'
90
+ )
91
+ })
92
+
93
+ it('returns delete view name', () => {
94
+ expect(controller).toHaveProperty('listDeleteViewName', 'item-delete')
95
+ })
96
+
97
+ it('returns repeater config', () => {
98
+ expect(controller).toHaveProperty('repeat', {
99
+ options: {
100
+ name: 'pizza',
101
+ title: 'Pizza'
102
+ },
103
+ schema: {
104
+ max: 3,
105
+ min: 2
106
+ }
107
+ })
108
+ })
109
+ })
110
+
111
+ describe('Path methods', () => {
112
+ describe('Summary path', () => {
113
+ it('returns path to summary page', () => {
114
+ expect(controller.getSummaryPath()).toBe('/summary')
115
+ })
116
+
117
+ it('returns path to repeater summary page', () => {
118
+ expect(controller.getSummaryPath(requestPage)).toBe(
119
+ '/pizza-order/summary'
120
+ )
121
+ })
122
+ })
123
+ })
124
+
125
+ describe('Item view model', () => {
126
+ let viewModel: FormPageViewModel
127
+
128
+ beforeEach(() => {
129
+ viewModel = controller.getViewModel(
130
+ requestPageItem,
131
+ model.getFormContext(requestPageItem, {})
132
+ )
133
+ })
134
+
135
+ it('updates section title with repeater title and count', () => {
136
+ expect(viewModel).toHaveProperty('sectionTitle', 'Food: Pizza 1')
137
+ })
138
+ })
139
+
140
+ describe.each([
141
+ {
142
+ description: 'No items',
143
+ list: [] satisfies RepeatListState,
144
+ viewModel: {
145
+ pageTitle: 'You have added 0 Pizzas',
146
+ showTitle: true,
147
+ sectionTitle: 'Food'
148
+ }
149
+ },
150
+ {
151
+ description: '1 item',
152
+ list: [
153
+ {
154
+ itemId: itemId1,
155
+ toppings: 'Ham',
156
+ quantity: 2
157
+ }
158
+ ] satisfies RepeatListState,
159
+ viewModel: {
160
+ pageTitle: 'You have added 1 Pizza',
161
+ showTitle: true,
162
+ sectionTitle: 'Food'
163
+ }
164
+ },
165
+ {
166
+ description: '2 items',
167
+ list: [
168
+ {
169
+ itemId: itemId1,
170
+ toppings: 'Ham',
171
+ quantity: 2
172
+ },
173
+ {
174
+ itemId: itemId2,
175
+ toppings: 'Cheese',
176
+ quantity: 1
177
+ }
178
+ ] satisfies RepeatListState,
179
+ viewModel: {
180
+ pageTitle: 'You have added 2 Pizzas',
181
+ showTitle: true,
182
+ sectionTitle: 'Food'
183
+ }
184
+ }
185
+ ])(
186
+ 'List summary view model ($description)',
187
+ ({ list, viewModel: expected }) => {
188
+ let viewModel: RepeaterSummaryPageViewModel
189
+
190
+ beforeEach(() => {
191
+ viewModel = controller.getListSummaryViewModel(
192
+ requestPageSummary,
193
+ model.getFormContext(requestPageSummary, {}),
194
+ list
195
+ )
196
+ })
197
+
198
+ it('should customise page title', () => {
199
+ expect(viewModel).toHaveProperty('pageTitle', expected.pageTitle)
200
+ expect(viewModel).toHaveProperty('showTitle', expected.showTitle)
201
+ })
202
+
203
+ it('should extend default view model', () => {
204
+ const defaults = controller.viewModel
205
+
206
+ expect(viewModel).toHaveProperty('name', defaults.name)
207
+ expect(viewModel).toHaveProperty('page', defaults.page)
208
+ expect(viewModel).toHaveProperty('sectionTitle', expected.sectionTitle)
209
+ expect(viewModel).toHaveProperty('isStartPage', defaults.isStartPage)
210
+ expect(viewModel).toHaveProperty('serviceUrl', defaults.serviceUrl)
211
+ expect(viewModel).toHaveProperty('feedbackLink', defaults.feedbackLink)
212
+
213
+ // Unless overridden
214
+ expect(viewModel).not.toHaveProperty('pageTitle', defaults.pageTitle)
215
+ })
216
+ }
217
+ )
218
+
219
+ describe('Form validation', () => {
220
+ it('includes title text and errors', () => {
221
+ const result = controller.collection.validate()
222
+
223
+ expect(result.errors).toEqual<FormSubmissionError[]>([
224
+ {
225
+ path: ['toppings'],
226
+ href: '#toppings',
227
+ name: 'toppings',
228
+ text: 'Select toppings',
229
+ context: {
230
+ key: 'toppings',
231
+ label: 'Toppings',
232
+ title: 'Toppings'
233
+ }
234
+ },
235
+ {
236
+ path: ['quantity'],
237
+ href: '#quantity',
238
+ name: 'quantity',
239
+ text: 'Enter quantity',
240
+ context: {
241
+ key: 'quantity',
242
+ label: 'Quantity',
243
+ title: 'Quantity'
244
+ }
245
+ },
246
+ {
247
+ path: ['itemId'],
248
+ href: '#itemId',
249
+ name: 'itemId',
250
+ text: 'Select itemId',
251
+ context: {
252
+ key: 'itemId',
253
+ label: 'itemId'
254
+ }
255
+ }
256
+ ])
257
+ })
258
+
259
+ it('includes all field errors', () => {
260
+ const result = controller.collection.validate()
261
+ expect(result.errors).toHaveLength(3)
262
+ })
263
+ })
264
+ })
@@ -0,0 +1,458 @@
1
+ import { randomUUID } from 'crypto'
2
+
3
+ import { type PageRepeat, type Repeat } from '@defra/forms-model'
4
+ import Boom from '@hapi/boom'
5
+ import { type ResponseToolkit } from '@hapi/hapi'
6
+ import Joi from 'joi'
7
+
8
+ import { isRepeatState } from '~/src/server/plugins/engine/components/FormComponent.js'
9
+ import { redirectPath } from '~/src/server/plugins/engine/helpers.js'
10
+ import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
11
+ import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
12
+ import {
13
+ type FormContext,
14
+ type FormContextRequest,
15
+ type FormPageViewModel,
16
+ type FormPayload,
17
+ type FormSubmissionState,
18
+ type ItemDeletePageViewModel,
19
+ type RepeatItemState,
20
+ type RepeatListState,
21
+ type RepeaterSummaryPageViewModel,
22
+ type SummaryList,
23
+ type SummaryListAction
24
+ } from '~/src/server/plugins/engine/types.js'
25
+ import {
26
+ FormAction,
27
+ type FormRequest,
28
+ type FormRequestPayload
29
+ } from '~/src/server/routes/types.js'
30
+
31
+ export class RepeatPageController extends QuestionPageController {
32
+ declare pageDef: PageRepeat
33
+
34
+ listSummaryViewName = 'repeat-list-summary'
35
+ listDeleteViewName = 'item-delete'
36
+ repeat: Repeat
37
+
38
+ constructor(model: FormModel, pageDef: PageRepeat) {
39
+ super(model, pageDef)
40
+
41
+ this.repeat = pageDef.repeat
42
+
43
+ const { options, schema } = this.repeat
44
+ const itemId = Joi.string().uuid().required()
45
+
46
+ this.collection.formSchema = this.collection.formSchema.append({ itemId })
47
+ this.collection.stateSchema = Joi.object<RepeatItemState>().keys({
48
+ [options.name]: Joi.array()
49
+ .items(this.collection.stateSchema.append({ itemId }))
50
+ .min(schema.min)
51
+ .max(schema.max)
52
+ .label(`${options.title} list`)
53
+ .required()
54
+ })
55
+ }
56
+
57
+ get keys() {
58
+ const { repeat } = this
59
+ return [repeat.options.name, ...super.keys]
60
+ }
61
+
62
+ getFormParams(request?: FormContextRequest) {
63
+ const params = super.getFormParams(request)
64
+
65
+ // Apply an itemId to the form payload
66
+ if (request?.payload) {
67
+ params.itemId = request.params.itemId ?? randomUUID()
68
+ }
69
+
70
+ return params
71
+ }
72
+
73
+ getFormDataFromState(
74
+ request: FormContextRequest | undefined,
75
+ state: FormSubmissionState
76
+ ) {
77
+ const { repeat } = this
78
+
79
+ const params = this.getFormParams(request)
80
+ const list = this.getListFromState(state)
81
+ const itemId = this.getItemId(request)
82
+
83
+ // Create payload with repeater list state
84
+ if (!itemId) {
85
+ return {
86
+ ...params,
87
+ [repeat.options.name]: list
88
+ }
89
+ }
90
+
91
+ // Create payload with repeater item state
92
+ const item = this.getItemFromList(list, itemId)
93
+
94
+ return {
95
+ ...params,
96
+ ...item
97
+ }
98
+ }
99
+
100
+ getStateFromValidForm(
101
+ request: FormContextRequest,
102
+ state: FormSubmissionState,
103
+ payload: FormPayload
104
+ ) {
105
+ const itemId = this.getItemId(request)
106
+
107
+ if (!itemId) {
108
+ throw Boom.badRequest('No item ID found')
109
+ }
110
+
111
+ const list = this.getListFromState(state)
112
+ const item = this.getItemFromList(list, itemId)
113
+
114
+ const itemState = super.getStateFromValidForm(request, state, payload)
115
+ const updated: RepeatItemState = { ...itemState, itemId }
116
+ const newList = [...list]
117
+
118
+ if (!item) {
119
+ // Adding a new item
120
+ newList.push(updated)
121
+ } else {
122
+ // Update an existing item
123
+ newList[list.indexOf(item)] = updated
124
+ }
125
+
126
+ return {
127
+ [this.repeat.options.name]: newList
128
+ }
129
+ }
130
+
131
+ proceed(
132
+ request: FormContextRequest,
133
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
134
+ ) {
135
+ const nextPath = this.getSummaryPath(request)
136
+ return super.proceed(request, h, nextPath)
137
+ }
138
+
139
+ getItemFromList(list: RepeatListState, itemId?: string) {
140
+ return list.find((item) => item.itemId === itemId)
141
+ }
142
+
143
+ getListFromState(state: FormSubmissionState) {
144
+ const { name } = this.repeat.options
145
+ const values = state[name]
146
+
147
+ return isRepeatState(values) ? values : []
148
+ }
149
+
150
+ makeGetRouteHandler() {
151
+ return async (
152
+ request: FormRequest,
153
+ context: FormContext,
154
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
155
+ ) => {
156
+ const { path } = this
157
+ const { query } = request
158
+ const { state } = context
159
+
160
+ const itemId = this.getItemId(request)
161
+ const list = this.getListFromState(state)
162
+
163
+ if (!itemId) {
164
+ const summaryPath = this.getSummaryPath(request)
165
+ const nextPath = redirectPath(`${path}/${randomUUID()}`, {
166
+ returnUrl: query.returnUrl,
167
+ force: query.force
168
+ })
169
+
170
+ // Only redirect to new item when list is empty
171
+ return super.proceed(request, h, list.length ? summaryPath : nextPath)
172
+ }
173
+
174
+ return super.makeGetRouteHandler()(request, context, h)
175
+ }
176
+ }
177
+
178
+ makeGetListSummaryRouteHandler() {
179
+ return (
180
+ request: FormRequest,
181
+ context: FormContext,
182
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
183
+ ) => {
184
+ const { path } = this
185
+ const { query } = request
186
+ const { state } = context
187
+
188
+ const list = this.getListFromState(state)
189
+
190
+ if (!list.length) {
191
+ const nextPath = redirectPath(`${path}/${randomUUID()}`, {
192
+ returnUrl: query.returnUrl
193
+ })
194
+
195
+ return super.proceed(request, h, nextPath)
196
+ }
197
+
198
+ const viewModel = this.getListSummaryViewModel(request, context, list)
199
+
200
+ return h.view(this.listSummaryViewName, viewModel)
201
+ }
202
+ }
203
+
204
+ makePostListSummaryRouteHandler() {
205
+ return (
206
+ request: FormRequestPayload,
207
+ context: FormContext,
208
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
209
+ ) => {
210
+ const { path, repeat } = this
211
+ const { query } = request
212
+ const { schema, options } = repeat
213
+ const { state } = context
214
+
215
+ const list = this.getListFromState(state)
216
+
217
+ if (!list.length) {
218
+ const nextPath = redirectPath(`${path}/${randomUUID()}`, {
219
+ returnUrl: query.returnUrl
220
+ })
221
+
222
+ return super.proceed(request, h, nextPath)
223
+ }
224
+
225
+ const { action } = this.getFormParams(request)
226
+
227
+ const hasErrorMin =
228
+ action === FormAction.Continue && list.length < schema.min
229
+
230
+ const hasErrorMax =
231
+ (action === FormAction.AddAnother && list.length >= schema.max) ||
232
+ (action === FormAction.Continue && list.length > schema.max)
233
+
234
+ // Show error if repeat limits apply
235
+ if (hasErrorMin || hasErrorMax) {
236
+ const count = hasErrorMax ? schema.max : schema.min
237
+ const itemTitle = `${options.title}${count === 1 ? '' : 's'}`
238
+
239
+ context.errors = [
240
+ {
241
+ path: [],
242
+ href: '',
243
+ name: '',
244
+ text: hasErrorMax
245
+ ? `You can only add up to ${count} ${itemTitle}`
246
+ : `You must add at least ${count} ${itemTitle}`
247
+ }
248
+ ]
249
+
250
+ const viewModel = this.getListSummaryViewModel(request, context, list)
251
+
252
+ return h.view(this.listSummaryViewName, viewModel)
253
+ }
254
+
255
+ if (action === FormAction.AddAnother) {
256
+ const nextPath = redirectPath(`${path}/${randomUUID()}`, {
257
+ returnUrl: query.returnUrl
258
+ })
259
+
260
+ return super.proceed(request, h, nextPath)
261
+ }
262
+
263
+ const nextPath = this.getNextPath(context)
264
+ return super.proceed(request, h, nextPath)
265
+ }
266
+ }
267
+
268
+ makeGetItemDeleteRouteHandler() {
269
+ return (
270
+ request: FormRequest,
271
+ context: FormContext,
272
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
273
+ ) => {
274
+ const { viewModel } = this
275
+ const { state } = context
276
+
277
+ const list = this.getListFromState(state)
278
+ const itemId = this.getItemId(request)
279
+ const item = this.getItemFromList(list, itemId)
280
+
281
+ if (!item || list.length === 1) {
282
+ throw Boom.notFound(
283
+ item
284
+ ? 'Last list item cannot be removed'
285
+ : 'List item to remove not found'
286
+ )
287
+ }
288
+
289
+ const { title } = this.repeat.options
290
+
291
+ return h.view(this.listDeleteViewName, {
292
+ ...viewModel,
293
+ context,
294
+ backLink: this.getBackLink(request, context),
295
+ pageTitle: `Are you sure you want to remove this ${title}?`,
296
+ itemTitle: `${title} ${list.indexOf(item) + 1}`,
297
+ buttonConfirm: { text: `Remove ${title}` },
298
+ buttonCancel: { text: 'Cancel' }
299
+ } satisfies ItemDeletePageViewModel)
300
+ }
301
+ }
302
+
303
+ makePostItemDeleteRouteHandler() {
304
+ return async (
305
+ request: FormRequestPayload,
306
+ context: FormContext,
307
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
308
+ ) => {
309
+ const { repeat } = this
310
+ const { state } = context
311
+
312
+ const { confirm } = this.getFormParams(request)
313
+
314
+ const list = this.getListFromState(state)
315
+ const itemId = this.getItemId(request)
316
+ const item = this.getItemFromList(list, itemId)
317
+
318
+ if (!item || list.length === 1) {
319
+ throw Boom.notFound(
320
+ item
321
+ ? 'Last list item cannot be removed'
322
+ : 'List item to remove not found'
323
+ )
324
+ }
325
+
326
+ // Remove the item from the list
327
+ if (confirm) {
328
+ list.splice(list.indexOf(item), 1)
329
+
330
+ const update = {
331
+ [repeat.options.name]: list
332
+ }
333
+
334
+ await this.mergeState(request, state, update)
335
+ }
336
+
337
+ return this.proceed(request, h)
338
+ }
339
+ }
340
+
341
+ getViewModel(
342
+ request: FormContextRequest,
343
+ context: FormContext
344
+ ): FormPageViewModel {
345
+ const { state } = context
346
+
347
+ const list = this.getListFromState(state)
348
+ const itemId = this.getItemId(request)
349
+ const item = this.getItemFromList(list, itemId)
350
+
351
+ const viewModel = super.getViewModel(request, context)
352
+ const itemNumber = item ? list.indexOf(item) + 1 : list.length + 1
353
+ const repeatCaption = `${this.repeat.options.title} ${itemNumber}`
354
+
355
+ return {
356
+ ...viewModel,
357
+
358
+ sectionTitle: viewModel.sectionTitle
359
+ ? `${viewModel.sectionTitle}: ${repeatCaption}`
360
+ : repeatCaption
361
+ }
362
+ }
363
+
364
+ getListSummaryViewModel(
365
+ request: FormContextRequest,
366
+ context: FormContext,
367
+ list: RepeatListState
368
+ ): RepeaterSummaryPageViewModel {
369
+ const { collection, href, repeat } = this
370
+ const { query } = request
371
+ const { isForceAccess, errors } = context
372
+
373
+ const { title } = repeat.options
374
+
375
+ const summaryList: SummaryList = {
376
+ classes: 'govuk-summary-list--long-actions',
377
+ rows: []
378
+ }
379
+
380
+ let count = 0
381
+
382
+ if (Array.isArray(list)) {
383
+ count = list.length
384
+
385
+ const summaryPath = this.getSummaryPath(request)
386
+
387
+ list.forEach((item, index) => {
388
+ const items: SummaryListAction[] = []
389
+
390
+ // Remove summary list actions from previews
391
+ if (!isForceAccess) {
392
+ items.push({
393
+ href: redirectPath(`${href}/${item.itemId}`, {
394
+ returnUrl: query.returnUrl ?? this.getHref(summaryPath)
395
+ }),
396
+ text: 'Change',
397
+ classes: 'govuk-link--no-visited-state',
398
+ visuallyHiddenText: `item ${index + 1}`
399
+ })
400
+
401
+ if (count > 1) {
402
+ items.push({
403
+ href: redirectPath(`${href}/${item.itemId}/confirm-delete`, {
404
+ returnUrl: query.returnUrl
405
+ }),
406
+ text: 'Remove',
407
+ classes: 'govuk-link--no-visited-state',
408
+ visuallyHiddenText: `item ${index + 1}`
409
+ })
410
+ }
411
+ }
412
+
413
+ const itemDisplayText = collection.fields.length
414
+ ? collection.fields[0].getDisplayStringFromState(item)
415
+ : ''
416
+
417
+ summaryList.rows.push({
418
+ key: {
419
+ text: `${title} ${index + 1}`
420
+ },
421
+ value: {
422
+ text: itemDisplayText || 'Not supplied'
423
+ },
424
+ actions: {
425
+ items
426
+ }
427
+ })
428
+ })
429
+ }
430
+
431
+ return {
432
+ ...this.viewModel,
433
+ backLink: this.getBackLink(request, context),
434
+ repeatTitle: title,
435
+ pageTitle: `You have added ${count} ${title}${count === 1 ? '' : 's'}`,
436
+ showTitle: true,
437
+ context,
438
+ errors,
439
+ checkAnswers: [{ summaryList }]
440
+ }
441
+ }
442
+
443
+ getSummaryPath(request?: FormContextRequest) {
444
+ const { path } = this
445
+
446
+ const summaryPath = super.getSummaryPath()
447
+
448
+ if (!request) {
449
+ return summaryPath
450
+ }
451
+
452
+ const { query } = request
453
+
454
+ return redirectPath(`${path}${summaryPath}`, {
455
+ returnUrl: query.returnUrl
456
+ })
457
+ }
458
+ }