@planning-inspectorate/dynamic-forms 3.20.5 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +100 -0
  2. package/docs/Dynamic Sections.md +47 -0
  3. package/docs/Getting Started.md +8 -2
  4. package/package.json +1 -1
  5. package/src/components/boolean/question.d.ts +1 -1
  6. package/src/components/boolean/question.js +1 -1
  7. package/src/components/date/question.d.ts +1 -1
  8. package/src/components/date/question.js +1 -1
  9. package/src/components/date-period/question.d.ts +1 -1
  10. package/src/components/date-period/question.js +1 -1
  11. package/src/components/date-time/question.d.ts +1 -1
  12. package/src/components/date-time/question.js +1 -1
  13. package/src/components/manage-list/question.d.ts.map +1 -1
  14. package/src/components/manage-list/question.js +5 -0
  15. package/src/components/manage-list/question.test.js +3 -1
  16. package/src/components/multi-field-input/question.d.ts.map +1 -1
  17. package/src/components/multi-field-input/question.js +8 -6
  18. package/src/components/multi-field-input/question.test.js +126 -213
  19. package/src/components/single-line-input/index.njk +4 -0
  20. package/src/components/single-line-input/question.d.ts +2 -0
  21. package/src/components/single-line-input/question.d.ts.map +1 -1
  22. package/src/components/single-line-input/question.js +4 -0
  23. package/src/components/single-line-input/question.test.js +6 -0
  24. package/src/components/text-entry-redact/question.d.ts.map +1 -1
  25. package/src/components/text-entry-redact/question.js +3 -1
  26. package/src/components/text-entry-redact/question.test.js +9 -0
  27. package/src/components/unit-option-entry/question.d.ts +1 -1
  28. package/src/components/unit-option-entry/question.d.ts.map +1 -1
  29. package/src/components/unit-option-entry/question.js +4 -2
  30. package/src/components/unit-option-entry/question.test.js +9 -0
  31. package/src/controller.d.ts +15 -3
  32. package/src/controller.d.ts.map +1 -1
  33. package/src/controller.js +19 -11
  34. package/src/controller.test.js +182 -153
  35. package/src/dynamic-section.d.ts +40 -0
  36. package/src/dynamic-section.d.ts.map +1 -0
  37. package/src/dynamic-section.js +51 -0
  38. package/src/dynamic-section.test.js +113 -0
  39. package/src/index.d.ts +2 -1
  40. package/src/index.js +2 -1
  41. package/src/index.test.js +3 -0
  42. package/src/journey/journey.d.ts +10 -0
  43. package/src/journey/journey.d.ts.map +1 -1
  44. package/src/journey/journey.js +15 -0
  45. package/src/lib/answer-utils.d.ts +22 -0
  46. package/src/lib/answer-utils.d.ts.map +1 -0
  47. package/src/lib/answer-utils.js +34 -0
  48. package/src/{components/manage-list/utils.test.js → lib/answer-utils.test.js} +9 -11
  49. package/src/lib/session-answer-store.d.ts.map +1 -1
  50. package/src/lib/session-answer-store.js +14 -6
  51. package/src/lib/session-answer-store.test.js +43 -0
  52. package/src/middleware/redirect-to-unanswered-question.d.ts.map +1 -1
  53. package/src/middleware/redirect-to-unanswered-question.js +4 -3
  54. package/src/middleware/redirect-to-unanswered-question.test.js +32 -0
  55. package/src/questions/options-question.js +1 -1
  56. package/src/questions/question.d.ts +8 -3
  57. package/src/questions/question.d.ts.map +1 -1
  58. package/src/questions/question.js +16 -6
  59. package/src/questions/question.test.js +19 -0
  60. package/src/section.d.ts +19 -0
  61. package/src/section.d.ts.map +1 -1
  62. package/src/section.js +50 -9
  63. package/src/section.test.js +9 -0
  64. package/test/dynamic-journey.test.js +372 -0
  65. package/typedefs/question-props.d.ts +3 -1
  66. package/typedefs/question-types.d.ts +3 -0
  67. package/src/components/manage-list/utils.d.ts +0 -18
  68. package/src/components/manage-list/utils.d.ts.map +0 -1
  69. package/src/components/manage-list/utils.js +0 -35
package/README.md CHANGED
@@ -82,6 +82,100 @@ The controllers available are:
82
82
  | buildSave | Returns a controller to handle saving the answer and either redirecting to the next question or show any validation errors if present. Accepts a save function. |
83
83
  | list/buildList | Renders a check-your-answers page showing all the answers, links to change any answer, and a button to submit the answers. |
84
84
 
85
+ #### Saving answers
86
+
87
+ The `buildSave` controller accepts a `SaveDataFn` which is passed the context required to save the answer(s). To save answers to session during a journey, a `SaveDataFn` implementation is provided called `saveDataToSession`.
88
+
89
+ For other use cases, write a custom `SaveDataFn`. A basic implementation (if you are not using ManageList or DynamicSection) needs to save the answers passed in as `data.answers`, which is an object whose keys are the question field names, and the values are the answers. For example:
90
+
91
+ ```typescript
92
+ const update: SaveDataFn = async ({data}) => {
93
+ const answers = data.answers;
94
+
95
+ console.log('saving', answers);
96
+
97
+ // TODO: map to database
98
+ // TODO: write to database
99
+ }
100
+ ```
101
+
102
+ To support `ManageList` questions as well, handle the `isManageListItem`, `manageListQuestionFieldName`, `manageListItemRemove` parameters. For example:
103
+
104
+ ```typescript
105
+ const update: SaveDataFn = async ({data, req, isManageListItem, manageListQuestionFieldName, manageListItemRemove}) => {
106
+ if (isManageListItem) {
107
+ const update = {
108
+ id: req.params.manageListItemId,
109
+ ...data.answers
110
+ };
111
+
112
+ console.log('saving', update);
113
+ // TODO: map to database
114
+ // TODO: write to database
115
+ return;
116
+ } else if (manageListItemRemove) {
117
+ const id = req.params.manageListItemId;
118
+ // identify which type of item it is via the field name
119
+ const type = manageListQuestionFieldName;
120
+ console.log('removing item', {id, type});
121
+ // TODO: delete from database
122
+ return;
123
+ }
124
+ const answers = data.answers;
125
+
126
+ console.log('saving', answers);
127
+
128
+ // TODO: map to database
129
+ // TODO: write to database
130
+ }
131
+ ```
132
+
133
+ To support `DynamicSection`, handle `isDynamicSection`, `dynamicSectionFieldName`, and `dynamicSectionId`. The implementation is very similar to the manage lists and the logic can be combined. For example:
134
+
135
+ ```typescript
136
+ const update: SaveDataFn = async ({
137
+ data,
138
+ req,
139
+ isManageListItem,
140
+ manageListQuestionFieldName,
141
+ manageListItemRemove,
142
+ isDynamicSection,
143
+ dynamicSectionFieldName,
144
+ dynamicSectionId
145
+ }) => {
146
+ if (isManageListItem || isDynamicSection) {
147
+ // manage list and dynamic sections can be handled the same, just with different properties
148
+ // for the fieldName and item id
149
+ const fieldName = isManageListItem ? manageListQuestionFieldName : dynamicSectionFieldName;
150
+ const itemId = isManageListItem ? req.params.manageListItemId : dynamicSectionId;
151
+
152
+ const update = {
153
+ id: itemId,
154
+ ...data.answers
155
+ };
156
+
157
+ console.log('saving', update);
158
+ // TODO: map to database
159
+ // TODO: write to database
160
+ return;
161
+ } else if (manageListItemRemove) {
162
+ const id = req.params.manageListItemId;
163
+ // identify which type of item it is via the field name
164
+ const type = manageListQuestionFieldName;
165
+ console.log('removing item', {id, type});
166
+ // TODO: delete from database
167
+ return;
168
+ }
169
+ const answers = data.answers;
170
+
171
+ console.log('saving', answers);
172
+
173
+ // TODO: map to database
174
+ // TODO: write to database
175
+ }
176
+ ```
177
+
178
+
85
179
  ### Conditions
86
180
 
87
181
  Conditions are used to show/hide questions based on the answers to other questions. There are fluent methods on a Section
@@ -221,6 +315,12 @@ formatSummaryValue: ({ formattedAnswer, journey }) => {
221
315
 
222
316
  > **Note:** `formattedAnswer` is already escaped by dynamic-forms; you are responsible for escaping any additional user-controlled values you include (for example `answer` or values read from `journey.response.answers`).
223
317
 
318
+ ### Other features
319
+
320
+ See documentation in [docs](./docs), including:
321
+
322
+ * [Dynamic Sections](./docs/Dynamic%20Sections.md)
323
+
224
324
  ## Contributing
225
325
 
226
326
  When contributing to this package, ensure changes are generic and not service-specific. Speak to the R&D devs if you are not sure. Prefer configuration over hardcoding values, and ensure the code is well documented.
@@ -0,0 +1,47 @@
1
+ # Dynamic Sections
2
+
3
+ Use a dynamic section for sections which are generated from an array of objects.
4
+
5
+ This can be useful for adding a section for each entry in a ManageList, or each row in a database. The controller logic, when encountering a DynamicSection, will check for answers within `journeyResponse.answers[section.fieldName]` which is expected to be an array, and it will look for an object with an `id` property matching the sections' segment.
6
+
7
+ For example, considering a dynamic section for a Person object:
8
+
9
+ ```typescript
10
+ function createPersonSection(person) {
11
+ return new DynamicSection(person.name, person.id, 'people').addQuestion(questions.name);
12
+ // ... other questions
13
+ }
14
+ ```
15
+
16
+ Then the answers object should be of the form:
17
+
18
+ ```typescript
19
+ answers = {
20
+ myField: 'some answer', // a normal question field
21
+ people: [
22
+ // dynamic section fields
23
+ { id: 'person-id-1', name: 'Grogu' },
24
+ { id: 'person-id-2', name: 'Mando' }
25
+ ]
26
+ };
27
+ ```
28
+
29
+ ## Saving data
30
+
31
+ When using with `buildSave`, be sure to handle the parameters: `isDynamicSection`, `dynamicSectionId`,`dynamicSectionFieldName`. The implementation `buildSaveDataToSession` (in `session-answer-store.js`) may be a useful reference.
32
+
33
+ ```typescript
34
+ const buildSaveFn: SaveDataFn = async (params) => {
35
+ if (params.isDynamicSection) {
36
+ // saving a response within a dynamic section
37
+ const itemId = params.dynamicSectionId;
38
+ const listType = params.dynamicSectionFieldName;
39
+
40
+ // some logic to save the data for this item
41
+ }
42
+ };
43
+ ```
44
+
45
+ ## Conditions
46
+
47
+ Question conditions can only access other questions within the section.
@@ -78,7 +78,7 @@ Dynamic forms requires two templates to work for rendering questions and renderi
78
78
  ### Journey (or question) template
79
79
 
80
80
  The journey template is used for question pages. It must include a block called `dynQuestionContent`, and a block called `head` (which the default gov.uk template has).
81
- It should also include the rendering of the errorSummary component. The path to this template is configured via the `journeyTemplate` property on a Journey.
81
+ It should also include the rendering of the errorSummary component, and optionally the back link. The path to this template is configured via the `journeyTemplate` property on a Journey.
82
82
 
83
83
  For example:
84
84
 
@@ -87,7 +87,13 @@ e.g.
87
87
  ```nunjucks
88
88
  {% extends "govuk/template.njk" %}
89
89
 
90
- {% block beforeContent %}
90
+ {% from "govuk/components/back-link/macro.njk" import govukBackLink %}
91
+ {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
92
+
93
+ {% block containerStart %}
94
+ {% if backLink %}
95
+ {{ govukBackLink({ href: backLink }) }}
96
+ {% endif %}
91
97
  {% if errorSummary %}
92
98
  <div class="govuk-grid-row">
93
99
  <div class="govuk-grid-column-two-thirds">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "3.20.5",
3
+ "version": "3.22.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",
@@ -17,7 +17,7 @@ export class BooleanQuestion extends RadioQuestion {
17
17
  /**
18
18
  * Get the data to save from the request, returns an object of answers
19
19
  * @param {import('express').Request} req
20
- * @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
20
+ * @param {JourneyResponse} journeyResponse - current journey response
21
21
  * @returns {Promise<{ answers: Record<string, unknown> }>}
22
22
  */ getDataToSave(req: import("express").Request, journeyResponse: JourneyResponse): Promise<{
23
23
  answers: Record<string, unknown>;
@@ -97,7 +97,7 @@ export class BooleanQuestion extends RadioQuestion {
97
97
  /**
98
98
  * Get the data to save from the request, returns an object of answers
99
99
  * @param {import('express').Request} req
100
- * @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
100
+ * @param {JourneyResponse} journeyResponse - current journey response
101
101
  * @returns {Promise<{ answers: Record<string, unknown> }>}
102
102
  */ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
103
103
  async getDataToSave(req, journeyResponse) {
@@ -10,7 +10,7 @@ export class DateQuestion extends Question {
10
10
  /**
11
11
  * Get the data to save from the request, returns an object of answers
12
12
  * @param {import('express').Request} req
13
- * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response, modified with the new answers
13
+ * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response
14
14
  * @returns {Promise.<Object>}
15
15
  */ getDataToSave(req: import("express").Request, journeyResponse: import("#journey-response").JourneyResponse): Promise<Object>;
16
16
  answerForViewModel(answers: any, isPayload: any): {
@@ -29,7 +29,7 @@ export class DateQuestion extends Question {
29
29
  /**
30
30
  * Get the data to save from the request, returns an object of answers
31
31
  * @param {import('express').Request} req
32
- * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response, modified with the new answers
32
+ * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response
33
33
  * @returns {Promise.<Object>}
34
34
  */ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
35
35
  async getDataToSave(req, journeyResponse) {
@@ -19,7 +19,7 @@ export class DatePeriodQuestion extends Question {
19
19
  /**
20
20
  * Get the data to save from the request, returns an object of answers
21
21
  * @param {import('express').Request} req
22
- * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response, modified with the new answers
22
+ * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response
23
23
  * @returns {Promise.<Object>}
24
24
  */ getDataToSave(req: import("express").Request, journeyResponse: import("#journey-response").JourneyResponse): Promise<Object>;
25
25
  answerForViewModel(answers: any, isPayload: any): {
@@ -54,7 +54,7 @@ export class DatePeriodQuestion extends Question {
54
54
  /**
55
55
  * Get the data to save from the request, returns an object of answers
56
56
  * @param {import('express').Request} req
57
- * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response, modified with the new answers
57
+ * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response
58
58
  * @returns {Promise.<Object>}
59
59
  */ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
60
60
  async getDataToSave(req, journeyResponse) {
@@ -10,7 +10,7 @@ export class DateTimeQuestion extends Question {
10
10
  /**
11
11
  * Get the data to save from the request, returns an object of answers
12
12
  * @param {import('express').Request} req
13
- * @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
13
+ * @param {JourneyResponse} journeyResponse - current journey response
14
14
  * @returns {Promise.<Object>}
15
15
  */ getDataToSave(req: import("express").Request, journeyResponse: JourneyResponse): Promise<Object>;
16
16
  answerForViewModel(answers: any, isPayload: any): {
@@ -38,7 +38,7 @@ export class DateTimeQuestion extends Question {
38
38
  /**
39
39
  * Get the data to save from the request, returns an object of answers
40
40
  * @param {import('express').Request} req
41
- * @param {JourneyResponse} journeyResponse - current journey response, modified with the new answers
41
+ * @param {JourneyResponse} journeyResponse - current journey response
42
42
  * @returns {Promise.<Object>}
43
43
  */ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
44
44
  async getDataToSave(req, journeyResponse) {
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;;;;;;GAMG;AAEH;IAOC;;OAEG;IACH,oBAFW,OAAO,+BAA+B,EAAE,wBAAwB,EAe1E;IAcD,wCAEC;IAyDD;;;;OAMC;IAED;;OAEG;IACH,+BAkBC;IAED,4EAQC;IAoCD,yDAEC;IATD,kDAKC;;CAKD;;;;;;mBApLa,MAAM;;;;;;;;;;;;;;yBAPK,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAKA;;;;;;GAMG;AAEH;IAOC;;OAEG;IACH,oBAFW,OAAO,+BAA+B,EAAE,wBAAwB,EAe1E;IAcD,wCAEC;IA8DD;;;;OAMC;IAED;;OAEG;IACH,+BAkBC;IAED,4EAQC;IAoCD,yDAEC;IATD,kDAKC;;CAKD;;;;;;mBAzLa,MAAM;;;;;;;;;;;;;;yBAPK,WAAW"}
@@ -85,6 +85,11 @@ export class ManageListQuestion extends Question {
85
85
  }
86
86
  const mockJourney = {
87
87
  getCurrentQuestionUrl() {},
88
+ responseForSection() {
89
+ // this function is already passed the array-item
90
+ // call to formatAnswerForSummary may use responseForSection so we implement here
91
+ return this.response;
92
+ },
88
93
  response: {
89
94
  answers: answer
90
95
  }
@@ -54,7 +54,9 @@ describe('components/manage-list/question', () => {
54
54
  const innerQ2 = {
55
55
  url: 'second-question',
56
56
  title: 'Q 2',
57
- formatAnswerForSummary() {
57
+ formatAnswerForSummary(sectionSegment, journey) {
58
+ // simulate a question which may call responseForSection, such as multi field input
59
+ journey.responseForSection(sectionSegment);
58
60
  return [{ value: 'mock answer 2' }];
59
61
  }
60
62
  };
@@ -1 +1 @@
1
- {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAIA;;GAEG;AACH;IACC;;OAEG;IACH,8CAFW,OAAO,+BAA+B,EAAE,6BAA6B,EAe/E;IAJC,kEAA8B;IAchC;;;;;;;;;;;;;;;QAOC;IA4CD;;;;;OAKG;IACH,uCAJW,MAAM,WACN,OAAO,UAAU,EAAE,OAAO,GACxB,OAAO,+BAA+B,EAAE,UAAU,EAAE,CAuChE;;CAsDD;;yBAtLwB,WAAW"}
1
+ {"version":3,"file":"question.d.ts","sourceRoot":"","sources":["question.js"],"names":[],"mappings":"AAIA;;GAEG;AACH;IACC;;OAEG;IACH,8CAFW,OAAO,+BAA+B,EAAE,6BAA6B,EAe/E;IAJC,kEAA8B;IAchC;;;;;;;;;;;;;;;QAOC;IA4CD;;;;;OAKG;IACH,uCAJW,MAAM,WACN,OAAO,UAAU,EAAE,OAAO,GACxB,OAAO,+BAA+B,EAAE,UAAU,EAAE,CAyChE;;CAsDD;;yBAxLwB,WAAW"}
@@ -44,7 +44,7 @@ export class MultiFieldInputQuestion extends Question {
44
44
  /**
45
45
  * Get the data to save from the request, returns an object of answers
46
46
  * @param {import('express').Request} req
47
- * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response, modified with the new answers
47
+ * @param {import('#journey-response').JourneyResponse} journeyResponse - current journey response
48
48
  * @returns {Promise<{ answers: Record<string, unknown> }>}
49
49
  */ //eslint-disable-next-line no-unused-vars -- journeyResponse kept for other questions to use
50
50
  async getDataToSave(req, journeyResponse) {
@@ -90,8 +90,10 @@ export class MultiFieldInputQuestion extends Question {
90
90
  * @returns {import('#typedefs/question-types.d.ts').SummaryRow[]}
91
91
  */
92
92
  formatAnswerForSummary(sectionSegment, journey) {
93
+ // get the response/answers for the section we're in - which might be a dynamic section
94
+ const response = journey.responseForSection(sectionSegment);
93
95
  // Handle unanswered case - delegate to parent for notStartedText
94
- if (this.#allQuestionsUnanswered(journey)) {
96
+ if (this.#allQuestionsUnanswered(response)) {
95
97
  return super.formatAnswerForSummary(sectionSegment, journey, null);
96
98
  }
97
99
 
@@ -99,7 +101,7 @@ export class MultiFieldInputQuestion extends Question {
99
101
  const defaultJoinString = this.isInManageListSection ? '\n' : '<br>';
100
102
 
101
103
  let summaryDetails = this.inputFields.reduce((accumulator, field) => {
102
- const rawAnswer = journey.response.answers[field.fieldName];
104
+ const rawAnswer = response.answers[field.fieldName];
103
105
  if (rawAnswer === undefined || rawAnswer === null || rawAnswer === '') return accumulator;
104
106
 
105
107
  const formatted = this.#formatFieldForSummary(rawAnswer, field, journey, sectionSegment);
@@ -159,11 +161,11 @@ export class MultiFieldInputQuestion extends Question {
159
161
 
160
162
  /**
161
163
  * checks whether any answers have been provided for input field questions
162
- * @param {import('#journey').Journey} journey
164
+ * @param {import('#journey-response').JourneyResponse} response
163
165
  * @returns {boolean}
164
166
  */
165
- #allQuestionsUnanswered(journey) {
166
- return this.inputFields.every((field) => journey.response.answers[field.fieldName] === undefined);
167
+ #allQuestionsUnanswered(response) {
168
+ return this.inputFields.every((field) => response.answers[field.fieldName] === undefined);
167
169
  }
168
170
 
169
171
  /**