@planning-inspectorate/dynamic-forms 2.4.0 → 3.0.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 +1 -1
- package/src/components/manage-list/README.md +97 -0
- package/src/components/manage-list/answer-summary-list.njk +10 -0
- package/src/components/manage-list/index.njk +61 -0
- package/src/components/manage-list/manage-list-section.js +39 -0
- package/src/components/manage-list/manage-list-section.test.js +52 -0
- package/src/components/manage-list/question.js +147 -0
- package/src/components/manage-list/question.test.js +139 -0
- package/src/components/utils/nunjucks.js +20 -0
- package/src/controller.js +39 -10
- package/src/controller.test.js +90 -24
- package/src/index.js +3 -2
- package/src/journey/journey-types.d.ts +11 -1
- package/src/journey/journey.js +64 -18
- package/src/journey/journey.test.js +80 -15
- package/src/questions/question-props.d.ts +2 -1
- package/src/questions/question.js +60 -12
- package/src/questions/question.test.js +26 -3
- package/src/questions/questions.js +3 -1
- package/src/section-types.d.ts +18 -0
- package/src/section.js +40 -11
- package/src/section.test.js +89 -0
- package/test/journey.js +5 -0
- package/test/mock/journey.js +17 -0
- package/test/questions.js +30 -1
- package/test/questions.test.js +104 -40
- package/test/snapshots/check-your-answers-not-started.html +11 -0
- package/test/snapshots/check-your-answers-with-answers.html +11 -0
- package/test/snapshots/manage-list-answer-summary.html +14 -0
- package/test/snapshots/manage-list-render-questions.html +97 -0
- package/test/snapshots/manage-list-render.html +91 -0
- package/test/snapshots/travel-companions.html +19 -0
- package/test/snapshots/travel-companions_travel-companion-email.html +21 -0
- package/test/snapshots/travel-companions_travel-companion-name.html +21 -0
package/package.json
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Manage list
|
|
2
|
+
|
|
3
|
+
The manage list component uses a similar "add to a list" pattern found in other design systems, including:
|
|
4
|
+
|
|
5
|
+
- [Ministry of Justice
|
|
6
|
+
Design System](https://design-patterns.service.justice.gov.uk/patterns/add-to-a-list/)
|
|
7
|
+
- [HM Revenue & Customs
|
|
8
|
+
Design resources](https://design.tax.service.gov.uk/hmrc-design-patterns/add-to-a-list/)
|
|
9
|
+
- [Home Office User-Centred Design Manual](https://design.homeoffice.gov.uk/design-system/patterns/help-users-to/add-multiple-things)
|
|
10
|
+
|
|
11
|
+
Users are able to:
|
|
12
|
+
- repeat a small journey to add multiple items into a list
|
|
13
|
+
- change items in the list
|
|
14
|
+
- remove items from the list
|
|
15
|
+
- check their answers
|
|
16
|
+
|
|
17
|
+
The question view is the check-your-answers page, which by default shows a summary list. The summary list has the value for the item added, and action links for editing or removing the item.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
To use the manage list component, configure a question for the manage list and for each question within the "add to list" journey. The questions for the manage list journey are added to a `ManageListSection` and passed to `Section.addQuestion`. For example:
|
|
22
|
+
|
|
23
|
+
### 1. Set up the questions
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
// Define question configuration
|
|
27
|
+
const questionProps = {
|
|
28
|
+
// ... other questions
|
|
29
|
+
// manage list question
|
|
30
|
+
holidayActivitiesList: {
|
|
31
|
+
type: COMPONENT_TYPES.MANAGE_LIST,
|
|
32
|
+
title: 'Holiday Activities',
|
|
33
|
+
question: 'Holiday Activities',
|
|
34
|
+
fieldName: 'holidayActivitiesList',
|
|
35
|
+
url: 'holiday-activities-list'
|
|
36
|
+
},
|
|
37
|
+
// other questions to use for each item in the list
|
|
38
|
+
holidayActivityType: {
|
|
39
|
+
type: COMPONENT_TYPES.CHECKBOX,
|
|
40
|
+
title: 'Holiday Activity Type',
|
|
41
|
+
question: 'Which type of holiday activity is it?',
|
|
42
|
+
fieldName: 'holidayActivityType',
|
|
43
|
+
url: 'holiday-activity-type',
|
|
44
|
+
options: [
|
|
45
|
+
{ value: 'swimming', text: 'Swimming' },
|
|
46
|
+
{ value: 'hiking', text: 'Hiking' },
|
|
47
|
+
{ value: 'sightseeing', text: 'Sightseeing' }
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
holidayActivityDate: {
|
|
51
|
+
type: COMPONENT_TYPES.DATE,
|
|
52
|
+
title: 'Holiday Activity Date',
|
|
53
|
+
question: 'On which date will you do the holiday activity?',
|
|
54
|
+
fieldName: 'holidayActivityDate',
|
|
55
|
+
url: 'holiday-activity-date'
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 2. Set up the section within the journey
|
|
61
|
+
|
|
62
|
+
You can use conditional logic as normal in the `ManageListSection`.
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
new Section('New Holiday', 'holiday')
|
|
66
|
+
.addQuestion(questions.holidayDestination)
|
|
67
|
+
// add a manage list question like any other, but include the `ManageListSection` as well
|
|
68
|
+
.addQuestion(
|
|
69
|
+
questions.holidayActivitiesList,
|
|
70
|
+
new ManageListSection()
|
|
71
|
+
.addQuestion(questions.holidayActivityType)
|
|
72
|
+
.addQuestion(questions.holidayActivityDate)
|
|
73
|
+
)
|
|
74
|
+
.addQuestion(questions.departureDate)
|
|
75
|
+
.addQuestion(questions.holidayPeriod)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 3. Add extra route parameters
|
|
79
|
+
|
|
80
|
+
`{/:manageListAction/:manageListItemId/:manageListQuestion}` optional route parameters are added to the get and post routes for questions
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
router.get('/:section/:question{/:manageListAction/:manageListItemId/:manageListQuestion}',
|
|
84
|
+
getJourneyResponse,
|
|
85
|
+
getJourney,
|
|
86
|
+
question
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
router.post(
|
|
90
|
+
'/:section/:question{/:manageListAction/:manageListItemId/:manageListQuestion}',
|
|
91
|
+
getJourneyResponse,
|
|
92
|
+
getJourney,
|
|
93
|
+
validate,
|
|
94
|
+
validationErrorHandler,
|
|
95
|
+
buildSave(saveDataToSession)
|
|
96
|
+
);
|
|
97
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
|
|
11
|
+
<h1 class="govuk-heading-l">You have added {{ question.value.length }} {{ question.pageTitle }}</h1>
|
|
12
|
+
|
|
13
|
+
<dl class="govuk-summary-list">
|
|
14
|
+
{% for item in question.valueSummary %}
|
|
15
|
+
<div class="govuk-summary-list__row ">
|
|
16
|
+
<dt class="govuk-summary-list__key">
|
|
17
|
+
{{ titleSingular }} {{ loop.index }}
|
|
18
|
+
</dt>
|
|
19
|
+
<dt class="govuk-summary-list__value">
|
|
20
|
+
{% for value in item.value %}
|
|
21
|
+
{% if showManageListQuestions %}
|
|
22
|
+
{{ value.question }}:<br>
|
|
23
|
+
{% endif %}
|
|
24
|
+
{{ value.answer }}
|
|
25
|
+
{% if not loop.last %}
|
|
26
|
+
<br>{% if showManageListQuestions %}<br>{% endif %}
|
|
27
|
+
|
|
28
|
+
{% endif %}
|
|
29
|
+
{% endfor %}
|
|
30
|
+
</dt>
|
|
31
|
+
<dd class="govuk-summary-list__actions">
|
|
32
|
+
<ul class="govuk-summary-list__actions-list">
|
|
33
|
+
<li class="govuk-summary-list__actions-list-item">
|
|
34
|
+
<a class="govuk-link" href="{{ originalUrl }}/edit/{{ item.id }}/{{ question.firstQuestionUrl }}">
|
|
35
|
+
Change <span class="govuk-visually-hidden">{{ titleSingular }} {{ loop.index }}</span>
|
|
36
|
+
</a>
|
|
37
|
+
</li>
|
|
38
|
+
<li class="govuk-summary-list__actions-list-item">
|
|
39
|
+
<a class="govuk-link" href="{{ originalUrl }}/remove/{{ item.id }}/confirm">
|
|
40
|
+
Remove <span class="govuk-visually-hidden">{{ titleSingular }} {{ loop.index }}</span>
|
|
41
|
+
</a>
|
|
42
|
+
</li>
|
|
43
|
+
</ul>
|
|
44
|
+
</dd>
|
|
45
|
+
</div>
|
|
46
|
+
{% endfor %}
|
|
47
|
+
</dl>
|
|
48
|
+
|
|
49
|
+
<p class="govuk-body">
|
|
50
|
+
<a href="{{ originalUrl }}/{{ question.addAnotherLink }}" class="govuk-link">Add another</a>
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
{{ govukButton({
|
|
54
|
+
text: continueButtonText,
|
|
55
|
+
type: "submit",
|
|
56
|
+
attributes: { "data-cy":"button-save-and-continue"}
|
|
57
|
+
}) }}
|
|
58
|
+
</form>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
{% endblock %}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Section } from '#src/section.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extends the Section class for extra logic around managing lists.
|
|
5
|
+
*/
|
|
6
|
+
export class ManageListSection extends Section {
|
|
7
|
+
constructor() {
|
|
8
|
+
super('unused', 'unused');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Is this section a manage list section?
|
|
13
|
+
*
|
|
14
|
+
* Used by controller and other logic.
|
|
15
|
+
*
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
get isManageListSection() {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Fluent API method for adding questions
|
|
24
|
+
* @param {import('#src/questions/question.js').Question} question
|
|
25
|
+
* @param {import('#src/components/manage-list/manage-list-section.js').ManageListSection} [manageListSection]
|
|
26
|
+
* @returns {Section}
|
|
27
|
+
*/
|
|
28
|
+
addQuestion(question, manageListSection) {
|
|
29
|
+
if (!question) {
|
|
30
|
+
throw new Error('question is required');
|
|
31
|
+
}
|
|
32
|
+
if (question.isManageListQuestion || Boolean(manageListSection)) {
|
|
33
|
+
throw new Error('manage list sections do not support nested manage list questions');
|
|
34
|
+
}
|
|
35
|
+
// mark that this question is within a manage list section for routing and controller logic
|
|
36
|
+
question.isInManageListSection = true;
|
|
37
|
+
return super.addQuestion(question);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { ManageListSection } from '#src/components/manage-list/manage-list-section.js';
|
|
4
|
+
import { Section } from '#src/section.js';
|
|
5
|
+
|
|
6
|
+
describe('components/manage-list/manage-list-section', () => {
|
|
7
|
+
it('should extend Section', () => {
|
|
8
|
+
assert.ok(ManageListSection.prototype instanceof Section);
|
|
9
|
+
});
|
|
10
|
+
it('should implement isManageListSection', () => {
|
|
11
|
+
const mls = new ManageListSection();
|
|
12
|
+
assert.strictEqual(mls.isManageListSection, true);
|
|
13
|
+
});
|
|
14
|
+
it('should mark each question with isInManageListSection', () => {
|
|
15
|
+
const mls = new ManageListSection();
|
|
16
|
+
const mockSetter = mock.fn();
|
|
17
|
+
const q = {
|
|
18
|
+
set isInManageListSection(value) {
|
|
19
|
+
mockSetter(value);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
mls.addQuestion(q);
|
|
23
|
+
assert.strictEqual(mockSetter.mock.callCount(), 1);
|
|
24
|
+
assert.strictEqual(mockSetter.mock.calls[0].arguments[0], true);
|
|
25
|
+
});
|
|
26
|
+
it('should not allow nested manage list questions', () => {
|
|
27
|
+
const mls = new ManageListSection();
|
|
28
|
+
const q = {
|
|
29
|
+
get isManageListQuestion() {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
assert.throws(
|
|
34
|
+
() => mls.addQuestion(q),
|
|
35
|
+
(thrown) => {
|
|
36
|
+
assert.strictEqual(thrown.message, 'manage list sections do not support nested manage list questions');
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
it('should not allow passing nested manageListSection', () => {
|
|
42
|
+
const mls = new ManageListSection();
|
|
43
|
+
const q = {};
|
|
44
|
+
assert.throws(
|
|
45
|
+
() => mls.addQuestion(q, {}),
|
|
46
|
+
(thrown) => {
|
|
47
|
+
assert.strictEqual(thrown.message, 'manage list sections do not support nested manage list questions');
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Question } from '../../questions/question.js';
|
|
2
|
+
import { Uuid } from '#src/lib/uuid.js';
|
|
3
|
+
import escape from 'escape-html';
|
|
4
|
+
import { nunjucksEnv } from '#src/components/utils/nunjucks.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} ManageListQuestionParameters
|
|
8
|
+
* @property {string} titleSingular - the single name of the list item, e.g. "Holiday activity"
|
|
9
|
+
* @property {boolean} [showManageListQuestions] - whether to show the question titles as well as answers on the manage list summary page
|
|
10
|
+
* @property {boolean} [showAnswersInSummary] - whether to show the answers on the main check-your-answers page (or just a count)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export default class ManageListQuestion extends Question {
|
|
14
|
+
/** @type {import('../../section.js').Section} */
|
|
15
|
+
#section;
|
|
16
|
+
/** @type {boolean} */
|
|
17
|
+
#showAnswersInSummary;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {import('#question-types').QuestionParameters & ManageListQuestionParameters} params
|
|
21
|
+
*/
|
|
22
|
+
constructor(params) {
|
|
23
|
+
super({
|
|
24
|
+
...params,
|
|
25
|
+
pageTitle: params.title,
|
|
26
|
+
viewFolder: 'manage-list',
|
|
27
|
+
viewData: {
|
|
28
|
+
...params.viewData,
|
|
29
|
+
titleSingular: params.titleSingular,
|
|
30
|
+
showManageListQuestions: params.showManageListQuestions
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
this.#showAnswersInSummary = params.showAnswersInSummary || false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is this question a manage list question?
|
|
38
|
+
*
|
|
39
|
+
* Used by controller and other logic.
|
|
40
|
+
* Added as a getter so custom components can also be a manage list question.
|
|
41
|
+
*
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
get isManageListQuestion() {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
prepQuestionForRendering(section, journey, customViewData, payload) {
|
|
49
|
+
const viewModel = super.prepQuestionForRendering(section, journey, customViewData, payload);
|
|
50
|
+
viewModel.question.addAnotherLink = this.#addAnotherLink;
|
|
51
|
+
viewModel.question.firstQuestionUrl = this.#firstQuestionUrl;
|
|
52
|
+
viewModel.question.valueSummary = [];
|
|
53
|
+
if (viewModel.question.value && Array.isArray(viewModel.question.value)) {
|
|
54
|
+
viewModel.question.valueSummary = viewModel.question.value.map((v) => {
|
|
55
|
+
return {
|
|
56
|
+
id: v.id,
|
|
57
|
+
value: this.#formatItemAnswers(v)
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return viewModel;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Format the answers to each of the manage list questions
|
|
66
|
+
* @param {{id: string, [k: string]: string}} answer
|
|
67
|
+
* @returns {{question: string, answer: string}[]}
|
|
68
|
+
*/
|
|
69
|
+
#formatItemAnswers(answer) {
|
|
70
|
+
if (this.section.questions.length === 0) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const mockJourney = {
|
|
74
|
+
getCurrentQuestionUrl() {}
|
|
75
|
+
};
|
|
76
|
+
return this.section.questions.map((q) => {
|
|
77
|
+
const formatted = q
|
|
78
|
+
.formatAnswerForSummary('', mockJourney, answer[q.fieldName])
|
|
79
|
+
.map((a) => escape(a.value))
|
|
80
|
+
.join(', ');
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
question: q.title,
|
|
84
|
+
answer: formatted
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
formatAnswerForSummary(sectionSegment, journey, answer) {
|
|
90
|
+
let formattedAnswer = this.notStartedText;
|
|
91
|
+
if (answer && Array.isArray(answer)) {
|
|
92
|
+
if (this.#showAnswersInSummary) {
|
|
93
|
+
const answers = answer.map((a) => this.#formatItemAnswers(a));
|
|
94
|
+
formattedAnswer = nunjucksEnv().render('components/manage-list/answer-summary-list.njk', { answers });
|
|
95
|
+
} else if (answer.length > 0) {
|
|
96
|
+
formattedAnswer = `${answer.length} ${this.title}`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const action = this.getAction(sectionSegment, journey, answer);
|
|
100
|
+
const key = this.title ?? this.question;
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
key: key,
|
|
104
|
+
value: formattedAnswer,
|
|
105
|
+
action: action
|
|
106
|
+
}
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Create an 'add another' link,
|
|
112
|
+
* to the first question in the manage list section
|
|
113
|
+
*
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
get #addAnotherLink() {
|
|
117
|
+
if (this.section.questions.length === 0) {
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
const nextItemId = Uuid.randomUUID();
|
|
121
|
+
const firstQuestion = this.#firstQuestionUrl;
|
|
122
|
+
return `add/${nextItemId}/${firstQuestion}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* First question URL
|
|
127
|
+
*
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
get #firstQuestionUrl() {
|
|
131
|
+
if (this.section.questions.length === 0) {
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
return this.section.questions[0].url;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get section() {
|
|
138
|
+
if (!this.#section) {
|
|
139
|
+
throw new Error('manage list section not set');
|
|
140
|
+
}
|
|
141
|
+
return this.#section;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
set section(section) {
|
|
145
|
+
this.#section = section;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, mock } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import ManageListQuestion from '#src/components/manage-list/question.js';
|
|
4
|
+
import { ManageListSection } from '#src/components/manage-list/manage-list-section.js';
|
|
5
|
+
import { mockJourney } from '#test/mock/journey.js';
|
|
6
|
+
import { mockRandomUUID } from '#src/lib/uuid.test.js';
|
|
7
|
+
import { configureNunjucksTestEnv } from '#test/utils/nunjucks.js';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { snapshotsDir } from '#test/utils/utils.js';
|
|
10
|
+
|
|
11
|
+
describe('components/manage-list/question', () => {
|
|
12
|
+
const TITLE = 'Things';
|
|
13
|
+
const QUESTION = 'Manage list';
|
|
14
|
+
const DESCRIPTION = 'A list of things';
|
|
15
|
+
const FIELDNAME = 'manageListTest';
|
|
16
|
+
const newQuestion = (options = {}) => {
|
|
17
|
+
return new ManageListQuestion({
|
|
18
|
+
title: TITLE,
|
|
19
|
+
question: QUESTION,
|
|
20
|
+
description: DESCRIPTION,
|
|
21
|
+
fieldName: FIELDNAME,
|
|
22
|
+
titleSingular: 'Thing',
|
|
23
|
+
...options
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
it('should create', () => {
|
|
27
|
+
const q = newQuestion();
|
|
28
|
+
assert.strictEqual(q.title, TITLE);
|
|
29
|
+
assert.strictEqual(q.question, QUESTION);
|
|
30
|
+
assert.strictEqual(q.description, DESCRIPTION);
|
|
31
|
+
assert.strictEqual(q.fieldName, FIELDNAME);
|
|
32
|
+
assert.strictEqual(q.viewFolder, 'manage-list');
|
|
33
|
+
assert.strictEqual(q.isManageListQuestion, true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should populate addAnotherLink & firstQuestionUrl', (ctx) => {
|
|
37
|
+
const q = newQuestion();
|
|
38
|
+
mockRandomUUID(ctx);
|
|
39
|
+
q.section = new ManageListSection().addQuestion({ url: 'first-question' });
|
|
40
|
+
const viewModel = q.prepQuestionForRendering({}, mockJourney());
|
|
41
|
+
// add/<v4-guid>/<question-url>
|
|
42
|
+
assert.match(viewModel?.question?.addAnotherLink, /^add\/00000000-0000-0000-0000-000000000000\/first-question/);
|
|
43
|
+
assert.strictEqual(viewModel?.question?.firstQuestionUrl, 'first-question');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const questionWithManageQuestions = (ctx, options) => {
|
|
47
|
+
const q = newQuestion(options);
|
|
48
|
+
const innerQ1 = {
|
|
49
|
+
url: 'first-question',
|
|
50
|
+
title: 'Q 1',
|
|
51
|
+
formatAnswerForSummary() {
|
|
52
|
+
return [{ value: 'mock answer' }];
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const innerQ2 = {
|
|
56
|
+
url: 'second-question',
|
|
57
|
+
title: 'Q 2',
|
|
58
|
+
formatAnswerForSummary() {
|
|
59
|
+
return [{ value: 'mock answer 2' }];
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
mockRandomUUID(ctx);
|
|
63
|
+
const journey = mockJourney();
|
|
64
|
+
journey.response.answers = {
|
|
65
|
+
[FIELDNAME]: [{ id: 'id-1' }, { id: 'id-2' }, { id: 'id-3' }]
|
|
66
|
+
};
|
|
67
|
+
q.section = new ManageListSection().addQuestion(innerQ1).addQuestion(innerQ2);
|
|
68
|
+
return { q, journey };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
it('should format answers to questions in the section', (ctx) => {
|
|
72
|
+
const { q, journey } = questionWithManageQuestions(ctx);
|
|
73
|
+
const viewModel = q.prepQuestionForRendering({}, journey);
|
|
74
|
+
assert.ok(Array.isArray(viewModel?.question?.valueSummary));
|
|
75
|
+
assert.strictEqual(viewModel?.question?.valueSummary.length, 3);
|
|
76
|
+
assert.deepStrictEqual(viewModel?.question?.valueSummary[0], {
|
|
77
|
+
id: 'id-1',
|
|
78
|
+
value: [
|
|
79
|
+
{ question: 'Q 1', answer: 'mock answer' },
|
|
80
|
+
{ question: 'Q 2', answer: 'mock answer 2' }
|
|
81
|
+
]
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should format list answer for summary', (ctx) => {
|
|
86
|
+
const q = newQuestion();
|
|
87
|
+
mockRandomUUID(ctx);
|
|
88
|
+
const answerForSummary = q.formatAnswerForSummary('section-1', mockJourney(), [{}, {}, {}]);
|
|
89
|
+
assert.strictEqual(answerForSummary.length, 1);
|
|
90
|
+
assert.strictEqual(answerForSummary[0].value, '3 Things');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should support showing all answers in the format list answer for summary', (ctx) => {
|
|
94
|
+
const { q } = questionWithManageQuestions(ctx, { showAnswersInSummary: true });
|
|
95
|
+
const answerForSummary = q.formatAnswerForSummary('section-1', mockJourney(), [{}, {}, {}]);
|
|
96
|
+
assert.strictEqual(answerForSummary.length, 1);
|
|
97
|
+
ctx.assert.fileSnapshot(
|
|
98
|
+
answerForSummary[0].value.replaceAll('\r\n', '\n'),
|
|
99
|
+
path.join(snapshotsDir(), 'manage-list-answer-summary.html'),
|
|
100
|
+
{
|
|
101
|
+
serializers: [(v) => v]
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should render with answers', (ctx) => {
|
|
107
|
+
const { q, journey } = questionWithManageQuestions(ctx);
|
|
108
|
+
const viewModel = q.prepQuestionForRendering({}, journey);
|
|
109
|
+
const nunjucks = configureNunjucksTestEnv();
|
|
110
|
+
const res = {
|
|
111
|
+
render: mock.fn((view, data) => nunjucks.render(view + '.njk', data))
|
|
112
|
+
};
|
|
113
|
+
q.renderAction(res, viewModel);
|
|
114
|
+
assert.strictEqual(res.render.mock.callCount(), 1);
|
|
115
|
+
ctx.assert.fileSnapshot(res.render.mock.calls[0].result, path.join(snapshotsDir(), 'manage-list-render.html'), {
|
|
116
|
+
serializers: [(v) => v]
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should render with answers & questions', (ctx) => {
|
|
121
|
+
const { q, journey } = questionWithManageQuestions(ctx, {
|
|
122
|
+
showManageListQuestions: true
|
|
123
|
+
});
|
|
124
|
+
const viewModel = q.prepQuestionForRendering({}, journey);
|
|
125
|
+
const nunjucks = configureNunjucksTestEnv();
|
|
126
|
+
const res = {
|
|
127
|
+
render: mock.fn((view, data) => nunjucks.render(view + '.njk', data))
|
|
128
|
+
};
|
|
129
|
+
q.renderAction(res, viewModel);
|
|
130
|
+
assert.strictEqual(res.render.mock.callCount(), 1);
|
|
131
|
+
ctx.assert.fileSnapshot(
|
|
132
|
+
res.render.mock.calls[0].result,
|
|
133
|
+
path.join(snapshotsDir(), 'manage-list-render-questions.html'),
|
|
134
|
+
{
|
|
135
|
+
serializers: [(v) => v]
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import nunjucks from 'nunjucks';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns a nunjucks environment configured with the `src` folder
|
|
6
|
+
* @returns {import('nunjucks').Environment}
|
|
7
|
+
*/
|
|
8
|
+
export function nunjucksEnv() {
|
|
9
|
+
const utilsDir = path.dirname(new URL(import.meta.url).pathname);
|
|
10
|
+
const srcDir = path.resolve(utilsDir, '..', '..');
|
|
11
|
+
|
|
12
|
+
return nunjucks.configure([srcDir], {
|
|
13
|
+
// output with dangerous characters are escaped automatically
|
|
14
|
+
autoescape: true,
|
|
15
|
+
// automatically remove trailing newlines from a block/tag
|
|
16
|
+
trimBlocks: true,
|
|
17
|
+
// automatically remove leading whitespace from a block/tag
|
|
18
|
+
lstripBlocks: true
|
|
19
|
+
});
|
|
20
|
+
}
|
package/src/controller.js
CHANGED
|
@@ -143,10 +143,11 @@ export async function list(req, res, pageCaption, viewData) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
+
* Render an individual question
|
|
147
|
+
*
|
|
146
148
|
* @type {import('express').Handler}
|
|
147
149
|
*/
|
|
148
150
|
export async function question(req, res) {
|
|
149
|
-
//render an individual question
|
|
150
151
|
const { journey } = res.locals;
|
|
151
152
|
|
|
152
153
|
const section = journey.getSection(req.params.section);
|
|
@@ -156,8 +157,23 @@ export async function question(req, res) {
|
|
|
156
157
|
return res.redirect(journey.taskListUrl);
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
let manageListQuestion;
|
|
161
|
+
if (question.isInManageListSection) {
|
|
162
|
+
// find parent question for the manage list
|
|
163
|
+
manageListQuestion = journey.getQuestionByParams({ section: req.params.section, question: req.params.question });
|
|
164
|
+
if (!manageListQuestion) {
|
|
165
|
+
return res.redirect(journey.taskListUrl);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const viewModel = question.toViewModel({
|
|
170
|
+
params: req.params,
|
|
171
|
+
manageListQuestion,
|
|
172
|
+
section,
|
|
173
|
+
journey,
|
|
174
|
+
customViewData: {
|
|
175
|
+
originalUrl: req.originalUrl
|
|
176
|
+
}
|
|
161
177
|
});
|
|
162
178
|
return question.renderAction(res, viewModel);
|
|
163
179
|
}
|
|
@@ -194,9 +210,18 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
|
|
|
194
210
|
return res.redirect(journey.taskListUrl);
|
|
195
211
|
}
|
|
196
212
|
|
|
213
|
+
let manageListQuestion;
|
|
214
|
+
if (question.isInManageListSection) {
|
|
215
|
+
// find parent question for the manage list
|
|
216
|
+
manageListQuestion = journey.getQuestionByParams({ section: req.params.section, question: req.params.question });
|
|
217
|
+
if (!manageListQuestion) {
|
|
218
|
+
return res.redirect(journey.taskListUrl);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
197
222
|
try {
|
|
198
223
|
// check for validation errors
|
|
199
|
-
const errorViewModel = question.checkForValidationErrors(req, section, journey);
|
|
224
|
+
const errorViewModel = question.checkForValidationErrors(req, section, journey, manageListQuestion);
|
|
200
225
|
if (errorViewModel) {
|
|
201
226
|
return question.renderAction(res, errorViewModel);
|
|
202
227
|
}
|
|
@@ -221,13 +246,17 @@ export function buildSave(saveData, redirectToTaskListOnSuccess) {
|
|
|
221
246
|
return res.redirect(journey.taskListUrl);
|
|
222
247
|
}
|
|
223
248
|
// move to the next question
|
|
224
|
-
return journey.redirectToNextQuestion(res,
|
|
225
|
-
section: section.segment,
|
|
226
|
-
question: question.fieldName
|
|
227
|
-
});
|
|
249
|
+
return journey.redirectToNextQuestion(res, req.params, manageListQuestion);
|
|
228
250
|
} catch (err) {
|
|
229
|
-
const viewModel = question.
|
|
230
|
-
|
|
251
|
+
const viewModel = question.toViewModel({
|
|
252
|
+
params: req.params,
|
|
253
|
+
manageListQuestion,
|
|
254
|
+
section,
|
|
255
|
+
journey,
|
|
256
|
+
customViewData: {
|
|
257
|
+
originalUrl: req.originalUrl,
|
|
258
|
+
errorSummary: err.errorSummary ?? [{ text: err.toString(), href: '#' }]
|
|
259
|
+
}
|
|
231
260
|
});
|
|
232
261
|
return question.renderAction(res, viewModel);
|
|
233
262
|
}
|