@planning-inspectorate/dynamic-forms 3.16.0 → 3.18.0

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 (77) hide show
  1. package/README.md +124 -189
  2. package/docs/Email Validation.md +86 -0
  3. package/docs/Getting Started.md +299 -0
  4. package/package.json +1 -1
  5. package/src/components/address/question.d.ts +1 -9
  6. package/src/components/address/question.d.ts.map +1 -1
  7. package/src/components/address/question.js +6 -17
  8. package/src/components/checkbox/question.d.ts +1 -21
  9. package/src/components/checkbox/question.d.ts.map +1 -1
  10. package/src/components/checkbox/question.js +38 -50
  11. package/src/components/checkbox/question.test.js +177 -26
  12. package/src/components/date/question.d.ts +0 -8
  13. package/src/components/date/question.d.ts.map +1 -1
  14. package/src/components/date/question.js +6 -14
  15. package/src/components/date-period/question.d.ts +1 -5
  16. package/src/components/date-period/question.d.ts.map +1 -1
  17. package/src/components/date-period/question.js +1 -1
  18. package/src/components/date-time/question.d.ts +0 -8
  19. package/src/components/date-time/question.d.ts.map +1 -1
  20. package/src/components/date-time/question.js +4 -12
  21. package/src/components/email/question.d.ts +0 -9
  22. package/src/components/email/question.d.ts.map +1 -1
  23. package/src/components/email/question.js +2 -13
  24. package/src/components/email/question.test.js +19 -0
  25. package/src/components/manage-list/answer-summary-list.njk +1 -1
  26. package/src/components/manage-list/question.d.ts +1 -5
  27. package/src/components/manage-list/question.d.ts.map +1 -1
  28. package/src/components/manage-list/question.js +1 -1
  29. package/src/components/multi-field-input/question.d.ts +4 -19
  30. package/src/components/multi-field-input/question.d.ts.map +1 -1
  31. package/src/components/multi-field-input/question.js +89 -23
  32. package/src/components/multi-field-input/question.test.js +441 -3
  33. package/src/components/number-entry/question.d.ts +0 -28
  34. package/src/components/number-entry/question.d.ts.map +1 -1
  35. package/src/components/number-entry/question.js +3 -20
  36. package/src/components/number-entry/question.test.js +17 -0
  37. package/src/components/radio/question.d.ts +1 -9
  38. package/src/components/radio/question.d.ts.map +1 -1
  39. package/src/components/radio/question.js +29 -46
  40. package/src/components/radio/question.test.js +139 -60
  41. package/src/components/select/question.d.ts +1 -15
  42. package/src/components/select/question.d.ts.map +1 -1
  43. package/src/components/select/question.js +4 -47
  44. package/src/components/single-line-input/question.d.ts +0 -4
  45. package/src/components/single-line-input/question.d.ts.map +1 -1
  46. package/src/components/single-line-input/question.js +1 -1
  47. package/src/components/text-entry/question.d.ts +0 -4
  48. package/src/components/text-entry/question.d.ts.map +1 -1
  49. package/src/components/text-entry/question.js +1 -1
  50. package/src/components/text-entry-redact/question.d.ts +2 -6
  51. package/src/components/text-entry-redact/question.d.ts.map +1 -1
  52. package/src/components/text-entry-redact/question.js +1 -1
  53. package/src/components/unit-option-entry/question.d.ts.map +1 -1
  54. package/src/components/unit-option-entry/question.js +3 -1
  55. package/src/components/unit-option-entry/question.test.js +15 -0
  56. package/src/components/utils/question-has-answer.js +1 -1
  57. package/src/components/utils/question-utils.d.ts +19 -1
  58. package/src/components/utils/question-utils.d.ts.map +1 -1
  59. package/src/components/utils/question-utils.js +31 -2
  60. package/src/components/utils/question-utils.test.js +89 -2
  61. package/src/questions/options-question.d.ts +4 -4
  62. package/src/questions/options-question.d.ts.map +1 -1
  63. package/src/questions/options-question.js +27 -2
  64. package/src/questions/options-question.test.js +106 -0
  65. package/src/questions/question.d.ts +16 -1
  66. package/src/questions/question.d.ts.map +1 -1
  67. package/src/questions/question.js +64 -9
  68. package/src/questions/question.test.js +86 -0
  69. package/test/journey.js +2 -0
  70. package/test/questions.js +50 -0
  71. package/test/snapshots/check-your-answers-not-started.html +22 -0
  72. package/test/snapshots/check-your-answers-with-answers.html +23 -1
  73. package/test/snapshots/favourite-activity-reason.html +54 -0
  74. package/test/snapshots/travel-requirements.html +48 -0
  75. package/test/utils/question-test-utils.js +38 -7
  76. package/typedefs/question-props.d.ts +33 -0
  77. package/typedefs/question-types.d.ts +4 -0
@@ -53,36 +53,187 @@ describe('./src/dynamic-forms/components/checkbox/question.js', () => {
53
53
  assert.deepStrictEqual(question.validators, [...VALIDATORS, new ValidOptionValidator()]);
54
54
  assert.deepStrictEqual(question.options, OPTIONS);
55
55
  });
56
+ it('should allow capitaliseAnswer override', () => {
57
+ const question = new CheckboxQuestion({
58
+ ...CHECKBOX_PARAMS,
59
+ capitaliseAnswer: true
60
+ });
56
61
 
57
- it('should return option label when formatAnswerForSummary is called with one answer', () => {
58
- const question = new CheckboxQuestion(CHECKBOX_PARAMS);
59
- question.getAction = () => {};
60
- const formattedAnswer = question.formatAnswerForSummary({}, JOURNEY, '1');
61
- assert.strictEqual(formattedAnswer[0].value, 'a');
62
+ assert.strictEqual(question.capitaliseAnswer, true);
62
63
  });
63
64
 
64
- it('should return formatted option labels when formatAnswerForSummary is called with a string representing several non-conditional answers', () => {
65
- const question = new CheckboxQuestion(CHECKBOX_PARAMS);
66
- question.getAction = () => {};
67
- const formattedAnswer = question.formatAnswerForSummary({}, JOURNEY, '1,2');
68
- assert.strictEqual(formattedAnswer[0].value, 'a<br>b');
69
- });
65
+ describe('formatAnswer', () => {
66
+ it('should delegate simple string answers to parent class', () => {
67
+ const question = new CheckboxQuestion(CHECKBOX_PARAMS);
68
+ // Simple string should use parent's formatAnswer
69
+ const result = question.formatAnswer('1,2');
70
+ assert.strictEqual(result, 'a<br>b');
71
+ });
70
72
 
71
- it('should return formatted option labels when formatAnswerForSummary is called with a string representing several answers including a conditional', () => {
72
- const question = new CheckboxQuestion(CHECKBOX_PARAMS);
73
- question.getAction = () => {};
74
- const formattedAnswer = question.formatAnswerForSummary({}, JOURNEY, '1,2,3');
75
- assert.strictEqual(formattedAnswer[0].value, `a<br>b<br>c<br>${CONDITIONAL_ANSWER_TEXT}`);
76
- });
73
+ it('should format single answer with conditional', () => {
74
+ const question = new CheckboxQuestion(CHECKBOX_PARAMS);
75
+ const answer = {
76
+ value: '3',
77
+ conditional: {
78
+ 3: 'conditional details'
79
+ }
80
+ };
81
+ const result = question.formatAnswer(answer);
82
+ assert.strictEqual(result, 'c<br>conditional details');
83
+ });
77
84
 
78
- it('should return a formatted option label when formatAnswerForSummary is called with a single conditional answer', () => {
79
- const question = new CheckboxQuestion(CHECKBOX_PARAMS);
80
- const conditionalAnswer = {
81
- value: '3',
82
- conditional: CONDITIONAL_ANSWER_TEXT
83
- };
84
- question.getAction = () => {};
85
- const formattedAnswer = question.formatAnswerForSummary({}, JOURNEY, conditionalAnswer);
86
- assert.strictEqual(formattedAnswer[0].value, `c<br>${CONDITIONAL_ANSWER_TEXT}`);
85
+ it('should format single answer with conditional and label', () => {
86
+ const optionsWithLabel = [
87
+ { text: 'Option A', value: 'a' },
88
+ {
89
+ text: 'Option B',
90
+ value: 'b',
91
+ conditional: {
92
+ fieldName: 'details',
93
+ label: 'Please provide details:'
94
+ }
95
+ }
96
+ ];
97
+ const question = new CheckboxQuestion({
98
+ ...CHECKBOX_PARAMS,
99
+ options: optionsWithLabel
100
+ });
101
+ const answer = {
102
+ value: 'b',
103
+ conditional: {
104
+ b: 'my details'
105
+ }
106
+ };
107
+ const result = question.formatAnswer(answer);
108
+ assert.strictEqual(result, 'Option B<br>Please provide details: my details');
109
+ });
110
+
111
+ it('should format multiple answers where one has conditional', () => {
112
+ const question = new CheckboxQuestion(CHECKBOX_PARAMS);
113
+ const answer = {
114
+ value: '1,3',
115
+ conditional: {
116
+ 3: 'conditional for c'
117
+ }
118
+ };
119
+ const result = question.formatAnswer(answer);
120
+ assert.strictEqual(result, 'a<br>c<br>conditional for c');
121
+ });
122
+
123
+ it('should format multiple answers with multiple conditionals', () => {
124
+ const optionsWithMultipleConditionals = [
125
+ {
126
+ text: 'Option A',
127
+ value: 'a',
128
+ conditional: { fieldName: 'details-a' }
129
+ },
130
+ { text: 'Option B', value: 'b' },
131
+ {
132
+ text: 'Option C',
133
+ value: 'c',
134
+ conditional: { fieldName: 'details-c' }
135
+ }
136
+ ];
137
+ const question = new CheckboxQuestion({
138
+ ...CHECKBOX_PARAMS,
139
+ options: optionsWithMultipleConditionals
140
+ });
141
+ const answer = {
142
+ value: 'a,c',
143
+ conditional: {
144
+ a: 'details for A',
145
+ c: 'details for C'
146
+ }
147
+ };
148
+ const result = question.formatAnswer(answer);
149
+ assert.strictEqual(result, 'Option A<br>details for A<br>Option C<br>details for C');
150
+ });
151
+
152
+ it('should handle object answer with empty conditional', () => {
153
+ const question = new CheckboxQuestion(CHECKBOX_PARAMS);
154
+ const answer = {
155
+ value: '1,2',
156
+ conditional: {}
157
+ };
158
+ const result = question.formatAnswer(answer);
159
+ assert.strictEqual(result, 'a<br>b');
160
+ });
161
+
162
+ it('should handle object answer with null conditional', () => {
163
+ const question = new CheckboxQuestion(CHECKBOX_PARAMS);
164
+ const answer = {
165
+ value: '1,2',
166
+ conditional: null
167
+ };
168
+ const result = question.formatAnswer(answer);
169
+ assert.strictEqual(result, 'a<br>b');
170
+ });
171
+
172
+ it('should fall back to value when option not found in object answer', () => {
173
+ const question = new CheckboxQuestion(CHECKBOX_PARAMS);
174
+ const answer = {
175
+ value: 'unknown',
176
+ conditional: {}
177
+ };
178
+ const result = question.formatAnswer(answer);
179
+ assert.strictEqual(result, 'unknown');
180
+ });
181
+
182
+ it('should escape HTML in option text', () => {
183
+ const question = new CheckboxQuestion({
184
+ ...CHECKBOX_PARAMS,
185
+ options: [{ text: '<script>alert("xss")</script>', value: '1' }]
186
+ });
187
+ const result = question.formatAnswer('1');
188
+ assert.strictEqual(result, '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
189
+ });
190
+
191
+ it('should escape HTML in conditional value', () => {
192
+ const question = new CheckboxQuestion({
193
+ ...CHECKBOX_PARAMS,
194
+ options: [
195
+ {
196
+ text: 'Option',
197
+ value: '1',
198
+ conditional: { fieldName: 'details', type: 'text' }
199
+ }
200
+ ]
201
+ });
202
+ const answer = {
203
+ value: '1',
204
+ conditional: { 1: '<img src=x onerror=alert(1)>' }
205
+ };
206
+ const result = question.formatAnswer(answer);
207
+ assert.strictEqual(result, 'Option<br>&lt;img src=x onerror=alert(1)&gt;');
208
+ });
209
+
210
+ it('should escape HTML in conditional label', () => {
211
+ const question = new CheckboxQuestion({
212
+ ...CHECKBOX_PARAMS,
213
+ options: [
214
+ {
215
+ text: 'Option',
216
+ value: '1',
217
+ conditional: { fieldName: 'details', type: 'text', label: '<b>Label</b>' }
218
+ }
219
+ ]
220
+ });
221
+ const answer = {
222
+ value: '1',
223
+ conditional: { 1: 'safe value' }
224
+ };
225
+ const result = question.formatAnswer(answer);
226
+ assert.strictEqual(result, 'Option<br>&lt;b&gt;Label&lt;/b&gt; safe value');
227
+ });
228
+
229
+ it('should escape HTML in fallback value when option not found', () => {
230
+ const question = new CheckboxQuestion(CHECKBOX_PARAMS);
231
+ const answer = {
232
+ value: '<script>xss</script>',
233
+ conditional: {}
234
+ };
235
+ const result = question.formatAnswer(answer);
236
+ assert.strictEqual(result, '&lt;script&gt;xss&lt;/script&gt;');
237
+ });
87
238
  });
88
239
  });
@@ -16,14 +16,6 @@ export class DateQuestion extends Question {
16
16
  answerForViewModel(answers: any, isPayload: any): {
17
17
  [x: string]: any;
18
18
  };
19
- /**
20
- * returns the formatted answers values to be used to build task list elements
21
- */
22
- formatAnswerForSummary(sectionSegment: any, journey: any, answer: any): {
23
- key: string;
24
- value: string;
25
- action: import("../../index.js").ActionView | import("../../index.js").ActionView[] | undefined;
26
- }[];
27
19
  }
28
20
  export default DateQuestion;
29
21
  import { Question } from '../../questions/question.js';
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;;GAEG;AACH;IACC;;OAEG;IACH,6CAFW,OAAO,+BAA+B,EAAE,kBAAkB,EAQpE;IADA,mBAA4B;IAW7B;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAAE,MAAM,CAAC,CAY5B;IAED;;MAwBC;IAED;;OAEG;IACH;;;;QAaC;CACD;;yBAxFwB,6BAA6B"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;;GAEG;AACH;IACC;;OAEG;IACH,6CAFW,OAAO,+BAA+B,EAAE,kBAAkB,EAQpE;IADA,mBAA4B;IAW7B;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAAE,MAAM,CAAC,CAY5B;IAED;;MAwBC;CAWD;;yBAhFwB,6BAA6B"}
@@ -71,21 +71,13 @@ export class DateQuestion extends Question {
71
71
  }
72
72
 
73
73
  /**
74
- * returns the formatted answers values to be used to build task list elements
74
+ * Formats a date answer for display in the summary.
75
+ * @param {unknown} answer - the date value
76
+ * @returns {string} the formatted date
75
77
  */
76
- formatAnswerForSummary(sectionSegment, journey, answer) {
77
- let formattedAnswer;
78
-
79
- if (answer) {
80
- formattedAnswer = formatDateForDisplay(answer, { format: this.dateFormat });
81
- } else {
82
- formattedAnswer = this.notStartedText;
83
- }
84
-
85
- const action = this.getAction(sectionSegment, journey, answer);
86
- const key = this.title ?? this.question;
87
-
88
- return [{ key: key, value: formattedAnswer, action: action }];
78
+ formatAnswer(answer) {
79
+ if (!answer) return this.notStartedText;
80
+ return formatDateForDisplay(answer, { format: this.dateFormat });
89
81
  }
90
82
  }
91
83
 
@@ -25,17 +25,13 @@ export class DatePeriodQuestion extends Question {
25
25
  answerForViewModel(answers: any, isPayload: any): {
26
26
  [x: string]: any;
27
27
  };
28
- /**
29
- * @param {import('#question').QuestionViewModel} viewModel
30
- */
31
- addCustomDataToViewModel(viewModel: import("#question").QuestionViewModel): void;
32
28
  /**
33
29
  * returns the formatted answers values to be used to build task list elements
34
30
  */
35
31
  formatAnswerForSummary(sectionSegment: any, journey: any, answer: any): {
36
32
  key: string;
37
33
  value: string;
38
- action: import("../../index.js").ActionView | import("../../index.js").ActionView[] | undefined;
34
+ action: import("#typedefs/question-types.d.ts").ActionView | import("#typedefs/question-types.d.ts").ActionView[] | undefined;
39
35
  }[];
40
36
  }
41
37
  export default DatePeriodQuestion;
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAOA;;;GAGG;AACH;IACC;;OAEG;IACH,mGAFW,OAAO,+BAA+B,EAAE,wBAAwB,EAuB1E;IAPA,mBAA4B;IAC5B;;;MAAsD;IAEtD,8BAA0B;IAC1B,4BAAsB;IACtB,kEAA+D;IAC/D,gEAA2D;IAkB5D;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAAE,MAAM,CAAC,CAgC5B;IAED;;MAuCC;IAED;;OAEG;IACH,oCAFW,OAAO,WAAW,EAAE,iBAAiB,QAM/C;IAED;;OAEG;IACH;;;;QAyBC;CACD;;yBAzKwB,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAOA;;;GAGG;AACH;IACC;;OAEG;IACH,mGAFW,OAAO,+BAA+B,EAAE,wBAAwB,EAuB1E;IAPA,mBAA4B;IAC5B;;;MAAsD;IAEtD,8BAA0B;IAC1B,4BAAsB;IACtB,kEAA+D;IAC/D,gEAA2D;IAkB5D;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,mBAAmB,EAAE,eAAe,GACzC,OAAO,CAAE,MAAM,CAAC,CAgC5B;IAED;;MAuCC;IAWD;;OAEG;IACH;;;;QAyBC;CACD;;yBAzKwB,WAAW"}
@@ -131,7 +131,7 @@ export class DatePeriodQuestion extends Question {
131
131
  }
132
132
 
133
133
  /**
134
- * @param {import('#question').QuestionViewModel} viewModel
134
+ * @param {import('#typedefs/question-types.d.ts').QuestionViewModel} viewModel
135
135
  */
136
136
  addCustomDataToViewModel(viewModel) {
137
137
  viewModel.labels = this.labels;
@@ -16,14 +16,6 @@ export class DateTimeQuestion extends Question {
16
16
  answerForViewModel(answers: any, isPayload: any): {
17
17
  [x: string]: any;
18
18
  };
19
- /**
20
- * returns the formatted answers values to be used to build task list elements
21
- */
22
- formatAnswerForSummary(sectionSegment: any, journey: any, answer: any): {
23
- key: string;
24
- value: string;
25
- action: import("../../index.js").ActionView | import("../../index.js").ActionView[] | undefined;
26
- }[];
27
19
  #private;
28
20
  }
29
21
  export default DateTimeQuestion;
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAMA;IACC,kBAAiB;IACjB,kBAAiB;IAEjB;;OAEG;IACH,yDAFW,OAAO,+BAA+B,EAAE,sBAAsB,EASxE;IAFA,mBAA4B;IAC5B,mBAA4B;IAkB7B;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,eAAe,GACb,OAAO,CAAE,MAAM,CAAC,CAqB5B;IAED;;MAqCC;IAED;;OAEG;IACH;;;;QAQC;;CAsBD;;yBAxIwB,6BAA6B"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAMA;IACC,kBAAiB;IACjB,kBAAiB;IAEjB;;OAEG;IACH,yDAFW,OAAO,+BAA+B,EAAE,sBAAsB,EASxE;IAFA,mBAA4B;IAC5B,mBAA4B;IAkB7B;;;;;OAKG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,eAAe,GACb,OAAO,CAAE,MAAM,CAAC,CAqB5B;IAED;;MAqCC;;CA2BD;;yBAhIwB,6BAA6B"}
@@ -102,19 +102,11 @@ export class DateTimeQuestion extends Question {
102
102
  }
103
103
 
104
104
  /**
105
- * returns the formatted answers values to be used to build task list elements
105
+ * Formats a date-time answer for display in the summary.
106
+ * @param {unknown} answer - the date value
107
+ * @returns {string} the formatted date and time
106
108
  */
107
- formatAnswerForSummary(sectionSegment, journey, answer) {
108
- return [
109
- {
110
- key: this.title,
111
- value: this.#formatDateTimeValue(answer),
112
- action: this.getAction(sectionSegment, journey, answer)
113
- }
114
- ];
115
- }
116
-
117
- #formatDateTimeValue(answer) {
109
+ formatAnswer(answer) {
118
110
  if (!answer) return this.notStartedText;
119
111
 
120
112
  const formattedDate = formatDateForDisplay(answer, { format: this.dateFormat });
@@ -8,15 +8,6 @@ export class EmailQuestion extends SingleLineInputQuestion {
8
8
  * @param {import('#typedefs/question-props.d.ts').EmailQuestionParams} params
9
9
  */
10
10
  constructor(params: import("#typedefs/question-props.d.ts").EmailQuestionParams);
11
- /**
12
- * Override formatAnswerForSummary to prevent capitalization of email addresses
13
- * Email addresses should remain in their original case (typically lowercase)
14
- * @param {string} sectionSegment
15
- * @param {import('#journey').Journey} journey
16
- * @param {string} answer
17
- * @returns {Array}
18
- */
19
- formatAnswerForSummary(sectionSegment: string, journey: import("#journey").Journey, answer: string): any[];
20
11
  }
21
12
  export default EmailQuestion;
22
13
  import SingleLineInputQuestion from '../single-line-input/question.js';
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAEA;;;;GAIG;AACH;IACC;;OAEG;IACH,oBAFW,OAAO,+BAA+B,EAAE,mBAAmB,EAmBrE;IAED;;;;;;;OAOG;IACH,uCALW,MAAM,WACN,OAAO,UAAU,EAAE,OAAO,UAC1B,MAAM,SAMhB;CACD;;oCA1CmC,kCAAkC"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAEA;;;;GAIG;AACH;IACC;;OAEG;IACH,oBAFW,OAAO,+BAA+B,EAAE,mBAAmB,EAqBrE;CACD;;oCA/BmC,kCAAkC"}
@@ -21,25 +21,14 @@ export class EmailQuestion extends SingleLineInputQuestion {
21
21
  const autocomplete = params.autocomplete || 'email';
22
22
 
23
23
  super({
24
+ // Prevent capitalisation of email answers by default, but allow override
25
+ capitaliseAnswer: false,
24
26
  ...params,
25
27
  viewFolder: 'single-line-input', // Reuse single-line-input template
26
28
  inputAttributes: emailInputAttributes,
27
29
  autocomplete: autocomplete
28
30
  });
29
31
  }
30
-
31
- /**
32
- * Override formatAnswerForSummary to prevent capitalization of email addresses
33
- * Email addresses should remain in their original case (typically lowercase)
34
- * @param {string} sectionSegment
35
- * @param {import('#journey').Journey} journey
36
- * @param {string} answer
37
- * @returns {Array}
38
- */
39
- formatAnswerForSummary(sectionSegment, journey, answer) {
40
- // Call parent method with capitals=false to prevent email capitalization
41
- return super.formatAnswerForSummary(sectionSegment, journey, answer, false);
42
- }
43
32
  }
44
33
 
45
34
  export default EmailQuestion;
@@ -16,6 +16,25 @@ describe('src/dynamic-forms/components/email/question.js', () => {
16
16
  assert.strictEqual(emailQuestion.autocomplete, 'email');
17
17
  });
18
18
 
19
+ it('should default capitaliseAnswer to false', () => {
20
+ const emailQuestion = new EmailQuestion({
21
+ title: 'Email Address',
22
+ question: 'What is your email address?',
23
+ fieldName: 'email'
24
+ });
25
+ assert.strictEqual(emailQuestion.capitaliseAnswer, false);
26
+ });
27
+
28
+ it('should allow capitaliseAnswer to be set via constructor', () => {
29
+ const emailQuestion = new EmailQuestion({
30
+ title: 'Email Address',
31
+ question: 'What is your email address?',
32
+ fieldName: 'email',
33
+ capitaliseAnswer: true
34
+ });
35
+ assert.strictEqual(emailQuestion.capitaliseAnswer, true);
36
+ });
37
+
19
38
  it('should allow custom input attributes while preserving email defaults', () => {
20
39
  const customAttributes = {
21
40
  placeholder: 'Enter your email',
@@ -2,7 +2,7 @@
2
2
  {% for question in answers %}
3
3
  <li>
4
4
  {% for value in question %}
5
- {{ value.answer }}{% if not loop.last %}<br>{% endif %}
5
+ {{ value.answer | safe }}{% if not loop.last %}<br>{% endif %}
6
6
 
7
7
  {% endfor %}
8
8
  </li>
@@ -11,10 +11,6 @@ export class ManageListQuestion extends Question {
11
11
  */
12
12
  constructor(params: import("#typedefs/question-props.d.ts").ManageListQuestionParams);
13
13
  get confirmationQuestionParam(): string;
14
- /**
15
- * @param {import('#question').QuestionViewModel} viewModel
16
- */
17
- addCustomDataToViewModel(viewModel: import("#question").QuestionViewModel): void;
18
14
  getDataToSave(req: any, journeyResponse: any): Promise<{
19
15
  answers: {
20
16
  [x: string]: any;
@@ -26,7 +22,7 @@ export class ManageListQuestion extends Question {
26
22
  formatAnswerForSummary(sectionSegment: any, journey: any, answer: any): {
27
23
  key: string;
28
24
  value: string;
29
- action: import("../../index.js").ActionView | import("../../index.js").ActionView[] | undefined;
25
+ action: import("#typedefs/question-types.d.ts").ActionView | import("#typedefs/question-types.d.ts").ActionView[] | undefined;
30
26
  }[];
31
27
  renderConfirmationAction(res: any, itemToRemove: any, viewModel: any): void;
32
28
  set section(section: import("../../section.js").Section);
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;;;;;;GAMG;AAEH;IAOC;;OAEG;IACH,oBAFW,OAAO,+BAA+B,EAAE,wBAAwB,EAe1E;IAcD,wCAEC;IAED;;OAEG;IACH,oCAFW,OAAO,WAAW,EAAE,iBAAiB,QAmB/C;IAmCD;;;;OAMC;IAED;;OAEG;IACH;;;;QAsBC;IAED,4EAQC;IAoCD,yDAEC;IATD,kDAKC;;CAKD;;;;;;mBAxLa,MAAM;;;;;;;;;;;;;;yBAPK,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;;;;;;GAMG;AAEH;IAOC;;OAEG;IACH,oBAFW,OAAO,+BAA+B,EAAE,wBAAwB,EAe1E;IAcD,wCAEC;IAyDD;;;;OAMC;IAED;;OAEG;IACH;;;;QAsBC;IAED,4EAQC;IAoCD,yDAEC;IATD,kDAKC;;CAKD;;;;;;mBAxLa,MAAM;;;;;;;;;;;;;;yBAPK,WAAW"}
@@ -53,7 +53,7 @@ export class ManageListQuestion extends Question {
53
53
  }
54
54
 
55
55
  /**
56
- * @param {import('#question').QuestionViewModel} viewModel
56
+ * @param {import('#typedefs/question-types.d.ts').QuestionViewModel} viewModel
57
57
  */
58
58
  addCustomDataToViewModel(viewModel) {
59
59
  viewModel.question.addAnotherLink = this.#addAnotherLink;
@@ -14,6 +14,7 @@ export class MultiFieldInputQuestion extends Question {
14
14
  formatJoinString?: string;
15
15
  formatPrefix?: string;
16
16
  formatTextFunction?: (text: string) => string;
17
+ formatSummaryValue?: (context: import("#typedefs/question-props.d.ts").InputFieldSummaryContext) => string;
17
18
  attributes?: Record<string, string>;
18
19
  suffix?: import("#typedefs/question-props.d.ts").Affix;
19
20
  prefix?: import("#typedefs/question-props.d.ts").Affix;
@@ -24,27 +25,11 @@ export class MultiFieldInputQuestion extends Question {
24
25
  }[];
25
26
  /**
26
27
  * returns the formatted answers values to be used to build task list elements
27
- * @param {import('#journey').Journey} journey
28
28
  * @param {string} sectionSegment
29
- * @returns {Array<{
30
- * key: string;
31
- * value: string | Object;
32
- * action: {
33
- * href: string;
34
- * text: string;
35
- * visuallyHiddenText: string;
36
- * };
37
- * }>}
29
+ * @param {import('#journey').Journey} journey
30
+ * @returns {import('#typedefs/question-types.d.ts').SummaryRow[]}
38
31
  */
39
- formatAnswerForSummary(sectionSegment: string, journey: import("#journey").Journey): Array<{
40
- key: string;
41
- value: string | Object;
42
- action: {
43
- href: string;
44
- text: string;
45
- visuallyHiddenText: string;
46
- };
47
- }>;
32
+ formatAnswerForSummary(sectionSegment: string, journey: import("#journey").Journey): import("#typedefs/question-types.d.ts").SummaryRow[];
48
33
  #private;
49
34
  }
50
35
  export default MultiFieldInputQuestion;
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAIA;;GAEG;AACH;IACC;;OAEG;IACH,8CAFW,OAAO,+BAA+B,EAAE,6BAA6B,EAa/E;IAJC,kEAA8B;IAchC;;;;;;;;;;;;;;QAOC;IAsBD;;;;;;;;;;;;;OAaG;IACH,uCAXW,MAAM,WADN,OAAO,UAAU,EAAE,OAAO,GAExB,KAAK,CAAC;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;QACvB,MAAM,EAAE;YACN,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,MAAM,CAAC;YACb,kBAAkB,EAAE,MAAM,CAAC;SAC5B,CAAC;KACH,CAAC,CAkBJ;;CAyBD;;yBApHwB,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAIA;;GAEG;AACH;IACC;;OAEG;IACH,8CAFW,OAAO,+BAA+B,EAAE,6BAA6B,EAe/E;IAJC,kEAA8B;IAchC;;;;;;;;;;;;;;;QAOC;IA4CD;;;;;OAKG;IACH,uCAJW,MAAM,WACN,OAAO,UAAU,EAAE,OAAO,GACxB,OAAO,+BAA+B,EAAE,UAAU,EAAE,CAuChE;;CAsDD;;yBAtLwB,WAAW"}
@@ -1,6 +1,6 @@
1
1
  import { Question } from '#question';
2
2
  import escape from 'escape-html';
3
- import { nl2br } from '../../lib/utils.js';
3
+ import { capitalize, nl2br } from '../../lib/utils.js';
4
4
 
5
5
  /**
6
6
  * @class
@@ -11,6 +11,8 @@ export class MultiFieldInputQuestion extends Question {
11
11
  */
12
12
  constructor({ inputFields, ...parentParams }) {
13
13
  super({
14
+ // default but allow overrides
15
+ capitaliseAnswer: false,
14
16
  ...parentParams,
15
17
  viewFolder: 'multi-field-input'
16
18
  });
@@ -59,36 +61,100 @@ export class MultiFieldInputQuestion extends Question {
59
61
  return { answers };
60
62
  }
61
63
 
64
+ /**
65
+ * Formats an answer value for display in the summary.
66
+ * Handles the ManageListSection edge case where nl2br should not be applied.
67
+ *
68
+ * @param {unknown} answer - the raw answer value (composed from multiple fields)
69
+ * @returns {string} the formatted answer for display
70
+ */
71
+ formatAnswer(answer) {
72
+ // Only show notStartedText for null/undefined, not for empty string
73
+ if (answer === null || answer === undefined) return this.notStartedText;
74
+ if (answer === '') return '';
75
+
76
+ // Coerce to string and apply optional capitalisation
77
+ let formatted = String(answer);
78
+ if (this.capitaliseAnswer) {
79
+ formatted = capitalize(formatted);
80
+ }
81
+
82
+ // Do not convert new lines to breaks in ManageListSection, as this causes a "doubled up" <br>
83
+ return this.isInManageListSection ? escape(formatted) : nl2br(escape(formatted));
84
+ }
85
+
62
86
  /**
63
87
  * returns the formatted answers values to be used to build task list elements
64
- * @param {import('#journey').Journey} journey
65
88
  * @param {string} sectionSegment
66
- * @returns {Array<{
67
- * key: string;
68
- * value: string | Object;
69
- * action: {
70
- * href: string;
71
- * text: string;
72
- * visuallyHiddenText: string;
73
- * };
74
- * }>}
89
+ * @param {import('#journey').Journey} journey
90
+ * @returns {import('#typedefs/question-types.d.ts').SummaryRow[]}
75
91
  */
76
92
  formatAnswerForSummary(sectionSegment, journey) {
77
- const summaryDetails = this.inputFields.reduce((acc, field) => {
78
- const answer = this.#formatValue(journey.response.answers[field.fieldName], field.formatTextFunction);
79
- return answer ? acc + (field.formatPrefix || '') + answer + (field.formatJoinString || '\n') : acc;
93
+ // Handle unanswered case - delegate to parent for notStartedText
94
+ if (this.#allQuestionsUnanswered(journey)) {
95
+ return super.formatAnswerForSummary(sectionSegment, journey, null);
96
+ }
97
+
98
+ // Default join string depends on context
99
+ const defaultJoinString = this.isInManageListSection ? '\n' : '<br>';
100
+
101
+ let summaryDetails = this.inputFields.reduce((accumulator, field) => {
102
+ const rawAnswer = journey.response.answers[field.fieldName];
103
+ if (rawAnswer === undefined || rawAnswer === null || rawAnswer === '') return accumulator;
104
+
105
+ const formatted = this.#formatFieldForSummary(rawAnswer, field, journey, sectionSegment);
106
+ return accumulator + (field.formatPrefix || '') + formatted + (field.formatJoinString ?? defaultJoinString);
80
107
  }, '');
81
108
 
82
- const formattedAnswer = this.#allQuestionsUnanswered(journey) ? this.notStartedText : summaryDetails || '';
109
+ // Remove trailing join string
110
+ if (summaryDetails.endsWith(defaultJoinString)) {
111
+ summaryDetails = summaryDetails.slice(0, -defaultJoinString.length);
112
+ }
83
113
 
84
- return [
85
- {
86
- key: `${this.title}`,
87
- // Do not convert new lines to breaks in ManageListSection, as this causes a "doubled up" <br>
88
- value: this.isInManageListSection ? escape(formattedAnswer) : nl2br(escape(formattedAnswer)),
89
- action: this.getAction(sectionSegment, journey, summaryDetails)
90
- }
91
- ];
114
+ // Apply question-level formatSummaryValue if provided
115
+ const displayValue = this.formatSummaryValue
116
+ ? this.formatSummaryValue({
117
+ answer: summaryDetails,
118
+ formattedAnswer: summaryDetails,
119
+ question: this,
120
+ journey,
121
+ sectionSegment
122
+ })
123
+ : summaryDetails;
124
+
125
+ // Build result directly - escaping already handled per-field
126
+ const action = this.getAction(sectionSegment, journey, summaryDetails);
127
+ const key = this.title ?? this.question;
128
+ return [{ key, value: displayValue || '', action }];
129
+ }
130
+
131
+ /**
132
+ * Formats a single field value for summary display
133
+ * @param {unknown} answer - the raw answer value
134
+ * @param {import('#typedefs/question-props.d.ts').InputField} field - the input field config
135
+ * @param {import('#journey').Journey} journey - the journey instance
136
+ * @param {string} sectionSegment - the section segment
137
+ * @returns {string}
138
+ */
139
+ #formatFieldForSummary(answer, field, journey, sectionSegment) {
140
+ // Apply formatTextFunction (if provided) before escaping, same as answerForViewModel
141
+ const textFormattedAnswer = this.#formatValue(answer, field.formatTextFunction);
142
+ const formattedAnswer = escape(String(textFormattedAnswer));
143
+
144
+ // If formatSummaryValue is provided, use it with full context (output is not escaped)
145
+ if (typeof field.formatSummaryValue === 'function') {
146
+ return field.formatSummaryValue({
147
+ answer,
148
+ formattedAnswer,
149
+ question: this,
150
+ journey,
151
+ sectionSegment,
152
+ field
153
+ });
154
+ }
155
+
156
+ // Default: escaped, formatTextFunction-applied value
157
+ return formattedAnswer;
92
158
  }
93
159
 
94
160
  /**