@planning-inspectorate/dynamic-forms 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +9 -9
  2. package/package.json +1 -1
  3. package/src/components/address/question.js +2 -4
  4. package/src/components/boolean/question.js +6 -11
  5. package/src/components/date/question.js +5 -11
  6. package/src/components/date-period/question.js +5 -9
  7. package/src/components/date-time/question.js +5 -8
  8. package/src/components/date-time/question.test.js +3 -24
  9. package/src/components/manage-list/README.md +21 -2
  10. package/src/components/manage-list/index.njk +3 -3
  11. package/src/components/manage-list/question.js +24 -15
  12. package/src/components/manage-list/question.test.js +38 -1
  13. package/src/components/manage-list/utils.js +35 -0
  14. package/src/components/manage-list/utils.test.js +80 -0
  15. package/src/components/multi-field-input/question.js +7 -11
  16. package/src/components/multi-field-input/question.test.js +29 -1
  17. package/src/components/text-entry-redact/question.js +3 -4
  18. package/src/components/unit-option-entry/question.js +6 -13
  19. package/src/components/utils/question-has-answer.js +45 -11
  20. package/src/components/utils/question-utils.js +3 -37
  21. package/src/controller.js +11 -0
  22. package/src/controller.test.js +13 -1
  23. package/src/journey/journey.js +1 -0
  24. package/src/lib/utils.js +11 -0
  25. package/src/lib/utils.test.js +19 -0
  26. package/src/lib/uuid.test.js +0 -12
  27. package/src/questions/options-question.js +6 -13
  28. package/src/questions/question.js +17 -31
  29. package/src/questions/question.test.js +5 -19
  30. package/src/section-types.d.ts +2 -0
  31. package/src/section.js +5 -1
  32. package/src/section.test.js +7 -3
  33. package/test/check-your-answers.test.js +1 -1
  34. package/test/mock/uuid.js +13 -0
  35. package/test/questions.test.js +3 -200
  36. package/test/utils/question-test-utils.js +200 -0
  37. package/src/components/macros/answer-list.njk +0 -29
  38. package/src/middleware/check-not-submitted.js +0 -13
  39. package/src/middleware/check-not-submitted.test.js +0 -118
  40. package/src/middleware/dynamic-req-files-to-req-body-files.js +0 -46
  41. package/src/middleware/dynamic-req-files-to-req-body-files.test.js +0 -221
  42. package/src/validator/required-file-upload-validator.js +0 -38
  43. package/src/validator/required-file-upload-validator.test.js +0 -141
  44. package/src/validator/schemas/required-file-upload-schema.js +0 -35
@@ -60,10 +60,9 @@ export default class TextEntryRedactQuestion extends Question {
60
60
  async getDataToSave(req, journeyResponse) {
61
61
  if (this.useRedactedFieldNameForSave) {
62
62
  const fieldName = this.fieldName + 'Redacted';
63
- const responseToSave = { answers: {} };
64
- responseToSave.answers[fieldName] = req.body[this.fieldName];
65
- journeyResponse.answers[fieldName] = responseToSave.answers[fieldName];
66
- return responseToSave;
63
+ const answers = {};
64
+ answers[fieldName] = req.body[this.fieldName];
65
+ return { answers };
67
66
  }
68
67
  return super.getDataToSave(req, journeyResponse);
69
68
  }
@@ -127,17 +127,13 @@ export default class UnitOptionEntryQuestion extends Question {
127
127
  }
128
128
 
129
129
  /**
130
- * returns the data to send to the DB
131
- * side effect: modifies journeyResponse with the new answers
130
+ * Get the data to save from the request, returns an object of answers
132
131
  * @param {import('express').Request} req
133
132
  * @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
134
133
  * @returns {Promise<{ answers: Record<string, unknown> }>}
135
- */
134
+ */ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
136
135
  async getDataToSave(req, journeyResponse) {
137
- /**
138
- * @type {{ answers: Record<string, unknown> }}
139
- */
140
- let responseToSave = { answers: {} };
136
+ const answers = {};
141
137
 
142
138
  /** @type {string[]} */
143
139
  const fields = Array.isArray(req.body[this.fieldName]) ? req.body[this.fieldName] : [req.body[this.fieldName]];
@@ -150,8 +146,7 @@ export default class UnitOptionEntryQuestion extends Question {
150
146
  if (!selectedOptions.length)
151
147
  throw new Error(`User submitted option(s) did not correlate with valid answers to ${this.fieldName} question`);
152
148
 
153
- responseToSave.answers[this.fieldName] = fieldValues.join(this.optionJoinString);
154
- journeyResponse.answers[this.fieldName] = fieldValues;
149
+ answers[this.fieldName] = fieldValues.join(this.optionJoinString);
155
150
 
156
151
  this.options.forEach((option) => {
157
152
  if (!option.conditional) return;
@@ -162,13 +157,11 @@ export default class UnitOptionEntryQuestion extends Question {
162
157
  if (optionIsSelectedOption) {
163
158
  if (conditionalIsJustHTML(option.conditional)) return;
164
159
  const conversionFactor = option.conditional.conversionFactor || 1;
165
- const value = req.body[option.conditional.fieldName] * conversionFactor;
166
- responseToSave.answers[this.conditionalFieldName] = value;
167
- journeyResponse.answers[this.conditionalFieldName] = value;
160
+ answers[this.conditionalFieldName] = req.body[option.conditional.fieldName] * conversionFactor;
168
161
  }
169
162
  });
170
163
 
171
- return responseToSave;
164
+ return { answers };
172
165
  }
173
166
 
174
167
  /**
@@ -1,7 +1,10 @@
1
- /** @typedef {import('../../journey/journey-response').JourneyResponse} JourneyResponse */
2
- /** @typedef {import('../../questions/question').Question} Question */
1
+ /** @typedef {[any, unknown][]} QuestionKeyTuples */
2
+ /** @typedef {(questionKeyTuples: QuestionKeyTuples) => boolean} CombinationFunc */
3
3
 
4
- /** @type {(response: JourneyResponse) => {and: (questionKeyTuples: [any, unknown][]) => boolean, or: (questionKeyTuples: [any, unknown][]) => boolean}} */
4
+ /**
5
+ * @param {import('#src/journey/journey-response.js').JourneyResponse} response
6
+ * @returns {{and: CombinationFunc, or: CombinationFunc}}
7
+ */
5
8
  export const logicalCombinations = (response) => ({
6
9
  and: (questionKeyTuples) =>
7
10
  questionKeyTuples.every((questionKeyTuple) => questionHasAnswer(response, ...questionKeyTuple)),
@@ -9,8 +12,28 @@ export const logicalCombinations = (response) => ({
9
12
  questionKeyTuples.some((questionKeyTuple) => questionHasAnswer(response, ...questionKeyTuple))
10
13
  });
11
14
 
12
- // TODO Make a type for all the question classes and use it here
13
- /** @type {(response: JourneyResponse, question: any, expectedValue: unknown) => boolean} */
15
+ /**
16
+ * A wrapper around questionHasAnswer to use as a condition directly
17
+ *
18
+ * @example
19
+ * .withCondition(whenQuestionHasAnswer(question.q1, 'answer-1'))
20
+ *
21
+ * @param {import('#question').Question} question
22
+ * @param {unknown} expectedValue
23
+ * @returns {import('#src/section.js').QuestionCondition}
24
+ */
25
+ export const whenQuestionHasAnswer = (question, expectedValue) => {
26
+ return (response) => questionHasAnswer(response, question, expectedValue);
27
+ };
28
+
29
+ /**
30
+ * Does the question have the expected answer?
31
+ *
32
+ * @param {import('#src/journey/journey-response.js').JourneyResponse} response
33
+ * @param {import('#question').Question|{optionJoinString: string}} question
34
+ * @param {unknown} expectedValue
35
+ * @returns {boolean}
36
+ */
14
37
  export const questionHasAnswer = (response, question, expectedValue) => {
15
38
  if (!response.answers) return false;
16
39
  const answerField = response.answers[question.fieldName];
@@ -46,8 +69,13 @@ export const questionArrayMeetsCondition = (response, question, conditionFn = ()
46
69
  return false;
47
70
  };
48
71
 
49
- // TODO Make a type for all the question classes and use it here
50
- /** @type {(response: JourneyResponse, questionKeyTuples: [any, unknown][], options?: {logicalCombinator: 'and' | 'or'}) => boolean} */
72
+ /**
73
+ * @param {import('#src/journey/journey-response.js').JourneyResponse} response
74
+ * @param {QuestionKeyTuples} questionKeyTuples
75
+ * @param {Object} [options]
76
+ * @param {'and' | 'or'} options.logicalCombinator
77
+ * @returns {boolean}
78
+ */
51
79
  export const questionsHaveAnswers = (
52
80
  response,
53
81
  questionKeyTuples,
@@ -58,16 +86,22 @@ export const questionsHaveAnswers = (
58
86
  return combinators[logicalCombinator](questionKeyTuples);
59
87
  };
60
88
 
61
- // TODO Make a type for all the question classes and use it here
62
- /** @type {(response: JourneyResponse, question: any) => boolean} */
89
+ /**
90
+ * @param {import('#src/journey/journey-response.js').JourneyResponse} response
91
+ * @param {import('#question').Question} question
92
+ * @returns {boolean}
93
+ */
63
94
  export const questionHasNonEmptyStringAnswer = (response, question) => {
64
95
  if (!response.answers) return false;
65
96
  const answerField = response.answers[question.fieldName];
66
97
  return typeof answerField === 'string' && answerField.trim().length > 0;
67
98
  };
68
99
 
69
- // TODO Make a type for all the question classes and use it here
70
- /** @type {(response: JourneyResponse, question: any) => boolean} */
100
+ /**
101
+ * @param {import('#src/journey/journey-response.js').JourneyResponse} response
102
+ * @param {import('#question').Question} question
103
+ * @returns {boolean}
104
+ */
71
105
  export const questionHasNonEmptyNumberAnswer = (response, question) => {
72
106
  if (!response.answers) return false;
73
107
  const answerField = response.answers[question.fieldName];
@@ -1,7 +1,3 @@
1
- /**
2
- * @typedef {import('../../journey/journey-response').JourneyResponse} JourneyResponse
3
- */
4
-
5
1
  export function getConditionalFieldName(parentField, conditionalField) {
6
2
  return `${parentField}_${conditionalField}`;
7
3
  }
@@ -12,44 +8,14 @@ export function getConditionalAnswer(answers, question, answer) {
12
8
  }
13
9
 
14
10
  /**
15
- * @param {JourneyResponse} journeyResponse
16
- * @param {string} fieldName
17
- */
18
- export function getAddressesForQuestion(journeyResponse, fieldName) {
19
- const addresses = journeyResponse.answers?.SubmissionAddress || [];
20
-
21
- return addresses.filter((address) => address.fieldName === fieldName);
22
- }
23
-
24
- /**
25
- * @param {JourneyResponse} journeyResponse
26
- * @param {string} fieldName
11
+ * @param {unknown} conditional
12
+ * @returns {conditional is {html: string}}
27
13
  */
28
- export function getLinkedCasesForQuestion(journeyResponse, fieldName) {
29
- const linkedCases = journeyResponse.answers?.SubmissionLinkedCase || [];
30
-
31
- return linkedCases.filter((linkedCase) => linkedCase.fieldName === fieldName);
32
- }
33
-
34
- /**
35
- * @param {JourneyResponse} journeyResponse
36
- * @param {string} fieldName
37
- */
38
- export function getListedBuildingForQuestion(journeyResponse, fieldName) {
39
- const listedBuildings = journeyResponse.answers?.SubmissionListedBuilding || [];
40
-
41
- return listedBuildings.filter((listedBuilding) => listedBuilding.fieldName === fieldName);
42
- }
43
-
44
- /** @type {(conditional: unknown) => conditional is { html: string }} */
45
14
  export function conditionalIsJustHTML(conditional) {
46
15
  return !!conditional && Object.hasOwn(conditional, 'html') && Object.keys(conditional).length === 1;
47
16
  }
48
17
 
49
18
  export default {
50
- getAddressesForQuestion,
51
19
  getConditionalAnswer,
52
- getConditionalFieldName,
53
- getListedBuildingForQuestion,
54
- getLinkedCasesForQuestion
20
+ getConditionalFieldName
55
21
  };
package/src/controller.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { SECTION_STATUS } from './section.js';
2
2
  import questionUtils from './components/utils/question-utils.js';
3
+ import { answerObjectForManageListSaving } from '#src/components/manage-list/utils.js';
3
4
 
4
5
  /**
5
6
  * @typedef {import('./journey/journey.js').Journey} Journey
@@ -249,6 +250,16 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
249
250
  if (redirectToTaskListOnSuccess) {
250
251
  return res.redirect(journey.taskListUrl);
251
252
  }
253
+ // edit the journey.response which question.shouldDisplay uses
254
+ // we need to ensure the latest answer just submitted is included
255
+ // as question.shouldDisplay checks the response and is used to determine the next question
256
+ let answers = journeyResponse.answers;
257
+ if (question.isInManageListSection) {
258
+ answers = answerObjectForManageListSaving(journeyResponse, manageListQuestion, req.params);
259
+ }
260
+ for (const [k, v] of Object.entries(data?.answers || {})) {
261
+ answers[k] = v;
262
+ }
252
263
  // move to the next question
253
264
  return journey.redirectToNextQuestion(res, req.params, manageListQuestion);
254
265
  } catch (err) {
@@ -379,6 +379,7 @@ describe('dynamic-form/controller', () => {
379
379
  const saveData = mock.fn();
380
380
  await buildSave(saveData)(req, res, journeyId);
381
381
 
382
+ assert.strictEqual(sampleQuestionObj.getDataToSave.mock.callCount(), 1);
382
383
  assert.strictEqual(saveData.mock.callCount(), 1);
383
384
  assert.deepStrictEqual(saveData.mock.calls[0].arguments, [
384
385
  {
@@ -499,6 +500,9 @@ describe('dynamic-form/controller', () => {
499
500
  const sampleQuestionObjWithActions = {
500
501
  ...sampleQuestionObj
501
502
  };
503
+ sampleQuestionObjWithActions.getDataToSave.mock.mockImplementationOnce(() => ({
504
+ answers: { [sampleQuestionObj.fieldName]: 'my-answer' }
505
+ }));
502
506
 
503
507
  req.params = {
504
508
  referenceId: mockRef,
@@ -522,6 +526,8 @@ describe('dynamic-form/controller', () => {
522
526
  assert.strictEqual(saveData.mock.callCount(), 1);
523
527
  assert.strictEqual(res.redirect.mock.callCount(), 1);
524
528
  assert.strictEqual(res.redirect.mock.calls[0].arguments[0], expectedUrl);
529
+ // journey response should be edited if redirecting to next question
530
+ assert.strictEqual(res.locals.journeyResponse.answers?.sampleFieldName, 'my-answer');
525
531
  });
526
532
 
527
533
  it('should redirect to task list if configured', async () => {
@@ -531,6 +537,9 @@ describe('dynamic-form/controller', () => {
531
537
  const sampleQuestionObjWithActions = {
532
538
  ...sampleQuestionObj
533
539
  };
540
+ sampleQuestionObjWithActions.getDataToSave.mock.mockImplementationOnce(() => ({
541
+ answers: { [sampleQuestionObj.fieldName]: 'my-answer' }
542
+ }));
534
543
 
535
544
  req.params = {
536
545
  referenceId: mockRef,
@@ -555,6 +564,8 @@ describe('dynamic-form/controller', () => {
555
564
  assert.strictEqual(res.redirect.mock.callCount(), 1);
556
565
  assert.match(res.redirect.mock.calls[0].arguments[0], /\/task-list$/);
557
566
  assert.strictEqual(mockJourney.getNextQuestionUrl.mock.callCount(), 0);
567
+ // journey response should not be edited if redirecting to task list
568
+ assert.strictEqual(Object.keys(res.locals.journeyResponse.answers).length, 0);
558
569
  });
559
570
 
560
571
  describe('manageListQuestions', () => {
@@ -585,7 +596,8 @@ describe('dynamic-form/controller', () => {
585
596
  const journey = {
586
597
  getSection: mock.fn(() => ({ segment: 'section-1' })),
587
598
  getQuestionByParams: mock.fn(),
588
- redirectToNextQuestion: mock.fn()
599
+ redirectToNextQuestion: mock.fn(),
600
+ response: { answers: {} }
589
601
  };
590
602
  const res = {
591
603
  locals: {
@@ -269,6 +269,7 @@ export class Journey {
269
269
  response: this.response,
270
270
  manageListQuestion,
271
271
  takeNextQuestion,
272
+ routeParams: params,
272
273
  reverse
273
274
  });
274
275
  if (question === END_OF_SECTION) {
package/src/lib/utils.js CHANGED
@@ -17,3 +17,14 @@ export function capitalize(str) {
17
17
  if (typeof str !== 'string') return '';
18
18
  return str.charAt(0).toUpperCase() + str.slice(1);
19
19
  }
20
+
21
+ /**
22
+ * Trim a trailing slash if present
23
+ *
24
+ * @param {string} [str]
25
+ * @returns {string}
26
+ */
27
+ export function trimTrailingSlash(str) {
28
+ if (typeof str !== 'string') return str;
29
+ return str.replace(/\/$/, '');
30
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { trimTrailingSlash } from '#src/lib/utils.js';
4
+
5
+ describe('utils', () => {
6
+ describe('trimTrailingSlash', () => {
7
+ it('should trim a trailing slash', () => {
8
+ assert.strictEqual(trimTrailingSlash('my-url.com/hello/'), 'my-url.com/hello');
9
+ });
10
+ it('should do nothing if no trailing slash', () => {
11
+ assert.strictEqual(trimTrailingSlash('my-url.com/hello'), 'my-url.com/hello');
12
+ });
13
+ it('should return non-string types', () => {
14
+ assert.strictEqual(trimTrailingSlash(null), null);
15
+ assert.strictEqual(trimTrailingSlash(undefined), undefined);
16
+ assert.strictEqual(trimTrailingSlash(5), 5);
17
+ });
18
+ });
19
+ });
@@ -2,18 +2,6 @@ import { Uuid } from '#src/lib/uuid.js';
2
2
  import { describe, it } from 'node:test';
3
3
  import assert from 'node:assert';
4
4
 
5
- /**
6
- *
7
- * @param {import('node:test').TestContext} ctx
8
- * @param {string} [uuid]
9
- */
10
- export function mockRandomUUID(ctx, uuid = '00000000-0000-0000-0000-000000000000') {
11
- const original = Uuid.randomUUID;
12
- Uuid.randomUUID = ctx.mock.fn(() => uuid);
13
- // restore after the tests
14
- ctx.after(() => (Uuid.randomUUID = original));
15
- }
16
-
17
5
  describe('uuid', () => {
18
6
  it('should return a valid v4 UUID', () => {
19
7
  assert.match(
@@ -120,17 +120,13 @@ export default class OptionsQuestion extends Question {
120
120
  }
121
121
 
122
122
  /**
123
- * returns the data to send to the DB
124
- * side effect: modifies journeyResponse with the new answers
123
+ * Get the data to save from the request, returns an object of answers
125
124
  * @param {import('express').Request} req
126
125
  * @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
127
126
  * @returns {Promise<{ answers: Record<string, unknown> }>}
128
- */
127
+ */ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
129
128
  async getDataToSave(req, journeyResponse) {
130
- /**
131
- * @type {{ answers: Record<string, unknown> }}
132
- */
133
- let responseToSave = { answers: {} };
129
+ const answers = {};
134
130
 
135
131
  const fields = Array.isArray(req.body[this.fieldName]) ? req.body[this.fieldName] : [req.body[this.fieldName]];
136
132
  const fieldValues = fields.map((x) => x.trim());
@@ -142,8 +138,7 @@ export default class OptionsQuestion extends Question {
142
138
  if (!selectedOptions.length)
143
139
  throw new Error(`User submitted option(s) did not correlate with valid answers to ${this.fieldName} question`);
144
140
 
145
- responseToSave.answers[this.fieldName] = fieldValues.join(this.optionJoinString);
146
- journeyResponse.answers[this.fieldName] = fieldValues;
141
+ answers[this.fieldName] = fieldValues.join(this.optionJoinString);
147
142
 
148
143
  this.options.forEach((option) => {
149
144
  if (!option.conditional) return;
@@ -152,11 +147,9 @@ export default class OptionsQuestion extends Question {
152
147
  (selectedOption) => option.text === selectedOption.text && option.value === selectedOption.value
153
148
  );
154
149
 
155
- const value = optionIsSelectedOption ? req.body[key]?.trim() : null;
156
- responseToSave.answers[key] = value;
157
- journeyResponse.answers[key] = value;
150
+ answers[key] = optionIsSelectedOption ? req.body[key]?.trim() : null;
158
151
  });
159
152
 
160
- return responseToSave;
153
+ return { answers };
161
154
  }
162
155
  }
@@ -1,9 +1,9 @@
1
1
  import escape from 'escape-html';
2
2
  import RequiredValidator from '../validator/required-validator.js';
3
- import RequiredFileUploadValidator from '../validator/required-file-upload-validator.js';
4
- import { capitalize, nl2br } from '../lib/utils.js';
3
+ import { capitalize, nl2br, trimTrailingSlash } from '../lib/utils.js';
5
4
  import AddressValidator from '../validator/address-validator.js';
6
5
  import MultiFieldInputValidator from '../validator/multi-field-input-validator.js';
6
+ import { answerObjectForManageList } from '#src/components/manage-list/utils.js';
7
7
 
8
8
  /**
9
9
  * @typedef {import('../validator/base-validator.js')} BaseValidator
@@ -33,6 +33,8 @@ import MultiFieldInputValidator from '../validator/multi-field-input-validator.j
33
33
  * @property {string} backLink
34
34
  * @property {boolean} showBackToListLink
35
35
  * @property {string} listLink
36
+ * @property {Object} util
37
+ * @property {function(string): string} util.trimTrailingSlash
36
38
  */
37
39
 
38
40
  /**
@@ -239,6 +241,10 @@ export class Question {
239
241
 
240
242
  continueButtonText: this.continueButtonText,
241
243
 
244
+ util: {
245
+ trimTrailingSlash
246
+ },
247
+
242
248
  ...customViewData,
243
249
  ...this.viewData
244
250
  };
@@ -284,12 +290,7 @@ export class Question {
284
290
  throw new Error('no manageListQuestion for manage list question');
285
291
  }
286
292
  // 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
+ return answerObjectForManageList(response, manageListQuestion, params.manageListItemId);
293
294
  }
294
295
  return response.answers;
295
296
  }
@@ -337,38 +338,24 @@ export class Question {
337
338
  }
338
339
 
339
340
  /**
340
- * returns the data to send to the DB
341
- * side effect: modifies journeyResponse with the new answers
341
+ * Get the data to save from the request, returns an object of answers
342
+ *
342
343
  * @param {import('express').Request} req
343
- * @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
344
+ * @param {JourneyResponse} journeyResponse - current journey response
344
345
  * @returns {Promise<{ answers: Record<string, unknown> }>}
345
- */
346
+ */ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
346
347
  async getDataToSave(req, journeyResponse) {
347
- /**
348
- * @type {{ answers: Record<string, unknown> }}
349
- */
350
- let responseToSave = { answers: {} };
348
+ const answers = {};
351
349
 
352
- responseToSave.answers[this.fieldName] = req.body[this.fieldName];
350
+ answers[this.fieldName] = req.body[this.fieldName];
353
351
 
354
352
  for (const propName in req.body) {
355
353
  if (propName.startsWith(this.fieldName + '_')) {
356
- responseToSave.answers[propName] = req.body[propName];
357
- journeyResponse.answers[propName] = req.body[propName];
354
+ answers[propName] = req.body[propName];
358
355
  }
359
- // todo: sort this
360
- // } else if (numericFields.has(propName)) {
361
- // const numericValue = Number(req.body[propName]);
362
- // if (!isNaN(numericValue)) {
363
- // responseToSave.answers[propName] = numericValue;
364
- // journeyResponse.answers[propName] = numericValue;
365
- // }
366
- // }
367
356
  }
368
357
 
369
- journeyResponse.answers[this.fieldName] = responseToSave.answers[this.fieldName];
370
-
371
- return responseToSave;
358
+ return { answers };
372
359
  }
373
360
 
374
361
  /**
@@ -470,7 +457,6 @@ export class Question {
470
457
  return this.validators?.some(
471
458
  (item) =>
472
459
  item instanceof RequiredValidator ||
473
- item instanceof RequiredFileUploadValidator ||
474
460
  (item instanceof AddressValidator && item.isRequired()) ||
475
461
  (item instanceof MultiFieldInputValidator && item.isRequired())
476
462
  );
@@ -179,6 +179,8 @@ describe('./src/dynamic-forms/question.js', () => {
179
179
  assert.deepStrictEqual(result.listLink, journey.taskListUrl);
180
180
  assert.deepStrictEqual(result.journeyTitle, journey.journeyTitle);
181
181
  assert.deepStrictEqual(result.hello, 'hi');
182
+ assert.ok(result.util);
183
+ assert.ok(typeof result.util.trimTrailingSlash === 'function');
182
184
  });
183
185
  it('should include viewData in viewModel', () => {
184
186
  const question = getTestQuestion({ viewData: { test: 'data' } });
@@ -323,7 +325,7 @@ describe('./src/dynamic-forms/question.js', () => {
323
325
  });
324
326
 
325
327
  describe('getDataToSave', () => {
326
- it('should return answer from req.body and modify journeyResponse', async () => {
328
+ it('should return answer from req.body', async () => {
327
329
  const question = getTestQuestion();
328
330
 
329
331
  const req = {
@@ -331,14 +333,8 @@ describe('./src/dynamic-forms/question.js', () => {
331
333
  [question.fieldName]: { a: 1 }
332
334
  }
333
335
  };
334
- const journeyResponse = {
335
- answers: {
336
- [question.fieldName]: { b: 1 },
337
- other: 'another-answer'
338
- }
339
- };
340
336
 
341
- const result = await question.getDataToSave(req, journeyResponse);
337
+ const result = await question.getDataToSave(req);
342
338
 
343
339
  const expectedResult = {
344
340
  answers: {
@@ -346,8 +342,6 @@ describe('./src/dynamic-forms/question.js', () => {
346
342
  }
347
343
  };
348
344
  assert.deepStrictEqual(result, expectedResult);
349
- expectedResult.answers.other = 'another-answer';
350
- assert.deepStrictEqual(journeyResponse, expectedResult);
351
345
  });
352
346
 
353
347
  it('should handle nested properties', async () => {
@@ -360,14 +354,8 @@ describe('./src/dynamic-forms/question.js', () => {
360
354
  [question.fieldName + '_2']: { a: 3 }
361
355
  }
362
356
  };
363
- const journeyResponse = {
364
- answers: {
365
- [question.fieldName]: { b: 1 },
366
- other: 'another-answer'
367
- }
368
- };
369
357
 
370
- const result = await question.getDataToSave(req, journeyResponse);
358
+ const result = await question.getDataToSave(req);
371
359
 
372
360
  const expectedResult = {
373
361
  answers: {
@@ -377,8 +365,6 @@ describe('./src/dynamic-forms/question.js', () => {
377
365
  }
378
366
  };
379
367
  assert.deepStrictEqual(result, expectedResult);
380
- expectedResult.answers.other = 'another-answer';
381
- assert.deepStrictEqual(journeyResponse, expectedResult);
382
368
  });
383
369
  });
384
370
 
@@ -1,6 +1,7 @@
1
1
  import { JourneyResponse } from '#src/journey/journey-response.js';
2
2
  import ManageListQuestion from '#src/components/manage-list/question.js';
3
3
  import { Question } from '#src/questions/question.js';
4
+ import { RouteParams } from '#src/journey/journey-types.d.ts';
4
5
 
5
6
  export interface GetNextQuestionParams {
6
7
  questionFieldName: string;
@@ -8,6 +9,7 @@ export interface GetNextQuestionParams {
8
9
  // if this is part of a manage list section
9
10
  manageListQuestion?: ManageListQuestion;
10
11
  takeNextQuestion: boolean;
12
+ routeParams: RouteParams;
11
13
  // to get previous question instead of next
12
14
  reverse: boolean;
13
15
  }
package/src/section.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import RequiredValidator from './validator/required-validator.js';
8
+ import { answerObjectForManageList } from '#src/components/manage-list/utils.js';
8
9
 
9
10
  /**
10
11
  * A value indicating the final question of a section has been reached
@@ -203,11 +204,14 @@ export class Section {
203
204
  * @returns {Question|Symbol|null}
204
205
  */
205
206
  getNextQuestion(params) {
206
- const { manageListQuestion } = params;
207
+ const { response, manageListQuestion, routeParams } = params;
207
208
  if (manageListQuestion) {
208
209
  // first check if the next question is within the manage list section
210
+ // here we get the answers for the manage list item for the question.shouldDisplay logic
211
+ const answers = answerObjectForManageList(response, manageListQuestion, routeParams.manageListItemId);
209
212
  const next = Section.getNextQuestion({
210
213
  ...params,
214
+ response: { answers },
211
215
  questions: manageListQuestion.section.questions
212
216
  });
213
217
  if (next === END_OF_SECTION) {
@@ -715,12 +715,14 @@ describe('./src/dynamic-forms/section.js', () => {
715
715
  const section = new Section('s1', 'S').addQuestion(q1).addQuestion(manageListQ, manageListSection);
716
716
  return { section, q1, manageListQ, q3, q4 };
717
717
  };
718
+ const response = { answers: {} };
718
719
  it(`should get first question within a manage list section`, () => {
719
720
  const { section, manageListQ, q3 } = newSectionWithManageList();
720
721
  const next = section.getNextQuestion({
721
722
  questionFieldName: manageListQ.fieldName,
722
- response: {},
723
+ response,
723
724
  manageListQuestion: manageListQ,
725
+ routeParams: {},
724
726
  takeNextQuestion: true,
725
727
  reverse: false
726
728
  });
@@ -730,8 +732,9 @@ describe('./src/dynamic-forms/section.js', () => {
730
732
  const { section, manageListQ, q3, q4 } = newSectionWithManageList();
731
733
  const next = section.getNextQuestion({
732
734
  questionFieldName: q3.fieldName,
733
- response: {},
735
+ response,
734
736
  manageListQuestion: manageListQ,
737
+ routeParams: {},
735
738
  takeNextQuestion: false,
736
739
  reverse: false
737
740
  });
@@ -741,8 +744,9 @@ describe('./src/dynamic-forms/section.js', () => {
741
744
  const { section, manageListQ, q4 } = newSectionWithManageList();
742
745
  const next = section.getNextQuestion({
743
746
  questionFieldName: q4.fieldName,
744
- response: {},
747
+ response,
745
748
  manageListQuestion: manageListQ,
749
+ routeParams: {},
746
750
  takeNextQuestion: false,
747
751
  reverse: false
748
752
  });
@@ -3,7 +3,7 @@ import assert from 'assert';
3
3
  import { manageListQuestions, questionsInOrder } from './questions.js';
4
4
  import path from 'path';
5
5
  import { escapeForRegExp, snapshotsDir } from './utils/utils.js';
6
- import { createAppWithQuestions, mockAnswer, mockAnswerBody } from './questions.test.js';
6
+ import { createAppWithQuestions, mockAnswer, mockAnswerBody } from './utils/question-test-utils.js';
7
7
  import { COMPONENT_TYPES } from '#src/index.js';
8
8
 
9
9
  describe('check-your-answers', () => {
@@ -0,0 +1,13 @@
1
+ import { Uuid } from '#src/lib/uuid.js';
2
+
3
+ /**
4
+ *
5
+ * @param {import('node:test').TestContext} ctx
6
+ * @param {string} [uuid]
7
+ */
8
+ export function mockRandomUUID(ctx, uuid = '00000000-0000-0000-0000-000000000000') {
9
+ const original = Uuid.randomUUID;
10
+ Uuid.randomUUID = ctx.mock.fn(() => uuid);
11
+ // restore after the tests
12
+ ctx.after(() => (Uuid.randomUUID = original));
13
+ }