@planning-inspectorate/dynamic-forms 3.1.0 → 3.3.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 (31) hide show
  1. package/README.md +9 -9
  2. package/package.json +1 -1
  3. package/src/components/manage-list/README.md +21 -2
  4. package/src/components/manage-list/index.njk +3 -3
  5. package/src/components/manage-list/question.js +22 -10
  6. package/src/components/manage-list/utils.js +14 -0
  7. package/src/components/manage-list/utils.test.js +47 -0
  8. package/src/components/utils/question-has-answer.js +45 -11
  9. package/src/components/utils/question-utils.js +3 -37
  10. package/src/controller.js +4 -0
  11. package/src/controller.test.js +5 -1
  12. package/src/journey/journey.js +1 -0
  13. package/src/lib/session-answer-store.js +12 -3
  14. package/src/lib/session-answer-store.test.js +42 -0
  15. package/src/lib/utils.js +11 -0
  16. package/src/lib/utils.test.js +19 -0
  17. package/src/questions/question.js +9 -9
  18. package/src/questions/question.test.js +2 -0
  19. package/src/section-types.d.ts +2 -0
  20. package/src/section.js +5 -1
  21. package/src/section.test.js +7 -3
  22. package/test/check-your-answers.test.js +19 -1
  23. package/test/questions.test.js +3 -3
  24. package/src/components/macros/answer-list.njk +0 -29
  25. package/src/middleware/check-not-submitted.js +0 -13
  26. package/src/middleware/check-not-submitted.test.js +0 -118
  27. package/src/middleware/dynamic-req-files-to-req-body-files.js +0 -46
  28. package/src/middleware/dynamic-req-files-to-req-body-files.test.js +0 -221
  29. package/src/validator/required-file-upload-validator.js +0 -38
  30. package/src/validator/required-file-upload-validator.test.js +0 -141
  31. package/src/validator/schemas/required-file-upload-schema.js +0 -35
package/README.md CHANGED
@@ -146,12 +146,12 @@ to add conditions to questions in multiple ways.
146
146
  The simplest condition applies to just one question:
147
147
 
148
148
  ```javascript
149
- import { questionHasAnswer } from '@pins/dynamic-forms/src/components/utils/question-has-answer';
149
+ import { whenQuestionHasAnswer } from '@pins/dynamic-forms/src/components/utils/question-has-answer';
150
150
 
151
151
  new Section('section-key', 'Section title')
152
152
  .addQuestion(questions.q1)
153
153
  .addQuestion(questions.q2)
154
- .withCondition((response) => questionHasAnswer(response, questions.q1, 'yes'))
154
+ .withCondition(whenQuestionHasAnswer(questions.q1, 'yes'))
155
155
  ```
156
156
 
157
157
  In this scenario question two will only be shown if question one has an answer of 'yes'.
@@ -160,10 +160,10 @@ Conditions can also be added to an entire section:
160
160
 
161
161
  ```javascript
162
162
  new Section('section-key', 'Section title')
163
- .withSectionCondition((response) => questionHasAnswer(response, questions.q1, 'yes'))
163
+ .withSectionCondition(whenQuestionHasAnswer(questions.q1, 'yes'))
164
164
  .addQuestion(questions.q3)
165
165
  .addQuestion(questions.q4)
166
- .withCondition((response) => questionHasAnswer(response, questions.q3, 'yes'))
166
+ .withCondition(whenQuestionHasAnswer(questions.q3, 'yes'))
167
167
  ```
168
168
 
169
169
  In this case q3 and q4 will only be shown if q1 (from a previous section) is answered 'yes'. Q4 will only show if q1 is
@@ -173,16 +173,16 @@ Finally, conditions can be added to multiple questions - these can overlap:
173
173
 
174
174
  ```javascript
175
175
  new Section('section-key', 'Section title')
176
- .withSectionCondition((response) => questionHasAnswer(response, questions.q1, 'yes'))
176
+ .withSectionCondition(whenQuestionHasAnswer(questions.q1, 'yes'))
177
177
  .addQuestion(questions.q2)
178
- .startMultiQuestionCondition('group-1', (response) => questionHasAnswer(response, questions.q2, 'valid'))
178
+ .startMultiQuestionCondition('group-1', whenQuestionHasAnswer(questions.q2, 'valid'))
179
179
  .addQuestion(questions.q3)
180
180
  .addQuestion(questions.q4)
181
- .withCondition((response) => questionHasAnswer(response, questions.q3, 'yes'))
181
+ .withCondition(whenQuestionHasAnswer(questions.q3, 'yes'))
182
182
  .endMultiQuestionCondition('group-1')
183
183
  .addQuestion(questions.q5)
184
- .withCondition((response) => questionHasAnswer(response, questions.q4, 'yes'))
185
- .startMultiQuestionCondition('group-2', (response) => questionHasAnswer(response, questions.q5, 90))
184
+ .withCondition(whenQuestionHasAnswer(questions.q4, 'yes'))
185
+ .startMultiQuestionCondition('group-2', whenQuestionHasAnswer(questions.q5, 90))
186
186
  .addQuestion(questions.q6)
187
187
  .addQuestion(questions.q7)
188
188
  .endMultiQuestionCondition('group-2')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "3.1.0",
3
+ "version": "3.3.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",
@@ -59,7 +59,7 @@ const questionProps = {
59
59
 
60
60
  ### 2. Set up the section within the journey
61
61
 
62
- You can use conditional logic as normal in the `ManageListSection`.
62
+ You can use conditional logic in the `ManageListSection` (see [Conditions](#conditions))
63
63
 
64
64
  ```js
65
65
  new Section('New Holiday', 'holiday')
@@ -94,4 +94,23 @@ router.post(
94
94
  validationErrorHandler,
95
95
  buildSave(saveDataToSession)
96
96
  );
97
- ```
97
+ ```
98
+
99
+ ### Conditions
100
+
101
+ The usual conditions can be used in a `ManageListSection`. However, only answers in the `ManageListSection` will be available to check, not answers for main the journey. For example:
102
+
103
+ ```js
104
+ new Section('New Holiday', 'holiday')
105
+ .addQuestion(questions.holidayDestination)
106
+ // add a manage list question like any other, but include the `ManageListSection` as well
107
+ .addQuestion(
108
+ questions.holidayActivitiesList,
109
+ new ManageListSection()
110
+ .addQuestion(questions.holidayActivityType)
111
+ .addQuestion(questions.holidayActivityDate)
112
+ .withCondition(whenQuestionHasAnswer(questions.holidayActivityType, 'swimming'))
113
+ )
114
+ .addQuestion(questions.departureDate)
115
+ .addQuestion(questions.holidayPeriod)
116
+ ```
@@ -31,12 +31,12 @@
31
31
  <dd class="govuk-summary-list__actions">
32
32
  <ul class="govuk-summary-list__actions-list">
33
33
  <li class="govuk-summary-list__actions-list-item">
34
- <a class="govuk-link" href="{{ originalUrl }}/edit/{{ item.id }}/{{ question.firstQuestionUrl }}">
34
+ <a class="govuk-link" href="{{ util.trimTrailingSlash(originalUrl) }}/edit/{{ item.id }}/{{ question.firstQuestionUrl }}">
35
35
  Change <span class="govuk-visually-hidden">{{ titleSingular }} {{ loop.index }}</span>
36
36
  </a>
37
37
  </li>
38
38
  <li class="govuk-summary-list__actions-list-item">
39
- <a class="govuk-link" href="{{ originalUrl }}/remove/{{ item.id }}/confirm">
39
+ <a class="govuk-link" href="{{ util.trimTrailingSlash(originalUrl) }}/remove/{{ item.id }}/confirm">
40
40
  Remove <span class="govuk-visually-hidden">{{ titleSingular }} {{ loop.index }}</span>
41
41
  </a>
42
42
  </li>
@@ -47,7 +47,7 @@
47
47
  </dl>
48
48
 
49
49
  <p class="govuk-body">
50
- <a href="{{ originalUrl }}/{{ question.addAnotherLink }}" class="govuk-link">Add another</a>
50
+ <a href="{{ util.trimTrailingSlash(originalUrl) }}/{{ question.addAnotherLink }}" class="govuk-link">Add another</a>
51
51
  </p>
52
52
 
53
53
  {{ govukButton({
@@ -73,17 +73,29 @@ export default class ManageListQuestion extends Question {
73
73
  const mockJourney = {
74
74
  getCurrentQuestionUrl() {}
75
75
  };
76
- return this.section.questions.map((q) => {
77
- const formatted = q
78
- .formatAnswerForSummary('', mockJourney, answer[q.fieldName])
79
- .map((a) => a.value)
80
- .join(', ');
76
+ return (
77
+ this.section.questions
78
+ // only show questions which should be displayed based on any conditional logic
79
+ .filter((q) => q.shouldDisplay({ answers: answer }))
80
+ .map((q) => {
81
+ const formatted = q
82
+ .formatAnswerForSummary('', mockJourney, answer[q.fieldName])
83
+ .map((a) => a.value)
84
+ .join(', ');
81
85
 
82
- return {
83
- question: q.title,
84
- answer: formatted
85
- };
86
- });
86
+ return {
87
+ question: q.title,
88
+ answer: formatted
89
+ };
90
+ })
91
+ );
92
+ }
93
+
94
+ async getDataToSave(req, journeyResponse) {
95
+ let responseToSave = { answers: {} };
96
+ const data = journeyResponse.answers[this.fieldName];
97
+ responseToSave.answers[this.fieldName] = data || [];
98
+ return responseToSave;
87
99
  }
88
100
 
89
101
  formatAnswerForSummary(sectionSegment, journey, answer) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ *
3
+ * @param {import('#src/journey/journey-response.js').JourneyResponse} response
4
+ * @param {import('./question.js')} manageListQuestion
5
+ * @param {string} manageListItemId
6
+ * @returns {Record<string, unknown>}
7
+ */
8
+ export function answerObjectForManageList(response, manageListQuestion, manageListItemId) {
9
+ const answers = response.answers[manageListQuestion.fieldName];
10
+ if (!Array.isArray(answers)) {
11
+ return {};
12
+ }
13
+ return answers.find((a) => a.id === manageListItemId) || {};
14
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { answerObjectForManageList } from '#src/components/manage-list/utils.js';
4
+
5
+ describe('manage-list-utils', () => {
6
+ describe('answerObjectForManageList', () => {
7
+ it('should return the manage list item object for manage list questions', () => {
8
+ const manageListQuestion = {
9
+ fieldName: 'manageListQuestion'
10
+ };
11
+ const answers = {
12
+ id: '1',
13
+ myField: 'my-value'
14
+ };
15
+ const journeyResponse = {
16
+ answers: {
17
+ manageListQuestion: [answers]
18
+ }
19
+ };
20
+ const manageListItemId = '1';
21
+ const got = answerObjectForManageList(journeyResponse, manageListQuestion, manageListItemId);
22
+ assert.strictEqual(got, answers);
23
+ });
24
+
25
+ it('should fallback to {} if manage list item id not found', () => {
26
+ const manageListQuestion = {
27
+ fieldName: 'manageListQuestion'
28
+ };
29
+ const answers = {
30
+ id: '2',
31
+ myField: 'my-value'
32
+ };
33
+ const journeyResponse = {
34
+ answers: {
35
+ manageListQuestion: [answers]
36
+ }
37
+ };
38
+ const manageListItemId = '1';
39
+ let got = answerObjectForManageList(journeyResponse, manageListQuestion, manageListItemId);
40
+ assert.deepStrictEqual(got, {});
41
+ // also fallback to {} if there is no manageListQuestion answer
42
+ delete journeyResponse.answers.manageListQuestion;
43
+ got = answerObjectForManageList(journeyResponse, manageListQuestion, manageListItemId);
44
+ assert.deepStrictEqual(got, {});
45
+ });
46
+ });
47
+ });
@@ -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
@@ -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'
@@ -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) {
@@ -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();
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
+ });
@@ -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
  }
@@ -470,7 +471,6 @@ export class Question {
470
471
  return this.validators?.some(
471
472
  (item) =>
472
473
  item instanceof RequiredValidator ||
473
- item instanceof RequiredFileUploadValidator ||
474
474
  (item instanceof AddressValidator && item.isRequired()) ||
475
475
  (item instanceof MultiFieldInputValidator && item.isRequired())
476
476
  );
@@ -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' } });
@@ -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) {