@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,567 @@
1
+ import { ComponentType, type MonthYearFieldComponent } from '@defra/forms-model'
2
+ import { startOfDay } from 'date-fns'
3
+
4
+ import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js'
5
+ import {
6
+ getAnswer,
7
+ type Field
8
+ } from '~/src/server/plugins/engine/components/helpers.js'
9
+ import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js'
10
+ import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js'
11
+ import {
12
+ type FormPayload,
13
+ type FormState
14
+ } from '~/src/server/plugins/engine/types.js'
15
+ import definition from '~/test/form/definitions/blank.js'
16
+
17
+ describe('MonthYearField', () => {
18
+ let model: FormModel
19
+
20
+ beforeEach(() => {
21
+ model = new FormModel(definition, {
22
+ basePath: 'test'
23
+ })
24
+ })
25
+
26
+ describe('Defaults', () => {
27
+ let def: MonthYearFieldComponent
28
+ let collection: ComponentCollection
29
+ let field: Field
30
+
31
+ beforeEach(() => {
32
+ def = {
33
+ title: 'Example month/year field',
34
+ name: 'myComponent',
35
+ type: ComponentType.MonthYearField,
36
+ options: {}
37
+ } satisfies MonthYearFieldComponent
38
+
39
+ collection = new ComponentCollection([def], { model })
40
+ field = collection.fields[0]
41
+ })
42
+
43
+ describe('Schema', () => {
44
+ it('uses collection titles as labels', () => {
45
+ const { formSchema } = collection
46
+ const { keys } = formSchema.describe()
47
+
48
+ expect(keys).toEqual(
49
+ expect.objectContaining({
50
+ myComponent__month: expect.objectContaining({
51
+ flags: expect.objectContaining({ label: 'Month' })
52
+ }),
53
+ myComponent__year: expect.objectContaining({
54
+ flags: expect.objectContaining({ label: 'Year' })
55
+ })
56
+ })
57
+ )
58
+ })
59
+
60
+ it('uses collection names as keys', () => {
61
+ const { formSchema } = collection
62
+ const { keys } = formSchema.describe()
63
+
64
+ expect(field.keys).toEqual([
65
+ 'myComponent',
66
+ 'myComponent__month',
67
+ 'myComponent__year'
68
+ ])
69
+
70
+ expect(field.collection?.keys).not.toHaveProperty('myComponent')
71
+
72
+ for (const key of field.collection?.keys ?? []) {
73
+ expect(keys).toHaveProperty(key)
74
+ }
75
+ })
76
+
77
+ it('is required by default', () => {
78
+ const { formSchema } = collection
79
+ const { keys } = formSchema.describe()
80
+
81
+ expect(keys).toEqual(
82
+ expect.objectContaining({
83
+ myComponent__month: expect.objectContaining({
84
+ flags: expect.objectContaining({ presence: 'required' })
85
+ }),
86
+ myComponent__year: expect.objectContaining({
87
+ flags: expect.objectContaining({ presence: 'required' })
88
+ })
89
+ })
90
+ )
91
+ })
92
+
93
+ it('is optional when configured', () => {
94
+ const collectionOptional = new ComponentCollection(
95
+ [
96
+ {
97
+ title: 'Example month/year field',
98
+ name: 'myComponent',
99
+ type: ComponentType.MonthYearField,
100
+ options: { required: false }
101
+ }
102
+ ],
103
+ { model }
104
+ )
105
+
106
+ const { formSchema } = collectionOptional
107
+ const { keys } = formSchema.describe()
108
+
109
+ expect(keys).toEqual(
110
+ expect.objectContaining({
111
+ myComponent__month: expect.objectContaining({
112
+ allow: ['']
113
+ }),
114
+ myComponent__year: expect.objectContaining({
115
+ allow: ['']
116
+ })
117
+ })
118
+ )
119
+
120
+ // Empty optional payload (valid)
121
+ const result1 = collectionOptional.validate(
122
+ getFormData({
123
+ month: '',
124
+ year: ''
125
+ })
126
+ )
127
+
128
+ // Partial optional payload (invalid)
129
+ const result2 = collectionOptional.validate(
130
+ getFormData({
131
+ month: '12',
132
+ year: ''
133
+ })
134
+ )
135
+
136
+ expect(result1.errors).toBeUndefined()
137
+ expect(result2.errors).toEqual([
138
+ expect.objectContaining({
139
+ text: 'Example month/year field must include a year'
140
+ })
141
+ ])
142
+ })
143
+
144
+ it('accepts valid values', () => {
145
+ const result1 = collection.validate(
146
+ getFormData({
147
+ month: '12',
148
+ year: '2024'
149
+ })
150
+ )
151
+
152
+ const result2 = collection.validate(
153
+ getFormData({
154
+ month: '2',
155
+ year: '2024'
156
+ })
157
+ )
158
+
159
+ expect(result1.errors).toBeUndefined()
160
+ expect(result2.errors).toBeUndefined()
161
+ })
162
+
163
+ it('adds errors for empty value', () => {
164
+ const result = collection.validate(
165
+ getFormData({
166
+ month: '',
167
+ year: ''
168
+ })
169
+ )
170
+
171
+ expect(result.errors).toEqual([
172
+ expect.objectContaining({
173
+ text: 'Example month/year field must include a month'
174
+ }),
175
+ expect.objectContaining({
176
+ text: 'Example month/year field must include a year'
177
+ })
178
+ ])
179
+ })
180
+
181
+ it('adds errors for invalid values', () => {
182
+ const result1 = collection.validate(getFormData({ unknown: 'invalid' }))
183
+
184
+ const result2 = collection.validate(
185
+ getFormData({
186
+ month: ['invalid'],
187
+ year: ['invalid']
188
+ })
189
+ )
190
+
191
+ const result3 = collection.validate(
192
+ getFormData({
193
+ month: 'invalid',
194
+ year: 'invalid'
195
+ })
196
+ )
197
+
198
+ expect(result1.errors).toBeTruthy()
199
+ expect(result2.errors).toBeTruthy()
200
+ expect(result3.errors).toBeTruthy()
201
+ })
202
+ })
203
+
204
+ describe('State', () => {
205
+ const date = new Date('2024-12-31')
206
+
207
+ it('returns text from state', () => {
208
+ const state1 = getFormState(date)
209
+ const state2 = getFormState({})
210
+
211
+ const answer1 = getAnswer(field, state1)
212
+ const answer2 = getAnswer(field, state2)
213
+
214
+ expect(answer1).toBe('December 2024')
215
+ expect(answer2).toBe('')
216
+ })
217
+
218
+ it('returns payload from state', () => {
219
+ const state1 = getFormState(startOfDay(date))
220
+ const state2 = getFormState({})
221
+
222
+ const payload1 = field.getFormDataFromState(state1)
223
+ const payload2 = field.getFormDataFromState(state2)
224
+
225
+ expect(payload1).toEqual(getFormData(date))
226
+ expect(payload2).toEqual({})
227
+ })
228
+
229
+ it('returns value from state', () => {
230
+ const state1 = getFormState(startOfDay(date))
231
+ const state2 = getFormState({})
232
+
233
+ const value1 = field.getFormValueFromState(state1)
234
+ const value2 = field.getFormValueFromState(state2)
235
+
236
+ expect(value1).toEqual({
237
+ month: 12,
238
+ year: 2024
239
+ })
240
+
241
+ expect(value2).toBeUndefined()
242
+ })
243
+
244
+ it('returns context for conditions and form submission', () => {
245
+ const state1 = getFormState(startOfDay(date))
246
+ const state2 = getFormState({})
247
+
248
+ const value1 = field.getContextValueFromState(state1)
249
+ const value2 = field.getContextValueFromState(state2)
250
+
251
+ expect(value1).toBe('2024-12')
252
+ expect(value2).toBeNull()
253
+ })
254
+
255
+ it('returns null context when date is invalid', () => {
256
+ const state1 = getFormState({ month: 0, year: 2025 })
257
+ const state2 = getFormState({})
258
+ const state3 = getFormState({ month: 13, year: 2025 })
259
+
260
+ const value1 = field.getContextValueFromState(state1)
261
+ const value2 = field.getContextValueFromState(state2)
262
+ const value3 = field.getContextValueFromState(state3)
263
+
264
+ expect(value1).toBeNull()
265
+ expect(value2).toBeNull()
266
+ expect(value3).toBeNull()
267
+ })
268
+
269
+ it('returns state from payload', () => {
270
+ const payload1 = getFormData(date)
271
+ const payload2 = {}
272
+
273
+ const state1 = field.getStateFromValidForm(payload1)
274
+ const state2 = field.getStateFromValidForm(payload2)
275
+
276
+ expect(state1).toEqual(getFormState(date))
277
+ expect(state2).toEqual(getFormState({}))
278
+ })
279
+ })
280
+
281
+ describe('View model', () => {
282
+ const date = new Date('2024-12-31')
283
+
284
+ it('sets Nunjucks component defaults', () => {
285
+ const payload = getFormData(date)
286
+ const viewModel = field.getViewModel(payload)
287
+
288
+ expect(viewModel).toEqual(
289
+ expect.objectContaining({
290
+ label: { text: def.title },
291
+ name: 'myComponent',
292
+ id: 'myComponent',
293
+ value: undefined,
294
+ items: [
295
+ expect.objectContaining(
296
+ getViewModel(date, 'month', {
297
+ label: { text: 'Month' },
298
+ classes: 'govuk-input--width-2',
299
+ value: 12
300
+ })
301
+ ),
302
+
303
+ expect.objectContaining(
304
+ getViewModel(date, 'year', {
305
+ label: { text: 'Year' },
306
+ classes: 'govuk-input--width-4',
307
+ value: 2024
308
+ })
309
+ )
310
+ ]
311
+ })
312
+ )
313
+ })
314
+
315
+ it('sets Nunjucks component value when invalid', () => {
316
+ const payload = getFormData({
317
+ month: 'MM',
318
+ year: 'YYYY'
319
+ })
320
+
321
+ const viewModel = field.getViewModel(payload)
322
+
323
+ expect(viewModel).toEqual(
324
+ expect.objectContaining({
325
+ items: [
326
+ expect.objectContaining(
327
+ getViewModel(date, 'month', { value: 'MM' })
328
+ ),
329
+
330
+ expect.objectContaining(
331
+ getViewModel(date, 'year', { value: 'YYYY' })
332
+ )
333
+ ]
334
+ })
335
+ )
336
+ })
337
+
338
+ it('sets Nunjucks component fieldset', () => {
339
+ const payload = getFormData(date)
340
+ const viewModel = field.getViewModel(payload)
341
+
342
+ expect(viewModel.fieldset).toEqual({
343
+ legend: {
344
+ text: def.title,
345
+ classes: 'govuk-fieldset__legend--m'
346
+ }
347
+ })
348
+ })
349
+ })
350
+ })
351
+
352
+ describe('Validation', () => {
353
+ const date = new Date('2001-01-01')
354
+
355
+ describe.each([
356
+ {
357
+ description: 'Trim empty spaces',
358
+ component: {
359
+ title: 'Example month/year field',
360
+ name: 'myComponent',
361
+ type: ComponentType.MonthYearField,
362
+ options: {}
363
+ } satisfies MonthYearFieldComponent,
364
+ assertions: [
365
+ {
366
+ input: getFormData({
367
+ month: ' 01',
368
+ year: ' 2001'
369
+ }),
370
+ output: {
371
+ value: getFormData(date)
372
+ }
373
+ },
374
+ {
375
+ input: getFormData({
376
+ month: '01 ',
377
+ year: '2001 '
378
+ }),
379
+ output: {
380
+ value: getFormData(date)
381
+ }
382
+ },
383
+ {
384
+ input: getFormData({
385
+ month: ' 01 \n\n',
386
+ year: ' 2001 \n\n'
387
+ }),
388
+ output: {
389
+ value: getFormData(date)
390
+ }
391
+ }
392
+ ]
393
+ },
394
+ {
395
+ description: 'Decimals',
396
+ component: {
397
+ title: 'Example month/year field',
398
+ name: 'myComponent',
399
+ type: ComponentType.MonthYearField,
400
+ options: {}
401
+ } satisfies MonthYearFieldComponent,
402
+ assertions: [
403
+ {
404
+ input: getFormData({
405
+ month: '1.2',
406
+ year: '2001.3'
407
+ }),
408
+ output: {
409
+ value: getFormData({
410
+ month: 1.2,
411
+ year: 2001.3
412
+ }),
413
+ errors: [
414
+ expect.objectContaining({
415
+ text: 'Example month/year field must be a real date'
416
+ }),
417
+ expect.objectContaining({
418
+ text: 'Example month/year field must be a real date'
419
+ })
420
+ ]
421
+ }
422
+ }
423
+ ]
424
+ },
425
+ {
426
+ description: 'Out of range values',
427
+ component: {
428
+ title: 'Example month/year field',
429
+ name: 'myComponent',
430
+ type: ComponentType.MonthYearField,
431
+ options: {}
432
+ } satisfies MonthYearFieldComponent,
433
+ assertions: [
434
+ {
435
+ input: getFormData({
436
+ month: '13',
437
+ year: '2024'
438
+ }),
439
+ output: {
440
+ value: getFormData({
441
+ month: 13,
442
+ year: 2024
443
+ }),
444
+ errors: [
445
+ expect.objectContaining({
446
+ text: 'Example month/year field must be a real date'
447
+ })
448
+ ]
449
+ }
450
+ },
451
+ {
452
+ input: getFormData({
453
+ month: '1',
454
+ year: '999'
455
+ }),
456
+ output: {
457
+ value: getFormData({
458
+ month: 1,
459
+ year: 999
460
+ }),
461
+ errors: [
462
+ expect.objectContaining({
463
+ text: 'Example month/year field must be a real date'
464
+ })
465
+ ]
466
+ }
467
+ }
468
+ ]
469
+ },
470
+ {
471
+ description: 'Optional fields',
472
+ component: {
473
+ title: 'Example month/year field',
474
+ name: 'myComponent',
475
+ type: ComponentType.MonthYearField,
476
+ options: {
477
+ required: false
478
+ }
479
+ } satisfies MonthYearFieldComponent,
480
+ assertions: [
481
+ {
482
+ input: getFormData({
483
+ month: '',
484
+ year: ''
485
+ }),
486
+ output: {
487
+ value: getFormData({
488
+ month: '',
489
+ year: ''
490
+ })
491
+ }
492
+ }
493
+ ]
494
+ }
495
+ ])('$description', ({ component: def, assertions }) => {
496
+ let collection: ComponentCollection
497
+
498
+ beforeEach(() => {
499
+ collection = new ComponentCollection([def], { model })
500
+ })
501
+
502
+ it.each([...assertions])(
503
+ 'validates custom example',
504
+ ({ input, output }) => {
505
+ const result = collection.validate(input)
506
+ expect(result).toEqual(output)
507
+ }
508
+ )
509
+ })
510
+ })
511
+ })
512
+
513
+ /**
514
+ * Month & year field view model
515
+ */
516
+ function getViewModel(
517
+ date: Date,
518
+ name: string,
519
+ overrides?: Partial<DateInputItem>
520
+ ): DateInputItem {
521
+ const payload = getFormData(date)
522
+
523
+ const fieldName = `myComponent__${name}`
524
+ const fieldValue = overrides?.value ?? payload[fieldName]
525
+ const fieldClasses = overrides?.classes ?? expect.any(String)
526
+
527
+ return {
528
+ label: expect.objectContaining(
529
+ overrides?.label ?? {
530
+ text: expect.any(String)
531
+ }
532
+ ),
533
+ name: fieldName,
534
+ id: fieldName,
535
+ value: fieldValue as DateInputItem['value'],
536
+ classes: fieldClasses
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Month & year form data
542
+ */
543
+ function getFormData(date: Date | FormPayload): FormPayload {
544
+ if (date instanceof Date) {
545
+ date = {
546
+ month: date.getMonth() + 1,
547
+ year: date.getFullYear()
548
+ }
549
+ }
550
+
551
+ return {
552
+ myComponent__month: date.month,
553
+ myComponent__year: date.year
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Month & year session state
559
+ */
560
+ function getFormState(date: Date | FormPayload): FormState {
561
+ const [month, year] = Object.values(getFormData(date))
562
+
563
+ return {
564
+ myComponent__month: month ?? null,
565
+ myComponent__year: year ?? null
566
+ }
567
+ }