@planning-inspectorate/dynamic-forms 3.17.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.
- package/README.md +70 -246
- package/docs/Email Validation.md +86 -0
- package/docs/Getting Started.md +299 -0
- package/package.json +1 -1
- package/src/components/checkbox/question.d.ts.map +1 -1
- package/src/components/checkbox/question.js +3 -2
- package/src/components/checkbox/question.test.js +8 -0
- package/src/components/email/question.d.ts +0 -9
- package/src/components/email/question.d.ts.map +1 -1
- package/src/components/email/question.js +2 -13
- package/src/components/email/question.test.js +19 -0
- package/src/components/multi-field-input/question.d.ts +4 -19
- package/src/components/multi-field-input/question.d.ts.map +1 -1
- package/src/components/multi-field-input/question.js +89 -23
- package/src/components/multi-field-input/question.test.js +441 -3
- package/src/components/number-entry/question.d.ts.map +1 -1
- package/src/components/number-entry/question.js +3 -2
- package/src/components/number-entry/question.test.js +17 -0
- package/src/components/unit-option-entry/question.d.ts.map +1 -1
- package/src/components/unit-option-entry/question.js +3 -1
- package/src/components/unit-option-entry/question.test.js +15 -0
- package/src/questions/question.d.ts +2 -2
- package/src/questions/question.js +2 -2
- package/test/snapshots/check-your-answers-with-answers.html +1 -1
- package/test/utils/question-test-utils.js +2 -1
- package/typedefs/question-props.d.ts +33 -0
- package/typedefs/question-types.d.ts +0 -23
|
@@ -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/package.json
CHANGED
|
@@ -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,
|
|
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', () => {
|
|
@@ -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,
|
|
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
|
-
* @
|
|
30
|
-
*
|
|
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):
|
|
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,
|
|
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
|
-
* @
|
|
67
|
-
*
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
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
|
-
|
|
109
|
+
// Remove trailing join string
|
|
110
|
+
if (summaryDetails.endsWith(defaultJoinString)) {
|
|
111
|
+
summaryDetails = summaryDetails.slice(0, -defaultJoinString.length);
|
|
112
|
+
}
|
|
83
113
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
/**
|