@planning-inspectorate/dynamic-forms 3.17.0 → 3.18.1

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 (37) hide show
  1. package/README.md +70 -246
  2. package/commitlint.config.js +3 -5
  3. package/docs/Email Validation.md +86 -0
  4. package/docs/Getting Started.md +299 -0
  5. package/eslint.config.js +3 -17
  6. package/package.json +4 -1
  7. package/prettier.config.js +7 -0
  8. package/src/components/checkbox/question.d.ts.map +1 -1
  9. package/src/components/checkbox/question.js +3 -2
  10. package/src/components/checkbox/question.test.js +8 -0
  11. package/src/components/conditional/text.njk +1 -0
  12. package/src/components/email/question.d.ts +0 -9
  13. package/src/components/email/question.d.ts.map +1 -1
  14. package/src/components/email/question.js +2 -13
  15. package/src/components/email/question.test.js +19 -0
  16. package/src/components/multi-field-input/question.d.ts +4 -19
  17. package/src/components/multi-field-input/question.d.ts.map +1 -1
  18. package/src/components/multi-field-input/question.js +89 -23
  19. package/src/components/multi-field-input/question.test.js +441 -3
  20. package/src/components/number-entry/question.d.ts.map +1 -1
  21. package/src/components/number-entry/question.js +3 -2
  22. package/src/components/number-entry/question.test.js +17 -0
  23. package/src/components/unit-option-entry/question.d.ts.map +1 -1
  24. package/src/components/unit-option-entry/question.js +3 -1
  25. package/src/components/unit-option-entry/question.test.js +15 -0
  26. package/src/questions/options-question.d.ts +19 -7
  27. package/src/questions/options-question.d.ts.map +1 -1
  28. package/src/questions/options-question.js +9 -7
  29. package/src/questions/question.d.ts +2 -2
  30. package/src/questions/question.js +2 -2
  31. package/test/snapshots/check-your-answers-with-answers.html +1 -1
  32. package/test/utils/question-test-utils.js +2 -1
  33. package/typedefs/journey-types.d.ts +1 -1
  34. package/typedefs/question-props.d.ts +33 -0
  35. package/typedefs/question-types.d.ts +0 -23
  36. package/typedefs/section-types.d.ts +4 -4
  37. package/.prettierrc +0 -12
@@ -0,0 +1,299 @@
1
+ # Getting started
2
+
3
+ Follow these steps to get started using Dynamic Forms. This guide assumes you have a [Node.js project](https://nodejs.org/) setup with [npm](https://www.npmjs.com/), [expressjs](https://expressjs.com/), [Nunjucks](https://mozilla.github.io/nunjucks/), and the [GOV.UK frontend](https://design-system.service.gov.uk/) already.
4
+
5
+ ## Step 1 - add the package dependency
6
+
7
+ Add dynamic-forms to your project with npm:
8
+
9
+ `npm install --save @planning-inspectorate/dynamic-forms`
10
+
11
+ If you are using the select component with [accessible-autocomplete](https://www.npmjs.com/package/accessible-autocomplete), also install this:
12
+
13
+ `npm install --save accessible-autocomplete`
14
+
15
+ ## Step 2 - configure the dynamic forms root for Nunjucks
16
+
17
+ So that the nunjucks rendered can resolve the dynamic forms components, add the root to the nunjucks configuration. For example:
18
+
19
+ ```typescript
20
+ import { createRequire } from 'node:module';
21
+ import path from 'node:path';
22
+ import nunjucks from 'nunjucks';
23
+
24
+ export function configureNunjucks() {
25
+ // get the require function, see https://nodejs.org/api/module.html#modulecreaterequirefilename
26
+ const require = createRequire(import.meta.url);
27
+ // path to dynamic forms folder
28
+ const dynamicFormsRoot = path.resolve(require.resolve('@planning-inspectorate/dynamic-forms'), '..');
29
+ // get the path to the govuk-frontend folder, in node_modules, using the node require resolution
30
+ const govukFrontendRoot = path.resolve(require.resolve('govuk-frontend'), '../..');
31
+ const appDir = path.join('some/path/to/the', 'app');
32
+
33
+ // configure nunjucks
34
+ return nunjucks.configure(
35
+ // ensure nunjucks templates can use govuk-frontend components, and templates we've defined
36
+ [dynamicFormsRoot, govukFrontendRoot, appDir],
37
+ {
38
+ // output with dangerous characters are escaped automatically
39
+ autoescape: true,
40
+ // automatically remove trailing newlines from a block/tag
41
+ trimBlocks: true,
42
+ // automatically remove leading whitespace from a block/tag
43
+ lstripBlocks: true
44
+ }
45
+ );
46
+ }
47
+ ```
48
+
49
+ ## Step 3 - add required assets
50
+
51
+ The select component can use the [accessible-autocomplete](https://www.npmjs.com/package/accessible-autocomplete) package. This requires configuring, the component assumes the following paths are available:
52
+
53
+ - `/assets/css/accessible-autocomplete.min.css`
54
+ - `/assets/js/accessible-autocomplete.min.js`
55
+
56
+ If you have a build step for static assets, copy the accessible-autocomplete assets into the static folder. For example:
57
+
58
+ ```typescript
59
+ /**
60
+ * Copy accessible-autocomplete assets into the static folder
61
+ */
62
+ async function copyAutocompleteAssets({ staticDir, root }: AutocompleteOptions): Promise<void> {
63
+ const js = path.join(root, 'accessible-autocomplete.min.js');
64
+ const css = path.join(root, 'accessible-autocomplete.min.css');
65
+
66
+ const staticJs = path.join(staticDir, 'assets', 'js', 'accessible-autocomplete.min.js');
67
+ const staticCss = path.join(staticDir, 'assets', 'css', 'accessible-autocomplete.min.css');
68
+
69
+ await copyFile(js, staticJs);
70
+ await copyFile(css, staticCss);
71
+ }
72
+ ```
73
+
74
+ ## Step 4 - add the question and check-your-answers templates
75
+
76
+ Dynamic forms requires two templates to work for rendering questions and rendering check-your-answers page. Different journeys can use different templates if required, but usually they can be shared.
77
+
78
+ ### Journey (or question) template
79
+
80
+ The journey template is used for question pages. It must include a block called `dynQuestionContent`, and a block called `head` (which the default gov.uk template has).
81
+ It should also include the rendering of the errorSummary component. The path to this template is configured via the `journeyTemplate` property on a Journey.
82
+
83
+ For example:
84
+
85
+ e.g.
86
+
87
+ ```nunjucks
88
+ {% extends "govuk/template.njk" %}
89
+
90
+ {% block beforeContent %}
91
+ {% if errorSummary %}
92
+ <div class="govuk-grid-row">
93
+ <div class="govuk-grid-column-two-thirds">
94
+ {{ govukErrorSummary({
95
+ titleText: "There is a problem",
96
+ errorList: errorSummary,
97
+ attributes: {"data-cy": "error-wrapper"}
98
+ }) }}
99
+ </div>
100
+ </div>
101
+ {% endif %}
102
+ {% endblock %}
103
+
104
+ {% block content %}
105
+ <div class="govuk-grid-row">
106
+ <div class="govuk-grid-column-full">
107
+ <h1 class="govuk-heading-xl">
108
+ {% if pageCaption %}
109
+ <span class="govuk-caption-xl">{{ pageCaption }}</span>
110
+ {% endif %}
111
+ {{ pageHeading }}
112
+ </h1>
113
+ </div>
114
+ </div>
115
+
116
+ {% block dynQuestionContent %}{% endblock %}
117
+ {% endblock %}
118
+ ```
119
+
120
+ ### Task list (or check your answers) template
121
+
122
+ The task list template is used for the task list or "check your answers" page. It must include a block called `dynTaskList`. The path to this template is configured via the `taskListTemplate` property on a Journey.
123
+
124
+ e.g.
125
+
126
+ ```nunjucks
127
+ {% extends "govuk/template.njk" %}
128
+
129
+ {% block content %}
130
+ <div class="govuk-grid-row">
131
+ <div class="govuk-grid-column-full">
132
+ <h1 class="govuk-heading-xl">
133
+ {% if pageCaption %}
134
+ <span class="govuk-caption-xl">{{ pageCaption }}</span>
135
+ {% endif %}
136
+ {{ pageHeading }}
137
+ </h1>
138
+ </div>
139
+ </div>
140
+
141
+ {% block dynTaskList %}{% endblock %}
142
+ {% endblock %}
143
+ ```
144
+
145
+ ## Step 5 - add questions, journeys, and routes
146
+
147
+ Now you are ready to implement any user journeys for your service. Add questions, journeys, and routes for each of them.
148
+
149
+ For example:
150
+
151
+ `questions.ts`
152
+
153
+ Use `satisfies Record<string, QuestionProps>` to get autocomplete for available question props specific for each component type.
154
+
155
+ ```typescript
156
+ import {
157
+ COMPONENT_TYPES,
158
+ createQuestions,
159
+ questionClasses,
160
+ RequiredValidator
161
+ } from '@planning-inspectorate/dynamic-forms';
162
+ import type { QuestionProps } from '@planning-inspectorate/dynamic-forms';
163
+
164
+ export const questionProps = {
165
+ destinationType: {
166
+ type: COMPONENT_TYPES.RADIO,
167
+ title: 'Destination type',
168
+ question: 'Which sort of place would you like to visit?',
169
+ fieldName: 'destinationType',
170
+ url: 'destination-type',
171
+ options: [
172
+ { value: 'beach', text: 'Beach' },
173
+ { value: 'mountains', text: 'Mountains' },
174
+ { value: 'city', text: 'City' }
175
+ ],
176
+ validators: [new RequiredValidator('Select a destination type')]
177
+ },
178
+ nights: {
179
+ type: COMPONENT_TYPES.NUMBER,
180
+ title: 'Number of Nights',
181
+ question: 'How many nights will you stay on holiday?',
182
+ fieldName: 'nights',
183
+ url: 'nights',
184
+ label: 'Nights'
185
+ }
186
+ } satisfies Record<string, QuestionProps>;
187
+
188
+ export function getQuestions() {
189
+ return createQuestions(questionProps, questionClasses, {}, {});
190
+ }
191
+
192
+ export type Questions = ReturnType<typeof getQuestions>;
193
+ ```
194
+
195
+ `journey.ts`
196
+
197
+ ```typescript
198
+ import {
199
+ BOOLEAN_OPTIONS,
200
+ Journey,
201
+ type JourneyResponse,
202
+ Section,
203
+ whenQuestionHasAnswer
204
+ } from '@planning-inspectorate/dynamic-forms';
205
+ import type { Request } from 'express';
206
+ import type { Questions } from './questions.ts';
207
+
208
+ export const JOURNEY_ID = 'holidays';
209
+
210
+ export function createJourney(req: Request, response: JourneyResponse, questions: Questions) {
211
+ return new Journey({
212
+ journeyId: JOURNEY_ID,
213
+ sections: [
214
+ new Section('Questions', 'questions').addQuestion(questions.destinationType).addQuestion(questions.nights)
215
+ ],
216
+ taskListUrl: 'check-your-answers',
217
+ journeyTemplate: 'views/layouts/question.njk',
218
+ taskListTemplate: 'views/layouts/check-your-answers.njk',
219
+ journeyTitle: 'Holidays',
220
+ returnToListing: false,
221
+ makeBaseUrl: () => req.baseUrl,
222
+ initialBackLink: '/',
223
+ response
224
+ });
225
+ }
226
+ ```
227
+
228
+ `index.ts`
229
+
230
+ ```typescript
231
+ import { Router as createRouter } from 'express';
232
+ import type { IRouter } from 'express';
233
+ import {
234
+ buildGetJourney,
235
+ buildGetJourneyResponseFromSession,
236
+ buildList,
237
+ buildSave,
238
+ question,
239
+ saveDataToSession,
240
+ validate,
241
+ validationErrorHandler
242
+ } from '@planning-inspectorate/dynamic-forms';
243
+ import { createJourney, JOURNEY_ID } from './journey.ts';
244
+ import { getQuestions } from './questions.ts';
245
+
246
+ export function createRoutes(): IRouter {
247
+ const router = createRouter({ mergeParams: true });
248
+
249
+ const questions = getQuestions();
250
+ const getJourneyResponse = buildGetJourneyResponseFromSession(JOURNEY_ID);
251
+ const getJourney = buildGetJourney((req, journeyResponse) => createJourney(req, journeyResponse, questions));
252
+
253
+ router.use(getJourneyResponse, getJourney);
254
+
255
+ // route for rendering each question
256
+ router.get('/:section/:question', question);
257
+ // route for saving each answer (to session)
258
+ router.post('/:section/:question', validate, validationErrorHandler, buildSave(saveDataToSession));
259
+
260
+ // route for check-your-answers page
261
+ router.get('/check-your-answers', buildList());
262
+ // route for saving all answers
263
+ router.post('/check-your-answers', (req, res) => {
264
+ const answers = res.locals?.journeyResponse?.answers as JourneyAnswers;
265
+ if (typeof answers !== 'object' || answers === null) {
266
+ throw new Error('answers should be an object');
267
+ }
268
+ console.log('answers', answers);
269
+ // TODO: save to a database or similar!
270
+ res.redirect('/');
271
+ });
272
+
273
+ return router;
274
+ }
275
+
276
+ interface JourneyAnswers {
277
+ destinationType: string;
278
+ nights: number;
279
+ }
280
+ ```
281
+
282
+ `router.ts`
283
+
284
+ ```typescript
285
+ import type { IRouter } from 'express';
286
+ import { Router as createRouter } from 'express';
287
+ import { viewHomepage } from './views/home/controller.ts';
288
+ import { createRoutes as createHolidayRoutes } from './views/holidays/index.ts';
289
+
290
+ export function buildRouter(): IRouter {
291
+ const router = createRouter();
292
+
293
+ router.route('/').get(viewHomepage);
294
+
295
+ router.use('/holidays', createHolidayRoutes());
296
+
297
+ return router;
298
+ }
299
+ ```
package/eslint.config.js CHANGED
@@ -1,18 +1,4 @@
1
- import js from '@eslint/js';
2
- import eslintConfigPrettier from 'eslint-config-prettier';
3
- import globals from 'globals';
1
+ import { eslintConfig } from '@planning-inspectorate/coding-standards';
2
+ import { defineConfig } from 'eslint/config';
4
3
 
5
- export default [
6
- js.configs.recommended,
7
- {
8
- ignores: ['dist/**', 'node_modules/**', '**/*.min*.js', '**/static/scripts/app.js'],
9
- languageOptions: {
10
- ecmaVersion: 2025,
11
- sourceType: 'module',
12
- globals: {
13
- ...globals.node
14
- }
15
- }
16
- },
17
- eslintConfigPrettier
18
- ];
4
+ export default defineConfig(eslintConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "3.17.0",
3
+ "version": "3.18.1",
4
4
  "description": "This package is for building GDS forms using a configuration-based approach.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -45,6 +45,7 @@
45
45
  "@commitlint/cli": "^21.0.0",
46
46
  "@commitlint/config-conventional": "^21.0.0",
47
47
  "@eslint/js": "^10.0.1",
48
+ "@planning-inspectorate/coding-standards": "^1.0.0",
48
49
  "@types/express": "^5.0.6",
49
50
  "body-parser": "^2.2.0",
50
51
  "conventional-changelog-conventionalcommits": "^10.2.0",
@@ -58,6 +59,8 @@
58
59
  "husky": "^9.1.7",
59
60
  "lint-staged": "^17.0.4",
60
61
  "prettier": "^3.6.2",
62
+ "prettier-plugin-organize-imports": "^4.3.0",
63
+ "prettier-plugin-prisma": "^5.0.0",
61
64
  "semantic-release": "^25.0.1"
62
65
  },
63
66
  "publishConfig": {
@@ -0,0 +1,7 @@
1
+ import { prettierConfig } from '@planning-inspectorate/coding-standards';
2
+
3
+ export default {
4
+ ...prettierConfig,
5
+ // disable 'prettier-plugin-organize-imports' for now
6
+ plugins: ['prettier-plugin-prisma']
7
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;IACC;;OAEG;IACH,iCAFW,OAAO,+BAA+B,EAAE,sBAAsB,EAUxE;CA4CD;;4BA5D2B,qCAAqC"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;IACC;;OAEG;IACH,iCAFW,OAAO,+BAA+B,EAAE,sBAAsB,EAWxE;CA4CD;;4BA7D2B,qCAAqC"}
@@ -9,9 +9,10 @@ export class CheckboxQuestion extends OptionsQuestion {
9
9
  */
10
10
  constructor({ ...parentParams }) {
11
11
  super({
12
+ // default but allow overrides
13
+ capitaliseAnswer: false,
12
14
  ...parentParams,
13
- viewFolder: 'checkbox',
14
- capitaliseAnswer: false
15
+ viewFolder: 'checkbox'
15
16
  });
16
17
 
17
18
  this.optionJoinString = defaultOptionJoinString;
@@ -53,6 +53,14 @@ 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
+ });
61
+
62
+ assert.strictEqual(question.capitaliseAnswer, true);
63
+ });
56
64
 
57
65
  describe('formatAnswer', () => {
58
66
  it('should delegate simple string answers to parent class', () => {
@@ -9,6 +9,7 @@
9
9
  value: value,
10
10
  classes: inputClasses,
11
11
  prefix: prefix,
12
+ suffix: suffix,
12
13
  errorMessage: errors[fieldName] and {
13
14
  text: errors[fieldName].msg
14
15
  }
@@ -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',
@@ -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
  /**