@planning-inspectorate/dynamic-forms 3.0.0 → 3.2.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 (36) hide show
  1. package/README.md +12 -1
  2. package/package.json +3 -2
  3. package/src/components/address/question.js +25 -19
  4. package/src/components/address/question.test.js +1 -1
  5. package/src/components/date/question.js +7 -21
  6. package/src/components/date/question.test.js +1 -1
  7. package/src/components/date-period/question.js +17 -25
  8. package/src/components/date-period/question.test.js +1 -1
  9. package/src/components/date-time/question.js +10 -23
  10. package/src/components/identifier/question.js +3 -6
  11. package/src/components/manage-list/index.njk +1 -1
  12. package/src/components/manage-list/question.js +13 -6
  13. package/src/components/multi-field-input/index.njk +1 -1
  14. package/src/components/multi-field-input/question.js +12 -13
  15. package/src/components/multi-field-input/question.test.js +8 -8
  16. package/src/components/number-entry/question.js +7 -13
  17. package/src/components/radio/question.js +2 -4
  18. package/src/components/select/question.js +2 -4
  19. package/src/components/single-line-input/question.js +5 -6
  20. package/src/components/text-entry/question.js +5 -5
  21. package/src/components/text-entry-redact/question.js +18 -9
  22. package/src/components/text-entry-redact/question.test.js +12 -19
  23. package/src/components/unit-option-entry/question.js +9 -8
  24. package/src/controller.js +4 -0
  25. package/src/controller.test.js +5 -1
  26. package/src/journey/journey-response.js +2 -2
  27. package/src/journey/journey-types.d.ts +10 -0
  28. package/src/lib/session-answer-store.js +12 -3
  29. package/src/lib/session-answer-store.test.js +42 -0
  30. package/src/questions/options-question.js +6 -7
  31. package/src/questions/question-types.d.ts +26 -0
  32. package/src/questions/question.js +65 -9
  33. package/src/questions/question.test.js +72 -2
  34. package/test/check-your-answers.test.js +19 -1
  35. package/test/questions.test.js +3 -3
  36. package/tsconfig.json +2 -1
package/README.md CHANGED
@@ -296,4 +296,15 @@ There are some lightweight tests in the `test` directory which sets up a basic j
296
296
 
297
297
  When adding a new question type, be sure to add an example question into `test/questions.js`, and mock answers into `test/questions.test.js#mockAnswerBody` and `test/questions.test.js#mockAnswer`. Also, the question should be added to the journey in `test/journey.js`.
298
298
 
299
- To update any snapshots with rendering changes (or new questions), run `node --test --test-update-snapshots`.
299
+ To update any snapshots with rendering changes (or new questions), run `node --test --test-update-snapshots`.
300
+
301
+ ### Components
302
+
303
+ When implement a new component, extend the base `Question` class. Common methods to override are:
304
+
305
+ * `formatAnswerForSummary` - used for check-your-answers display
306
+ * `getDataToSave` - answer data to save
307
+ * `addCustomDataToViewModel` - customise the view model with extra data/configuration
308
+ * `answerForViewModel` - customise the view model answer value
309
+
310
+ Where possible `addCustomDataToViewModel` and `answerForViewModel` should be overridden instead of `prepQuestionForRendering`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "This package is for building GDS forms using a configuration-based approach.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -27,7 +27,8 @@
27
27
  "imports": {
28
28
  "#question-types": "./src/questions/question-types.d.ts",
29
29
  "#src/*": "./src/*",
30
- "#test/*": "./test/*"
30
+ "#test/*": "./test/*",
31
+ "#question": "./src/questions/question.js"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@commitlint/cli": "^20.0.0",
@@ -1,4 +1,4 @@
1
- import { Question } from '../../questions/question.js';
1
+ import { Question } from '#question';
2
2
 
3
3
  import escape from 'escape-html';
4
4
  import { Address } from '../../lib/address.js';
@@ -40,29 +40,35 @@ export default class AddressQuestion extends Question {
40
40
  }
41
41
 
42
42
  /**
43
- * @param {Section} section
44
- * @param {Journey} journey
45
- * @param {Record<string, unknown>} customViewData
46
- * @returns {QuestionViewModel}
43
+ * @param {Record<string, any>} answers
44
+ * @returns {*|string}
47
45
  */
48
- prepQuestionForRendering(section, journey, customViewData) {
49
- const viewModel = super.prepQuestionForRendering(section, journey, customViewData);
50
- const address = journey.response.answers[this.fieldName] || {};
51
-
52
- // will only ever have 1
53
- if (address) {
54
- viewModel.question.value = {
55
- addressLine1: address.addressLine1 || '',
56
- addressLine2: address.addressLine2 || '',
57
- townCity: address.townCity || '',
58
- county: address.county || '',
59
- postcode: address.postcode || ''
46
+ answerForViewModel(answers) {
47
+ let address = answers[this.fieldName];
48
+ if (!address) {
49
+ address = {
50
+ addressLine1: answers[this.fieldName + '_addressLine1'],
51
+ addressLine2: answers[this.fieldName + '_addressLine2'],
52
+ townCity: answers[this.fieldName + '_townCity'],
53
+ county: answers[this.fieldName + '_county'],
54
+ postcode: answers[this.fieldName + '_postcode']
60
55
  };
61
56
  }
62
57
 
63
- viewModel.question.labels = this.addressLabels;
58
+ return {
59
+ addressLine1: address?.addressLine1 || '',
60
+ addressLine2: address?.addressLine2 || '',
61
+ townCity: address?.townCity || '',
62
+ county: address?.county || '',
63
+ postcode: address?.postcode || ''
64
+ };
65
+ }
64
66
 
65
- return viewModel;
67
+ /**
68
+ * @param {import('#question').QuestionViewModel} viewModel
69
+ */
70
+ addCustomDataToViewModel(viewModel) {
71
+ viewModel.question.labels = this.addressLabels;
66
72
  }
67
73
 
68
74
  /**
@@ -3,7 +3,7 @@ import assert from 'node:assert';
3
3
  import AddressQuestion from './question.js';
4
4
  import { Address } from '../../lib/address.js';
5
5
  import AddressValidator from '../../validator/address-validator.js';
6
- import { configureNunjucksTestEnv } from '../../../test/utils/nunjucks.js';
6
+ import { configureNunjucksTestEnv } from '#test/utils/nunjucks.js';
7
7
 
8
8
  describe('AddressQuestion', () => {
9
9
  const TITLE = 'What is the site address?';
@@ -50,29 +50,17 @@ export default class DateQuestion extends Question {
50
50
  return responseToSave;
51
51
  }
52
52
 
53
- /**
54
- * gets the view model for this question
55
- * @param {Section} section - the current section
56
- * @param {Journey} journey - the journey we are in
57
- * @param {Object|undefined} [customViewData] additional data to send to view
58
- * @returns {QuestionViewModel & { answer: Record<string, unknown> }}
59
- */
60
- prepQuestionForRendering(section, journey, customViewData, payload) {
61
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
62
-
63
- /** @type {Record<string, unknown>} */
64
- let answer = {};
53
+ answerForViewModel(answers, isPayload) {
65
54
  let day;
66
55
  let month;
67
56
  let year;
68
57
 
69
- if (payload) {
70
- day = payload[`${this.fieldName}_day`];
71
- month = payload[`${this.fieldName}_month`];
72
- year = payload[`${this.fieldName}_year`];
58
+ if (isPayload && answers) {
59
+ day = answers[`${this.fieldName}_day`];
60
+ month = answers[`${this.fieldName}_month`];
61
+ year = answers[`${this.fieldName}_year`];
73
62
  } else {
74
- const answerDateString = journey.response.answers[this.fieldName];
75
-
63
+ const answerDateString = answers[this.fieldName];
76
64
  if (answerDateString && (typeof answerDateString === 'string' || answerDateString instanceof Date)) {
77
65
  const answerDate = new Date(answerDateString);
78
66
  day = formatDateForDisplay(answerDate, { format: 'd' });
@@ -81,13 +69,11 @@ export default class DateQuestion extends Question {
81
69
  }
82
70
  }
83
71
 
84
- answer = {
72
+ return {
85
73
  [`${this.fieldName}_day`]: day,
86
74
  [`${this.fieldName}_month`]: month,
87
75
  [`${this.fieldName}_year`]: year
88
76
  };
89
-
90
- return { ...viewModel, answer, question: { ...viewModel.question, value: answer } };
91
77
  }
92
78
 
93
79
  /**
@@ -1,7 +1,7 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
 
4
- import { Question } from '../../questions/question.js';
4
+ import { Question } from '#question';
5
5
  import DateQuestion from './question.js';
6
6
 
7
7
  describe('DateQuestion', () => {
@@ -1,5 +1,5 @@
1
1
  import { formatDateForDisplay, parseDateInput } from '../../lib/date-utils.js';
2
- import { Question } from '../../questions/question.js';
2
+ import { Question } from '#question';
3
3
  import { nl2br } from '../../lib/utils.js';
4
4
  import escape from 'escape-html';
5
5
 
@@ -77,20 +77,7 @@ export default class DatePeriodQuestion extends Question {
77
77
  return responseToSave;
78
78
  }
79
79
 
80
- /**
81
- * gets the view model for this question
82
- * @param {Section} section - the current section
83
- * @param {Journey} journey - the journey we are in
84
- * @param {Object|undefined} [customViewData] additional data to send to view
85
- * @param {Object|undefined} [payload] the current payload
86
- * @returns {QuestionViewModel & { answer: Record<string, unknown> }}
87
- */
88
- prepQuestionForRendering(section, journey, customViewData, payload) {
89
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
90
- viewModel.labels = this.labels;
91
-
92
- /** @type {Record<string, unknown>} */
93
- let answer;
80
+ answerForViewModel(answers, isPayload) {
94
81
  let startDay;
95
82
  let startMonth;
96
83
  let startYear;
@@ -98,15 +85,15 @@ export default class DatePeriodQuestion extends Question {
98
85
  let endMonth;
99
86
  let endYear;
100
87
 
101
- if (payload) {
102
- startDay = payload[`${this.fieldName}_start_day`];
103
- startMonth = payload[`${this.fieldName}_start_month`];
104
- startYear = payload[`${this.fieldName}_start_year`];
105
- endDay = payload[`${this.fieldName}_end_day`];
106
- endMonth = payload[`${this.fieldName}_end_month`];
107
- endYear = payload[`${this.fieldName}_end_year`];
88
+ if (isPayload) {
89
+ startDay = answers[`${this.fieldName}_start_day`];
90
+ startMonth = answers[`${this.fieldName}_start_month`];
91
+ startYear = answers[`${this.fieldName}_start_year`];
92
+ endDay = answers[`${this.fieldName}_end_day`];
93
+ endMonth = answers[`${this.fieldName}_end_month`];
94
+ endYear = answers[`${this.fieldName}_end_year`];
108
95
  } else {
109
- const answerPeriod = journey.response.answers[this.fieldName];
96
+ const answerPeriod = answers[this.fieldName];
110
97
  if (answerPeriod && answerPeriod.start) {
111
98
  const startDate = new Date(answerPeriod.start);
112
99
  startDay = formatDateForDisplay(startDate, { format: 'd' });
@@ -121,7 +108,7 @@ export default class DatePeriodQuestion extends Question {
121
108
  }
122
109
  }
123
110
 
124
- answer = {
111
+ return {
125
112
  [`${this.fieldName}_start_day`]: startDay,
126
113
  [`${this.fieldName}_start_month`]: startMonth,
127
114
  [`${this.fieldName}_start_year`]: startYear,
@@ -129,8 +116,13 @@ export default class DatePeriodQuestion extends Question {
129
116
  [`${this.fieldName}_end_month`]: endMonth,
130
117
  [`${this.fieldName}_end_year`]: endYear
131
118
  };
119
+ }
132
120
 
133
- return { ...viewModel, answer, question: { ...viewModel.question, value: answer } };
121
+ /**
122
+ * @param {import('#question').QuestionViewModel} viewModel
123
+ */
124
+ addCustomDataToViewModel(viewModel) {
125
+ viewModel.labels = this.labels;
134
126
  }
135
127
 
136
128
  /**
@@ -1,7 +1,7 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
 
4
- import { Question } from '../../questions/question.js';
4
+ import { Question } from '#question';
5
5
  import DatePeriodQuestion from './question.js';
6
6
 
7
7
  describe('DatePeriodQuestion', () => {
@@ -52,18 +52,7 @@ export default class DateTimeQuestion extends Question {
52
52
  return responseToSave;
53
53
  }
54
54
 
55
- /**
56
- * gets the view model for this question
57
- * @param {Section} section - the current section
58
- * @param {Journey} journey - the journey we are in
59
- * @param {Object|undefined} [customViewData] additional data to send to view
60
- * @returns {QuestionViewModel & { answer: Record<string, unknown> }}
61
- */
62
- prepQuestionForRendering(section, journey, customViewData, payload) {
63
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
64
-
65
- /** @type {Record<string, unknown>} */
66
- let answer = {};
55
+ answerForViewModel(answers, isPayload) {
67
56
  let day;
68
57
  let month;
69
58
  let year;
@@ -71,15 +60,15 @@ export default class DateTimeQuestion extends Question {
71
60
  let minutes;
72
61
  let period;
73
62
 
74
- if (payload) {
75
- day = payload[`${this.fieldName}_day`];
76
- month = payload[`${this.fieldName}_month`];
77
- year = payload[`${this.fieldName}_year`];
78
- hour = payload[`${this.fieldName}_hour`];
79
- minutes = payload[`${this.fieldName}_minutes`];
80
- period = payload[`${this.fieldName}_period`];
63
+ if (isPayload) {
64
+ day = answers[`${this.fieldName}_day`];
65
+ month = answers[`${this.fieldName}_month`];
66
+ year = answers[`${this.fieldName}_year`];
67
+ hour = answers[`${this.fieldName}_hour`];
68
+ minutes = answers[`${this.fieldName}_minutes`];
69
+ period = answers[`${this.fieldName}_period`];
81
70
  } else {
82
- const answerDateString = journey.response.answers[this.fieldName];
71
+ const answerDateString = answers[this.fieldName];
83
72
 
84
73
  if (answerDateString && (typeof answerDateString === 'string' || answerDateString instanceof Date)) {
85
74
  const answerDate = new Date(answerDateString);
@@ -92,7 +81,7 @@ export default class DateTimeQuestion extends Question {
92
81
  }
93
82
  }
94
83
 
95
- answer = {
84
+ return {
96
85
  [`${this.fieldName}_day`]: day,
97
86
  [`${this.fieldName}_month`]: month,
98
87
  [`${this.fieldName}_year`]: year,
@@ -100,8 +89,6 @@ export default class DateTimeQuestion extends Question {
100
89
  [`${this.fieldName}_minutes`]: minutes,
101
90
  [`${this.fieldName}_period`]: period
102
91
  };
103
-
104
- return { ...viewModel, answer, question: { ...viewModel.question, value: answer } };
105
92
  }
106
93
 
107
94
  /**
@@ -1,4 +1,4 @@
1
- import { Question } from '../../questions/question.js';
1
+ import { Question } from '#question';
2
2
 
3
3
  export default class IdentifierQuestion extends Question {
4
4
  /** @type {string|undefined} page h1, optional, will default to use question's label */
@@ -22,13 +22,10 @@ export default class IdentifierQuestion extends Question {
22
22
  }
23
23
 
24
24
  /**
25
- * adds custom identifier info to view model
25
+ * @param {import('#question').QuestionViewModel} viewModel
26
26
  */
27
- prepQuestionForRendering(section, journey, customViewData, payload) {
28
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
27
+ addCustomDataToViewModel(viewModel) {
29
28
  viewModel.question.inputClasses = this.inputClasses;
30
29
  viewModel.question.label = this.label;
31
- viewModel.question.value = payload ? payload[viewModel.question.fieldName] : viewModel.question.value;
32
- return viewModel;
33
30
  }
34
31
  }
@@ -21,7 +21,7 @@
21
21
  {% if showManageListQuestions %}
22
22
  {{ value.question }}:<br>
23
23
  {% endif %}
24
- {{ value.answer }}
24
+ {{ value.answer | safe }}
25
25
  {% if not loop.last %}
26
26
  <br>{% if showManageListQuestions %}<br>{% endif %}
27
27
 
@@ -1,6 +1,5 @@
1
- import { Question } from '../../questions/question.js';
1
+ import { Question } from '#question';
2
2
  import { Uuid } from '#src/lib/uuid.js';
3
- import escape from 'escape-html';
4
3
  import { nunjucksEnv } from '#src/components/utils/nunjucks.js';
5
4
 
6
5
  /**
@@ -45,8 +44,10 @@ export default class ManageListQuestion extends Question {
45
44
  return true;
46
45
  }
47
46
 
48
- prepQuestionForRendering(section, journey, customViewData, payload) {
49
- const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload);
47
+ /**
48
+ * @param {import('#question').QuestionViewModel} viewModel
49
+ */
50
+ addCustomDataToViewModel(viewModel) {
50
51
  viewModel.question.addAnotherLink = this.#addAnotherLink;
51
52
  viewModel.question.firstQuestionUrl = this.#firstQuestionUrl;
52
53
  viewModel.question.valueSummary = [];
@@ -58,7 +59,6 @@ export default class ManageListQuestion extends Question {
58
59
  };
59
60
  });
60
61
  }
61
- return viewModel;
62
62
  }
63
63
 
64
64
  /**
@@ -76,7 +76,7 @@ export default class ManageListQuestion extends Question {
76
76
  return this.section.questions.map((q) => {
77
77
  const formatted = q
78
78
  .formatAnswerForSummary('', mockJourney, answer[q.fieldName])
79
- .map((a) => escape(a.value))
79
+ .map((a) => a.value)
80
80
  .join(', ');
81
81
 
82
82
  return {
@@ -86,6 +86,13 @@ export default class ManageListQuestion extends Question {
86
86
  });
87
87
  }
88
88
 
89
+ async getDataToSave(req, journeyResponse) {
90
+ let responseToSave = { answers: {} };
91
+ const data = journeyResponse.answers[this.fieldName];
92
+ responseToSave.answers[this.fieldName] = data || [];
93
+ return responseToSave;
94
+ }
95
+
89
96
  formatAnswerForSummary(sectionSegment, journey, answer) {
90
97
  let formattedAnswer = this.notStartedText;
91
98
  if (answer && Array.isArray(answer)) {
@@ -5,7 +5,7 @@
5
5
  {% from "govuk/components/fieldset/macro.njk" import govukFieldset %}
6
6
  {% from "govuk/components/button/macro.njk" import govukButton %}
7
7
 
8
- {% set inputFields = question.inputFields %}
8
+ {% set inputFields = question.value %}
9
9
 
10
10
  {% block dynQuestionContent %}
11
11
  <div class="govuk-grid-row">
@@ -1,4 +1,4 @@
1
- import { Question } from '../../questions/question.js';
1
+ import { Question } from '#question';
2
2
  import escape from 'escape-html';
3
3
  import { nl2br } from '../../lib/utils.js';
4
4
 
@@ -56,22 +56,21 @@ export default class MultiFieldInputQuestion extends Question {
56
56
  }
57
57
  }
58
58
 
59
- prepQuestionForRendering(section, journey, customViewData, payload) {
60
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
61
-
62
- const inputFields = this.inputFields.map((inputField) => {
63
- return payload
64
- ? { ...inputField, value: this.#formatValue(payload[inputField.fieldName], inputField.formatTextFunction) }
65
- : {
66
- ...inputField,
67
- value: this.#formatValue(journey.response.answers[inputField.fieldName], inputField.formatTextFunction)
68
- };
59
+ answerForViewModel(answers) {
60
+ return this.inputFields.map((inputField) => {
61
+ return {
62
+ ...inputField,
63
+ value: this.#formatValue(answers[inputField.fieldName], inputField.formatTextFunction)
64
+ };
69
65
  });
66
+ }
70
67
 
71
- viewModel.question.inputFields = inputFields;
68
+ /**
69
+ * @param {import('#question').QuestionViewModel} viewModel
70
+ */
71
+ addCustomDataToViewModel(viewModel) {
72
72
  viewModel.question.label = this.label;
73
73
  viewModel.question.attributes = this.inputAttributes;
74
- return viewModel;
75
74
  }
76
75
 
77
76
  /**
@@ -73,8 +73,8 @@ describe('./src/dynamic-forms/components/single-line-input/question.js', () => {
73
73
  const result = question.prepQuestionForRendering({}, journey, customViewData);
74
74
 
75
75
  assert.strictEqual(result.question?.fieldName, FIELDNAME);
76
- assert.strictEqual(result.question?.inputFields[0].fieldName, INPUTFIELDS[0].fieldName);
77
- assert.strictEqual(result.question?.inputFields[1].fieldName, INPUTFIELDS[1].fieldName);
76
+ assert.strictEqual(result.question?.value[0].fieldName, INPUTFIELDS[0].fieldName);
77
+ assert.strictEqual(result.question?.value[1].fieldName, INPUTFIELDS[1].fieldName);
78
78
  assert.strictEqual(result.hello, 'hi');
79
79
  });
80
80
  it('should set inputFields with values from payload if provided', () => {
@@ -94,8 +94,8 @@ describe('./src/dynamic-forms/components/single-line-input/question.js', () => {
94
94
 
95
95
  const result = question.prepQuestionForRendering({}, journey, {}, payload);
96
96
 
97
- assert.strictEqual(result.question?.inputFields[0].value, 'payloadValue1');
98
- assert.strictEqual(result.question?.inputFields[1].value, 'payloadValue2');
97
+ assert.strictEqual(result.question?.value[0].value, 'payloadValue1');
98
+ assert.strictEqual(result.question?.value[1].value, 'payloadValue2');
99
99
  });
100
100
  it('should set inputFields with values from journey response if payload is not provided', () => {
101
101
  const question = createMultiFieldInputQuestion();
@@ -113,8 +113,8 @@ describe('./src/dynamic-forms/components/single-line-input/question.js', () => {
113
113
 
114
114
  const result = question.prepQuestionForRendering({}, journey, {});
115
115
 
116
- assert.strictEqual(result.question?.inputFields[0].value, 'responseValue1');
117
- assert.strictEqual(result.question?.inputFields[1].value, 'responseValue2');
116
+ assert.strictEqual(result.question?.value[0].value, 'responseValue1');
117
+ assert.strictEqual(result.question?.value[1].value, 'responseValue2');
118
118
  });
119
119
  it('should set inputFields with formatted values from journey response if formatTextFunction is provided', () => {
120
120
  const formatTextFunction = (value) => `Formatted: ${value}`;
@@ -136,8 +136,8 @@ describe('./src/dynamic-forms/components/single-line-input/question.js', () => {
136
136
 
137
137
  const result = question.prepQuestionForRendering({}, journey, {});
138
138
 
139
- assert.strictEqual(result.question?.inputFields[0].value, 'Formatted: responseValue1');
140
- assert.strictEqual(result.question?.inputFields[1].value, 'Formatted: responseValue2');
139
+ assert.strictEqual(result.question?.value[0].value, 'Formatted: responseValue1');
140
+ assert.strictEqual(result.question?.value[1].value, 'Formatted: responseValue2');
141
141
  });
142
142
  });
143
143
 
@@ -1,4 +1,4 @@
1
- import { Question } from '../../questions/question.js';
1
+ import { Question } from '#question';
2
2
  import { getPersistedNumberAnswer } from '../utils/persisted-number-answer.js';
3
3
 
4
4
  /**
@@ -21,22 +21,16 @@ export default class NumberEntryQuestion extends Question {
21
21
  this.label = label;
22
22
  }
23
23
 
24
+ answerForViewModel(answers) {
25
+ return getPersistedNumberAnswer(answers[this.fieldName] || '');
26
+ }
27
+
24
28
  /**
25
- * adds label and suffix property to view model
29
+ * @param {import('#question').QuestionViewModel} viewModel
26
30
  */
27
- prepQuestionForRendering(section, journey, customViewData, payload) {
28
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload);
29
-
30
- const answer = journey.response.answers[this.fieldName];
31
- const persistedAnswer = getPersistedNumberAnswer(answer);
32
-
33
- viewModel.question.value = persistedAnswer;
34
- viewModel.answer = persistedAnswer;
35
-
31
+ addCustomDataToViewModel(viewModel) {
36
32
  viewModel.question.label = this.label;
37
33
  viewModel.question.suffix = this.suffix;
38
-
39
- return viewModel;
40
34
  }
41
35
 
42
36
  /**
@@ -60,13 +60,11 @@ export default class RadioQuestion extends OptionsQuestion {
60
60
  }
61
61
 
62
62
  /**
63
- * adds label property to view model
63
+ * @param {import('#question').QuestionViewModel} viewModel
64
64
  */
65
- prepQuestionForRendering(section, journey, customViewData, payload) {
66
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload);
65
+ addCustomDataToViewModel(viewModel) {
67
66
  viewModel.question.label = this.label;
68
67
  viewModel.question.legend = this.legend;
69
- return viewModel;
70
68
  }
71
69
 
72
70
  /**
@@ -54,13 +54,11 @@ export default class SelectQuestion extends OptionsQuestion {
54
54
  }
55
55
 
56
56
  /**
57
- * adds label property to view model
57
+ * @param {import('#question').QuestionViewModel} viewModel
58
58
  */
59
- prepQuestionForRendering(section, journey, customViewData, payload) {
60
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload);
59
+ addCustomDataToViewModel(viewModel) {
61
60
  viewModel.question.label = this.label;
62
61
  viewModel.question.legend = this.legend;
63
- return viewModel;
64
62
  }
65
63
 
66
64
  /**
@@ -1,4 +1,4 @@
1
- import { Question } from '../../questions/question.js';
1
+ import { Question } from '#question';
2
2
 
3
3
  /**
4
4
  * @typedef {import('../../questions/question.js').QuestionViewModel} QuestionViewModel
@@ -32,17 +32,16 @@ export default class SingleLineInputQuestion extends Question {
32
32
  this.classes = params.classes || '';
33
33
  }
34
34
 
35
- prepQuestionForRendering(section, journey, customViewData, payload) {
36
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
35
+ /**
36
+ * @param {import('#question').QuestionViewModel} viewModel
37
+ */
38
+ addCustomDataToViewModel(viewModel) {
37
39
  viewModel.question.label = this.label;
38
- viewModel.question.value = payload ? payload[viewModel.question.fieldName] : viewModel.question.value;
39
-
40
40
  // Extract type from attributes to pass separately to avoid duplication
41
41
  const { type, ...otherAttributes } = this.inputAttributes;
42
42
  viewModel.question.attributes = otherAttributes;
43
43
  viewModel.question.type = type;
44
44
 
45
45
  viewModel.question.classes = this.classes;
46
- return viewModel;
47
46
  }
48
47
  }
@@ -1,4 +1,4 @@
1
- import { Question } from '../../questions/question.js';
1
+ import { Question } from '#question';
2
2
  /**
3
3
  * @typedef {import('../../questions/question.js').QuestionViewModel} QuestionViewModel
4
4
  * @typedef {import('../../journey/journey.js').Journey} Journey
@@ -33,11 +33,11 @@ export default class TextEntryQuestion extends Question {
33
33
  this.label = label;
34
34
  }
35
35
 
36
- prepQuestionForRendering(section, journey, customViewData, payload) {
37
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
36
+ /**
37
+ * @param {import('#question').QuestionViewModel} viewModel
38
+ */
39
+ addCustomDataToViewModel(viewModel) {
38
40
  viewModel.question.label = this.label;
39
41
  viewModel.question.textEntryCheckbox = this.textEntryCheckbox;
40
- viewModel.question.value = payload ? payload[viewModel.question.fieldName] : viewModel.question.value;
41
- return viewModel;
42
42
  }
43
43
  }
@@ -1,4 +1,4 @@
1
- import { Question } from '../../questions/question.js';
1
+ import { Question } from '#question';
2
2
  import { nl2br } from '../../lib/utils.js';
3
3
 
4
4
  /**
@@ -68,18 +68,27 @@ export default class TextEntryRedactQuestion extends Question {
68
68
  return super.getDataToSave(req, journeyResponse);
69
69
  }
70
70
 
71
- prepQuestionForRendering(section, journey, customViewData, payload) {
72
- let viewModel = super.prepQuestionForRendering(section, journey, customViewData);
71
+ prepQuestionForRendering(section, journey, customViewData, payload, options) {
72
+ const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload, options);
73
+ const answers = this.answerObjectFromJourneyResponse(journey.response, options);
74
+ const answer = viewModel.question.value;
75
+ viewModel.question.valueRedacted = answers[this.fieldName + 'Redacted'] || answer;
76
+ viewModel.question.valueOriginal = answers[this.fieldName + 'Original'] || answer;
77
+ return viewModel;
78
+ }
79
+
80
+ answerForViewModel(answers) {
81
+ return nl2br(answers[this.fieldName]);
82
+ }
83
+
84
+ /**
85
+ * @param {import('#question').QuestionViewModel} viewModel
86
+ */
87
+ addCustomDataToViewModel(viewModel) {
73
88
  viewModel.question.label = this.label;
74
89
  viewModel.question.textEntryCheckbox = this.textEntryCheckbox;
75
- viewModel.question.value = nl2br(payload ? payload[viewModel.question.fieldName] : viewModel.question.value);
76
- viewModel.question.valueRedacted =
77
- journey.response.answers[this.fieldName + 'Redacted'] || viewModel.question.value;
78
- viewModel.question.valueOriginal =
79
- journey.response.answers[this.fieldName + 'Original'] || viewModel.question.value;
80
90
  viewModel.question.summaryText = this.summaryText;
81
91
  viewModel.showSuggestionsUi = this.showSuggestionsUi;
82
- return viewModel;
83
92
  }
84
93
 
85
94
  formatAnswerForSummary(sectionSegment, journey, answer, capitals = true) {
@@ -5,7 +5,7 @@ import { configureNunjucksTestEnv } from '../../../test/utils/nunjucks.js';
5
5
 
6
6
  describe('./src/dynamic-forms/components/text-entry-redact/question.js', () => {
7
7
  const TITLE = 'title';
8
- const QUESTION = 'Question?';
8
+ const QUESTION = 'Redaction Question';
9
9
  const FIELDNAME = 'field-name';
10
10
  const VALIDATORS = [1, 2];
11
11
  const HTML = '/path/to/html.njk';
@@ -130,18 +130,15 @@ describe('./src/dynamic-forms/components/text-entry-redact/question.js', () => {
130
130
  return 'back';
131
131
  },
132
132
  response: {
133
- answers: {}
133
+ answers: {
134
+ 'field-name': 'value',
135
+ 'field-nameRedacted': 'value-redacted',
136
+ 'field-nameOriginal': 'value'
137
+ }
134
138
  }
135
139
  };
136
140
  const customViewData = {
137
141
  layoutTemplate: 'views/layout-journey.njk',
138
- question: {
139
- question: 'Redaction Question',
140
- fieldName: 'field-name',
141
- value: 'value',
142
- valueRedacted: 'value-redacted',
143
- valueOriginal: 'value'
144
- },
145
142
  redactionSuggestions: [
146
143
  { category: 'Person', suggestion: 'Test Person' },
147
144
  { category: 'Address', suggestion: '123 Fake Street' }
@@ -177,17 +174,13 @@ describe('./src/dynamic-forms/components/text-entry-redact/question.js', () => {
177
174
  return 'back';
178
175
  },
179
176
  response: {
180
- answers: {}
177
+ answers: {
178
+ 'field-name': 'This is my comment.\nIt has multiple lines.\r\nHere is another line.'
179
+ }
181
180
  }
182
181
  };
183
182
  const customViewData = {
184
183
  layoutTemplate: 'views/layout-journey.njk',
185
- question: {
186
- question: 'Redaction Question',
187
- fieldName: 'field-name',
188
- value: 'This is my comment.\nIt has multiple lines.\r\nHere is another line.',
189
- valueRedacted: 'value-redacted'
190
- },
191
184
  redactionSuggestions: [
192
185
  { category: 'Person', suggestion: 'Test Person' },
193
186
  { category: 'Address', suggestion: '123 Fake Street' }
@@ -235,7 +228,7 @@ describe('./src/dynamic-forms/components/text-entry-redact/question.js', () => {
235
228
  action: {
236
229
  href: '/redacted-comment',
237
230
  text: 'Change',
238
- visuallyHiddenText: 'Question?'
231
+ visuallyHiddenText: 'Redaction Question'
239
232
  }
240
233
  }
241
234
  ]);
@@ -271,7 +264,7 @@ describe('./src/dynamic-forms/components/text-entry-redact/question.js', () => {
271
264
  action: {
272
265
  href: '/redacted-comment',
273
266
  text: 'Change',
274
- visuallyHiddenText: 'Question?'
267
+ visuallyHiddenText: 'Redaction Question'
275
268
  }
276
269
  }
277
270
  ]);
@@ -308,7 +301,7 @@ describe('./src/dynamic-forms/components/text-entry-redact/question.js', () => {
308
301
  action: {
309
302
  href: '/redacted-comment',
310
303
  text: 'Change',
311
- visuallyHiddenText: 'Question?'
304
+ visuallyHiddenText: 'Redaction Question'
312
305
  }
313
306
  }
314
307
  ]);
@@ -71,19 +71,20 @@ export default class UnitOptionEntryQuestion extends Question {
71
71
  * @param {Journey} journey - the journey we are in
72
72
  * @param {Record<string, unknown>} [customViewData] additional data to send to view
73
73
  * @param {Record<string, unknown>} [payload]
74
+ * @param {import('#src/questions/question-types.d.ts').PrepQuestionForRenderingOptions} options
74
75
  * @returns {QuestionViewModel & {
75
76
  * question: QuestionViewModel['question'] & {
76
77
  * options:UnitOption[]
77
78
  * }
78
79
  * }}
79
80
  */
80
- prepQuestionForRendering(section, journey, customViewData, payload) {
81
- const answer = payload ? payload[this.fieldName] : journey.response.answers[this.fieldName] || '';
82
-
83
- const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload);
81
+ prepQuestionForRendering(section, journey, customViewData, payload, options) {
82
+ const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload, options);
83
+ const answer = viewModel.question.value;
84
+ const answers = this.answerObjectFromJourneyResponse(journey.response, options);
84
85
 
85
86
  /** @type {Array<UnitOption>} */
86
- const options = [];
87
+ viewModel.question.options = [];
87
88
 
88
89
  for (const option of this.options) {
89
90
  let optionData = { ...option };
@@ -101,7 +102,7 @@ export default class UnitOptionEntryQuestion extends Question {
101
102
  if (conditionalIsJustHTML(conditionalField)) continue;
102
103
 
103
104
  const conversionFactor = conditionalField.conversionFactor || 1;
104
- const unconvertedAnswer = journey.response.answers[this.conditionalFieldName];
105
+ const unconvertedAnswer = answers[this.conditionalFieldName];
105
106
 
106
107
  const existingValue =
107
108
  answer === optionData.value && typeof unconvertedAnswer === 'number'
@@ -119,10 +120,10 @@ export default class UnitOptionEntryQuestion extends Question {
119
120
  };
120
121
  }
121
122
 
122
- options.push(optionData);
123
+ viewModel.question.options.push(optionData);
123
124
  }
124
125
 
125
- return { ...viewModel, question: { ...viewModel.question, options } };
126
+ return viewModel;
126
127
  }
127
128
 
128
129
  /**
package/src/controller.js CHANGED
@@ -184,6 +184,8 @@ export async function question(req, res) {
184
184
  * @property {import('express').Response} res
185
185
  * @property {string} journeyId
186
186
  * @property {string} referenceId
187
+ * @property {boolean} isManageListItem
188
+ * @property {string} [manageListQuestionFieldName]
187
189
  * @property {Object<string, any>} data
188
190
  */
189
191
 
@@ -234,6 +236,8 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
234
236
  res,
235
237
  journeyId: journeyResponse.journeyId,
236
238
  referenceId: journeyResponse.referenceId,
239
+ isManageListItem: question.isInManageListSection,
240
+ manageListQuestionFieldName: manageListQuestion?.fieldName,
237
241
  data
238
242
  });
239
243
 
@@ -386,6 +386,8 @@ describe('dynamic-form/controller', () => {
386
386
  res,
387
387
  journeyId: journeyParams.journeyId,
388
388
  referenceId: journeyParams.referenceId,
389
+ isManageListItem: undefined,
390
+ manageListQuestionFieldName: undefined,
389
391
  data: undefined
390
392
  }
391
393
  ]);
@@ -443,7 +445,9 @@ describe('dynamic-form/controller', () => {
443
445
  res,
444
446
  journeyId: journeyParams.journeyId,
445
447
  referenceId: journeyParams.referenceId,
446
- data: undefined
448
+ data: undefined,
449
+ isManageListItem: undefined,
450
+ manageListQuestionFieldName: undefined
447
451
  }
448
452
  ],
449
453
  'save data args'
@@ -18,7 +18,7 @@ export class JourneyResponse {
18
18
  journeyId;
19
19
 
20
20
  /**
21
- * @type {Record<string, unknown>} - answers to the journey
21
+ * @type {import('./journey-types.d.ts').JourneyAnswers} - answers to the journey
22
22
  */
23
23
  answers;
24
24
 
@@ -26,7 +26,7 @@ export class JourneyResponse {
26
26
  * creates an instance of a JourneyResponse
27
27
  * @param {JourneyType} journeyId
28
28
  * @param {string} referenceId
29
- * @param {Record<string, unknown> | null} answers
29
+ * @param {import('./journey-types.d.ts').JourneyAnswers | null} answers
30
30
  * @param {string} [lpaCode]
31
31
  */
32
32
  constructor(journeyId, referenceId, answers, lpaCode) {
@@ -16,3 +16,13 @@ export type ManageListRouteParams =
16
16
  manageListQuestion: string;
17
17
  }
18
18
  | {};
19
+
20
+ export interface JourneyAnswers {
21
+ // if the answer is for a manage list question, the answer will be an array of answer objects
22
+ [k: string]: unknown | ManageListAnswers[];
23
+ }
24
+
25
+ export interface ManageListAnswers {
26
+ id: string;
27
+ [k: string]: unknown;
28
+ }
@@ -35,11 +35,12 @@ import { booleanToYesNoValue } from '../components/boolean/question.js';
35
35
  *
36
36
  *
37
37
  * @param {Object} opts
38
- * @param {string} [opts.reqParam] - optional request parameter to use as a key
38
+ * @param {string} [opts.reqParam]
39
+ * - optional request parameter to use as a key
39
40
  * @returns {import('../controller').SaveDataFn}
40
41
  */
41
42
  export function buildSaveDataToSession({ reqParam } = {}) {
42
- return async ({ req, journeyId, data }) => {
43
+ return async ({ req, journeyId, data, isManageListItem, manageListQuestionFieldName }) => {
43
44
  if (!req.session) {
44
45
  throw new Error('request session required');
45
46
  }
@@ -50,8 +51,16 @@ export function buildSaveDataToSession({ reqParam } = {}) {
50
51
  // key by a further param
51
52
  forms = forms[reqParamValue] || (forms[reqParamValue] = {});
52
53
  }
53
- const answers = forms[journeyId] || (forms[journeyId] = {});
54
+ let answers = forms[journeyId] || (forms[journeyId] = {});
55
+ if (isManageListItem) {
56
+ const answersList = answers[manageListQuestionFieldName] || (answers[manageListQuestionFieldName] = []);
57
+ answers = answersList.find((item) => item.id === req.params.manageListItemId);
54
58
 
59
+ if (!answers) {
60
+ answers = { id: req.params.manageListItemId };
61
+ answersList.push(answers);
62
+ }
63
+ }
55
64
  for (const [k, v] of Object.entries(data?.answers || {})) {
56
65
  answers[k] = v;
57
66
  }
@@ -80,6 +80,48 @@ describe('session-answer-store', () => {
80
80
  }
81
81
  });
82
82
  });
83
+ it('should add a new managed list item when not present in the session', async () => {
84
+ const req = mockReq();
85
+ req.params = { manageListItemId: '12345' };
86
+ req.session = {};
87
+ const journeyId = 'j-1';
88
+ const data = { answers: { q1: 'a1' } };
89
+ const isManageListItem = true;
90
+ const manageListQuestionFieldName = 'myManagedList';
91
+ const saveDataToSession = buildSaveDataToSession();
92
+ await saveDataToSession({ req, journeyId, data, isManageListItem, manageListQuestionFieldName });
93
+ assert.deepStrictEqual(req.session, {
94
+ forms: {
95
+ 'j-1': {
96
+ myManagedList: [{ id: '12345', q1: 'a1' }]
97
+ }
98
+ }
99
+ });
100
+ });
101
+ it('should add to an existing managed list item using the id when present in the session', async () => {
102
+ const req = mockReq();
103
+ req.params = { manageListItemId: '12345' };
104
+ req.session = {
105
+ forms: {
106
+ 'j-1': {
107
+ myManagedList: [{ id: '12345', q1: 'old value' }]
108
+ }
109
+ }
110
+ };
111
+ const journeyId = 'j-1';
112
+ const data = { answers: { q2: 'a1' } };
113
+ const isManageListItem = true;
114
+ const manageListQuestionFieldName = 'myManagedList';
115
+ const saveDataToSession = buildSaveDataToSession();
116
+ await saveDataToSession({ req, journeyId, data, isManageListItem, manageListQuestionFieldName });
117
+ assert.deepStrictEqual(req.session, {
118
+ forms: {
119
+ 'j-1': {
120
+ myManagedList: [{ id: '12345', q1: 'old value', q2: 'a1' }]
121
+ }
122
+ }
123
+ });
124
+ });
83
125
 
84
126
  it('should overwrite existing data by reqParam & journeyId', async () => {
85
127
  const req = mockReq();
@@ -69,12 +69,13 @@ export default class OptionsQuestion extends Question {
69
69
  * @param {Journey} journey - the journey we are in
70
70
  * @param {Record<string, unknown>} [customViewData] additional data to send to view
71
71
  * @param {Record<string, unknown>} [payload]
72
+ * @param {import('#src/questions/question-types.d.ts').PrepQuestionForRenderingOptions} options
72
73
  * @returns {QuestionViewModel}
73
74
  */
74
- prepQuestionForRendering(section, journey, customViewData, payload) {
75
- const answer = payload ? payload[this.fieldName] : journey.response.answers[this.fieldName] || '';
76
-
77
- const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload);
75
+ prepQuestionForRendering(section, journey, customViewData, payload, options) {
76
+ const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload, options);
77
+ const answers = payload || this.answerObjectFromJourneyResponse(journey.response, options);
78
+ const answer = viewModel.question.value;
78
79
 
79
80
  viewModel.question.options = [];
80
81
 
@@ -96,9 +97,7 @@ export default class OptionsQuestion extends Question {
96
97
  let conditionalField = { ...optionData.conditional };
97
98
 
98
99
  conditionalField.fieldName = getConditionalFieldName(this.fieldName, conditionalField.fieldName);
99
- conditionalField.value = payload
100
- ? payload[conditionalField.fieldName]
101
- : journey.response.answers[conditionalField.fieldName] || '';
100
+ conditionalField.value = answers[conditionalField.fieldName] || '';
102
101
 
103
102
  optionData.conditional = {
104
103
  html: nunjucks.render(`./components/conditional/${conditionalField.type}.njk`, {
@@ -1,5 +1,7 @@
1
1
  import BaseValidator from '../validator/base-validator';
2
2
  import { JourneyResponse } from '../journey/journey-response';
3
+ import { RouteParams } from '#src/journey/journey-types.d.ts';
4
+ import ManageListQuestion from '#src/components/manage-list/question.js';
3
5
 
4
6
  export interface QuestionParameters {
5
7
  title: string;
@@ -27,3 +29,27 @@ export interface ActionLink {
27
29
  text: string;
28
30
  href: string;
29
31
  }
32
+
33
+ export interface QuestionViewModel {
34
+ question: {
35
+ value: string | number | Record<string, any>;
36
+ question: string;
37
+ fieldName: string;
38
+ pageTitle: string;
39
+ description?: string;
40
+ html?: string;
41
+ };
42
+ layoutTemplate: string;
43
+ pageCaption: string;
44
+ continueButtonText: string;
45
+ backLink: string;
46
+ showBackToListLink: string;
47
+ listLink: string;
48
+ journeyTitle: string;
49
+ [k: string]: any;
50
+ }
51
+
52
+ export interface PrepQuestionForRenderingOptions {
53
+ params: RouteParams;
54
+ manageListQuestion?: ManageListQuestion;
55
+ }
@@ -25,7 +25,7 @@ import MultiFieldInputValidator from '../validator/multi-field-input-validator.j
25
25
  */
26
26
 
27
27
  /**
28
- * @typedef {Object} QuestionViewModel
28
+ * @typedef {Record<string, any>} QuestionViewModel
29
29
  * @property {PreppedQuestion} question
30
30
  * @property {string} layoutTemplate
31
31
  * @property {string} pageCaption
@@ -186,14 +186,17 @@ export class Question {
186
186
  * @param {Object} options
187
187
  * @param {import('#src/journey/journey-types.d.ts').RouteParams} options.params
188
188
  * @param {import('#src/components/manage-list/question.js')} [options.manageListQuestion]
189
- * @param {Section} section - the current section
190
- * @param {Journey} journey - the journey we are in
191
- * @param {Record<string, unknown>} [customViewData] additional data to send to view
192
- * @param {unknown} [payload]
189
+ * @param {Section} options.section - the current section
190
+ * @param {Journey} options.journey - the journey we are in
191
+ * @param {Record<string, unknown>} [options.customViewData] additional data to send to view
192
+ * @param {unknown} [options.payload]
193
193
  * @returns {QuestionViewModel}
194
194
  */
195
195
  toViewModel({ params, manageListQuestion, section, journey, customViewData, payload }) {
196
- const viewModel = this.prepQuestionForRendering(section, journey, customViewData, payload);
196
+ const viewModel = this.prepQuestionForRendering(section, journey, customViewData, payload, {
197
+ params,
198
+ manageListQuestion
199
+ });
197
200
  viewModel.backLink = journey.getBackLink({ params, manageListQuestion });
198
201
  return viewModel;
199
202
  }
@@ -205,11 +208,14 @@ export class Question {
205
208
  * @param {Journey} journey - the journey we are in
206
209
  * @param {Record<string, unknown>} [customViewData] additional data to send to view
207
210
  * @param {unknown} [payload]
211
+ * @param {import('./question-types.d.ts').PrepQuestionForRenderingOptions} [options] - required to support manage list question
208
212
  * @returns {QuestionViewModel}
209
213
  */
210
- prepQuestionForRendering(section, journey, customViewData, payload) {
211
- const answer = journey.response.answers[this.fieldName] || '';
212
- return {
214
+ prepQuestionForRendering(section, journey, customViewData, payload, options) {
215
+ const answers = payload || this.answerObjectFromJourneyResponse(journey.response, options);
216
+ const answer = this.answerForViewModel(answers, Boolean(payload));
217
+
218
+ const viewModel = {
213
219
  question: {
214
220
  value: answer,
215
221
  question: this.question,
@@ -236,6 +242,56 @@ export class Question {
236
242
  ...customViewData,
237
243
  ...this.viewData
238
244
  };
245
+ this.addCustomDataToViewModel(viewModel);
246
+ return viewModel;
247
+ }
248
+
249
+ /**
250
+ * The answer to this question for use in the viewModel
251
+ *
252
+ * Question implementations can override this for more complex answer types
253
+ *
254
+ * @param {Record<string, any>} answers - collection of answers to pull the answer from, may be from the response or the request/payload
255
+ * @param {Boolean} isPayload - whether the answers object is from the request/payload
256
+ * @returns {*|string}
257
+ */ // eslint-disable-next-line no-unused-vars
258
+ answerForViewModel(answers, isPayload) {
259
+ return answers[this.fieldName] || '';
260
+ }
261
+
262
+ /**
263
+ * Question implementations can override this to add configuration or other values to the view model
264
+ *
265
+ * If possible override this method instead of prepQuestionForRendering for simple changes to the view model
266
+ *
267
+ * @param {QuestionViewModel} viewModel
268
+ */ // eslint-disable-next-line no-unused-vars
269
+ addCustomDataToViewModel(viewModel) {}
270
+
271
+ /**
272
+ * Get the answers object from the journey response, which may be nested in an array for manage list questions
273
+ *
274
+ * @param {JourneyResponse} response
275
+ * @param {import('./question-types.d.ts').PrepQuestionForRenderingOptions} [options]
276
+ * @returns {Record<string, any>}
277
+ */
278
+ answerObjectFromJourneyResponse(response, { params, manageListQuestion } = {}) {
279
+ if (this.isInManageListSection) {
280
+ if (!params?.manageListItemId) {
281
+ throw new Error('no list item id for manage list question');
282
+ }
283
+ if (!manageListQuestion) {
284
+ throw new Error('no manageListQuestion for manage list question');
285
+ }
286
+ // if this is a manage list question, the response is within the 'parent' manage list answers array
287
+ const answers = response.answers[manageListQuestion.fieldName];
288
+ if (!Array.isArray(answers)) {
289
+ return {};
290
+ }
291
+ const itemId = params.manageListItemId;
292
+ return answers.find((a) => a.id === itemId) || {};
293
+ }
294
+ return response.answers;
239
295
  }
240
296
 
241
297
  /**
@@ -1,7 +1,7 @@
1
1
  import { describe, it, mock } from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { Question } from './question.js';
4
- import { mockRes } from '../../test/utils/utils.js';
3
+ import { Question } from '#question';
4
+ import { mockRes } from '#test/utils/utils.js';
5
5
 
6
6
  const res = mockRes();
7
7
 
@@ -208,6 +208,76 @@ describe('./src/dynamic-forms/question.js', () => {
208
208
  });
209
209
  });
210
210
 
211
+ describe('answerObjectFromJourney', () => {
212
+ it('should return journey response for regular questions', () => {
213
+ const question = getTestQuestion();
214
+ const answers = { myField: 'my-value' };
215
+ const journeyResponse = {
216
+ answers
217
+ };
218
+ const got = question.answerObjectFromJourneyResponse(journeyResponse);
219
+ assert.strictEqual(got, answers);
220
+ });
221
+ it('should return the manage list item object for manage list questions', () => {
222
+ const question = getTestQuestion();
223
+ question.isInManageListSection = true;
224
+ const manageListQuestion = {
225
+ fieldName: 'manageListQuestion'
226
+ };
227
+ const answers = {
228
+ id: '1',
229
+ myField: 'my-value'
230
+ };
231
+ const journeyResponse = {
232
+ answers: {
233
+ manageListQuestion: [answers]
234
+ }
235
+ };
236
+ const params = { manageListItemId: '1' };
237
+ const got = question.answerObjectFromJourneyResponse(journeyResponse, { params, manageListQuestion });
238
+ assert.strictEqual(got, answers);
239
+ });
240
+
241
+ it('should fallback to {} if manage list item id not found', () => {
242
+ const question = getTestQuestion();
243
+ question.isInManageListSection = true;
244
+ const manageListQuestion = {
245
+ fieldName: 'manageListQuestion'
246
+ };
247
+ const answers = {
248
+ id: '2',
249
+ myField: 'my-value'
250
+ };
251
+ const journeyResponse = {
252
+ answers: {
253
+ manageListQuestion: [answers]
254
+ }
255
+ };
256
+ const params = { manageListItemId: '1' };
257
+ let got = question.answerObjectFromJourneyResponse(journeyResponse, { params, manageListQuestion });
258
+ assert.deepStrictEqual(got, {});
259
+ // also fallback to {} if there is no manageListQuestion answer
260
+ delete journeyResponse.answers.manageListQuestion;
261
+ got = question.answerObjectFromJourneyResponse(journeyResponse, { params, manageListQuestion });
262
+ assert.deepStrictEqual(got, {});
263
+ });
264
+ it('should error if a manage list question is missing required parameters', () => {
265
+ const question = getTestQuestion();
266
+ question.isInManageListSection = true;
267
+ const answers = { myField: 'my-value' };
268
+ const journeyResponse = { answers };
269
+ assert.throws(
270
+ () => question.answerObjectFromJourneyResponse(journeyResponse),
271
+ /no list item id for manage list question/
272
+ );
273
+ const params = { manageListItemId: '1' };
274
+ assert.throws(
275
+ () => question.answerObjectFromJourneyResponse(journeyResponse, { params }),
276
+ /no manageListQuestion for manage list question/
277
+ );
278
+ });
279
+ });
280
+
211
281
  describe('renderAction', () => {
212
282
  it('should renderAction', () => {
213
283
  const question = getTestQuestion();
@@ -1,9 +1,10 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'assert';
3
- import { questionsInOrder } from './questions.js';
3
+ import { manageListQuestions, questionsInOrder } from './questions.js';
4
4
  import path from 'path';
5
5
  import { escapeForRegExp, snapshotsDir } from './utils/utils.js';
6
6
  import { createAppWithQuestions, mockAnswer, mockAnswerBody } from './questions.test.js';
7
+ import { COMPONENT_TYPES } from '#src/index.js';
7
8
 
8
9
  describe('check-your-answers', () => {
9
10
  it(`should render all question rows with no answers`, async (ctx) => {
@@ -39,6 +40,23 @@ describe('check-your-answers', () => {
39
40
 
40
41
  // 'answer' each question
41
42
  for (const q of questionsInOrder) {
43
+ // Save happens on subquestion for manage list questions
44
+ if (q.type === COMPONENT_TYPES.MANAGE_LIST) {
45
+ // Get the list of subquestions for this manage list question
46
+ const manageListQuestion = manageListQuestions.find(
47
+ (question) => question.manageListQuestion.fieldName === q.fieldName
48
+ );
49
+ const subQuestionAnswers = mockAnswerBody(q)[manageListQuestion.manageListQuestion.fieldName];
50
+ // Post to them individually
51
+ for (const answer of subQuestionAnswers) {
52
+ const id = answer.id;
53
+ for (const subQuestion of manageListQuestion.questions) {
54
+ const key = subQuestion.fieldName;
55
+ const body = { [key]: answer[key] };
56
+ await testServer.post(`/questions/${q.url}/add/${id}/${subQuestion.url}`, body, { redirect: 'manual' });
57
+ }
58
+ }
59
+ }
42
60
  await testServer.post(`/questions/${q.url}`, mockAnswerBody(q), { redirect: 'manual' });
43
61
  }
44
62
 
@@ -257,9 +257,9 @@ export function mockAnswerBody(q) {
257
257
  // manage list questions not available here so just hard coded
258
258
  return {
259
259
  [q.fieldName]: [
260
- { travelCompanionName: 'Companion 1', travelCompanionEmail: 'compaion-1@example.com' },
261
- { travelCompanionName: 'Companion 2', travelCompanionEmail: 'compaion-2@example.com' },
262
- { travelCompanionName: 'Companion 3', travelCompanionEmail: 'compaion-3@example.com' }
260
+ { id: '1234', travelCompanionName: 'Companion 1', travelCompanionEmail: 'companion-1@example.com' },
261
+ { id: '4567', travelCompanionName: 'Companion 2', travelCompanionEmail: 'companion-2@example.com' },
262
+ { id: '8901', travelCompanionName: 'Companion 3', travelCompanionEmail: 'companion-3@example.com' }
263
263
  ]
264
264
  };
265
265
  }
package/tsconfig.json CHANGED
@@ -17,7 +17,8 @@
17
17
  "paths": {
18
18
  "#question-types": ["./src/questions/question-types.d.ts"],
19
19
  "#src/*": ["./src/*"],
20
- "#test/*": ["./test/*"]
20
+ "#test/*": ["./test/*"],
21
+ "#question": ["./src/questions/question.js"]
21
22
  }
22
23
  }
23
24
  }