@planning-inspectorate/dynamic-forms 3.21.0 → 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 (52) hide show
  1. package/README.md +52 -1
  2. package/docs/Dynamic Sections.md +47 -0
  3. package/package.json +1 -1
  4. package/src/components/manage-list/question.d.ts.map +1 -1
  5. package/src/components/manage-list/question.js +5 -0
  6. package/src/components/manage-list/question.test.js +3 -1
  7. package/src/components/multi-field-input/question.d.ts.map +1 -1
  8. package/src/components/multi-field-input/question.js +7 -5
  9. package/src/components/multi-field-input/question.test.js +126 -213
  10. package/src/components/text-entry-redact/question.d.ts.map +1 -1
  11. package/src/components/text-entry-redact/question.js +3 -1
  12. package/src/components/text-entry-redact/question.test.js +9 -0
  13. package/src/components/unit-option-entry/question.d.ts.map +1 -1
  14. package/src/components/unit-option-entry/question.js +3 -1
  15. package/src/components/unit-option-entry/question.test.js +9 -0
  16. package/src/controller.d.ts +15 -3
  17. package/src/controller.d.ts.map +1 -1
  18. package/src/controller.js +19 -11
  19. package/src/controller.test.js +182 -153
  20. package/src/dynamic-section.d.ts +40 -0
  21. package/src/dynamic-section.d.ts.map +1 -0
  22. package/src/dynamic-section.js +51 -0
  23. package/src/dynamic-section.test.js +113 -0
  24. package/src/index.d.ts +2 -1
  25. package/src/index.js +2 -1
  26. package/src/index.test.js +3 -0
  27. package/src/journey/journey.d.ts +10 -0
  28. package/src/journey/journey.d.ts.map +1 -1
  29. package/src/journey/journey.js +15 -0
  30. package/src/lib/answer-utils.d.ts +22 -0
  31. package/src/lib/answer-utils.d.ts.map +1 -0
  32. package/src/lib/answer-utils.js +34 -0
  33. package/src/{components/manage-list/utils.test.js → lib/answer-utils.test.js} +9 -11
  34. package/src/lib/session-answer-store.d.ts.map +1 -1
  35. package/src/lib/session-answer-store.js +14 -6
  36. package/src/lib/session-answer-store.test.js +43 -0
  37. package/src/middleware/redirect-to-unanswered-question.d.ts.map +1 -1
  38. package/src/middleware/redirect-to-unanswered-question.js +4 -3
  39. package/src/middleware/redirect-to-unanswered-question.test.js +32 -0
  40. package/src/questions/question.d.ts +8 -3
  41. package/src/questions/question.d.ts.map +1 -1
  42. package/src/questions/question.js +16 -6
  43. package/src/questions/question.test.js +19 -0
  44. package/src/section.d.ts +19 -0
  45. package/src/section.d.ts.map +1 -1
  46. package/src/section.js +50 -9
  47. package/src/section.test.js +9 -0
  48. package/test/dynamic-journey.test.js +372 -0
  49. package/typedefs/question-types.d.ts +3 -0
  50. package/src/components/manage-list/utils.d.ts +0 -18
  51. package/src/components/manage-list/utils.d.ts.map +0 -1
  52. package/src/components/manage-list/utils.js +0 -35
package/README.md CHANGED
@@ -86,7 +86,7 @@ The controllers available are:
86
86
 
87
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
88
 
89
- For other use cases, write a custom `SaveDataFn`. A basic implementation (if you are not using ManageList) 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:
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
90
 
91
91
  ```typescript
92
92
  const update: SaveDataFn = async ({data}) => {
@@ -130,6 +130,51 @@ const update: SaveDataFn = async ({data, req, isManageListItem, manageListQuesti
130
130
  }
131
131
  ```
132
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
+
133
178
 
134
179
  ### Conditions
135
180
 
@@ -270,6 +315,12 @@ formatSummaryValue: ({ formattedAnswer, journey }) => {
270
315
 
271
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`).
272
317
 
318
+ ### Other features
319
+
320
+ See documentation in [docs](./docs), including:
321
+
322
+ * [Dynamic Sections](./docs/Dynamic%20Sections.md)
323
+
273
324
  ## Contributing
274
325
 
275
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "3.21.0",
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",
@@ -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"}
@@ -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
  /**