@planning-inspectorate/dynamic-forms 3.16.0 → 3.17.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 (62) hide show
  1. package/README.md +112 -1
  2. package/package.json +1 -1
  3. package/src/components/address/question.d.ts +1 -9
  4. package/src/components/address/question.d.ts.map +1 -1
  5. package/src/components/address/question.js +6 -17
  6. package/src/components/checkbox/question.d.ts +1 -21
  7. package/src/components/checkbox/question.d.ts.map +1 -1
  8. package/src/components/checkbox/question.js +36 -49
  9. package/src/components/checkbox/question.test.js +170 -27
  10. package/src/components/date/question.d.ts +0 -8
  11. package/src/components/date/question.d.ts.map +1 -1
  12. package/src/components/date/question.js +6 -14
  13. package/src/components/date-period/question.d.ts +1 -5
  14. package/src/components/date-period/question.d.ts.map +1 -1
  15. package/src/components/date-period/question.js +1 -1
  16. package/src/components/date-time/question.d.ts +0 -8
  17. package/src/components/date-time/question.d.ts.map +1 -1
  18. package/src/components/date-time/question.js +4 -12
  19. package/src/components/manage-list/answer-summary-list.njk +1 -1
  20. package/src/components/manage-list/question.d.ts +1 -5
  21. package/src/components/manage-list/question.d.ts.map +1 -1
  22. package/src/components/manage-list/question.js +1 -1
  23. package/src/components/number-entry/question.d.ts +0 -28
  24. package/src/components/number-entry/question.d.ts.map +1 -1
  25. package/src/components/number-entry/question.js +3 -21
  26. package/src/components/radio/question.d.ts +1 -9
  27. package/src/components/radio/question.d.ts.map +1 -1
  28. package/src/components/radio/question.js +29 -46
  29. package/src/components/radio/question.test.js +139 -60
  30. package/src/components/select/question.d.ts +1 -15
  31. package/src/components/select/question.d.ts.map +1 -1
  32. package/src/components/select/question.js +4 -47
  33. package/src/components/single-line-input/question.d.ts +0 -4
  34. package/src/components/single-line-input/question.d.ts.map +1 -1
  35. package/src/components/single-line-input/question.js +1 -1
  36. package/src/components/text-entry/question.d.ts +0 -4
  37. package/src/components/text-entry/question.d.ts.map +1 -1
  38. package/src/components/text-entry/question.js +1 -1
  39. package/src/components/text-entry-redact/question.d.ts +2 -6
  40. package/src/components/text-entry-redact/question.d.ts.map +1 -1
  41. package/src/components/text-entry-redact/question.js +1 -1
  42. package/src/components/utils/question-has-answer.js +1 -1
  43. package/src/components/utils/question-utils.d.ts +19 -1
  44. package/src/components/utils/question-utils.d.ts.map +1 -1
  45. package/src/components/utils/question-utils.js +31 -2
  46. package/src/components/utils/question-utils.test.js +89 -2
  47. package/src/questions/options-question.d.ts +4 -4
  48. package/src/questions/options-question.d.ts.map +1 -1
  49. package/src/questions/options-question.js +27 -2
  50. package/src/questions/options-question.test.js +106 -0
  51. package/src/questions/question.d.ts +16 -1
  52. package/src/questions/question.d.ts.map +1 -1
  53. package/src/questions/question.js +64 -9
  54. package/src/questions/question.test.js +86 -0
  55. package/test/journey.js +2 -0
  56. package/test/questions.js +50 -0
  57. package/test/snapshots/check-your-answers-not-started.html +22 -0
  58. package/test/snapshots/check-your-answers-with-answers.html +22 -0
  59. package/test/snapshots/favourite-activity-reason.html +54 -0
  60. package/test/snapshots/travel-requirements.html +48 -0
  61. package/test/utils/question-test-utils.js +36 -6
  62. package/typedefs/question-types.d.ts +27 -0
@@ -32,7 +32,7 @@ export class TextEntryQuestion extends Question {
32
32
  }
33
33
 
34
34
  /**
35
- * @param {import('#question').QuestionViewModel} viewModel
35
+ * @param {import('#typedefs/question-types.d.ts').QuestionViewModel} viewModel
36
36
  */
37
37
  addCustomDataToViewModel(viewModel) {
38
38
  viewModel.question.label = this.label;
@@ -15,16 +15,12 @@ export class TextEntryRedactQuestion extends Question {
15
15
  showSuggestionsUi: boolean | undefined;
16
16
  summaryText: string | undefined;
17
17
  shouldTruncateSummary: boolean | undefined;
18
- prepQuestionForRendering(section: any, journey: any, customViewData: any, payload: any, options: any): import("../../index.js").QuestionViewModel<import("../../index.js").BaseQuestionViewData>;
18
+ prepQuestionForRendering(section: any, journey: any, customViewData: any, payload: any, options: any): import("#typedefs/question-types.d.ts").QuestionViewModel<import("#typedefs/question-types.d.ts").BaseQuestionViewData>;
19
19
  answerForViewModel(answers: any): string;
20
- /**
21
- * @param {import('#question').QuestionViewModel} viewModel
22
- */
23
- addCustomDataToViewModel(viewModel: import("#question").QuestionViewModel): void;
24
20
  /**
25
21
  * returns the formatted answers values to be used to build task list elements
26
22
  */
27
- formatAnswerForSummary(sectionSegment: any, journey: any, answer: any, capitals?: boolean): import("../../index.js").SummaryRow[];
23
+ formatAnswerForSummary(sectionSegment: any, journey: any, answer: any, capitals?: boolean): import("#typedefs/question-types.d.ts").SummaryRow[];
28
24
  }
29
25
  export default TextEntryRedactQuestion;
30
26
  import { Question } from '#question';
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAGA,+BAAgC,QAAG,CAAC;AACpC,mCAAoC,GAAG,CAAC;AAExC;;GAEG;AACH;IACC;;OAEG;IACH,gLAFW,OAAO,+BAA+B,EAAE,6BAA6B,EAwB/E;IAPA,yFAA0C;IAC1C,0BAAkB;IAClB,qDAAsE;IACtE,iDAA8D;IAC9D,uCAA0C;IAC1C,gCAA8B;IAC9B,2CAAkD;IAkBnD,iMAOC;IAED,yCAEC;IAED;;OAEG;IACH,oCAFW,OAAO,WAAW,EAAE,iBAAiB,QAO/C;IAED;;OAEG;IACH,kIAuBC;CACD;;yBAtGwB,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAGA,+BAAgC,QAAG,CAAC;AACpC,mCAAoC,GAAG,CAAC;AAExC;;GAEG;AACH;IACC;;OAEG;IACH,gLAFW,OAAO,+BAA+B,EAAE,6BAA6B,EAwB/E;IAPA,yFAA0C;IAC1C,0BAAkB;IAClB,qDAAsE;IACtE,iDAA8D;IAC9D,uCAA0C;IAC1C,gCAA8B;IAC9B,2CAAkD;IAkBnD,+NAOC;IAED,yCAEC;IAYD;;OAEG;IACH,iJAuBC;CACD;;yBAtGwB,WAAW"}
@@ -64,7 +64,7 @@ export class TextEntryRedactQuestion extends Question {
64
64
  }
65
65
 
66
66
  /**
67
- * @param {import('#question').QuestionViewModel} viewModel
67
+ * @param {import('#typedefs/question-types.d.ts').QuestionViewModel} viewModel
68
68
  */
69
69
  addCustomDataToViewModel(viewModel) {
70
70
  viewModel.question.label = this.label;
@@ -41,7 +41,7 @@ export const questionHasAnswer = (response, question, expectedValue) => {
41
41
  if (Array.isArray(answerField)) {
42
42
  return answerField.includes(expectedValue);
43
43
  } else if (question.optionJoinString && typeof answerField === 'string') {
44
- // todo: answers from options questions sometimes are array sometimes string, why?
44
+ // todo: DF-51 answers from options questions sometimes are array sometimes string, why?
45
45
  if (!answerField) return false;
46
46
  const answers = answerField.split(question.optionJoinString);
47
47
  return answers.includes(expectedValue);
@@ -1,5 +1,23 @@
1
1
  export function getConditionalFieldName(parentField: any, conditionalField: any): string;
2
- export function getConditionalAnswer(answers: any, question: any, answer: any): any;
2
+ /**
3
+ * Get conditional answer(s) for a question.
4
+ * Returns an object keyed by option value with conditional answers, or null if no conditionals exist.
5
+ *
6
+ * @param {Record<string, unknown>} answers - all answers
7
+ * @param {{options?: Array<{value: string, conditional?: {fieldName: string}}>, fieldName: string, optionJoinString?: string}} question - question with options
8
+ * @param {string} answer - the answer value (may be separated by optionJoinString for checkboxes)
9
+ * @returns {Record<string, string> | null} conditional answer(s) keyed by option value
10
+ */
11
+ export function getConditionalAnswer(answers: Record<string, unknown>, question: {
12
+ options?: Array<{
13
+ value: string;
14
+ conditional?: {
15
+ fieldName: string;
16
+ };
17
+ }>;
18
+ fieldName: string;
19
+ optionJoinString?: string;
20
+ }, answer: string): Record<string, string> | null;
3
21
  /**
4
22
  * @param {unknown} conditional
5
23
  * @returns {conditional is {html: string}}
@@ -1 +1 @@
1
- {"version":3,"file":"question-utils.d.ts","sourceRoot":"","sources":["question-utils.js"],"names":[],"mappings":"AAAA,yFAEC;AAED,oFAGC;AAED;;;GAGG;AACH,mDAHW,OAAO,GACL,WAAW,IAAI;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,CAIzC"}
1
+ {"version":3,"file":"question-utils.d.ts","sourceRoot":"","sources":["question-utils.js"],"names":[],"mappings":"AAAA,yFAEC;AAED;;;;;;;;GAQG;AACH,8CALW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,YACvB;IAAC,OAAO,CAAC,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE;YAAC,SAAS,EAAE,MAAM,CAAA;SAAC,CAAA;KAAC,CAAC,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAC,UACnH,MAAM,GACJ,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAyBzC;AAED;;;GAGG;AACH,mDAHW,OAAO,GACL,WAAW,IAAI;IAAC,IAAI,EAAE,MAAM,CAAA;CAAC,CAIzC"}
@@ -2,9 +2,38 @@ export function getConditionalFieldName(parentField, conditionalField) {
2
2
  return `${parentField}_${conditionalField}`;
3
3
  }
4
4
 
5
+ /**
6
+ * Get conditional answer(s) for a question.
7
+ * Returns an object keyed by option value with conditional answers, or null if no conditionals exist.
8
+ *
9
+ * @param {Record<string, unknown>} answers - all answers
10
+ * @param {{options?: Array<{value: string, conditional?: {fieldName: string}}>, fieldName: string, optionJoinString?: string}} question - question with options
11
+ * @param {string} answer - the answer value (may be separated by optionJoinString for checkboxes)
12
+ * @returns {Record<string, string> | null} conditional answer(s) keyed by option value
13
+ */
5
14
  export function getConditionalAnswer(answers, question, answer) {
6
- const conditionalFieldName = question.options?.find((option) => option.value === answer)?.conditional?.fieldName;
7
- return conditionalFieldName ? answers[getConditionalFieldName(question.fieldName, conditionalFieldName)] : null;
15
+ if (!answer || !question.options) {
16
+ return null;
17
+ }
18
+
19
+ // TODO DF-51 treat as an array
20
+ const joinString = question.optionJoinString || ',';
21
+ const answerValues = String(answer)
22
+ .split(joinString)
23
+ .map((v) => v.trim());
24
+
25
+ const conditionals = {};
26
+ for (const value of answerValues) {
27
+ const option = question.options.find((opt) => opt.value === value);
28
+ if (!option?.conditional?.fieldName) {
29
+ continue;
30
+ }
31
+ const conditionalValue = answers[getConditionalFieldName(question.fieldName, option.conditional.fieldName)];
32
+ if (conditionalValue) {
33
+ conditionals[value] = conditionalValue;
34
+ }
35
+ }
36
+ return Object.keys(conditionals).length > 0 ? conditionals : null;
8
37
  }
9
38
 
10
39
  /**
@@ -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.strictEqual(getConditionalAnswer(answers, question, 'yes'), expectedResult);
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('./question.js').QuestionViewModel & { question: { options: Option[] } }} OptionsViewModel
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('./question.js').QuestionViewModel} QuestionViewModel
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("./question.js").QuestionViewModel;
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("./question.js").QuestionViewModel & {
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":"AAQA;;;;;;;;;;;;;;;;;;;;;;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,eAAe,EAAE,iBAAiB,CAkDrD;CAmCD;;qBAhJY;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,eAAe,EAAE,iBAAiB,GAAG;IAAE,QAAQ,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;CAAE;wCAG/E,OAAO,+BAA+B,EAAE,kBAAkB,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAA;CAAE;yBAnC3E,eAAe"}
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('./question.js').QuestionViewModel & { question: { options: Option[] } }} OptionsViewModel
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('./question.js').QuestionViewModel} QuestionViewModel
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, '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
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, '&lt;img src=x onerror=alert(1)&gt;');
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, '&lt;b&gt;Bold&lt;/b&gt;<br>&lt;i&gt;Italic&lt;/i&gt;');
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-types.d.ts').SummaryValueFormatter|undefined} custom function to format the summary display value */
52
+ formatSummaryValue: import("#typedefs/question-types.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;IA6DC;;;OAGG;IACH,yLAHW,OAAO,+BAA+B,EAAE,kBAAkB,oBAC1D,MAAM,CAAC,MAAM,WAAW,EAsDlC;IApHD,6EAA6E;IAC7E,WADW,MAAM,CACP;IACV,sDAAsD;IACtD,OADW,MAAM,CACX;IACN,+DAA+D;IAC/D,UADW,MAAM,CACR;IACT,mFAAmF;IACnF,aADW,MAAM,GAAC,SAAS,CACf;IACZ,iDAAiD;IACjD,YADW,MAAM,CACN;IACX,qHAAqH;IACrH,WADW,MAAM,CACP;IACV,6FAA6F;IAC7F,UADW,OAAO,CACF;IAChB,0IAA0I;IAC1I,YADW,KAAK,CAAE,OAAO,gCAAgC,EAAE,aAAa,CAAC,CACzD;IAChB,2DAA2D;IAC3D,MADW,MAAM,GAAC,SAAS,CACtB;IACL,sEAAsE;IACtE,oBADW,OAAO,CACQ;IAC1B,oDAAoD;IACpD,KADW,MAAM,GAAC,SAAS,CACvB;IACJ,qDAAqD;IACrD,MADW,MAAM,GAAC,SAAS,CACtB;IACL,sDAAsD;IACtD,eADW,MAAM,GAAC,SAAS,CACb;IACd,gGAAgG;IAChG,YADW,OAAO,+BAA+B,EAAE,UAAU,GAAC,SAAS,CAC5D;IAEX,iFAAiF;IACjF,gBADW,MAAM,CACc;IAC/B,4CAA4C;IAC5C,oBADW,MAAM,CACe;IAChC,uDAAuD;IACvD,kBADW,MAAM,CACW;IAC5B,uDAAuD;IACvD,kBADW,MAAM,CACW;IAC5B,oDAAoD;IACpD,eADW,MAAM,CACK;IAEtB;;;OAGG;IAEH,gBAAiB,WAJN,OAAO,gCAAgC,EAAE,eAI3B,KAHZ,OAAO,CAGe;IAEnC;;;MAGE;IACF;;;;;;OAMG;IACH,wBAFU,OAAO,CAEc;IAyC9B,iCAAgC;IAChC,kBAAwB;IAExB;;;;MAAwB;IAgBzB;;;;;;OAMG;IACH,4BAFa,OAAO,CAInB;IAWD,iCANa,OAAO,EAWnB;IAdD;;;;OAIG;IACH,6BAFa,OAAO,CAInB;IASD;;;;;;OAMG;IACH,sBAFa,MAAM,EAAE,CAIpB;IAED;;;;;;;;;;;;;;OAcG;IACH,uFARG;QAAoE,MAAM,EAAlE,OAAO,8BAA8B,EAAE,WAAW;QACQ,kBAAkB;QACnC,OAAO,EAAhD,OAAO,eAAe,EAAE,OAAO;QACkB,OAAO,EAAxD,OAAO,uBAAuB,EAAE,OAAO;QACL,cAAc;QAC9B,OAAO,GAAzB,OAAO;KACf,GAAU,OAAO,+BAA+B,EAAE,iBAAiB,CASrE;IAED;;;;;;;;;OASG;IACH,kCAPW,OAAO,eAAe,EAAE,OAAO,WAC/B,OAAO,uBAAuB,EAAE,OAAO,mBACvC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,YACvB,OAAO,YACP,OAAO,+BAA+B,EAAE,+BAA+B,GACrE,OAAO,+BAA+B,EAAE,iBAAiB,CAuCrE;IAED;;;;;;;;OAQG,CACH,4BAJW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,uBAEjB,GAAC,GAAC,MAAM,CAIpB;IAED;;;;;;OAMG,CACH,oCAFW,OAAO,+BAA+B,EAAE,iBAAiB,QAE9B;IAEtC;;;;;;OAMG;IACH,0CAJW,OAAO,gCAAgC,EAAE,eAAe,mCACxD,OAAO,+BAA+B,EAAE,+BAA+B,GACrE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAc/B;IAED;;;;;OAKG;IACH,kBAJW,OAAO,SAAS,EAAE,QAAQ,aAC1B,OAAO,+BAA+B,EAAE,iBAAiB,GACvD,IAAI,CAShB;IAED;;;;;;;OAOG;IACH,8BANW,OAAO,SAAS,EAAE,OAAO,WAEzB,OAAO,eAAe,EAAE,OAAO,WAD/B,OAAO,uBAAuB,EAAE,OAAO,gFAGrC,OAAO,+BAA+B,EAAE,iBAAiB,GAAC,SAAS,CAmB/E;IAED;;;;;;OAMG,CACH,mBAJW,OAAO,SAAS,EAAE,OAAO,mBACzB,OAAO,gCAAgC,EAAE,eAAe,GACtD,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAczD;IAED;;;;;;OAMG,CACH,0BALW,OAAO,SAAS,EAAE,OAAO,cAEzB,OAAO,eAAe,EAAE,OAAO,WAD/B,OAAO,uBAAuB,EAAE,OAAO,GAErC,OAAO,+BAA+B,EAAE,iBAAiB,GAAG,SAAS,CAIjF;IAED;;;;;;;;;OASG;IACH,wBAPW,OAAO,SAAS,EAAE,QAAQ,WAC1B,OAAO,uBAAuB,EAAE,OAAO,kBACvC,MAAM,mBACN,MAAM,GACJ,IAAI,CAQhB;IAED;;;;;;OAMG;IACH,uCALW,MAAM,WACN,OAAO,uBAAuB,EAAE,OAAO,UACvC,OAAO,uBACL,OAAO,+BAA+B,EAAE,UAAU,EAAE,CAahE;IAED;;;;;;OAMG;IACH,0BALW,MAAM,WACN,OAAO,uBAAuB,EAAE,OAAO,UACvC,OAAO,GACL,OAAO,+BAA+B,EAAE,UAAU,GAAG,OAAO,+BAA+B,EAAE,UAAU,EAAE,GAAG,SAAS,CAqBjI;IAED;;;OAGG;IACH,eAHW,OAAO,GACL,OAAO,CAInB;IAED;;OAEG;IACH,cAFa,OAAO,CAInB;IACD;;;OAGG;IACH,4BAHW,MAAM,GACJ,OAAO,CAMnB;IAED;;;;OAIG;IACH,4BAJW,OAAO,gCAAgC,EAAE,eAAe,cACxD,MAAM,GACJ,OAAO,CAInB;CACD"}
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-types.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-types.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 = true) {
382
- const formattedAnswer = capitals ? capitalize(answer ?? this.notStartedText) : (answer ?? this.notStartedText);
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
- let rowParams = [];
386
- rowParams.push({
387
- key: key,
388
- value: nl2br(escape(formattedAnswer)),
389
- action: action
421
+
422
+ const displayValue = this.#applyCustomSummaryFormatter({
423
+ answer,
424
+ formattedAnswer,
425
+ question: this,
426
+ journey,
427
+ sectionSegment
390
428
  });
391
- return rowParams;
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
  */