@planning-inspectorate/dynamic-forms 3.2.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.
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.2.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,22 @@ 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
+ );
87
92
  }
88
93
 
89
94
  async getDataToSave(req, journeyResponse) {
@@ -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
  };
@@ -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
+ });
@@ -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) {
@@ -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
  });
@@ -1,29 +0,0 @@
1
- {% from "govuk/components/summary-list/macro.njk" import govukSummaryList %}
2
-
3
- {% macro answerList(answers) %}
4
- {% set summaryRows = [] %}
5
-
6
- {% for item in answers %}
7
- {% set summaryRows = (summaryRows.push({
8
- key: {
9
- text: item.label
10
- },
11
- value: {
12
- html: item.answer | escape | nl2br | safe
13
- },
14
- actions: {
15
- items: [
16
- {
17
- href: item.removeLink,
18
- text: "Remove",
19
- visuallyHiddenText: item.label + ", " + item.answer
20
- }
21
- ]
22
- }
23
- }), summaryRows) %}
24
- {% endfor %}
25
-
26
- {{ govukSummaryList({
27
- rows: summaryRows
28
- }) }}
29
- {% endmacro %}
@@ -1,13 +0,0 @@
1
- /**
2
- * @param {string} alreadySubmittedUrl
3
- * @returns {import('express').Handler}
4
- */
5
- export default (alreadySubmittedUrl) => (req, res, next) => {
6
- const journeyResponse = res?.locals?.journeyResponse;
7
-
8
- if (journeyResponse?.answers?.submitted === 'yes' || journeyResponse?.answers?.submitted === true) {
9
- return res.redirect(alreadySubmittedUrl);
10
- }
11
-
12
- return next();
13
- };
@@ -1,118 +0,0 @@
1
- import { describe, it, mock } from 'node:test';
2
- import assert from 'node:assert';
3
- import checkNotSubmitted from './check-not-submitted.js';
4
-
5
- const testUrl = '/test';
6
-
7
- describe('dynamic-forms/middleware/check-not-submitted', () => {
8
- const mockReq = () => ({
9
- cookies: {},
10
- log: {},
11
- params: {}
12
- });
13
-
14
- [
15
- {
16
- description: 'should do nothing if submitted is undefined',
17
- given: () => {
18
- return {
19
- req: mockReq(),
20
- res: {}
21
- };
22
- },
23
- expected: (req, res, next) => {
24
- assert.strictEqual(next.mock.callCount(), 1);
25
- }
26
- },
27
- {
28
- description: 'should do nothing if submitted is false',
29
- given: () => {
30
- return {
31
- req: mockReq(),
32
- res: {
33
- locals: {
34
- journeyResponse: {
35
- answers: { submitted: false }
36
- }
37
- }
38
- }
39
- };
40
- },
41
- expected: (req, res, next) => {
42
- assert.strictEqual(next.mock.callCount(), 1);
43
- }
44
- },
45
- {
46
- description: 'should do nothing if submitted is no',
47
- given: () => {
48
- return {
49
- req: mockReq(),
50
- res: {
51
- locals: {
52
- journeyResponse: {
53
- answers: {
54
- submitted: 'no'
55
- }
56
- }
57
- }
58
- }
59
- };
60
- },
61
- expected: (req, res, next) => {
62
- assert.strictEqual(next.mock.callCount(), 1);
63
- }
64
- },
65
- {
66
- description: 'should redirect if submitted is true',
67
- given: () => {
68
- return {
69
- req: mockReq(),
70
- res: {
71
- locals: {
72
- journeyResponse: {
73
- answers: {
74
- submitted: true
75
- }
76
- }
77
- },
78
- redirect: mock.fn()
79
- }
80
- };
81
- },
82
- expected: (req, res) => {
83
- assert.strictEqual(res.redirect.mock.callCount(), 1);
84
- }
85
- },
86
- {
87
- description: 'should redirect if submitted is yes',
88
- given: () => {
89
- return {
90
- req: mockReq(),
91
- res: {
92
- locals: {
93
- journeyResponse: {
94
- answers: {
95
- submitted: 'yes'
96
- }
97
- }
98
- },
99
- redirect: mock.fn()
100
- }
101
- };
102
- },
103
- expected: (req, res) => {
104
- assert.strictEqual(res.redirect.mock.callCount(), 1);
105
- }
106
- }
107
- ].forEach(({ description, given, expected }) => {
108
- it(description, () => {
109
- const next = mock.fn();
110
- const req = given().req;
111
- const res = given().res;
112
-
113
- checkNotSubmitted(testUrl)(req, res, next);
114
-
115
- expected(req, res, next);
116
- });
117
- });
118
- });
@@ -1,46 +0,0 @@
1
- /**
2
- * Middleware for converting `req.files` to `req.body.files`.
3
- *
4
- * The requirement for this is due to `express-validator` only considering a limited subset of
5
- * keys on the `req` object as being eligible for validation.
6
- *
7
- * This middleware is designed for use with component-based dynamic forms
8
- *
9
- * This has not been tested with
10
- * pages that contain two or more multiple file uploads.
11
- *
12
- * @returns {function(*, *, *): (*)}
13
- */
14
- export default () => (req, res, next) => {
15
- if (!req.body || !req.files || !req.params) {
16
- return next();
17
- }
18
-
19
- const { journey } = res.locals;
20
-
21
- const questionObj = journey.getQuestionByParams(req.params);
22
- const filesPropertyPath = questionObj.fieldName;
23
-
24
- if (typeof req.files[filesPropertyPath] === 'undefined') {
25
- req.body.files = [];
26
- return next();
27
- }
28
-
29
- try {
30
- req.body.files = {
31
- ...req.body.files,
32
- // Ensure we are always working with an array. Single files would otherwise be an object.
33
- [filesPropertyPath]: Array.isArray(req.files[filesPropertyPath])
34
- ? req.files[filesPropertyPath]
35
- : [req.files[filesPropertyPath]]
36
- };
37
- } catch (err) {
38
- // this feels like it should never happen, as nothing in the try block throws. This is a precaution.
39
- /* istanbul ignore next */
40
- req.log.debug({ err, filesPropertyPath }, 'Error extracting req.files[filesPropertyPath]');
41
- /* istanbul ignore next */
42
- req.body.files = [];
43
- }
44
-
45
- return next();
46
- };
@@ -1,221 +0,0 @@
1
- import { describe, it, mock, beforeEach } from 'node:test';
2
- import assert from 'node:assert';
3
- import dynamicReqFilesToReqBodyFilesMiddleware from './dynamic-req-files-to-req-body-files.js';
4
-
5
- describe('dynamic-forms/middleware/dynamic-req-files-to-req-body-files', () => {
6
- let counter = 0;
7
- let mockReq;
8
- let mockRes;
9
- let filesPropertyPath = `example-files-property-path-${counter}`;
10
-
11
- const mockgetQuestionByParams = mock.fn(() => {
12
- return { fieldName: filesPropertyPath };
13
- });
14
-
15
- beforeEach(() => {
16
- mockReq = () => ({
17
- cookies: {},
18
- log: {},
19
- params: {}
20
- });
21
-
22
- mockRes = () => ({
23
- clearCookie: mock.fn(),
24
- cookie: mock.fn(),
25
- locals: {
26
- journeyResponse: mock.fn(),
27
- journey: { getQuestionByParams: mockgetQuestionByParams }
28
- },
29
- redirect: mock.fn(),
30
- render: mock.fn(),
31
- sendStatus: mock.fn(),
32
- status: mock.fn()
33
- });
34
-
35
- counter++;
36
- filesPropertyPath = `example-files-property-path-${counter}`;
37
- });
38
-
39
- [
40
- {
41
- description: 'should return early if `req.body` is not set',
42
- given: () => mockReq(),
43
- expected: (req, res, next) => {
44
- assert.strictEqual(req.body, undefined);
45
- assert.strictEqual(next.mock.callCount(), 1);
46
- }
47
- },
48
- {
49
- description: 'should return early if `req.files` is not set',
50
- given: () => ({
51
- ...mockReq(),
52
- body: {}
53
- }),
54
- expected: (req, res, next) => {
55
- assert.strictEqual(req.body.files, undefined);
56
- assert.strictEqual(next.mock.callCount(), 1);
57
- }
58
- },
59
- {
60
- description: 'should return early if `req.params` is not set',
61
- given: () => ({
62
- cookies: {},
63
- log: {},
64
- body: {},
65
- files: {}
66
- }),
67
- expected: (req, res, next) => {
68
- assert.strictEqual(req.body.files, undefined);
69
- assert.strictEqual(next.mock.callCount(), 1);
70
- }
71
- },
72
- {
73
- description:
74
- 'should set `req.body.files = []` if unable to find the given file property path `req.files` - unset',
75
- given: () => ({
76
- ...mockReq(),
77
- body: {},
78
- files: {},
79
- params: {
80
- section: 'mock-section',
81
- question: 'mock-question'
82
- }
83
- }),
84
- expected: (req, res, next) => {
85
- assert.deepStrictEqual(req.body.files, []);
86
- assert.strictEqual(next.mock.callCount(), 1);
87
- }
88
- },
89
- {
90
- description:
91
- 'should set `req.body.files = []` if unable to find the given file property path `req.files` - wrong path',
92
- given: () => ({
93
- ...mockReq(),
94
- body: {},
95
- files: {
96
- 'some-different-path': {
97
- x: 'y'
98
- }
99
- },
100
- params: {
101
- section: 'mock-section',
102
- question: 'mock-question'
103
- }
104
- }),
105
- expected: (req, res, next) => {
106
- assert.deepStrictEqual(req.body.files, []);
107
- assert.strictEqual(next.mock.callCount(), 1);
108
- }
109
- },
110
- {
111
- description: 'should set `req.body.files = [req.files]` when given a single file',
112
- given: () => ({
113
- ...mockReq(),
114
- body: {},
115
- params: {
116
- section: 'mock-section',
117
- question: 'mock-question'
118
- },
119
- files: {
120
- [filesPropertyPath]: {
121
- a: 'b'
122
- }
123
- }
124
- }),
125
- expected: (req, res, next) => {
126
- assert.deepStrictEqual(req.body.files, { [filesPropertyPath]: [{ a: 'b' }] });
127
- assert.strictEqual(next.mock.callCount(), 1);
128
- }
129
- },
130
- {
131
- description: 'should set `req.body.files = [...req.files]` when given multiple files',
132
- given: () => ({
133
- ...mockReq(),
134
- body: {},
135
- params: {
136
- section: 'mock-section',
137
- question: 'mock-question'
138
- },
139
- files: {
140
- [filesPropertyPath]: [
141
- {
142
- a: 'b'
143
- },
144
- {
145
- c: 'd'
146
- }
147
- ]
148
- }
149
- }),
150
- expected: (req, res, next) => {
151
- assert.deepStrictEqual(req.body.files, {
152
- [filesPropertyPath]: [
153
- {
154
- a: 'b'
155
- },
156
- {
157
- c: 'd'
158
- }
159
- ]
160
- });
161
- assert.strictEqual(next.mock.callCount(), 1);
162
- }
163
- },
164
- {
165
- description: 'should retain existing req.body.files when working with a different key',
166
- given: () => ({
167
- ...mockReq(),
168
- body: {
169
- files: {
170
- 'an-existing-key-value-pair': [
171
- {
172
- rgb: 'xyz'
173
- }
174
- ]
175
- }
176
- },
177
- files: {
178
- [filesPropertyPath]: [
179
- {
180
- a: 'b'
181
- },
182
- {
183
- c: 'd'
184
- }
185
- ]
186
- },
187
- params: {
188
- section: 'mock-section',
189
- question: 'mock-question'
190
- }
191
- }),
192
- expected: (req, res, next) => {
193
- assert.deepStrictEqual(req.body.files, {
194
- 'an-existing-key-value-pair': [
195
- {
196
- rgb: 'xyz'
197
- }
198
- ],
199
- [filesPropertyPath]: [
200
- {
201
- a: 'b'
202
- },
203
- {
204
- c: 'd'
205
- }
206
- ]
207
- });
208
- assert.strictEqual(next.mock.callCount(), 1);
209
- }
210
- }
211
- ].forEach(({ description, given, expected }) => {
212
- it(description, () => {
213
- const next = mock.fn();
214
- const req = given();
215
-
216
- dynamicReqFilesToReqBodyFilesMiddleware()(req, mockRes(), next);
217
-
218
- expected(req, mockRes(), next);
219
- });
220
- });
221
- });
@@ -1,38 +0,0 @@
1
- import BaseValidator from './base-validator.js';
2
- import { checkSchema } from 'express-validator';
3
-
4
- import requiredFileUploadSchema from './schemas/required-file-upload-schema.js';
5
-
6
- /**
7
- * @typedef {import('../questions/question.js')} Question
8
- */
9
-
10
- /**
11
- * enforces that at least one file is present for a question, whether it is being/already uploaded
12
- * @class
13
- */
14
- export default class RequiredFileUploadValidator extends BaseValidator {
15
- /**
16
- * creates an instance of a RequiredFileUploadValidator
17
- * @param {string} [errorMessage] - custom error message to show on validation failure
18
- */
19
- constructor(errorMessage) {
20
- super();
21
-
22
- if (errorMessage) {
23
- this.errorMessage = errorMessage;
24
- } else {
25
- this.errorMessage = 'You must add your documents';
26
- }
27
- }
28
-
29
- /**
30
- * validates against path based on questionObj's fieldname
31
- * @param {Question} questionObj
32
- */
33
- validate(questionObj, journeyResponse) {
34
- const path = questionObj.fieldName;
35
- const documentType = questionObj.documentType.name;
36
- return checkSchema(requiredFileUploadSchema(path, journeyResponse, documentType, this.errorMessage))[0];
37
- }
38
- }
@@ -1,141 +0,0 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert';
3
- import RequiredFileUploadValidator from './required-file-upload-validator.js';
4
-
5
- describe('./src/dynamic-forms/validator/required-file-upload-validator.js', () => {
6
- describe('validate', () => {
7
- it('should have a rule for path featuring fieldName', () => {
8
- const requiredFileUploadValidator = new RequiredFileUploadValidator();
9
-
10
- const rule = requiredFileUploadValidator
11
- .validate({
12
- fieldName: 'test-field-name',
13
- documentType: {
14
- name: 'test-doc-type'
15
- }
16
- })
17
- .builder.build();
18
-
19
- assert.deepStrictEqual(rule.fields, ['test-field-name']);
20
- assert.strictEqual(!!rule.optional, false);
21
- assert.strictEqual(rule.stack.length, 1);
22
- assert.strictEqual(rule.stack[0].validator.name, 'options');
23
- assert.strictEqual(!!rule.stack[0].validator.negated, false);
24
- });
25
-
26
- it('should pass if file being uploaded', async () => {
27
- const questionObj = {
28
- fieldName: 'test-fieldname',
29
- documentType: {
30
- name: 'test-doc-type'
31
- }
32
- };
33
- const journeyResponse = {};
34
- const req = { files: { test: 'test123' } };
35
-
36
- const requiredFileUploadValidator = new RequiredFileUploadValidator();
37
- const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
38
-
39
- assert.strictEqual(validationResult.errors.length, 0);
40
- });
41
-
42
- it('should pass if no file being uploaded and file matching question document type already uploaded', async () => {
43
- const questionObj = {
44
- fieldName: 'another-test-fieldname',
45
- documentType: {
46
- name: 'test-doc-type'
47
- }
48
- };
49
- const uploadedFile = {
50
- file: 'already-uploaded',
51
- type: 'test-doc-type'
52
- };
53
- const journeyResponse = {
54
- answers: { SubmissionDocumentUpload: [uploadedFile] }
55
- };
56
- const req = { body: {} };
57
-
58
- const requiredFileUploadValidator = new RequiredFileUploadValidator();
59
- const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
60
-
61
- assert.strictEqual(validationResult.errors.length, 0);
62
- });
63
-
64
- it('should fail if no file being uploaded and no file matching question document type already uploaded', async () => {
65
- const questionObj = {
66
- fieldName: 'yet-another-test-fieldname',
67
- documentType: {
68
- name: 'test-doc-type'
69
- }
70
- };
71
- const journeyResponse = { answers: { SubmissionDocumentUpload: [] } };
72
- const req = { body: {} };
73
-
74
- const requiredFileUploadValidator = new RequiredFileUploadValidator();
75
- const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
76
-
77
- assert.strictEqual(validationResult.errors.length, 1);
78
- });
79
-
80
- it('should fail if no file being uploaded matching question doc type and all existing files matching doc type being removed', async () => {
81
- const questionObj = {
82
- fieldName: 'test-fieldname',
83
- documentType: {
84
- name: 'test-doc-type'
85
- }
86
- };
87
-
88
- const journeyResponse = {
89
- answers: {
90
- SubmissionDocumentUpload: [
91
- {
92
- file: 'to-be-removed',
93
- type: 'test-doc-type'
94
- },
95
- {
96
- file: 'to-be-removed-too',
97
- type: 'test-doc-type'
98
- }
99
- ]
100
- }
101
- };
102
- const req = { body: {} };
103
- req.body.removedFiles =
104
- '[{"file": "to-be-removed", "type": "test-doc-type"}, {"file": "to-be-removed-too", "type": "test-doc-type"}]';
105
- const requiredFileUploadValidator = new RequiredFileUploadValidator();
106
- const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
107
-
108
- assert.strictEqual(validationResult.errors.length, 1);
109
- });
110
-
111
- it('should pass if existing files matching question doc type and only some being removed', async () => {
112
- const questionObj = {
113
- fieldName: 'test-fieldname',
114
- documentType: {
115
- name: 'test-doc-type'
116
- }
117
- };
118
- const journeyResponse = {
119
- answers: {
120
- SubmissionDocumentUpload: [
121
- {
122
- file: 'to-be-removed',
123
- type: 'test-doc-type'
124
- },
125
- {
126
- file: 'to-be-kept',
127
- type: 'test-doc-type'
128
- }
129
- ]
130
- }
131
- };
132
- const req = { body: {} };
133
- req.body.removedFiles = '[{"file": "to-be-removed", "type": "test-doc-type"}]';
134
-
135
- const requiredFileUploadValidator = new RequiredFileUploadValidator();
136
- const validationResult = await requiredFileUploadValidator.validate(questionObj, journeyResponse).run(req);
137
-
138
- assert.strictEqual(validationResult.errors.length, 0);
139
- });
140
- });
141
- });
@@ -1,35 +0,0 @@
1
- const schema = (path, journeyResponse, documentType, errorMsg = 'Select a file to upload') => ({
2
- [path]: {
3
- custom: {
4
- options: async (value, { req }) => {
5
- if (!req.files) {
6
- const uploadedFiles = journeyResponse.answers.SubmissionDocumentUpload || [];
7
-
8
- const relevantUploadedFiles = uploadedFiles.filter((upload) => upload.type === documentType);
9
-
10
- if (relevantUploadedFiles) {
11
- //at this stage, uploadedFiles will still contain any files flagged for removal
12
- //so we need to disregard them when checking uploadedFiles
13
- let removedFiles = [];
14
-
15
- if ('removedFiles' in req.body) {
16
- removedFiles = JSON.parse(req.body.removedFiles) || [];
17
- }
18
-
19
- if (relevantUploadedFiles.length - removedFiles.length < 1) {
20
- throw new Error(errorMsg);
21
- }
22
-
23
- return true;
24
- }
25
-
26
- throw new Error(errorMsg);
27
- }
28
-
29
- return true;
30
- }
31
- }
32
- }
33
- });
34
-
35
- export default schema;