@defra/forms-engine-plugin 4.0.7 → 4.0.8

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 (44) hide show
  1. package/.server/server/forms/components.json +7 -0
  2. package/.server/server/forms/register-as-a-unicorn-breeder.yaml +12 -1
  3. package/.server/server/plugins/engine/components/ComponentBase.d.ts +1 -1
  4. package/.server/server/plugins/engine/components/ComponentBase.js.map +1 -1
  5. package/.server/server/plugins/engine/components/ComponentCollection.js.map +1 -1
  6. package/.server/server/plugins/engine/components/DeclarationField.d.ts +81 -0
  7. package/.server/server/plugins/engine/components/DeclarationField.js +123 -0
  8. package/.server/server/plugins/engine/components/DeclarationField.js.map +1 -0
  9. package/.server/server/plugins/engine/components/helpers/components.d.ts +1 -1
  10. package/.server/server/plugins/engine/components/helpers/components.js +3 -0
  11. package/.server/server/plugins/engine/components/helpers/components.js.map +1 -1
  12. package/.server/server/plugins/engine/components/index.d.ts +1 -0
  13. package/.server/server/plugins/engine/components/index.js +1 -0
  14. package/.server/server/plugins/engine/components/index.js.map +1 -1
  15. package/.server/server/plugins/engine/pageControllers/validationOptions.js +1 -0
  16. package/.server/server/plugins/engine/pageControllers/validationOptions.js.map +1 -1
  17. package/.server/server/plugins/engine/views/components/declarationfield.html +14 -0
  18. package/.server/server/plugins/nunjucks/filters/field.d.ts +1 -1
  19. package/.server/server/plugins/nunjucks/filters/index.d.ts +1 -0
  20. package/.server/server/plugins/nunjucks/filters/index.js +1 -0
  21. package/.server/server/plugins/nunjucks/filters/index.js.map +1 -1
  22. package/.server/server/plugins/nunjucks/filters/merge.d.ts +7 -0
  23. package/.server/server/plugins/nunjucks/filters/merge.js +16 -0
  24. package/.server/server/plugins/nunjucks/filters/merge.js.map +1 -0
  25. package/.server/server/plugins/nunjucks/filters/merge.test.js +19 -0
  26. package/.server/server/plugins/nunjucks/filters/merge.test.js.map +1 -0
  27. package/package.json +2 -2
  28. package/src/server/forms/components.json +7 -0
  29. package/src/server/forms/page-events.yaml +1 -1
  30. package/src/server/forms/register-as-a-unicorn-breeder.yaml +12 -1
  31. package/src/server/index.test.ts +1 -0
  32. package/src/server/plugins/engine/components/ComponentBase.ts +1 -0
  33. package/src/server/plugins/engine/components/ComponentCollection.ts +1 -0
  34. package/src/server/plugins/engine/components/DeclarationField.test.ts +426 -0
  35. package/src/server/plugins/engine/components/DeclarationField.ts +167 -0
  36. package/src/server/plugins/engine/components/helpers/components.ts +5 -0
  37. package/src/server/plugins/engine/components/index.ts +1 -0
  38. package/src/server/plugins/engine/models/SummaryViewModel.test.ts +76 -3
  39. package/src/server/plugins/engine/models/SummaryViewModel.ts +5 -1
  40. package/src/server/plugins/engine/pageControllers/validationOptions.ts +4 -0
  41. package/src/server/plugins/engine/views/components/declarationfield.html +14 -0
  42. package/src/server/plugins/nunjucks/filters/index.js +1 -0
  43. package/src/server/plugins/nunjucks/filters/merge.js +16 -0
  44. package/src/server/plugins/nunjucks/filters/merge.test.js +15 -0
@@ -0,0 +1,167 @@
1
+ import { type DeclarationFieldComponent, type Item } from '@defra/forms-model'
2
+ import joi, {
3
+ type ArraySchema,
4
+ type BooleanSchema,
5
+ type StringSchema
6
+ } from 'joi'
7
+
8
+ import {
9
+ FormComponent,
10
+ isFormValue
11
+ } from '~/src/server/plugins/engine/components/FormComponent.js'
12
+ import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js'
13
+ import {
14
+ type ErrorMessageTemplateList,
15
+ type FormPayload,
16
+ type FormState,
17
+ type FormStateValue,
18
+ type FormSubmissionError,
19
+ type FormSubmissionState,
20
+ type FormValue
21
+ } from '~/src/server/plugins/engine/types.js'
22
+
23
+ export class DeclarationField extends FormComponent {
24
+ private readonly DEFAULT_DECLARATION_LABEL = 'I understand and agree'
25
+
26
+ declare options: DeclarationFieldComponent['options']
27
+
28
+ declare declarationConfirmationLabel: string
29
+
30
+ declare formSchema: ArraySchema<StringSchema[]>
31
+ declare stateSchema: BooleanSchema
32
+ declare content: string
33
+
34
+ constructor(
35
+ def: DeclarationFieldComponent,
36
+ props: ConstructorParameters<typeof FormComponent>[1]
37
+ ) {
38
+ super(def, props)
39
+
40
+ const { options, content } = def
41
+
42
+ let checkboxSchema = joi.string().valid('true')
43
+
44
+ if (options.required !== false) {
45
+ checkboxSchema = checkboxSchema.required()
46
+ }
47
+
48
+ const formSchema = joi
49
+ .array()
50
+ .items(checkboxSchema, joi.string().valid('unchecked').strip())
51
+ .label(this.label)
52
+ .single()
53
+ .messages({
54
+ 'any.required': messageTemplate.declarationRequired as string,
55
+ 'any.unknown': messageTemplate.declarationRequired as string,
56
+ 'array.includesRequiredUnknowns':
57
+ messageTemplate.declarationRequired as string
58
+ }) as ArraySchema<StringSchema[]>
59
+
60
+ this.formSchema = formSchema
61
+ this.stateSchema = joi.boolean().cast('string').label(this.label).required()
62
+
63
+ this.options = options
64
+ this.content = content
65
+ this.declarationConfirmationLabel =
66
+ options.declarationConfirmationLabel ?? this.DEFAULT_DECLARATION_LABEL
67
+ }
68
+
69
+ getFormValueFromState(state: FormSubmissionState) {
70
+ const { name } = this
71
+ return state[name] === true ? 'true' : undefined
72
+ }
73
+
74
+ getFormDataFromState(state: FormSubmissionState): FormPayload {
75
+ const { name } = this
76
+ return { [name]: state[name] === true ? 'true' : undefined }
77
+ }
78
+
79
+ getStateFromValidForm(payload: FormPayload): FormState {
80
+ const { name } = this
81
+ const payloadValue = payload[name]
82
+ const value =
83
+ this.isValue(payloadValue) &&
84
+ payloadValue.length > 0 &&
85
+ payloadValue.every((v) => {
86
+ return v === 'true'
87
+ })
88
+
89
+ return { [name]: value }
90
+ }
91
+
92
+ getContextValueFromFormValue(value: FormValue | FormPayload): boolean {
93
+ return value === 'true'
94
+ }
95
+
96
+ getFormValue(value?: FormStateValue | FormState) {
97
+ return this.isValue(value) ? value : undefined
98
+ }
99
+
100
+ getDisplayStringFromFormValue(value: FormValue | FormPayload): string {
101
+ return value ? this.declarationConfirmationLabel : ''
102
+ }
103
+
104
+ getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) {
105
+ const defaultDeclarationConfirmationLabel =
106
+ 'I confirm that I understand and accept this declaration'
107
+ const {
108
+ title,
109
+ hint,
110
+ content,
111
+ declarationConfirmationLabel = defaultDeclarationConfirmationLabel
112
+ } = this
113
+ return {
114
+ ...super.getViewModel(payload, errors),
115
+ hint: hint ? { text: hint } : undefined,
116
+ fieldset: {
117
+ legend: {
118
+ text: title
119
+ }
120
+ },
121
+ content,
122
+ values: payload[this.name],
123
+ items: [
124
+ {
125
+ text: declarationConfirmationLabel,
126
+ value: 'true'
127
+ }
128
+ ]
129
+ }
130
+ }
131
+
132
+ isValue(value?: FormStateValue | FormState): value is Item['value'][] {
133
+ if (!Array.isArray(value)) {
134
+ return false
135
+ }
136
+
137
+ // Skip checks when empty
138
+ if (!value.length) {
139
+ return true
140
+ }
141
+
142
+ return value.every(isFormValue)
143
+ }
144
+
145
+ /**
146
+ * For error preview page that shows all possible errors on a component
147
+ */
148
+ getAllPossibleErrors(): ErrorMessageTemplateList {
149
+ return DeclarationField.getAllPossibleErrors()
150
+ }
151
+
152
+ /**
153
+ * Static version of getAllPossibleErrors that doesn't require a component instance.
154
+ */
155
+ static getAllPossibleErrors(): ErrorMessageTemplateList {
156
+ return {
157
+ baseErrors: [
158
+ { type: 'required', template: messageTemplate.declarationRequired }
159
+ ],
160
+ advancedSettingsErrors: []
161
+ }
162
+ }
163
+
164
+ static isBool(value?: FormStateValue | FormState): value is boolean {
165
+ return isFormValue(value) && typeof value === 'boolean'
166
+ }
167
+ }
@@ -20,6 +20,7 @@ export type Field = InstanceType<
20
20
  | typeof Components.YesNoField
21
21
  | typeof Components.CheckboxesField
22
22
  | typeof Components.DatePartsField
23
+ | typeof Components.DeclarationField
23
24
  | typeof Components.EastingNorthingField
24
25
  | typeof Components.EmailAddressField
25
26
  | typeof Components.LatLongField
@@ -102,6 +103,10 @@ export function createComponent(
102
103
  component = new Components.DatePartsField(def, options)
103
104
  break
104
105
 
106
+ case ComponentType.DeclarationField:
107
+ component = new Components.DeclarationField(def, options)
108
+ break
109
+
105
110
  case ComponentType.Details:
106
111
  component = new Components.Details(def, options)
107
112
  break
@@ -7,6 +7,7 @@
7
7
  export { AutocompleteField } from '~/src/server/plugins/engine/components/AutocompleteField.js'
8
8
  export { CheckboxesField } from '~/src/server/plugins/engine/components/CheckboxesField.js'
9
9
  export { DatePartsField } from '~/src/server/plugins/engine/components/DatePartsField.js'
10
+ export { DeclarationField } from '~/src/server/plugins/engine/components/DeclarationField.js'
10
11
  export { Details } from '~/src/server/plugins/engine/components/Details.js'
11
12
  export { EmailAddressField } from '~/src/server/plugins/engine/components/EmailAddressField.js'
12
13
  export { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js'
@@ -15,6 +15,7 @@ import {
15
15
  type FormContextRequest,
16
16
  type FormState
17
17
  } from '~/src/server/plugins/engine/types.js'
18
+ import v2Definition from '~/test/form/definitions/conditions-relative-dates-v2.js'
18
19
  import definition from '~/test/form/definitions/repeat-mixed.js'
19
20
  const basePath = `${FORM_PREFIX}/test`
20
21
 
@@ -326,7 +327,7 @@ describe('SummaryPageController', () => {
326
327
  expect(viewModel).toHaveProperty('allowSaveAndExit', true)
327
328
  })
328
329
 
329
- it('should display correct page title', () => {
330
+ it('should display correct page title for v1 form', () => {
330
331
  const state: FormState = {
331
332
  $$__referenceNumber: 'foobar',
332
333
  orderType: 'collection',
@@ -334,9 +335,81 @@ describe('SummaryPageController', () => {
334
335
  }
335
336
 
336
337
  const context = model.getFormContext(request, state)
337
- const viewModel = controller.getViewModel(request, context)
338
+ const viewModel = controller.getSummaryViewModel(request, context)
339
+
340
+ expect(viewModel.pageTitle).toBe(
341
+ 'Check your answers before sending your form'
342
+ )
343
+ })
344
+
345
+ it('should display default page title for v2 form when title not supplied', () => {
346
+ const state: FormState = {
347
+ $$__referenceNumber: 'foobar',
348
+ orderType: 'collection',
349
+ pizza: []
350
+ }
351
+
352
+ const titleModel = new FormModel(v2Definition, {
353
+ basePath: `${FORM_PREFIX}/test`
354
+ })
355
+
356
+ controller = new SummaryPageController(titleModel, v2Definition.pages[5])
357
+
358
+ request = {
359
+ method: 'get',
360
+ url: new URL('http://example.com/repeat/pizza-order/summary'),
361
+ path: '/test/summary',
362
+ params: {
363
+ path: 'summary',
364
+ slug: 'test'
365
+ },
366
+ query: {},
367
+ app: { model: titleModel },
368
+ server: serverWithSaveAndExit
369
+ }
370
+
371
+ const context = titleModel.getFormContext(request, state)
372
+ const viewModel = controller.getSummaryViewModel(request, context)
373
+
374
+ expect(viewModel.pageTitle).toBe(
375
+ 'Check your answers before sending your form'
376
+ )
377
+ })
378
+
379
+ it('should display override page title for v2 form when title supplied', () => {
380
+ const state: FormState = {
381
+ $$__referenceNumber: 'foobar',
382
+ orderType: 'collection',
383
+ pizza: []
384
+ }
385
+
386
+ const v2DefinitionWithSummaryTitle = structuredClone(v2Definition)
387
+ const summaryPage = v2DefinitionWithSummaryTitle.pages[5]
388
+ summaryPage.title = 'Override summary title'
389
+
390
+ const titleModel = new FormModel(v2DefinitionWithSummaryTitle, {
391
+ basePath: `${FORM_PREFIX}/test`
392
+ })
393
+
394
+ controller = new SummaryPageController(titleModel, summaryPage)
395
+
396
+ request = {
397
+ method: 'get',
398
+ url: new URL('http://example.com/repeat/pizza-order/summary'),
399
+ path: '/test/summary',
400
+ params: {
401
+ path: 'summary',
402
+ slug: 'test'
403
+ },
404
+ query: {},
405
+ app: { model: titleModel },
406
+ server: serverWithSaveAndExit
407
+ }
408
+
409
+ const context = titleModel.getFormContext(request, state)
410
+ const viewModel = controller.getSummaryViewModel(request, context)
338
411
 
339
- expect(viewModel.pageTitle).toBe('Check your answers')
412
+ expect(viewModel.pageTitle).toBe('Override summary title')
340
413
  })
341
414
  })
342
415
  })
@@ -1,4 +1,4 @@
1
- import { type Section } from '@defra/forms-model'
1
+ import { SchemaVersion, type Section } from '@defra/forms-model'
2
2
 
3
3
  import {
4
4
  getAnswer,
@@ -64,6 +64,10 @@ export class SummaryViewModel {
64
64
 
65
65
  this.page = page
66
66
  this.pageTitle = page.title
67
+ if (def.schema === SchemaVersion.V2 && !page.title) {
68
+ this.pageTitle = 'Check your answers before sending your form'
69
+ }
70
+
67
71
  this.serviceUrl = `/${basePath}`
68
72
  this.name = def.name
69
73
  this.declaration = def.declaration
@@ -20,6 +20,10 @@ const opts = {
20
20
  * see @link https://joi.dev/api/?v=17.4.2#template-syntax for template syntax
21
21
  */
22
22
  export const messageTemplate: Record<string, JoiExpression> = {
23
+ declarationRequired: joi.expression(
24
+ 'You must confirm you understand and agree with the {{lowerFirst(#label)}} to continue',
25
+ opts
26
+ ) as JoiExpression,
23
27
  required: joi.expression(
24
28
  'Enter {{lowerFirst(#label)}}',
25
29
  opts
@@ -0,0 +1,14 @@
1
+ {% from "govuk/components/fieldset/macro.njk" import govukFieldset %}
2
+ {% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %}
3
+ {% from "govuk/components/hint/macro.njk" import govukHint %}
4
+
5
+ {% macro DeclarationField(component) %}
6
+ {% set content %}
7
+ <div class="app-prose-scope">
8
+ {{ component.model.content | markdown | safe }}
9
+ </div>
10
+ {% endset %}
11
+ {% set checkboxes = component.model | merge({ formGroup: { beforeInputs: { html: content } } }) %}
12
+ {{ govukCheckboxes(checkboxes) }}
13
+ <input type="hidden" name="{{ component.model.name }}" value="unchecked">
14
+ {% endmacro %}
@@ -5,3 +5,4 @@ export { answer } from '~/src/server/plugins/nunjucks/filters/answer.js'
5
5
  export { href } from '~/src/server/plugins/nunjucks/filters/href.js'
6
6
  export { field } from '~/src/server/plugins/nunjucks/filters/field.js'
7
7
  export { page } from '~/src/server/plugins/nunjucks/filters/page.js'
8
+ export { merge } from '~/src/server/plugins/nunjucks/filters/merge.js'
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Nunjucks filter to get the page for a given path
3
+ * @param {Record<string, any>} targetDictionary - Object to extend
4
+ * @param {Record<string, any> | string} sourceDictionary - Object to merge into target
5
+ * @returns {Record<string, any>}
6
+ */
7
+ export function merge(targetDictionary, sourceDictionary) {
8
+ if (typeof sourceDictionary !== 'object') {
9
+ return targetDictionary
10
+ }
11
+
12
+ return {
13
+ ...targetDictionary,
14
+ ...sourceDictionary
15
+ }
16
+ }
@@ -0,0 +1,15 @@
1
+ import { merge } from '~/src/server/plugins/nunjucks/filters/merge.js'
2
+
3
+ describe('merge', () => {
4
+ const propertyToMerge = { lorem: 'ipsum' }
5
+ it('should return the target if source is not an object', () => {
6
+ expect(merge(propertyToMerge, 'dolar')).toBe(propertyToMerge)
7
+ })
8
+
9
+ it('should merge the properties if they are valid', () => {
10
+ expect(merge({ lorem: 'dolar', dolar: 'sit' }, propertyToMerge)).toEqual({
11
+ lorem: 'ipsum',
12
+ dolar: 'sit'
13
+ })
14
+ })
15
+ })