@planning-inspectorate/dynamic-forms 3.4.0 → 3.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "3.4.0",
3
+ "version": "3.6.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",
@@ -79,6 +79,10 @@ new Section('New Holiday', 'holiday')
79
79
 
80
80
  `{/:manageListAction/:manageListItemId/:manageListQuestion}` optional route parameters are added to the get and post routes for questions
81
81
 
82
+ * manageListAction should be either `add`, `edit` or `remove` to indicate the action being taken in the manage list. These can be imported from `manage-list-actions.js`.
83
+ * manageListItemId should be a unique identifier for the item being edited or removed, for example an id. When adding an item, an ID is generated.
84
+ * manageListQuestion should be the fieldName of the question being added, edited or removed, this is needed to know which question in the manage list journey to show when adding or editing an item.
85
+
82
86
  ```js
83
87
  router.get('/:section/:question{/:manageListAction/:manageListItemId/:manageListQuestion}',
84
88
  getJourneyResponse,
@@ -114,3 +118,4 @@ new Section('New Holiday', 'holiday')
114
118
  .addQuestion(questions.departureDate)
115
119
  .addQuestion(questions.holidayPeriod)
116
120
  ```
121
+
@@ -0,0 +1,22 @@
1
+ {% extends layoutTemplate %}
2
+
3
+ {% from "govuk/components/button/macro.njk" import govukButton %}
4
+
5
+ {% block dynQuestionContent %}
6
+ <div class="govuk-grid-row">
7
+ <div class="govuk-grid-column-two-thirds">
8
+ <form action="" method="post" novalidate>
9
+ {% if _csrf %}<input type="hidden" name="_csrf" value="{{ _csrf }}">{% endif %}
10
+ <h1 class="govuk-heading-l">Are you sure you want to remove the following {{ titleSingular }}?</h1>
11
+ {% for item in questionSummary %}
12
+ <p class="govuk-body">{{ item.answer | safe }}</p>
13
+ {% endfor %}
14
+ {{ govukButton({
15
+ text: continueButtonText,
16
+ type: "submit",
17
+ attributes: { "data-cy":"button-save-and-continue"}
18
+ }) }}
19
+ </form>
20
+ </div>
21
+ </div>
22
+ {% endblock %}
@@ -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="{{ util.trimTrailingSlash(originalUrl) }}/edit/{{ item.id }}/{{ question.firstQuestionUrl }}">
34
+ <a class="govuk-link" href="{{ util.trimTrailingSlash(originalUrl) }}/{{ actionParams.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="{{ util.trimTrailingSlash(originalUrl) }}/remove/{{ item.id }}/confirm">
39
+ <a class="govuk-link" href="{{ util.trimTrailingSlash(originalUrl) }}/{{ actionParams.remove }}/{{ item.id }}/{{ actionParams.confirmRemove }}">
40
40
  Remove <span class="govuk-visually-hidden">{{ titleSingular }} {{ loop.index }}</span>
41
41
  </a>
42
42
  </li>
@@ -0,0 +1,9 @@
1
+ /**
2
+ *
3
+ * @type {Readonly<{ADD: string, EDIT: string, REMOVE: string}>}
4
+ */
5
+ export const MANAGE_LIST_ACTIONS = Object.freeze({
6
+ ADD: 'add',
7
+ EDIT: 'edit',
8
+ REMOVE: 'remove'
9
+ });
@@ -1,12 +1,14 @@
1
1
  import { Question } from '#question';
2
2
  import { Uuid } from '#src/lib/uuid.js';
3
3
  import nunjucks from 'nunjucks';
4
+ import { MANAGE_LIST_ACTIONS } from './manage-list-actions.js';
4
5
 
5
6
  /**
6
7
  * @typedef {Object} ManageListQuestionParameters
7
8
  * @property {string} titleSingular - the single name of the list item, e.g. "Holiday activity"
8
9
  * @property {boolean} [showManageListQuestions] - whether to show the question titles as well as answers on the manage list summary page
9
10
  * @property {boolean} [showAnswersInSummary] - whether to show the answers on the main check-your-answers page (or just a count)
11
+ * @property {string} [confirmationQuestion] - the name of the confirmation question to use when removing an item, default 'confirm'
10
12
  */
11
13
 
12
14
  export default class ManageListQuestion extends Question {
@@ -14,6 +16,7 @@ export default class ManageListQuestion extends Question {
14
16
  #section;
15
17
  /** @type {boolean} */
16
18
  #showAnswersInSummary;
19
+ #confirmationQuestionParam;
17
20
 
18
21
  /**
19
22
  * @param {import('#question-types').QuestionParameters & ManageListQuestionParameters} params
@@ -30,6 +33,7 @@ export default class ManageListQuestion extends Question {
30
33
  }
31
34
  });
32
35
  this.#showAnswersInSummary = params.showAnswersInSummary || false;
36
+ this.#confirmationQuestionParam = params.confirmationQuestion || 'confirm';
33
37
  }
34
38
 
35
39
  /**
@@ -44,6 +48,10 @@ export default class ManageListQuestion extends Question {
44
48
  return true;
45
49
  }
46
50
 
51
+ get confirmationQuestionParam() {
52
+ return this.#confirmationQuestionParam;
53
+ }
54
+
47
55
  /**
48
56
  * @param {import('#question').QuestionViewModel} viewModel
49
57
  */
@@ -51,6 +59,11 @@ export default class ManageListQuestion extends Question {
51
59
  viewModel.question.addAnotherLink = this.#addAnotherLink;
52
60
  viewModel.question.firstQuestionUrl = this.#firstQuestionUrl;
53
61
  viewModel.question.valueSummary = [];
62
+ viewModel.actionParams = {
63
+ edit: MANAGE_LIST_ACTIONS.EDIT,
64
+ remove: MANAGE_LIST_ACTIONS.REMOVE,
65
+ confirmRemove: this.#confirmationQuestionParam
66
+ };
54
67
  if (viewModel.question.value && Array.isArray(viewModel.question.value)) {
55
68
  viewModel.question.valueSummary = viewModel.question.value.map((v) => {
56
69
  return {
@@ -126,6 +139,16 @@ export default class ManageListQuestion extends Question {
126
139
  ];
127
140
  }
128
141
 
142
+ renderConfirmationAction(res, itemToRemove, viewModel) {
143
+ viewModel.questionSummary = this.#formatItemAnswers(itemToRemove);
144
+ let view = `components/${this.viewFolder}/${this.confirmationQuestionParam}`;
145
+ if (this.viewFolder.includes('/')) {
146
+ // custom view folder
147
+ view = `${this.viewFolder}/${this.confirmationQuestionParam}`;
148
+ }
149
+ res.render(view, viewModel);
150
+ }
151
+
129
152
  /**
130
153
  * Create an 'add another' link,
131
154
  * to the first question in the manage list section
@@ -124,6 +124,21 @@ describe('components/manage-list/question', () => {
124
124
  assertSnapshot(ctx, res.render.mock.calls[0].result, 'manage-list-render-questions.html');
125
125
  });
126
126
 
127
+ it('should render the confirmation view model', (ctx) => {
128
+ const { q, journey } = questionWithManageQuestions(ctx, {
129
+ showManageListQuestions: true
130
+ });
131
+ const itemToRemove = { id: 'id-1', name: 'Test' };
132
+ const viewModel = q.prepQuestionForRendering({}, journey);
133
+ const nunjucks = configureNunjucksTestEnv();
134
+ const res = {
135
+ render: mock.fn((view, data) => nunjucks.render(view + '.njk', data))
136
+ };
137
+ q.renderConfirmationAction(res, itemToRemove, viewModel);
138
+ assert.strictEqual(res.render.mock.callCount(), 1);
139
+ assertSnapshot(ctx, res.render.mock.calls[0].result, 'manage-list-confirmation-render.html');
140
+ });
141
+
127
142
  describe('getDataToSave', () => {
128
143
  it('should return existing journey answers if present', async () => {
129
144
  const q = newQuestion();
package/src/controller.js CHANGED
@@ -2,6 +2,7 @@ import { SECTION_STATUS } from './section.js';
2
2
  import questionUtils from './components/utils/question-utils.js';
3
3
  import { answerObjectForManageListSaving } from '#src/components/manage-list/utils.js';
4
4
  import { booleanToYesNoValue } from '#src/components/boolean/question.js';
5
+ import { MANAGE_LIST_ACTIONS } from '#src/components/manage-list/manage-list-actions.js';
5
6
 
6
7
  /**
7
8
  * @typedef {import('./journey/journey.js').Journey} Journey
@@ -177,6 +178,19 @@ export async function question(req, res) {
177
178
  originalUrl: req.originalUrl
178
179
  }
179
180
  });
181
+ if (
182
+ question.isManageListQuestion &&
183
+ typeof question.renderConfirmationAction === 'function' &&
184
+ req.params.manageListAction === MANAGE_LIST_ACTIONS.REMOVE
185
+ ) {
186
+ const answers = journey.response.answers;
187
+ if (answers && Object.hasOwn(answers, question.fieldName) && Array.isArray(answers[question.fieldName])) {
188
+ const item = answers[question.fieldName].find((i) => i.id === req.params.manageListItemId);
189
+ if (item) return question.renderConfirmationAction(res, item, viewModel);
190
+ }
191
+ // if we can't find the item to remove, redirect to task list rather than erroring out as the session may have expired or been tampered with
192
+ return res.redirect(journey.taskListUrl);
193
+ }
180
194
  return question.renderAction(res, viewModel);
181
195
  }
182
196
 
@@ -188,6 +202,7 @@ export async function question(req, res) {
188
202
  * @property {string} referenceId
189
203
  * @property {boolean} isManageListItem
190
204
  * @property {string} [manageListQuestionFieldName]
205
+ * @property {boolean} [manageListItemRemove]
191
206
  * @property {Object<string, any>} data
192
207
  */
193
208
 
@@ -215,7 +230,7 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
215
230
  }
216
231
 
217
232
  let manageListQuestion;
218
- if (question.isInManageListSection) {
233
+ if (question.isInManageListSection || req.params.manageListAction === MANAGE_LIST_ACTIONS.REMOVE) {
219
234
  // find parent question for the manage list
220
235
  manageListQuestion = journey.getQuestionByParams({ section: req.params.section, question: req.params.question });
221
236
  if (!manageListQuestion) {
@@ -240,6 +255,7 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
240
255
  referenceId: journeyResponse.referenceId,
241
256
  isManageListItem: question.isInManageListSection,
242
257
  manageListQuestionFieldName: manageListQuestion?.fieldName,
258
+ manageListItemRemove: req.params?.manageListAction === MANAGE_LIST_ACTIONS.REMOVE,
243
259
  data
244
260
  });
245
261
 
@@ -7,6 +7,7 @@ import { SECTION_STATUS } from './section.js';
7
7
 
8
8
  import { mockReq, mockRes } from '#test/utils/utils.js';
9
9
  import { BOOLEAN_OPTIONS } from '#src/components/boolean/question.js';
10
+ import { MANAGE_LIST_ACTIONS } from '#src/components/manage-list/manage-list-actions.js';
10
11
 
11
12
  const res = mockRes();
12
13
  const mockBaseUrl = '/manage-appeals/questionnaire';
@@ -351,6 +352,129 @@ describe('dynamic-form/controller', () => {
351
352
  assert.ok(args?.customViewData?.originalUrl);
352
353
  assert.strictEqual(args?.customViewData?.originalUrl, req.originalUrl);
353
354
  });
355
+
356
+ it('should redirect to tasklist when manage list action is remove and journey response has no answers', async () => {
357
+ res.redirect.mock.resetCalls();
358
+
359
+ sampleQuestionObj.isManageListQuestion = true;
360
+ sampleQuestionObj.renderConfirmationAction = mock.fn();
361
+
362
+ req.params = {
363
+ referenceId: mockRef,
364
+ section: mockSection.name,
365
+ question: sampleQuestionObj.fieldName,
366
+ manageListAction: MANAGE_LIST_ACTIONS.REMOVE
367
+ };
368
+ const mockAnswer = undefined;
369
+ const mockBackLink = 'back';
370
+ const mockQuestionRendering = 'test';
371
+
372
+ sampleQuestionObj.toViewModel.mock.mockImplementationOnce(() => mockQuestionRendering);
373
+ mockJourney.getQuestionByParams = mock.fn();
374
+ mockJourney.getQuestionByParams.mock.mockImplementation(() => sampleQuestionObj);
375
+ mockJourney.response.answers = mockAnswer;
376
+ mockJourney.getNextQuestionUrl = mock.fn(() => mockBackLink);
377
+ mockJourney.getSection = mock.fn(() => mockSection);
378
+
379
+ await question(req, res);
380
+
381
+ assert.strictEqual(res.redirect.mock.callCount(), 1);
382
+ assert.deepStrictEqual(res.redirect.mock.calls[0].arguments, [mockJourney.taskListUrl]);
383
+ });
384
+
385
+ it('should redirect to tasklist when manage list action is remove and the manage list answer is undefined', async () => {
386
+ res.redirect.mock.resetCalls();
387
+
388
+ sampleQuestionObj.isManageListQuestion = true;
389
+ sampleQuestionObj.renderConfirmationAction = mock.fn();
390
+
391
+ req.params = {
392
+ referenceId: mockRef,
393
+ section: mockSection.name,
394
+ question: sampleQuestionObj.fieldName,
395
+ manageListAction: MANAGE_LIST_ACTIONS.REMOVE
396
+ };
397
+ const mockAnswer = {
398
+ wrongFieldName: 'Some value'
399
+ };
400
+ const mockBackLink = 'back';
401
+ const mockQuestionRendering = 'test';
402
+
403
+ sampleQuestionObj.toViewModel.mock.mockImplementationOnce(() => mockQuestionRendering);
404
+ mockJourney.getQuestionByParams = mock.fn();
405
+ mockJourney.getQuestionByParams.mock.mockImplementation(() => sampleQuestionObj);
406
+ mockJourney.response.answers = mockAnswer;
407
+ mockJourney.getNextQuestionUrl = mock.fn(() => mockBackLink);
408
+ mockJourney.getSection = mock.fn(() => mockSection);
409
+
410
+ await question(req, res);
411
+
412
+ assert.strictEqual(res.redirect.mock.callCount(), 1);
413
+ assert.deepStrictEqual(res.redirect.mock.calls[0].arguments, [mockJourney.taskListUrl]);
414
+ });
415
+
416
+ it('should redirect to tasklist when manage list action is remove and the manage list answer is not an array', async () => {
417
+ res.redirect.mock.resetCalls();
418
+
419
+ sampleQuestionObj.isManageListQuestion = true;
420
+ sampleQuestionObj.renderConfirmationAction = mock.fn();
421
+
422
+ req.params = {
423
+ referenceId: mockRef,
424
+ section: mockSection.name,
425
+ question: sampleQuestionObj.fieldName,
426
+ manageListAction: MANAGE_LIST_ACTIONS.REMOVE
427
+ };
428
+ const mockAnswer = {
429
+ sampleFieldName: 'not-an-array'
430
+ };
431
+ const mockBackLink = 'back';
432
+ const mockQuestionRendering = 'test';
433
+
434
+ sampleQuestionObj.toViewModel.mock.mockImplementationOnce(() => mockQuestionRendering);
435
+ mockJourney.getQuestionByParams = mock.fn();
436
+ mockJourney.getQuestionByParams.mock.mockImplementation(() => sampleQuestionObj);
437
+ mockJourney.response.answers = mockAnswer;
438
+ mockJourney.getNextQuestionUrl = mock.fn(() => mockBackLink);
439
+ mockJourney.getSection = mock.fn(() => mockSection);
440
+
441
+ await question(req, res);
442
+
443
+ assert.strictEqual(res.redirect.mock.callCount(), 1);
444
+ assert.deepStrictEqual(res.redirect.mock.calls[0].arguments, [mockJourney.taskListUrl]);
445
+ });
446
+
447
+ it('should render the confirmation page when manage list action is remove and the question has a renderConfirmationAction function', async () => {
448
+ res.redirect.mock.resetCalls();
449
+
450
+ sampleQuestionObj.isManageListQuestion = true;
451
+
452
+ req.params = {
453
+ referenceId: mockRef,
454
+ section: mockSection.name,
455
+ question: sampleQuestionObj.fieldName,
456
+ manageListAction: MANAGE_LIST_ACTIONS.REMOVE,
457
+ manageListItemId: 'item-id-1'
458
+ };
459
+ const mockAnswer = {
460
+ sampleFieldName: [{ id: 'item-id-1', name: 'Item 1' }]
461
+ };
462
+ const mockBackLink = 'back';
463
+ const mockQuestionRendering = 'test';
464
+
465
+ sampleQuestionObj.toViewModel.mock.mockImplementationOnce(() => mockQuestionRendering);
466
+ sampleQuestionObj.renderConfirmationAction = mock.fn();
467
+ mockJourney.getQuestionByParams = mock.fn();
468
+ mockJourney.getQuestionByParams.mock.mockImplementation(() => sampleQuestionObj);
469
+ mockJourney.response.answers = mockAnswer;
470
+ mockJourney.getNextQuestionUrl = mock.fn(() => mockBackLink);
471
+ mockJourney.getSection = mock.fn(() => mockSection);
472
+
473
+ await question(req, res);
474
+
475
+ assert.strictEqual(res.redirect.mock.callCount(), 0);
476
+ assert.strictEqual(sampleQuestionObj.renderConfirmationAction.mock.callCount(), 1);
477
+ });
354
478
  });
355
479
 
356
480
  describe('save', () => {
@@ -389,6 +513,7 @@ describe('dynamic-form/controller', () => {
389
513
  journeyId: journeyParams.journeyId,
390
514
  referenceId: journeyParams.referenceId,
391
515
  isManageListItem: undefined,
516
+ manageListItemRemove: false,
392
517
  manageListQuestionFieldName: undefined,
393
518
  data: undefined
394
519
  }
@@ -449,6 +574,7 @@ describe('dynamic-form/controller', () => {
449
574
  referenceId: journeyParams.referenceId,
450
575
  data: undefined,
451
576
  isManageListItem: undefined,
577
+ manageListItemRemove: false,
452
578
  manageListQuestionFieldName: undefined
453
579
  }
454
580
  ],
@@ -633,6 +759,14 @@ describe('dynamic-form/controller', () => {
633
759
  const params = journey.redirectToNextQuestion.mock.calls[0].arguments[1];
634
760
  assert.deepStrictEqual(params, req.params);
635
761
  });
762
+ it('should set manageListItemRemove is true when manageListAction is remove', async () => {
763
+ const { journey, req, res } = setupJourney();
764
+ req.params.manageListAction = MANAGE_LIST_ACTIONS.REMOVE;
765
+ const saveData = mock.fn();
766
+ await buildSave(saveData)(req, res, 'journey-1');
767
+ assert.strictEqual(journey.redirectToNextQuestion.mock.callCount(), 1);
768
+ assert.strictEqual(saveData.mock.calls[0].arguments[0].manageListItemRemove, true);
769
+ });
636
770
  });
637
771
  });
638
772
  });
@@ -4,6 +4,7 @@
4
4
  * instances of this class *
5
5
  ***********************************************************/
6
6
  import { END_OF_SECTION } from '#src/section.js';
7
+ import { MANAGE_LIST_ACTIONS } from '#src/components/manage-list/manage-list-actions.js';
7
8
 
8
9
  /**
9
10
  * @typedef {import('./journey-response').JourneyResponse} JourneyResponse
@@ -174,6 +175,13 @@ export class Journey {
174
175
  return undefined;
175
176
  }
176
177
  if (manageListParams && question.isManageListQuestion) {
178
+ if (
179
+ manageListParams.question === question.confirmationQuestionParam &&
180
+ manageListParams.action === MANAGE_LIST_ACTIONS.REMOVE
181
+ ) {
182
+ // special case for the delete confirmation page
183
+ return question;
184
+ }
177
185
  return question.section.questions.find((q) => matchQuestion(q, manageListParams.question));
178
186
  }
179
187
  return question;
@@ -247,6 +255,17 @@ export class Journey {
247
255
  const numberOfSections = this.sections.length;
248
256
  const sectionsStart = reverse ? numberOfSections - 1 : 0;
249
257
  const questionFieldName = manageListQuestion ? params.manageListQuestion : params.question;
258
+ if (
259
+ params.manageListAction === MANAGE_LIST_ACTIONS.REMOVE &&
260
+ Object.hasOwn(params, 'manageListItemId') &&
261
+ Object.hasOwn(params, 'manageListQuestion')
262
+ ) {
263
+ // If you have just confirmed removal of an item, go back to the parent manage list question
264
+ return this.#buildQuestionUrl({
265
+ section: params.section,
266
+ question: params.question
267
+ });
268
+ }
250
269
 
251
270
  let currentSectionIndex;
252
271
  let foundSection = false;
@@ -2,6 +2,7 @@ import { describe, it, mock, beforeEach } from 'node:test';
2
2
  import assert from 'node:assert';
3
3
  import { Journey } from './journey.js';
4
4
  import { Section } from '#src/section.js';
5
+ import { MANAGE_LIST_ACTIONS } from '#src/components/manage-list/manage-list-actions.js';
5
6
 
6
7
  const mockSectionDetails = [
7
8
  {
@@ -229,6 +230,19 @@ describe('Journey class', () => {
229
230
 
230
231
  assert.strictEqual(question, undefined);
231
232
  });
233
+
234
+ it('should return the parent question if the question is a remove manage list item confirmation', () => {
235
+ const journey = new Journey(constructorArgs);
236
+ journey.sections = mockSections;
237
+ const question = journey.getQuestionByParams({
238
+ section: mockSections[0].segment,
239
+ question: mockSections[0].questions[0].fieldName,
240
+ manageListAction: MANAGE_LIST_ACTIONS.REMOVE,
241
+ manageListQuestion: 'confirm',
242
+ manageListItemId: '123'
243
+ });
244
+ assert.strictEqual(question, mockSections[0].questions[0]);
245
+ });
232
246
  });
233
247
 
234
248
  describe('getBackLink', () => {
@@ -291,6 +305,25 @@ describe('Journey class', () => {
291
305
  });
292
306
 
293
307
  describe('getNextQuestionUrl', () => {
308
+ it('should return to parent manageList question if removing an answer', () => {
309
+ const section = mockSections[2];
310
+ const name = section.questions[0].fieldName;
311
+ const journey = new Journey(constructorArgs);
312
+ journey.sections = mockSections;
313
+ journey.returnToListing = false;
314
+ const nextQuestionUrl = journey.getNextQuestionUrl({
315
+ section: section.segment,
316
+ question: name,
317
+ manageListAction: MANAGE_LIST_ACTIONS.REMOVE,
318
+ manageListQuestion: 'confirm',
319
+ manageListItemId: '123'
320
+ });
321
+ assert.strictEqual(
322
+ nextQuestionUrl,
323
+ `${constructorArgs.makeBaseUrl()}/${section.segment}/${section.questions[0].fieldName}`
324
+ );
325
+ });
326
+
294
327
  for (const returnToListing of [true, false]) {
295
328
  it(`should return null if section is not found [${returnToListing}]`, () => {
296
329
  const section = 'section3'; // Non-existent section
@@ -33,6 +33,10 @@ import { booleanToYesNoValue } from '../components/boolean/question.js';
33
33
  * }
34
34
  * }
35
35
  *
36
+ * Delete behavior for manage-list items:
37
+ * - When `manageListItemRemove === true`, and both `manageListQuestionFieldName` and `req.params.manageListItemId`
38
+ * are present, the item with a matching `id` is removed from the array stored at `answers[manageListQuestionFieldName]`.
39
+ * - If the list does not exist or is not an array, no action is taken.
36
40
  *
37
41
  * @param {Object} opts
38
42
  * @param {string} [opts.reqParam]
@@ -40,7 +44,14 @@ import { booleanToYesNoValue } from '../components/boolean/question.js';
40
44
  * @returns {import('../controller').SaveDataFn}
41
45
  */
42
46
  export function buildSaveDataToSession({ reqParam } = {}) {
43
- return async ({ req, journeyId, data, isManageListItem, manageListQuestionFieldName }) => {
47
+ return async ({
48
+ req,
49
+ journeyId,
50
+ data,
51
+ isManageListItem,
52
+ manageListQuestionFieldName,
53
+ manageListItemRemove = false
54
+ }) => {
44
55
  if (!req.session) {
45
56
  throw new Error('request session required');
46
57
  }
@@ -52,14 +63,25 @@ export function buildSaveDataToSession({ reqParam } = {}) {
52
63
  forms = forms[reqParamValue] || (forms[reqParamValue] = {});
53
64
  }
54
65
  let answers = forms[journeyId] || (forms[journeyId] = {});
66
+
55
67
  if (isManageListItem) {
56
68
  const answersList = answers[manageListQuestionFieldName] || (answers[manageListQuestionFieldName] = []);
57
69
  answers = answersList.find((item) => item.id === req.params.manageListItemId);
58
-
59
70
  if (!answers) {
60
71
  answers = { id: req.params.manageListItemId };
61
72
  answersList.push(answers);
62
73
  }
74
+ } else if (manageListItemRemove && manageListQuestionFieldName && req.params.manageListItemId) {
75
+ const answersList = answers[manageListQuestionFieldName];
76
+
77
+ if (!answersList || !Array.isArray(answersList)) return; // nothing to remove
78
+
79
+ const index = answersList.findIndex((item) => item.id === req.params.manageListItemId);
80
+ if (index > -1) {
81
+ answersList.splice(index, 1);
82
+ return;
83
+ }
84
+ return; // item not found, nothing to remove
63
85
  }
64
86
  for (const [k, v] of Object.entries(data?.answers || {})) {
65
87
  answers[k] = v;
@@ -123,6 +123,34 @@ describe('session-answer-store', () => {
123
123
  });
124
124
  });
125
125
 
126
+ it('should remove a managed list item using the id when present in the session', async () => {
127
+ const req = mockReq();
128
+ req.params = { manageListItemId: '56789' };
129
+ req.session = {
130
+ forms: {
131
+ 'j-1': {
132
+ myManagedList: [
133
+ { id: '12345', q1: 'keep value' },
134
+ { id: '56789', q1: 'remove value' }
135
+ ]
136
+ }
137
+ }
138
+ };
139
+ const data = {};
140
+ const journeyId = 'j-1';
141
+ const manageListQuestionFieldName = 'myManagedList';
142
+ const manageListItemRemove = true;
143
+ const saveDataToSession = buildSaveDataToSession();
144
+ await saveDataToSession({ req, journeyId, data, manageListQuestionFieldName, manageListItemRemove });
145
+ assert.deepStrictEqual(req.session, {
146
+ forms: {
147
+ 'j-1': {
148
+ myManagedList: [{ id: '12345', q1: 'keep value' }]
149
+ }
150
+ }
151
+ });
152
+ });
153
+
126
154
  it('should overwrite existing data by reqParam & journeyId', async () => {
127
155
  const req = mockReq();
128
156
  req.params = { myParam: 'req-1' };
@@ -4,10 +4,12 @@ import { body } from 'express-validator';
4
4
  export default class DocumentUploadValidator extends BaseValidator {
5
5
  /**
6
6
  * @param {string} fieldName
7
+ * @param {string} errorMessage
7
8
  */
8
- constructor(fieldName) {
9
+ constructor(fieldName, errorMessage = 'Upload an attachment') {
9
10
  super();
10
11
  this.fieldName = fieldName;
12
+ this.errorMessage = errorMessage;
11
13
  }
12
14
 
13
15
  validate() {
@@ -17,7 +19,7 @@ export default class DocumentUploadValidator extends BaseValidator {
17
19
  const parsed = JSON.parse(decodedJson);
18
20
 
19
21
  if (parsed.length === 0) {
20
- throw new Error('Upload an attachment');
22
+ throw new Error(this.errorMessage);
21
23
  }
22
24
  return true;
23
25
  })
@@ -20,7 +20,7 @@ describe('./src/dynamic-forms/validator/document-upload-validator.js', () => {
20
20
  assert.strictEqual(Object.keys(errors).length, 0);
21
21
  });
22
22
 
23
- it('should return an error message if documents have not been uploaded', async () => {
23
+ it('should return default error message if documents have not been uploaded', async () => {
24
24
  const req = {
25
25
  body: {
26
26
  myselfAttachments: 'W10='
@@ -33,6 +33,20 @@ describe('./src/dynamic-forms/validator/document-upload-validator.js', () => {
33
33
  assert.strictEqual(Object.keys(errors).length, 1);
34
34
  assert.strictEqual(errors[FIELD_NAME].msg, 'Upload an attachment');
35
35
  });
36
+
37
+ it('should return custom error message if documents have not been uploaded', async () => {
38
+ const req = {
39
+ body: {
40
+ myselfAttachments: 'W10='
41
+ }
42
+ };
43
+ const documentUploadValidator = new DocumentUploadValidator(FIELD_NAME, 'Please ensure you upload your documents');
44
+
45
+ const errors = await _validationMappedErrors(req, documentUploadValidator);
46
+
47
+ assert.strictEqual(Object.keys(errors).length, 1);
48
+ assert.strictEqual(errors[FIELD_NAME].msg, 'Please ensure you upload your documents');
49
+ });
36
50
  });
37
51
 
38
52
  const _validationMappedErrors = async (req, validator) => {
@@ -0,0 +1,12 @@
1
+ <div class="govuk-grid-row">
2
+ <div class="govuk-grid-column-two-thirds">
3
+ <form action="" method="post" novalidate>
4
+ <h1 class="govuk-heading-l">Are you sure you want to remove the following Thing?</h1>
5
+ <p class="govuk-body">mock answer</p>
6
+ <p class="govuk-body">mock answer 2</p>
7
+ <button type="submit" class="govuk-button" data-module="govuk-button" data-cy="button-save-and-continue">
8
+ Continue
9
+ </button>
10
+ </form>
11
+ </div>
12
+ </div>
@@ -0,0 +1,10 @@
1
+ /**
2
+ *
3
+ * @type {Readonly<{ADD: string, EDIT: string, REMOVE: string}>}
4
+ */
5
+ export const MANAGE_LIST_ACTIONS: Readonly<{
6
+ ADD: string;
7
+ EDIT: string;
8
+ REMOVE: string;
9
+ }>;
10
+ //# sourceMappingURL=manage-list-actions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manage-list-actions.d.ts","sourceRoot":"","sources":["../../../../src/components/manage-list/manage-list-actions.js"],"names":[],"mappings":"AAAA;;;GAGG;AACH,kCAFU,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAC,CAAC,CAM5D"}
@@ -3,12 +3,14 @@
3
3
  * @property {string} titleSingular - the single name of the list item, e.g. "Holiday activity"
4
4
  * @property {boolean} [showManageListQuestions] - whether to show the question titles as well as answers on the manage list summary page
5
5
  * @property {boolean} [showAnswersInSummary] - whether to show the answers on the main check-your-answers page (or just a count)
6
+ * @property {string} [confirmationQuestion] - the name of the confirmation question to use when removing an item, default 'confirm'
6
7
  */
7
8
  export default class ManageListQuestion extends Question {
8
9
  /**
9
10
  * @param {import('#question-types').QuestionParameters & ManageListQuestionParameters} params
10
11
  */
11
12
  constructor(params: import("#question-types").QuestionParameters & ManageListQuestionParameters);
13
+ get confirmationQuestionParam(): string;
12
14
  getDataToSave(req: any, journeyResponse: any): Promise<{
13
15
  answers: {
14
16
  [x: string]: any;
@@ -19,6 +21,7 @@ export default class ManageListQuestion extends Question {
19
21
  value: string;
20
22
  action: import("../../controller.js").ActionView | import("../../controller.js").ActionView[] | undefined;
21
23
  }[];
24
+ renderConfirmationAction(res: any, itemToRemove: any, viewModel: any): void;
22
25
  set section(section: import("../../section.js").Section);
23
26
  get section(): import("../../section.js").Section;
24
27
  #private;
@@ -36,6 +39,10 @@ export type ManageListQuestionParameters = {
36
39
  * - whether to show the answers on the main check-your-answers page (or just a count)
37
40
  */
38
41
  showAnswersInSummary?: boolean | undefined;
42
+ /**
43
+ * - the name of the confirmation question to use when removing an item, default 'confirm'
44
+ */
45
+ confirmationQuestion?: string | undefined;
39
46
  };
40
47
  import { Question } from '#question';
41
48
  //# sourceMappingURL=question.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/manage-list/question.js"],"names":[],"mappings":"AAIA;;;;;GAKG;AAEH;IAMC;;OAEG;IACH,oBAFW,OAAO,iBAAiB,EAAE,kBAAkB,GAAG,4BAA4B,EAcrF;IAgED;;;;OAMC;IAED;;;;QAsBC;IAoCD,yDAEC;IATD,kDAKC;;CAKD;;;;;mBA/Ja,MAAM;;;;;;;;;;yBANK,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["../../../../src/components/manage-list/question.js"],"names":[],"mappings":"AAKA;;;;;;GAMG;AAEH;IAOC;;OAEG;IACH,oBAFW,OAAO,iBAAiB,EAAE,kBAAkB,GAAG,4BAA4B,EAerF;IAcD,wCAEC;IAyDD;;;;OAMC;IAED;;;;QAsBC;IAED,4EAQC;IAoCD,yDAEC;IATD,kDAKC;;CAKD;;;;;mBArLa,MAAM;;;;;;;;;;;;;;yBAPK,WAAW"}
@@ -24,6 +24,7 @@ export function question(req: any, res: any): Promise<any>;
24
24
  * @property {string} referenceId
25
25
  * @property {boolean} isManageListItem
26
26
  * @property {string} [manageListQuestionFieldName]
27
+ * @property {boolean} [manageListItemRemove]
27
28
  * @property {Object<string, any>} data
28
29
  */
29
30
  /**
@@ -70,6 +71,7 @@ export type SaveParams = {
70
71
  referenceId: string;
71
72
  isManageListItem: boolean;
72
73
  manageListQuestionFieldName?: string | undefined;
74
+ manageListItemRemove?: boolean | undefined;
73
75
  data: {
74
76
  [x: string]: any;
75
77
  };
@@ -1 +1 @@
1
- {"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../src/controller.js"],"names":[],"mappings":"AAsEA;;;GAGG;AACH,qCAHW,MAAM,OAKhB;AAED;;;;;GAKG;AACH,sDAHW,MAAM,YACN,MAAM,gBA8DhB;AAED;;;;GAIG;AACH,2DA6BC;AAED;;;;;;;;;GASG;AAEH;;GAEG;AAEH;;;;GAIG;AACH,oCAJW,UAAU,gCACV,OAAO,OAsFjB;sBAvRY,OAAO,sBAAsB,EAAE,OAAO;uBACtC,OAAO,yBAAyB,EAAE,QAAQ;;;aAMzC,MAAM;YACN,MAAM;UAEjB;QAAiC,IAAI,EAA1B,KAAK,CAAE,OAAO,CAAC;KAC5B;;;SAIa;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;WAChB;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;;eAC1B,UAAU,EAAE;;;;UAKrB,MAAM;UACN,MAAM;;;;;;eA6JN,MAAM;iBACN,MAAM;sBACN,OAAO;;;;;;yBAMR,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC"}
1
+ {"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../src/controller.js"],"names":[],"mappings":"AAuEA;;;GAGG;AACH,qCAHW,MAAM,OAKhB;AAED;;;;;GAKG;AACH,sDAHW,MAAM,YACN,MAAM,gBA8DhB;AAED;;;;GAIG;AACH,2DA0CC;AAED;;;;;;;;;;GAUG;AAEH;;GAEG;AAEH;;;;GAIG;AACH,oCAJW,UAAU,gCACV,OAAO,OAuFjB;sBAtSY,OAAO,sBAAsB,EAAE,OAAO;uBACtC,OAAO,yBAAyB,EAAE,QAAQ;;;aAMzC,MAAM;YACN,MAAM;UAEjB;QAAiC,IAAI,EAA1B,KAAK,CAAE,OAAO,CAAC;KAC5B;;;SAIa;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;WAChB;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;;eAC1B,UAAU,EAAE;;;;UAKrB,MAAM;UACN,MAAM;;;;;;eA0KN,MAAM;iBACN,MAAM;sBACN,OAAO;;;;;;;yBAOR,CAAC,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"journey.d.ts","sourceRoot":"","sources":["../../../src/journey/journey.js"],"names":[],"mappings":"AAOA;;;;GAIG;AAEH;;;GAGG;AACH;IAwBC;;;;;;;;;;;;;;OAcG;IACH,qLAZG;QAAwB,SAAS,EAAzB,MAAM;QACyC,WAAW,EAA1D,CAAC,QAAQ,EAAE,eAAe,KAAK,MAAM;QACpB,WAAW;QACH,QAAQ,EAAjC,eAAe;QACC,eAAe,EAA/B,MAAM;QACU,gBAAgB,EAAhC,MAAM;QACW,uBAAuB;QACxB,YAAY,EAA5B,MAAM;QACY,eAAe;QACd,QAAQ,EAA3B,OAAO,EAAE;QACQ,eAAe;KAC1C,EAoDA;IAzFD,8EAA8E;IAC9E,WADW,MAAM,CACP;IACV,qEAAqE;IACrE,UADW,KAAK,CAAE,OAAO,CAAC,CACZ;IACd,mFAAmF;IACnF,UADW,eAAe,CACjB;IACT,wFAAwF;IACxF,SADW,MAAM,CACJ;IACb,8HAA8H;IAC9H,aADW,CAAC,eAAe,EAAE,eAAe,KAAK,MAAM,CAChC;IACvB,kEAAkE;IAClE,aADW,MAAM,CACA;IACjB,uEAAuE;IACvE,iBADW,MAAM,CACI;IACrB,qFAAqF;IACrF,kBADW,MAAM,CACK;IACtB,4GAA4G;IAC5G,yBADW,MAAM,CACY;IAC7B,qFAAqF;IACrF,iBADW,OAAO,CACM;IACxB,2DAA2D;IAC3D,cADU,MAAM,CACH;IAmEZ,+BAA8C;IA2C/C;;;;OAIG;IACH,2BAHW,MAAM,GACJ,OAAO,GAAG,SAAS,CAM/B;IAuBD;;;;OAIG;IACH,4BAHW,OAAO,sBAAsB,EAAE,WAAW,GACxC,QAAQ,GAAG,SAAS,CAiBhC;IAED;;;;;;;OAOG;IACH,4CAJG;QAA4D,MAAM,EAA1D,OAAO,sBAAsB,EAAE,WAAW;QACkB,kBAAkB;KACtF,GAAU,MAAM,GAAC,IAAI,CAWvB;IAED;;;;;;;;OAQG;IACH,yCAJW,OAAO,sBAAsB,EAAE,WAAW,kFAExC,IAAI,CAKhB;IAED;;;;;;;;OAQG;IACH,2BANW,OAAO,sBAAsB,EAAE,WAAW,oCAElD;QAA0B,OAAO;QACmC,kBAAkB;KACtF,GAAU,MAAM,GAAC,IAAI,CA8DvB;IAED;;;;;OAKG;IACH,wBAAyB,gBAJd,MAI4B,EAAE,iBAH9B,MAG6C,KAF3C,MAAM,CAqBjB;IAEF;;;;OAIG;IACH,sCAAuC,iBAH5B,MAG2C,KAFzC,MAAM,CAIjB;IAEF;;;;;;OAMG;IACH,0BAA2B,gBALhB,MAK8B,EAAE,iBAJhC,MAI+C,EAAE,UAHjD,MAGyD,KAFvD,MAAM,CAuBjB;IAEF;;;OAGG;IACH,cAFa,OAAO,CAInB;IAED;;OAEG;IACH,6BAFW,eAAe,QAKzB;;CACD"}
1
+ {"version":3,"file":"journey.d.ts","sourceRoot":"","sources":["../../../src/journey/journey.js"],"names":[],"mappings":"AAQA;;;;GAIG;AAEH;;;GAGG;AACH;IAwBC;;;;;;;;;;;;;;OAcG;IACH,qLAZG;QAAwB,SAAS,EAAzB,MAAM;QACyC,WAAW,EAA1D,CAAC,QAAQ,EAAE,eAAe,KAAK,MAAM;QACpB,WAAW;QACH,QAAQ,EAAjC,eAAe;QACC,eAAe,EAA/B,MAAM;QACU,gBAAgB,EAAhC,MAAM;QACW,uBAAuB;QACxB,YAAY,EAA5B,MAAM;QACY,eAAe;QACd,QAAQ,EAA3B,OAAO,EAAE;QACQ,eAAe;KAC1C,EAoDA;IAzFD,8EAA8E;IAC9E,WADW,MAAM,CACP;IACV,qEAAqE;IACrE,UADW,KAAK,CAAE,OAAO,CAAC,CACZ;IACd,mFAAmF;IACnF,UADW,eAAe,CACjB;IACT,wFAAwF;IACxF,SADW,MAAM,CACJ;IACb,8HAA8H;IAC9H,aADW,CAAC,eAAe,EAAE,eAAe,KAAK,MAAM,CAChC;IACvB,kEAAkE;IAClE,aADW,MAAM,CACA;IACjB,uEAAuE;IACvE,iBADW,MAAM,CACI;IACrB,qFAAqF;IACrF,kBADW,MAAM,CACK;IACtB,4GAA4G;IAC5G,yBADW,MAAM,CACY;IAC7B,qFAAqF;IACrF,iBADW,OAAO,CACM;IACxB,2DAA2D;IAC3D,cADU,MAAM,CACH;IAmEZ,+BAA8C;IA2C/C;;;;OAIG;IACH,2BAHW,MAAM,GACJ,OAAO,GAAG,SAAS,CAM/B;IA8BD;;;;OAIG;IACH,4BAHW,OAAO,sBAAsB,EAAE,WAAW,GACxC,QAAQ,GAAG,SAAS,CAiBhC;IAED;;;;;;;OAOG;IACH,4CAJG;QAA4D,MAAM,EAA1D,OAAO,sBAAsB,EAAE,WAAW;QACkB,kBAAkB;KACtF,GAAU,MAAM,GAAC,IAAI,CAWvB;IAED;;;;;;;;OAQG;IACH,yCAJW,OAAO,sBAAsB,EAAE,WAAW,kFAExC,IAAI,CAKhB;IAED;;;;;;;;OAQG;IACH,2BANW,OAAO,sBAAsB,EAAE,WAAW,oCAElD;QAA0B,OAAO;QACmC,kBAAkB;KACtF,GAAU,MAAM,GAAC,IAAI,CAyEvB;IAED;;;;;OAKG;IACH,wBAAyB,gBAJd,MAI4B,EAAE,iBAH9B,MAG6C,KAF3C,MAAM,CAqBjB;IAEF;;;;OAIG;IACH,sCAAuC,iBAH5B,MAG2C,KAFzC,MAAM,CAIjB;IAEF;;;;;;OAMG;IACH,0BAA2B,gBALhB,MAK8B,EAAE,iBAJhC,MAI+C,EAAE,UAHjD,MAGyD,KAFvD,MAAM,CAuBjB;IAEF;;;OAGG;IACH,cAFa,OAAO,CAInB;IAED;;OAEG;IACH,6BAFW,eAAe,QAKzB;;CACD"}
@@ -28,6 +28,10 @@
28
28
  * }
29
29
  * }
30
30
  *
31
+ * Delete behavior for manage-list items:
32
+ * - When `manageListItemRemove === true`, and both `manageListQuestionFieldName` and `req.params.manageListItemId`
33
+ * are present, the item with a matching `id` is removed from the array stored at `answers[manageListQuestionFieldName]`.
34
+ * - If the list does not exist or is not an array, no action is taken.
31
35
  *
32
36
  * @param {Object} opts
33
37
  * @param {string} [opts.reqParam]
@@ -1 +1 @@
1
- {"version":3,"file":"session-answer-store.d.ts","sourceRoot":"","sources":["../../../src/lib/session-answer-store.js"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,sDAJG;IAAsB,QAAQ;CAE9B,OA4BF;AASD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,gFANG;IAA0C,GAAG,EAArC,GAAyB;IACV,SAAS,EAAxB,MAAM;IACuB,WAAW;;;IACxB,QAAQ;CAChC,GAAU,IAAI,CAkBhB;AAED;;;;;;GAMG;AACH,8DAJW,MAAM,aACN,MAAM,OA4BhB;AA/ED;;;;GAIG;AACH,oCAA0D"}
1
+ {"version":3,"file":"session-answer-store.d.ts","sourceRoot":"","sources":["../../../src/lib/session-answer-store.js"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,sDAJG;IAAsB,QAAQ;CAE9B,OA8CF;AASD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,gFANG;IAA0C,GAAG,EAArC,GAAyB;IACV,SAAS,EAAxB,MAAM;IACuB,WAAW;;;IACxB,QAAQ;CAChC,GAAU,IAAI,CAkBhB;AAED;;;;;;GAMG;AACH,8DAJW,MAAM,aACN,MAAM,OA4BhB;AA/ED;;;;GAIG;AACH,oCAA0D"}
@@ -1,8 +1,9 @@
1
1
  export default class DocumentUploadValidator extends BaseValidator {
2
2
  /**
3
3
  * @param {string} fieldName
4
+ * @param {string} errorMessage
4
5
  */
5
- constructor(fieldName: string);
6
+ constructor(fieldName: string, errorMessage?: string);
6
7
  fieldName: string;
7
8
  validate(): import("express-validator").ValidationChain[];
8
9
  }
@@ -1 +1 @@
1
- {"version":3,"file":"document-upload-validator.d.ts","sourceRoot":"","sources":["../../../src/validator/document-upload-validator.js"],"names":[],"mappings":"AAGA;IACC;;OAEG;IACH,uBAFW,MAAM,EAKhB;IADA,kBAA0B;IAG3B,0DAYC;CACD;0BAzByB,qBAAqB"}
1
+ {"version":3,"file":"document-upload-validator.d.ts","sourceRoot":"","sources":["../../../src/validator/document-upload-validator.js"],"names":[],"mappings":"AAGA;IACC;;;OAGG;IACH,uBAHW,MAAM,iBACN,MAAM,EAMhB;IAFA,kBAA0B;IAI3B,0DAYC;CACD;0BA3ByB,qBAAqB"}