@mcpher/gas-fakes 1.2.20 → 1.2.21

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.
@@ -1,7 +1,16 @@
1
1
  import { Proxies } from '../../support/proxies.js';
2
2
  import { newFakeFormItem } from './fakeformitem.js';
3
+ import { newFakeFormResponse } from './fakeformresponse.js';
4
+ import { newFakeGridItem } from './fakegriditem.js';
5
+ import { newFakeCheckboxGridItem } from './fakecheckboxgriditem.js';
6
+ import { newFakeSectionHeaderItem } from './fakesectionheaderitem.js';
7
+ import { newFakeScaleItem } from './fakescaleitem.js';
3
8
  import './formitems.js'; // Import for side effects (item class registration)
9
+ import { newFakeMultipleChoiceItem } from './fakemultiplechoiceitem.js';
4
10
  import { newFakeCheckboxItem } from './fakecheckboxitem.js';
11
+ import { newFakeListItem } from './fakelistitem.js';
12
+ import { newFakePageBreakItem } from './fakepagebreakitem.js';
13
+ import { newFakeTextItem } from './faketextitem.js';
5
14
 
6
15
  export const newFakeForm = (...args) => {
7
16
  return Proxies.guard(new FakeForm(...args));
@@ -17,19 +26,22 @@ export class FakeForm {
17
26
  * @param {object} resource the form resource from Forms API
18
27
  */
19
28
  constructor(resource) {
29
+ // Store the resource provided at creation as the single source of truth for this instance.
20
30
  this.__id = resource.formId;
21
31
  this.__file = DriveApp.getFileById(this.__id);
32
+ // Since the API doesn't allow setting the published state, we'll manage it internally for the fake.
33
+ // A new form defaults to accepting responses (true).
34
+ this.__publishedState = resource.settings?.state !== 'INACTIVE';
22
35
  }
23
36
 
24
37
  get __resource() {
25
38
  return Forms.Form.get(this.__id);
26
39
  }
27
-
28
40
  saveAndClose() {
29
41
  // this is a no-op in fake environment since it is stateless
30
42
  }
31
43
 
32
- _addItem(itemResource, itemFactory) {
44
+ __addItem(itemResource, itemFactory) {
33
45
  const createRequest = Forms.newRequest().setCreateItem({
34
46
  item: itemResource,
35
47
  location: {
@@ -62,16 +74,178 @@ export class FakeForm {
62
74
  question: {
63
75
  choiceQuestion: {
64
76
  type: 'CHECKBOX',
65
- // The API requires at least one non-empty option. Live Apps Script creates one with an empty value.
66
- // We'll emulate by creating a default "Option 1" in the fake environment.
77
+ // The API requires at least one option on creation.
67
78
  options: [{ value: 'Option 1' }],
68
79
  },
69
80
  },
70
81
  },
71
82
  };
72
- return this._addItem(itemResource, newFakeCheckboxItem);
83
+ return this.__addItem(itemResource, newFakeCheckboxItem);
84
+ }
85
+
86
+ /**
87
+ * Appends a new question item, presented as a grid of columns and rows, that allows the
88
+ * respondent to select one choice per row from a sequence of radio buttons.
89
+ * @returns {import('./fakegriditem.js').FakeGridItem} The new grid item.
90
+ */
91
+ addGridItem() {
92
+ const itemResource = {
93
+ questionGroupItem: {
94
+ grid: {
95
+ columns: {
96
+ type: 'RADIO',
97
+ // The API requires at least one column
98
+ options: [{ value: 'Column 1' }],
99
+ },
100
+ },
101
+ // and at least one row
102
+ questions: [
103
+ {
104
+ rowQuestion: {
105
+ title: 'Row 1',
106
+ },
107
+ },
108
+ ],
109
+ },
110
+ };
111
+ return this.__addItem(itemResource, newFakeGridItem);
112
+ }
113
+
114
+ /**
115
+ * Appends a new question item, presented as a grid of columns and rows, that allows the
116
+ * respondent to select multiple choices per row from a sequence of checkboxes.
117
+ * @returns {import('./fakecheckboxgriditem.js').FakeCheckboxGridItem} The new checkbox grid item.
118
+ */
119
+ addCheckboxGridItem() {
120
+ const itemResource = {
121
+ questionGroupItem: {
122
+ grid: {
123
+ columns: {
124
+ type: 'CHECKBOX',
125
+ // The API requires at least one column
126
+ options: [{ value: 'Column 1' }],
127
+ },
128
+ },
129
+ // and at least one row
130
+ questions: [
131
+ {
132
+ rowQuestion: {
133
+ title: 'Row 1',
134
+ },
135
+ },
136
+ ],
137
+ },
138
+ };
139
+ // Note: This will require a newFakeCheckboxGridItem factory.
140
+ return this.__addItem(itemResource, newFakeCheckboxGridItem);
141
+ }
142
+
143
+ /**
144
+ * Appends a new layout item that marks the beginning of a new page in the form.
145
+ * @returns {import('./fakepagebreakitem.js').FakePageBreakItem} The new page-break item.
146
+ */
147
+ addPageBreakItem() {
148
+ const itemResource = {
149
+ pageBreakItem: {},
150
+ };
151
+ return this.__addItem(itemResource, newFakePageBreakItem);
152
+ }
153
+
154
+
155
+ /**
156
+ * Appends a new question item that allows the respondent to choose one option
157
+ * from a drop-down list.
158
+ * @returns {import('./fakelistitem.js').FakeListItem} The new list item.
159
+ */
160
+ addListItem() {
161
+ const itemResource = {
162
+ questionItem: {
163
+ question: {
164
+ choiceQuestion: {
165
+ type: 'DROP_DOWN',
166
+ // The API requires at least one option on creation.
167
+ options: [{ value: 'Option 1' }],
168
+ },
169
+ },
170
+ },
171
+ };
172
+ return this.__addItem(itemResource, newFakeListItem);
173
+ }
174
+
175
+
176
+ /**
177
+ * Appends a new question item that allows the respondent to choose one option
178
+ * from a list of choices.
179
+ * @returns {import('./fakemultiplechoiceitem.js').FakeMultipleChoiceItem} The new multiple choice item.
180
+ */
181
+ addMultipleChoiceItem() {
182
+ const itemResource = {
183
+ questionItem: {
184
+ question: {
185
+ choiceQuestion: {
186
+ type: 'RADIO',
187
+ // The API requires at least one option on creation.
188
+ options: [{ value: 'Option 1' }],
189
+ },
190
+ },
191
+ },
192
+ };
193
+ return this.__addItem(itemResource, newFakeMultipleChoiceItem);
194
+ }
195
+
196
+ /**
197
+ * Appends a new layout item that visually indicates the start of a section.
198
+ * @returns {import('./fakesectionheaderitem.js').FakeSectionHeaderItem} The new section header item.
199
+ */
200
+ addSectionHeaderItem() {
201
+ const itemResource = {
202
+ // The API resource for a section header is an item with a title and description.
203
+ // It is identified by the presence of the `textItem` property.
204
+ title: '',
205
+ textItem: {},
206
+ };
207
+ return this.__addItem(itemResource, newFakeSectionHeaderItem);
208
+ }
209
+
210
+ /**
211
+ * Appends a new question item that allows the respondent to choose one option
212
+ * from a numbered sequence of radio buttons.
213
+ * @returns {import('./fakescaleitem.js').FakeScaleItem} The new scale item.
214
+ */
215
+ addScaleItem() {
216
+ const itemResource = {
217
+ questionItem: {
218
+ question: {
219
+ scaleQuestion: {
220
+ low: 1,
221
+ high: 5,
222
+ },
223
+ },
224
+ },
225
+ };
226
+ return this.__addItem(itemResource, newFakeScaleItem);
227
+ }
228
+
229
+ /**
230
+ * Appends a new question item that allows the respondent to enter a single
231
+ * line of text.
232
+ * @returns {import('./faketextitem.js').FakeTextItem} The new text item.
233
+ */
234
+ addTextItem() {
235
+ const itemResource = {
236
+ questionItem: {
237
+ question: {
238
+ textQuestion: {
239
+ paragraph: false, // false for short-answer
240
+ },
241
+ },
242
+ },
243
+ };
244
+ return this.__addItem(itemResource, newFakeTextItem);
73
245
  }
74
246
 
247
+
248
+
75
249
  /**
76
250
  * Gets the ID of the form.
77
251
  * @returns {string} The form ID.
@@ -89,10 +263,17 @@ export class FakeForm {
89
263
  if (!this.__resource.items) {
90
264
  return null;
91
265
  }
92
- // The API uses string IDs, but Apps Script often uses numbers.
93
- // We'll handle both by converting the input to a string for comparison.
94
- const stringId = id.toString();
95
- const itemResource = this.__resource.items.find(item => item.itemId === stringId);
266
+ const isKnownItem = (id, item) => {
267
+ if (item.itemId === id) return true;
268
+ if (item.questionItem?.question?.questionId === id) return true;
269
+ const qgroup = item.questionGroupItem?.questions
270
+ if (!qgroup) return false
271
+ const found = qgroup.some(q => q.questionId === id)
272
+ return found
273
+ }
274
+
275
+ const itemResource = this.__resource.items.find((item) => isKnownItem(id, item));
276
+
96
277
  if (!itemResource) {
97
278
  return null;
98
279
  }
@@ -101,10 +282,19 @@ export class FakeForm {
101
282
 
102
283
  /**
103
284
  * Gets all items in the form.
285
+ * @param {import('../enums/formsenums.js').ItemType} [itemType] If provided, only items of this type are returned.
104
286
  * @returns {import('./fakeformitem.js').FakeFormItem[]} An array of all items in the form.
105
287
  */
106
- getItems() {
107
- return this.__resource.items?.map((item) => newFakeFormItem(this, item.itemId)) || [];
288
+ getItems(itemType) {
289
+ const allItems = this.__resource.items?.map((item) => newFakeFormItem(this, item.itemId)) || [];
290
+
291
+ if (itemType) {
292
+ // The itemType from the enum will be an object, so we compare its string representation
293
+ // against the string representation of the item's type.
294
+ return allItems.filter(item => item.getType().toString() === itemType.toString());
295
+ }
296
+
297
+ return allItems;
108
298
  }
109
299
 
110
300
  /**
@@ -114,7 +304,13 @@ export class FakeForm {
114
304
  getTitle() {
115
305
  return this.__resource.info.title;
116
306
  }
117
-
307
+ /**
308
+ * Gets the title of the form.
309
+ * @returns {string} The form title.
310
+ */
311
+ getDescription() {
312
+ return this.__resource.info.description;
313
+ }
118
314
  /**
119
315
  * Gets the name of the form file in Google Drive.
120
316
  * @returns {string} The file name.
@@ -123,6 +319,34 @@ export class FakeForm {
123
319
  return this.__file.getName();
124
320
  }
125
321
 
322
+ /**
323
+ * Gets all of the form's responses.
324
+ * @returns {import('./fakeformresponse.js').FakeFormResponse[]} An array of form responses.
325
+ */
326
+ getResponses() {
327
+ // The advanced Forms service is needed here, but it's an implementation detail of the fake.
328
+ if (!Forms.Form?.Responses) {
329
+ throw new Error(
330
+ 'The faked Advanced Forms Service (Forms.Form.Responses) must be available to use getResponses().'
331
+ );
332
+ }
333
+ const responseList = Forms.Form.Responses.list(this.getId());
334
+ const responses = responseList.responses?.map((r) => newFakeFormResponse(this, r)) || [];
335
+
336
+ // The live Apps Script getResponses() method returns responses in chronological order.
337
+ // The API returns them in reverse chronological order, so we must sort them.
338
+ return responses.sort((a, b) => a.getTimestamp() - b.getTimestamp());
339
+ }
340
+
341
+ /**
342
+ * Gets whether the form is accepting responses.
343
+ * @returns {boolean} true if the form is accepting responses; false otherwise.
344
+ */
345
+ isPublished() {
346
+ // ACTIVE is the state for accepting responses. The default if not set is ACTIVE.
347
+ return this.__publishedState;
348
+ }
349
+
126
350
  /**
127
351
  * Sets the name of the form file in Google Drive.
128
352
  * @param {string} name The new file name.
@@ -133,6 +357,79 @@ export class FakeForm {
133
357
  return this;
134
358
  }
135
359
 
360
+ /**
361
+ * Deletes the item at the given index.
362
+ * @param {import('./fakeformitem.js').FakeFormItem | Integer} itemOrIndex The item to delete, or its 0-indexed position.
363
+ * @returns {FakeForm} The form, for chaining.
364
+ */
365
+ deleteItem(itemOrIndex) {
366
+ let indexToDelete
367
+ if (typeof itemOrIndex === 'number') {
368
+ indexToDelete = itemOrIndex
369
+ } else if (typeof itemOrIndex === 'object' && typeof itemOrIndex.getIndex === 'function') {
370
+ // It's an Item object, get its index.
371
+ indexToDelete = itemOrIndex.getIndex()
372
+ } else {
373
+ // This handles the case where an invalid object is passed, which can happen during development.
374
+ // The error from the API ("Starting an object on a scalar field") is because the fake was
375
+ // passing the whole object into the batchUpdate request instead of an index.
376
+ // By handling the object case properly, we now get the correct behavior.
377
+ throw new Error(
378
+ `The parameters (${typeof itemOrIndex}) don't match the method signature for FormApp.Form.deleteItem.`
379
+ )
380
+ }
381
+ const deleteRequest = Forms.newRequest().setDeleteItem({
382
+ location: { index: indexToDelete },
383
+ });
384
+ return this.__update(deleteRequest);
385
+ }
386
+
387
+ /**
388
+ * Moves the given form item to the specified index.
389
+ * @param {import('./fakeformitem.js').FakeFormItem | Integer} itemOrFrom The item to move, or its 0-indexed position.
390
+ * @param {Integer} toIndex The 0-indexed position to move the item to.
391
+ * @returns {import('./fakeformitem.js').FakeFormItem} The moved item.
392
+ */
393
+ moveItem(itemOrFrom, toIndex) {
394
+ let fromIndex;
395
+ if (typeof itemOrFrom === 'number') {
396
+ fromIndex = itemOrFrom;
397
+ } else if (typeof itemOrFrom === 'object' && typeof itemOrFrom.getIndex === 'function') {
398
+ fromIndex = itemOrFrom.getIndex();
399
+ } else {
400
+ throw new Error(`The parameters (${typeof itemOrFrom},number) don't match the method signature for FormApp.Form.moveItem.`);
401
+ }
402
+
403
+ const items = this.getItems();
404
+ if (fromIndex < 0 || fromIndex >= items.length) {
405
+ throw new Error(`The starting position ${fromIndex} is out of bounds.`);
406
+ }
407
+
408
+ // The item to return is the one at the original 'from' index.
409
+ const itemToMove = items[fromIndex];
410
+ const moveRequest = Forms.newRequest().setMoveItem({
411
+ originalLocation: { index: fromIndex },
412
+ newLocation: { index: toIndex },
413
+ });
414
+ this.__update(moveRequest);
415
+ return itemToMove;
416
+ }
417
+ /**
418
+ * Sets whether the form is accepting responses.
419
+ * @param {boolean} enabled true if the form should accept responses; false otherwise.
420
+ * @returns {FakeForm} The form, for chaining.
421
+ */
422
+ setPublished(enabled) {
423
+ throw new Error('setPublished is not yet implemented in the fake environment.');
424
+ }
425
+
426
+ __update(updateRequest) {
427
+ const batchRequest = Forms.newBatchUpdateFormRequest()
428
+ .setRequests([updateRequest])
429
+ Forms.Form.batchUpdate(batchRequest, this.getId());
430
+ return this;
431
+ }
432
+
136
433
  /**
137
434
  * Sets the title of the form.
138
435
  * @param {string} title The new title for the form.
@@ -145,12 +442,22 @@ export class FakeForm {
145
442
  .setInfo(updateInfo)
146
443
  .setUpdateMask("title")
147
444
  );
148
- const batchRequest = Forms.newBatchUpdateFormRequest()
149
- .setRequests([updateRequest]);
150
-
151
- Forms.Form.batchUpdate(batchRequest, this.getId());
445
+ return this.__update(updateRequest)
446
+ }
152
447
 
153
- return this;
448
+ /**
449
+ * sets the description of the form.
450
+ * @param {string} description The new title for the form.
451
+ * @returns {FakeForm} The form, for chaining.
452
+ */
453
+ setDescription(description) {
454
+ const updateInfo = Forms.newFormInfo().setDescription(description);
455
+ const updateRequest = Forms.newRequest().setUpdateFormInfo(
456
+ Forms.newUpdateFormInfoRequest()
457
+ .setInfo(updateInfo)
458
+ .setUpdateMask("description")
459
+ );
460
+ return this.__update(updateRequest)
154
461
  }
155
462
 
156
463
  /**
@@ -161,6 +468,14 @@ export class FakeForm {
161
468
  return `https://docs.google.com/forms/d/${this.getId()}/edit`;
162
469
  }
163
470
 
471
+ /**
472
+ * Gets the URL to respond to the form.
473
+ * @returns {string} The form URL.
474
+ */
475
+ getPublishedUrl() {
476
+ return `https://docs.google.com/forms/d/e/${this.getId()}/viewform`;
477
+ }
478
+
164
479
  toString() {
165
480
  return 'Form';
166
481
  }
@@ -96,7 +96,7 @@ class FakeFormApp {
96
96
  openByUrl(url) {
97
97
  const id = Url.getIdFromUrl(url);
98
98
  if (!id) {
99
- throw new Error(`Invalid form URL: ${url}`);
99
+ throw new Error(`Invalid argument: url`);
100
100
  }
101
101
  return this.openById(id);
102
102
  }
@@ -61,7 +61,7 @@ export class FakeFormItem {
61
61
  asGridItem() { return this.__cast(ItemType.GRID); }
62
62
  asImageItem() { return this.__cast(ItemType.IMAGE); }
63
63
  asListItem() { return this.__cast(ItemType.LIST); }
64
- asMultipleChoiceItem() { return this.__cast(ItemType.MULTIPLE_CHOICE); }
64
+ asMultipleChoiceItem() { return this.__cast(ItemType.MULTIPLE_CHOICE, true); }
65
65
  asPageBreakItem() { return this.__cast(ItemType.PAGE_BREAK); }
66
66
  asParagraphTextItem() { return this.__cast(ItemType.PARAGRAPH_TEXT); }
67
67
  asScaleItem() { return this.__cast(ItemType.SCALE); }
@@ -114,7 +114,8 @@ export class FakeFormItem {
114
114
  }
115
115
 
116
116
  getId() {
117
- return this.__resource.itemId;
117
+ // Live Apps Script returns IDs as decimal numbers, not the hex strings from the API.
118
+ return this.__itemId;
118
119
  }
119
120
 
120
121
  getIndex() {
@@ -148,7 +149,8 @@ export class FakeFormItem {
148
149
  if (question.scaleQuestion) return ItemType.SCALE;
149
150
  if (question.rowQuestion) return ItemType.GRID;
150
151
  } else if (item.questionGroupItem) {
151
- const gridType = item.questionGroupItem.grid.columns[0].choiceQuestion.type;
152
+ // For GRID and CHECKBOX_GRID, columns is an object, not an array.
153
+ const gridType = item.questionGroupItem.grid.columns.type;
152
154
  return gridType === 'RADIO' ? ItemType.GRID : ItemType.CHECKBOX_GRID;
153
155
  } else if (item.pageBreakItem) {
154
156
  return ItemType.PAGE_BREAK;
@@ -161,6 +163,12 @@ export class FakeFormItem {
161
163
  }
162
164
  throw new Error(`Unknown item type for resource: ${JSON.stringify(item)}`);
163
165
  }
166
+ __update(updateRequest) {
167
+ const response = this.__form.__update(updateRequest);
168
+ // The local resource is now stale, so we need to update it.
169
+ // The response from batchUpdate contains the updated form resource.
170
+ return this;
171
+ }
164
172
 
165
173
  setHelpText(text) {
166
174
  const { nargs, matchThrow } = signatureArgs(arguments, 'Item.setHelpText');
@@ -181,10 +189,7 @@ export class FakeFormItem {
181
189
  // Crucially, only ask the API to update the description field.
182
190
  updateMask: 'description',
183
191
  });
184
-
185
- const batchRequest = Forms.newBatchUpdateFormRequest().setRequests([updateRequest]);
186
- Forms.Form.batchUpdate(batchRequest, this.__form.getId());
187
- return this;
192
+ return this.__update (updateRequest)
188
193
  }
189
194
 
190
195
  setTitle(title) {
@@ -207,9 +212,56 @@ export class FakeFormItem {
207
212
  updateMask: 'title',
208
213
  });
209
214
 
210
- const batchRequest = Forms.newBatchUpdateFormRequest().setRequests([updateRequest]);
211
- Forms.Form.batchUpdate(batchRequest, this.__form.getId());
212
- return this;
215
+ return this.__update (updateRequest)
216
+
217
+ }
218
+
219
+ /**
220
+ * Determines whether the respondent must answer the question.
221
+ * @returns {boolean} whether the respondent must answer the question
222
+ */
223
+ isRequired() {
224
+ if (this.__resource.questionItem) {
225
+ return this.__resource.questionItem.question.required || false;
226
+ } else if (this.__resource.questionGroupItem) {
227
+ return this.__resource.questionGroupItem.questions?.some(q => q.required) || false;
228
+ }
229
+ return false;
230
+ }
231
+
232
+ /**
233
+ * Sets whether the respondent must answer the question.
234
+ * @param {boolean} enabled
235
+ * @returns {FakeFormItem} the current item (for chaining)
236
+ */
237
+ setRequired(enabled) {
238
+ const { nargs, matchThrow } = signatureArgs(arguments, 'Item.setRequired');
239
+ if (nargs !== 1 || !is.boolean(enabled)) matchThrow('Invalid arguments');
240
+
241
+ let updateMask;
242
+ const updatedResource = JSON.parse(JSON.stringify(this.__resource));
243
+
244
+ if (updatedResource.questionItem) {
245
+ updatedResource.questionItem.question.required = enabled;
246
+ updateMask = 'questionItem.question.required';
247
+ } else if (updatedResource.questionGroupItem) {
248
+ if (updatedResource.questionGroupItem.questions) {
249
+ updatedResource.questionGroupItem.questions.forEach(q => {
250
+ q.required = enabled;
251
+ });
252
+ }
253
+ updateMask = 'questionGroupItem.questions';
254
+ } else {
255
+ throw new Error('This item type does not support setRequired.');
256
+ }
257
+
258
+ const updateRequest = Forms.newRequest().setUpdateItem({
259
+ item: updatedResource,
260
+ location: { index: this.getIndex() },
261
+ updateMask: updateMask,
262
+ });
263
+
264
+ return this.__update (updateRequest)
213
265
  }
214
266
 
215
267
  toString() {
@@ -0,0 +1,84 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { newFakeItemResponse } from './fakeitemresponse.js';
3
+
4
+ export const newFakeFormResponse = (...args) => {
5
+ return Proxies.guard(new FakeFormResponse(...args));
6
+ };
7
+
8
+ /**
9
+ * @class FakeFormResponse
10
+ * @see https://developers.google.com/apps-script/reference/forms/form-response
11
+ */
12
+ export class FakeFormResponse {
13
+ /**
14
+ *
15
+ * @param {import('./fakeform.js').FakeForm} form the parent form
16
+ * @param {object} resource the response resource from the Forms API
17
+ */
18
+ constructor(form, resource) {
19
+ this.__form = form;
20
+ this.__resource = resource;
21
+ }
22
+
23
+ /**
24
+ * Gets the email address of the respondent.
25
+ * @returns {string} the respondent's email
26
+ */
27
+ getRespondentEmail() {
28
+ return this.__resource.respondentEmail || '';
29
+ }
30
+
31
+ /**
32
+ * Gets the unique ID for this form response.
33
+ * @returns {string} the unique ID
34
+ */
35
+ getId() {
36
+ return this.__resource.responseId;
37
+ }
38
+
39
+ /**
40
+ * Gets the date and time the response was submitted.
41
+ * @returns {Date} the submission timestamp
42
+ */
43
+ getTimestamp() {
44
+ return new Date(this.__resource.lastSubmittedTime);
45
+ }
46
+
47
+ /**
48
+ * Gets all item responses contained in the form response.
49
+ * @returns {import('./fakeitemresponse.js').FakeItemResponse[]} an array of item responses
50
+ */
51
+ getItemResponses() {
52
+ if (!this.__resource.answers) {
53
+ return [];
54
+ }
55
+
56
+ // Create a map to group answers by their parent form item. This is crucial for grid items,
57
+ // where each row is a separate answer in the API response but should be consolidated
58
+ // into a single ItemResponse in Apps Script.
59
+ const groupedAnswers = new Map();
60
+
61
+ for (const [questionId, answer] of Object.entries(this.__resource.answers)) {
62
+ const item = this.__form.getItemById(questionId);
63
+
64
+ if (item) {
65
+ const itemId = item.getId(); // Use the unique item ID as the key.
66
+ if (!groupedAnswers.has(itemId)) {
67
+ // Store the item itself along with an array for its answers.
68
+ groupedAnswers.set(itemId, { item, answers: [] });
69
+ }
70
+ // Add the raw answer object from the API response to the item's answer list.
71
+ // This correctly groups all row answers for a grid under the same parent item.
72
+ groupedAnswers.get(itemId).answers.push(answer);
73
+ }
74
+ }
75
+
76
+ // Now, create one FakeItemResponse for each grouped item.
77
+ const itemResponses = Array.from(groupedAnswers.values()).map(({ item, answers }) => {
78
+ return newFakeItemResponse(item, answers);
79
+ });
80
+
81
+ // Finally, sort the responses based on the item's index in the form.
82
+ return itemResponses.sort((a, b) => a.getItem().getIndex() - b.getItem().getIndex());
83
+ }
84
+ }