@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.
- package/README.md +124 -189
- package/docs/Email Validation.md +86 -0
- package/docs/Getting Started.md +299 -0
- package/package.json +1 -1
- package/src/components/address/question.d.ts +1 -9
- package/src/components/address/question.d.ts.map +1 -1
- package/src/components/address/question.js +6 -17
- package/src/components/checkbox/question.d.ts +1 -21
- package/src/components/checkbox/question.d.ts.map +1 -1
- package/src/components/checkbox/question.js +38 -50
- package/src/components/checkbox/question.test.js +177 -26
- package/src/components/date/question.d.ts +0 -8
- package/src/components/date/question.d.ts.map +1 -1
- package/src/components/date/question.js +6 -14
- package/src/components/date-period/question.d.ts +1 -5
- package/src/components/date-period/question.d.ts.map +1 -1
- package/src/components/date-period/question.js +1 -1
- package/src/components/date-time/question.d.ts +0 -8
- package/src/components/date-time/question.d.ts.map +1 -1
- package/src/components/date-time/question.js +4 -12
- 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/manage-list/answer-summary-list.njk +1 -1
- package/src/components/manage-list/question.d.ts +1 -5
- package/src/components/manage-list/question.d.ts.map +1 -1
- package/src/components/manage-list/question.js +1 -1
- 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 +0 -28
- package/src/components/number-entry/question.d.ts.map +1 -1
- package/src/components/number-entry/question.js +3 -20
- package/src/components/number-entry/question.test.js +17 -0
- package/src/components/radio/question.d.ts +1 -9
- package/src/components/radio/question.d.ts.map +1 -1
- package/src/components/radio/question.js +29 -46
- package/src/components/radio/question.test.js +139 -60
- package/src/components/select/question.d.ts +1 -15
- package/src/components/select/question.d.ts.map +1 -1
- package/src/components/select/question.js +4 -47
- package/src/components/single-line-input/question.d.ts +0 -4
- package/src/components/single-line-input/question.d.ts.map +1 -1
- package/src/components/single-line-input/question.js +1 -1
- package/src/components/text-entry/question.d.ts +0 -4
- package/src/components/text-entry/question.d.ts.map +1 -1
- package/src/components/text-entry/question.js +1 -1
- package/src/components/text-entry-redact/question.d.ts +2 -6
- package/src/components/text-entry-redact/question.d.ts.map +1 -1
- package/src/components/text-entry-redact/question.js +1 -1
- 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/components/utils/question-has-answer.js +1 -1
- package/src/components/utils/question-utils.d.ts +19 -1
- package/src/components/utils/question-utils.d.ts.map +1 -1
- package/src/components/utils/question-utils.js +31 -2
- package/src/components/utils/question-utils.test.js +89 -2
- package/src/questions/options-question.d.ts +4 -4
- package/src/questions/options-question.d.ts.map +1 -1
- package/src/questions/options-question.js +27 -2
- package/src/questions/options-question.test.js +106 -0
- package/src/questions/question.d.ts +16 -1
- package/src/questions/question.d.ts.map +1 -1
- package/src/questions/question.js +64 -9
- package/src/questions/question.test.js +86 -0
- package/test/journey.js +2 -0
- package/test/questions.js +50 -0
- package/test/snapshots/check-your-answers-not-started.html +22 -0
- package/test/snapshots/check-your-answers-with-answers.html +23 -1
- package/test/snapshots/favourite-activity-reason.html +54 -0
- package/test/snapshots/travel-requirements.html +48 -0
- package/test/utils/question-test-utils.js +38 -7
- package/typedefs/question-props.d.ts +33 -0
- package/typedefs/question-types.d.ts +4 -0
|
@@ -34,8 +34,8 @@ describe('getConditionalAnswer', () => {
|
|
|
34
34
|
|
|
35
35
|
it('returns conditional field value when it exists', () => {
|
|
36
36
|
const { answers, question } = setup();
|
|
37
|
-
const expectedResult = 'test';
|
|
38
|
-
assert.
|
|
37
|
+
const expectedResult = { yes: 'test' };
|
|
38
|
+
assert.deepStrictEqual(getConditionalAnswer(answers, question, 'yes'), expectedResult);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it('returns null when option chosen does not have conditional value', () => {
|
|
@@ -49,4 +49,91 @@ describe('getConditionalAnswer', () => {
|
|
|
49
49
|
delete question.options[0].conditional;
|
|
50
50
|
assert.strictEqual(getConditionalAnswer(answers, question, 'yes'), null);
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
it('returns null when answer is null', () => {
|
|
54
|
+
const { answers, question } = setup();
|
|
55
|
+
assert.strictEqual(getConditionalAnswer(answers, question, null), null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns null when answer is empty string', () => {
|
|
59
|
+
const { answers, question } = setup();
|
|
60
|
+
assert.strictEqual(getConditionalAnswer(answers, question, ''), null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns null when question has no options', () => {
|
|
64
|
+
const { answers, question } = setup();
|
|
65
|
+
delete question.options;
|
|
66
|
+
assert.strictEqual(getConditionalAnswer(answers, question, 'yes'), null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('multi-value answers (checkboxes)', () => {
|
|
70
|
+
const setupMultiValue = () => {
|
|
71
|
+
const answers = {
|
|
72
|
+
field: 'a,b,c',
|
|
73
|
+
field_detailsA: 'details for A',
|
|
74
|
+
field_detailsC: 'details for C'
|
|
75
|
+
};
|
|
76
|
+
const question = {
|
|
77
|
+
fieldName: 'field',
|
|
78
|
+
options: [
|
|
79
|
+
{
|
|
80
|
+
value: 'a',
|
|
81
|
+
conditional: {
|
|
82
|
+
fieldName: 'detailsA'
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
value: 'b'
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
value: 'c',
|
|
90
|
+
conditional: {
|
|
91
|
+
fieldName: 'detailsC'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
};
|
|
96
|
+
return { answers, question };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
it('returns object with conditional values for multiple selected options', () => {
|
|
100
|
+
const { answers, question } = setupMultiValue();
|
|
101
|
+
const result = getConditionalAnswer(answers, question, 'a,c');
|
|
102
|
+
assert.deepStrictEqual(result, {
|
|
103
|
+
a: 'details for A',
|
|
104
|
+
c: 'details for C'
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns object with only options that have conditional values', () => {
|
|
109
|
+
const { answers, question } = setupMultiValue();
|
|
110
|
+
const result = getConditionalAnswer(answers, question, 'a,b');
|
|
111
|
+
assert.deepStrictEqual(result, {
|
|
112
|
+
a: 'details for A'
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns null when no selected options have conditional values', () => {
|
|
117
|
+
const { answers, question } = setupMultiValue();
|
|
118
|
+
const result = getConditionalAnswer(answers, question, 'b');
|
|
119
|
+
assert.strictEqual(result, null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('returns null when conditional fields have no values', () => {
|
|
123
|
+
const { answers, question } = setupMultiValue();
|
|
124
|
+
delete answers.field_detailsA;
|
|
125
|
+
delete answers.field_detailsC;
|
|
126
|
+
const result = getConditionalAnswer(answers, question, 'a,c');
|
|
127
|
+
assert.strictEqual(result, null);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('handles whitespace in comma-separated values', () => {
|
|
131
|
+
const { answers, question } = setupMultiValue();
|
|
132
|
+
const result = getConditionalAnswer(answers, question, 'a , c');
|
|
133
|
+
assert.deepStrictEqual(result, {
|
|
134
|
+
a: 'details for A',
|
|
135
|
+
c: 'details for C'
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
52
139
|
});
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*}} Option
|
|
23
23
|
*/
|
|
24
24
|
/**
|
|
25
|
-
* @typedef {import('
|
|
25
|
+
* @typedef {import('#typedefs/question-types.d.ts').QuestionViewModel & { question: { options: Option[] } }} OptionsViewModel
|
|
26
26
|
*/
|
|
27
27
|
/**
|
|
28
28
|
* @typedef {import('#typedefs/question-types.d.ts').QuestionParameters & { options: Array<Option> }} OptionsQuestionParameters
|
|
@@ -42,9 +42,9 @@ export class OptionsQuestion extends Question {
|
|
|
42
42
|
* @param {Record<string, unknown>} [customViewData] additional data to send to view
|
|
43
43
|
* @param {Record<string, unknown>} [payload]
|
|
44
44
|
* @param {import('#typedefs/question-types.d.ts').PrepQuestionForRenderingOptions} options
|
|
45
|
-
* @returns {import('
|
|
45
|
+
* @returns {import('#typedefs/question-types.d.ts').QuestionViewModel} QuestionViewModel
|
|
46
46
|
*/
|
|
47
|
-
prepQuestionForRendering(section: import("#section").Section, journey: import("#journey").Journey, customViewData?: Record<string, unknown>, payload?: Record<string, unknown>, options: import("#typedefs/question-types.d.ts").PrepQuestionForRenderingOptions): import("
|
|
47
|
+
prepQuestionForRendering(section: import("#section").Section, journey: import("#journey").Journey, customViewData?: Record<string, unknown>, payload?: Record<string, unknown>, options: import("#typedefs/question-types.d.ts").PrepQuestionForRenderingOptions): import("#typedefs/question-types.d.ts").QuestionViewModel;
|
|
48
48
|
}
|
|
49
49
|
export default OptionsQuestion;
|
|
50
50
|
export type Option = {
|
|
@@ -68,7 +68,7 @@ export type Option = {
|
|
|
68
68
|
html: string;
|
|
69
69
|
};
|
|
70
70
|
};
|
|
71
|
-
export type OptionsViewModel = import("
|
|
71
|
+
export type OptionsViewModel = import("#typedefs/question-types.d.ts").QuestionViewModel & {
|
|
72
72
|
question: {
|
|
73
73
|
options: Option[];
|
|
74
74
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"options-question.d.ts","sourceRoot":"","sources":["options-question.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"options-question.d.ts","sourceRoot":"","sources":["options-question.js"],"names":[],"mappings":"AASA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;GAEG;AACH;;GAEG;AAEH;IAIC;;OAEG;IACH,oBAFW,yBAAyB,EAenC;IAnBD,4BAA4B;IAC5B,SADW,KAAK,CAAC,MAAM,CAAC,CAChB;IAiBP,yBAA+C;IAGhD;;;;;;;;OAQG;IACH,kCAPW,OAAO,UAAU,EAAE,OAAO,WAC1B,OAAO,UAAU,EAAE,OAAO,mBAC1B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,YACvB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WACvB,OAAO,+BAA+B,EAAE,+BAA+B,GACrE,OAAO,+BAA+B,EAAE,iBAAiB,CAkDrE;CA2DD;;qBAxKY;IACR,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,WAAW,CAAC,EAAE;QACZ,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,SAAS,EAAE,MAAM,CAAC;QACd,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,IAAI,CAAC,EAAE,MAAM,CAAC;QAClB,KAAK,CAAC,EAAE,OAAO,CAAC;QACZ,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAA;KAClB,CAAC;IACA,eAAe,CAAC,EAAE;QAChB,IAAI,EAAE,MAAM,CAAC;KAChB,CAAA;CACH;+BAIU,OAAO,+BAA+B,EAAE,iBAAiB,GAAG;IAAE,QAAQ,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;CAAE;wCAG/F,OAAO,+BAA+B,EAAE,kBAAkB,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAA;CAAE;yBApC3E,eAAe"}
|
|
@@ -3,6 +3,7 @@ import { Question } from './question.js';
|
|
|
3
3
|
import ValidOptionValidator from '../validator/valid-option-validator.js';
|
|
4
4
|
import { getConditionalFieldName } from '../components/utils/question-utils.js';
|
|
5
5
|
import { toArray } from '#src/lib/utils.js';
|
|
6
|
+
import escape from 'escape-html';
|
|
6
7
|
|
|
7
8
|
const defaultOptionJoinString = ',';
|
|
8
9
|
|
|
@@ -31,7 +32,7 @@ const defaultOptionJoinString = ',';
|
|
|
31
32
|
*/
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
|
-
* @typedef {import('
|
|
35
|
+
* @typedef {import('#typedefs/question-types.d.ts').QuestionViewModel & { question: { options: Option[] } }} OptionsViewModel
|
|
35
36
|
*/
|
|
36
37
|
/**
|
|
37
38
|
* @typedef {import('#typedefs/question-types.d.ts').QuestionParameters & { options: Array<Option> }} OptionsQuestionParameters
|
|
@@ -66,7 +67,7 @@ export class OptionsQuestion extends Question {
|
|
|
66
67
|
* @param {Record<string, unknown>} [customViewData] additional data to send to view
|
|
67
68
|
* @param {Record<string, unknown>} [payload]
|
|
68
69
|
* @param {import('#typedefs/question-types.d.ts').PrepQuestionForRenderingOptions} options
|
|
69
|
-
* @returns {import('
|
|
70
|
+
* @returns {import('#typedefs/question-types.d.ts').QuestionViewModel} QuestionViewModel
|
|
70
71
|
*/
|
|
71
72
|
prepQuestionForRendering(section, journey, customViewData, payload, options) {
|
|
72
73
|
const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload, options);
|
|
@@ -118,6 +119,29 @@ export class OptionsQuestion extends Question {
|
|
|
118
119
|
return viewModel;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Formats an answer value for display in the summary.
|
|
124
|
+
* Looks up the option text for the given value(s).
|
|
125
|
+
*
|
|
126
|
+
* @param {unknown} answer - the raw answer value
|
|
127
|
+
* @returns {string} the formatted answer for display
|
|
128
|
+
*/
|
|
129
|
+
formatAnswer(answer) {
|
|
130
|
+
if (answer === null || answer === undefined || answer === '') {
|
|
131
|
+
return this.notStartedText;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const answerValues = String(answer)
|
|
135
|
+
.split(this.optionJoinString)
|
|
136
|
+
.map((value) => value.trim());
|
|
137
|
+
const texts = answerValues.map((value) => {
|
|
138
|
+
const option = this.options.find((opt) => opt.value === value);
|
|
139
|
+
return escape(option ? option.text : value);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return texts.join('<br>');
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
/**
|
|
122
146
|
* Get the data to save from the request, returns an object of answers
|
|
123
147
|
* @param {import('express').Request} req
|
|
@@ -137,6 +161,7 @@ export class OptionsQuestion extends Question {
|
|
|
137
161
|
if (selectedOptions.length !== fieldValues.length)
|
|
138
162
|
throw new Error(`User submitted option(s) did not correlate with valid answers to ${this.fieldName} question`);
|
|
139
163
|
|
|
164
|
+
// TODO DF-51 treat as an array
|
|
140
165
|
answers[this.fieldName] = fieldValues.join(this.optionJoinString);
|
|
141
166
|
|
|
142
167
|
this.options.forEach((option) => {
|
|
@@ -303,4 +303,110 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
303
303
|
);
|
|
304
304
|
});
|
|
305
305
|
});
|
|
306
|
+
|
|
307
|
+
describe('formatAnswer', () => {
|
|
308
|
+
it('should return not started text for null answer', () => {
|
|
309
|
+
const question = getTestQuestion({
|
|
310
|
+
options: [{ text: 'Option A', value: 'a' }]
|
|
311
|
+
});
|
|
312
|
+
const result = question.formatAnswer(null);
|
|
313
|
+
assert.strictEqual(result, 'Not started');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should return not started text for undefined answer', () => {
|
|
317
|
+
const question = getTestQuestion({
|
|
318
|
+
options: [{ text: 'Option A', value: 'a' }]
|
|
319
|
+
});
|
|
320
|
+
const result = question.formatAnswer(undefined);
|
|
321
|
+
assert.strictEqual(result, 'Not started');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should return not started text for empty string answer', () => {
|
|
325
|
+
const question = getTestQuestion({
|
|
326
|
+
options: [{ text: 'Option A', value: 'a' }]
|
|
327
|
+
});
|
|
328
|
+
const result = question.formatAnswer('');
|
|
329
|
+
assert.strictEqual(result, 'Not started');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should format a single value using option text', () => {
|
|
333
|
+
const question = getTestQuestion({
|
|
334
|
+
options: [
|
|
335
|
+
{ text: 'Option A', value: 'a' },
|
|
336
|
+
{ text: 'Option B', value: 'b' }
|
|
337
|
+
]
|
|
338
|
+
});
|
|
339
|
+
const result = question.formatAnswer('a');
|
|
340
|
+
assert.strictEqual(result, 'Option A');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should format multiple values joined with line breaks', () => {
|
|
344
|
+
const question = getTestQuestion({
|
|
345
|
+
options: [
|
|
346
|
+
{ text: 'Option A', value: 'a' },
|
|
347
|
+
{ text: 'Option B', value: 'b' },
|
|
348
|
+
{ text: 'Option C', value: 'c' }
|
|
349
|
+
]
|
|
350
|
+
});
|
|
351
|
+
const result = question.formatAnswer('a,b');
|
|
352
|
+
assert.strictEqual(result, 'Option A<br>Option B');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should fall back to value when option not found', () => {
|
|
356
|
+
const question = getTestQuestion({
|
|
357
|
+
options: [{ text: 'Option A', value: 'a' }]
|
|
358
|
+
});
|
|
359
|
+
const result = question.formatAnswer('unknown');
|
|
360
|
+
assert.strictEqual(result, 'unknown');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should handle mixed known and unknown values', () => {
|
|
364
|
+
const question = getTestQuestion({
|
|
365
|
+
options: [
|
|
366
|
+
{ text: 'Option A', value: 'a' },
|
|
367
|
+
{ text: 'Option B', value: 'b' }
|
|
368
|
+
]
|
|
369
|
+
});
|
|
370
|
+
const result = question.formatAnswer('a,unknown,b');
|
|
371
|
+
assert.strictEqual(result, 'Option A<br>unknown<br>Option B');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should trim whitespace from values', () => {
|
|
375
|
+
const question = getTestQuestion({
|
|
376
|
+
options: [
|
|
377
|
+
{ text: 'Option A', value: 'a' },
|
|
378
|
+
{ text: 'Option B', value: 'b' }
|
|
379
|
+
]
|
|
380
|
+
});
|
|
381
|
+
const result = question.formatAnswer('a , b');
|
|
382
|
+
assert.strictEqual(result, 'Option A<br>Option B');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should escape HTML in option text', () => {
|
|
386
|
+
const question = getTestQuestion({
|
|
387
|
+
options: [{ text: '<script>alert("xss")</script>', value: 'a' }]
|
|
388
|
+
});
|
|
389
|
+
const result = question.formatAnswer('a');
|
|
390
|
+
assert.strictEqual(result, '<script>alert("xss")</script>');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should escape HTML in fallback value when option not found', () => {
|
|
394
|
+
const question = getTestQuestion({
|
|
395
|
+
options: [{ text: 'Option A', value: 'a' }]
|
|
396
|
+
});
|
|
397
|
+
const result = question.formatAnswer('<img src=x onerror=alert(1)>');
|
|
398
|
+
assert.strictEqual(result, '<img src=x onerror=alert(1)>');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should escape HTML in multiple option texts', () => {
|
|
402
|
+
const question = getTestQuestion({
|
|
403
|
+
options: [
|
|
404
|
+
{ text: '<b>Bold</b>', value: 'a' },
|
|
405
|
+
{ text: '<i>Italic</i>', value: 'b' }
|
|
406
|
+
]
|
|
407
|
+
});
|
|
408
|
+
const result = question.formatAnswer('a,b');
|
|
409
|
+
assert.strictEqual(result, '<b>Bold</b><br><i>Italic</i>');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
306
412
|
});
|
|
@@ -7,7 +7,7 @@ export class Question {
|
|
|
7
7
|
* @param {import('#typedefs/question-types.d.ts').QuestionParameters} params
|
|
8
8
|
* @param {Record<string, Function>} [methodOverrides]
|
|
9
9
|
*/
|
|
10
|
-
constructor({ title, question, viewFolder, fieldName, url, pageTitle, description, validators, html, hint, interfaceType, shouldDisplay, autocomplete, editable, actionLink, viewData }: import("#typedefs/question-types.d.ts").QuestionParameters, methodOverrides?: Record<string, Function>);
|
|
10
|
+
constructor({ title, question, viewFolder, fieldName, url, pageTitle, description, validators, html, hint, interfaceType, shouldDisplay, autocomplete, editable, actionLink, viewData, formatSummaryValue, capitaliseAnswer }: import("#typedefs/question-types.d.ts").QuestionParameters, methodOverrides?: Record<string, Function>);
|
|
11
11
|
/** @type {string} - html page title, defaults to question if not provided */
|
|
12
12
|
pageTitle: string;
|
|
13
13
|
/** @type {string} - title used in the summary list */
|
|
@@ -38,6 +38,8 @@ export class Question {
|
|
|
38
38
|
actionLink: import("#typedefs/question-types.d.ts").ActionLink | undefined;
|
|
39
39
|
/** @type {string} 'not started' text to display (if a question has no answer) */
|
|
40
40
|
notStartedText: string;
|
|
41
|
+
/** @type {boolean} whether to capitalize the first letter of the answer in summary */
|
|
42
|
+
capitaliseAnswer: boolean;
|
|
41
43
|
/** @type {string} button text to display */
|
|
42
44
|
continueButtonText: string;
|
|
43
45
|
/** @type {string} text to display for 'change' link */
|
|
@@ -46,6 +48,8 @@ export class Question {
|
|
|
46
48
|
answerActionText: string;
|
|
47
49
|
/** @type {string} text to display for 'add' link */
|
|
48
50
|
addActionText: string;
|
|
51
|
+
/** @type {import('#typedefs/question-props.d.ts').SummaryValueFormatter|undefined} custom function to format the summary display value */
|
|
52
|
+
formatSummaryValue: import("#typedefs/question-props.d.ts").SummaryValueFormatter | undefined;
|
|
49
53
|
/**
|
|
50
54
|
* @param {import('../journey/journey-response.js').JourneyResponse} [response]
|
|
51
55
|
* @returns {boolean}
|
|
@@ -199,6 +203,7 @@ export class Question {
|
|
|
199
203
|
* @param {string} sectionSegment
|
|
200
204
|
* @param {import('../journey/journey.js').Journey} journey
|
|
201
205
|
* @param {unknown} answer
|
|
206
|
+
* @param {boolean} [capitals] - deprecated: use capitaliseAnswer property instead
|
|
202
207
|
* @returns {import('#typedefs/question-types.d.ts').SummaryRow[]}
|
|
203
208
|
*/
|
|
204
209
|
formatAnswerForSummary(sectionSegment: string, journey: import("../journey/journey.js").Journey, answer: unknown, capitals?: boolean): import("#typedefs/question-types.d.ts").SummaryRow[];
|
|
@@ -215,6 +220,15 @@ export class Question {
|
|
|
215
220
|
* @returns {unknown}
|
|
216
221
|
*/
|
|
217
222
|
format(answer: unknown): unknown;
|
|
223
|
+
/**
|
|
224
|
+
* Formats an answer value for display in the summary.
|
|
225
|
+
* Subclasses can override this to provide custom formatting without
|
|
226
|
+
* needing to override the full formatAnswerForSummary method.
|
|
227
|
+
*
|
|
228
|
+
* @param {unknown} answer - the raw answer value
|
|
229
|
+
* @returns {string} the formatted answer for display
|
|
230
|
+
*/
|
|
231
|
+
formatAnswer(answer: unknown): string;
|
|
218
232
|
/**
|
|
219
233
|
* @returns {boolean}
|
|
220
234
|
*/
|
|
@@ -230,5 +244,6 @@ export class Question {
|
|
|
230
244
|
* @returns {boolean}
|
|
231
245
|
*/
|
|
232
246
|
isAnswered(journeyResponse: import("../journey/journey-response.js").JourneyResponse, fieldName?: string): boolean;
|
|
247
|
+
#private;
|
|
233
248
|
}
|
|
234
249
|
//# sourceMappingURL=question.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;;;GAGG;AACH;
|
|
1
|
+
{"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"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"}
|
|
@@ -39,6 +39,8 @@ export class Question {
|
|
|
39
39
|
|
|
40
40
|
/** @type {string} 'not started' text to display (if a question has no answer) */
|
|
41
41
|
notStartedText = 'Not started';
|
|
42
|
+
/** @type {boolean} whether to capitalize the first letter of the answer in summary */
|
|
43
|
+
capitaliseAnswer = true;
|
|
42
44
|
/** @type {string} button text to display */
|
|
43
45
|
continueButtonText = 'Continue';
|
|
44
46
|
/** @type {string} text to display for 'change' link */
|
|
@@ -47,6 +49,8 @@ export class Question {
|
|
|
47
49
|
answerActionText = 'Answer';
|
|
48
50
|
/** @type {string} text to display for 'add' link */
|
|
49
51
|
addActionText = 'Add';
|
|
52
|
+
/** @type {import('#typedefs/question-props.d.ts').SummaryValueFormatter|undefined} custom function to format the summary display value */
|
|
53
|
+
formatSummaryValue;
|
|
50
54
|
|
|
51
55
|
/**
|
|
52
56
|
* @param {import('../journey/journey-response.js').JourneyResponse} [response]
|
|
@@ -89,7 +93,9 @@ export class Question {
|
|
|
89
93
|
autocomplete,
|
|
90
94
|
editable = true,
|
|
91
95
|
actionLink,
|
|
92
|
-
viewData = {}
|
|
96
|
+
viewData = {},
|
|
97
|
+
formatSummaryValue,
|
|
98
|
+
capitaliseAnswer = true
|
|
93
99
|
},
|
|
94
100
|
methodOverrides
|
|
95
101
|
) {
|
|
@@ -111,6 +117,8 @@ export class Question {
|
|
|
111
117
|
this.editable = editable;
|
|
112
118
|
this.actionLink = actionLink;
|
|
113
119
|
this.viewData = viewData;
|
|
120
|
+
this.formatSummaryValue = formatSummaryValue;
|
|
121
|
+
this.capitaliseAnswer = capitaliseAnswer;
|
|
114
122
|
|
|
115
123
|
if (shouldDisplay) {
|
|
116
124
|
this.shouldDisplay = shouldDisplay;
|
|
@@ -153,6 +161,18 @@ export class Question {
|
|
|
153
161
|
this._isInManageListSection = value;
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Applies custom summary formatting if a formatSummaryValue function is provided
|
|
166
|
+
* @param {import('#typedefs/question-props.d.ts').SummaryFormatterContext} context - the context for formatting
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
#applyCustomSummaryFormatter(context) {
|
|
170
|
+
if (this.formatSummaryValue) {
|
|
171
|
+
return this.formatSummaryValue(context);
|
|
172
|
+
}
|
|
173
|
+
return context.formattedAnswer;
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
/**
|
|
157
177
|
* Gets the body field names used by this question in form submissions.
|
|
158
178
|
* Returns the field names that should be present in req.body when this question is submitted.
|
|
@@ -376,19 +396,38 @@ export class Question {
|
|
|
376
396
|
* @param {string} sectionSegment
|
|
377
397
|
* @param {import('../journey/journey.js').Journey} journey
|
|
378
398
|
* @param {unknown} answer
|
|
399
|
+
* @param {boolean} [capitals] - deprecated: use capitaliseAnswer property instead
|
|
379
400
|
* @returns {import('#typedefs/question-types.d.ts').SummaryRow[]}
|
|
380
401
|
*/
|
|
381
|
-
formatAnswerForSummary(sectionSegment, journey, answer, capitals
|
|
382
|
-
|
|
402
|
+
formatAnswerForSummary(sectionSegment, journey, answer, capitals) {
|
|
403
|
+
let formattedAnswer = this.formatAnswer(answer);
|
|
404
|
+
|
|
405
|
+
// Handle deprecated capitals parameter
|
|
406
|
+
// TODO DF-49 fully remove capitals parameter
|
|
407
|
+
if (capitals !== undefined && capitals !== this.capitaliseAnswer) {
|
|
408
|
+
console.warn(
|
|
409
|
+
`[Deprecation Warning] The 'capitals' parameter in formatAnswerForSummary is deprecated. ` +
|
|
410
|
+
`Set 'capitaliseAnswer: ${capitals}' in the question constructor instead.`
|
|
411
|
+
);
|
|
412
|
+
// Temporarily override for backwards compatibility
|
|
413
|
+
const originalCapitalize = this.capitaliseAnswer;
|
|
414
|
+
this.capitaliseAnswer = capitals;
|
|
415
|
+
formattedAnswer = this.formatAnswer(answer);
|
|
416
|
+
this.capitaliseAnswer = originalCapitalize;
|
|
417
|
+
}
|
|
418
|
+
|
|
383
419
|
const action = this.getAction(sectionSegment, journey, answer);
|
|
384
420
|
const key = this.title ?? this.question;
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
421
|
+
|
|
422
|
+
const displayValue = this.#applyCustomSummaryFormatter({
|
|
423
|
+
answer,
|
|
424
|
+
formattedAnswer,
|
|
425
|
+
question: this,
|
|
426
|
+
journey,
|
|
427
|
+
sectionSegment
|
|
390
428
|
});
|
|
391
|
-
|
|
429
|
+
|
|
430
|
+
return [{ key, value: displayValue, action }];
|
|
392
431
|
}
|
|
393
432
|
|
|
394
433
|
/**
|
|
@@ -427,6 +466,22 @@ export class Question {
|
|
|
427
466
|
return answer;
|
|
428
467
|
}
|
|
429
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Formats an answer value for display in the summary.
|
|
471
|
+
* Subclasses can override this to provide custom formatting without
|
|
472
|
+
* needing to override the full formatAnswerForSummary method.
|
|
473
|
+
*
|
|
474
|
+
* @param {unknown} answer - the raw answer value
|
|
475
|
+
* @returns {string} the formatted answer for display
|
|
476
|
+
*/
|
|
477
|
+
formatAnswer(answer) {
|
|
478
|
+
let formatted = answer ?? this.notStartedText;
|
|
479
|
+
if (this.capitaliseAnswer) {
|
|
480
|
+
formatted = capitalize(formatted);
|
|
481
|
+
}
|
|
482
|
+
return nl2br(escape(formatted));
|
|
483
|
+
}
|
|
484
|
+
|
|
430
485
|
/**
|
|
431
486
|
* @returns {boolean}
|
|
432
487
|
*/
|
|
@@ -457,4 +457,90 @@ describe('./src/dynamic-forms/question.js', () => {
|
|
|
457
457
|
assert.strictEqual(q.isRequired(), true);
|
|
458
458
|
});
|
|
459
459
|
});
|
|
460
|
+
|
|
461
|
+
describe('formatSummaryValue', () => {
|
|
462
|
+
it('should default to undefined', () => {
|
|
463
|
+
const question = getTestQuestion();
|
|
464
|
+
assert.strictEqual(question.formatSummaryValue, undefined);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should accept formatSummaryValue in constructor', () => {
|
|
468
|
+
const formatter = ({ formattedAnswer }) => `<em>${formattedAnswer}</em>`;
|
|
469
|
+
const question = new Question({
|
|
470
|
+
title: TITLE,
|
|
471
|
+
question: QUESTION_STRING,
|
|
472
|
+
viewFolder: TYPE,
|
|
473
|
+
fieldName: FIELDNAME,
|
|
474
|
+
formatSummaryValue: formatter
|
|
475
|
+
});
|
|
476
|
+
assert.strictEqual(question.formatSummaryValue, formatter);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should apply custom formatting in formatAnswerForSummary when provided', () => {
|
|
480
|
+
const journey = {
|
|
481
|
+
getCurrentQuestionUrl: () => 'current'
|
|
482
|
+
};
|
|
483
|
+
const question = new Question({
|
|
484
|
+
title: TITLE,
|
|
485
|
+
question: QUESTION_STRING,
|
|
486
|
+
viewFolder: TYPE,
|
|
487
|
+
fieldName: FIELDNAME,
|
|
488
|
+
formatSummaryValue: ({ formattedAnswer }) => `<strong>${formattedAnswer}</strong>`
|
|
489
|
+
});
|
|
490
|
+
const result = question.formatAnswerForSummary('segment', journey, 'test answer');
|
|
491
|
+
assert.strictEqual(result[0].value, '<strong>Test answer</strong>');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should use default formatting when formatSummaryValue is not provided', () => {
|
|
495
|
+
const journey = {
|
|
496
|
+
getCurrentQuestionUrl: () => 'current'
|
|
497
|
+
};
|
|
498
|
+
const question = getTestQuestion();
|
|
499
|
+
const result = question.formatAnswerForSummary('segment', journey, 'test answer');
|
|
500
|
+
assert.strictEqual(result[0].value, 'Test answer');
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('should allow access to journey in formatter', () => {
|
|
504
|
+
const journey = {
|
|
505
|
+
getCurrentQuestionUrl: () => 'current',
|
|
506
|
+
response: { answers: { otherField: 'other value' } }
|
|
507
|
+
};
|
|
508
|
+
const question = new Question({
|
|
509
|
+
title: TITLE,
|
|
510
|
+
question: QUESTION_STRING,
|
|
511
|
+
viewFolder: TYPE,
|
|
512
|
+
fieldName: FIELDNAME,
|
|
513
|
+
formatSummaryValue: ({ formattedAnswer, journey }) => {
|
|
514
|
+
const extra = journey.response.answers.otherField;
|
|
515
|
+
return `${formattedAnswer} (${extra})`;
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
const result = question.formatAnswerForSummary('segment', journey, 'test');
|
|
519
|
+
assert.strictEqual(result[0].value, 'Test (other value)');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('should pass all context properties to formatSummaryValue', () => {
|
|
523
|
+
const formatSummaryValue = mock.fn((ctx) => ctx.formattedAnswer);
|
|
524
|
+
const journey = {
|
|
525
|
+
id: 'test-journey',
|
|
526
|
+
getCurrentQuestionUrl: () => 'current'
|
|
527
|
+
};
|
|
528
|
+
const question = new Question({
|
|
529
|
+
title: TITLE,
|
|
530
|
+
question: QUESTION_STRING,
|
|
531
|
+
viewFolder: TYPE,
|
|
532
|
+
fieldName: FIELDNAME,
|
|
533
|
+
formatSummaryValue
|
|
534
|
+
});
|
|
535
|
+
question.formatAnswerForSummary('my-segment', journey, 'my-answer');
|
|
536
|
+
|
|
537
|
+
assert.strictEqual(formatSummaryValue.mock.callCount(), 1);
|
|
538
|
+
const receivedContext = formatSummaryValue.mock.calls[0].arguments[0];
|
|
539
|
+
assert.strictEqual(receivedContext.answer, 'my-answer');
|
|
540
|
+
assert.strictEqual(receivedContext.formattedAnswer, 'My-answer');
|
|
541
|
+
assert.strictEqual(receivedContext.question, question);
|
|
542
|
+
assert.strictEqual(receivedContext.journey, journey);
|
|
543
|
+
assert.strictEqual(receivedContext.sectionSegment, 'my-segment');
|
|
544
|
+
});
|
|
545
|
+
});
|
|
460
546
|
});
|
package/test/journey.js
CHANGED
|
@@ -30,6 +30,8 @@ export function createJourney(questions, response) {
|
|
|
30
30
|
.addQuestion(questions.hotelAddress)
|
|
31
31
|
.addQuestion(questions.luggageWeight)
|
|
32
32
|
.addQuestion(questions.contactEmail)
|
|
33
|
+
.addQuestion(questions.favouriteActivityReason)
|
|
34
|
+
.addQuestion(questions.travelRequirements)
|
|
33
35
|
.addQuestion(
|
|
34
36
|
questions.travelCompanions,
|
|
35
37
|
new ManageListSection().addQuestion(questions.travelCompanionName).addQuestion(questions.travelCompanionEmail)
|