• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

CBIIT / crdc-datahub-ui / 18789341118

24 Oct 2025 06:57PM UTC coverage: 78.178% (+15.5%) from 62.703%
18789341118

push

github

web-flow
Merge pull request #888 from CBIIT/3.4.0

3.4.0 Release

4977 of 5488 branches covered (90.69%)

Branch coverage included in aggregate %.

8210 of 9264 new or added lines in 257 files covered. (88.62%)

6307 existing lines in 120 files now uncovered.

30203 of 39512 relevant lines covered (76.44%)

213.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.42
/src/classes/QuestionnaireExcelMiddleware.ts
1
import { LazyQueryExecFunction } from "@apollo/client";
2
import ExcelJS from "exceljs";
1✔
3
import { cloneDeep, merge, union, some, values, toString } from "lodash";
1✔
4

5
import cancerTypeOptions from "@/config/CancerTypesConfig";
1✔
6
import DataTypes from "@/config/DataTypesConfig";
1✔
7
import { fileTypeExtensions } from "@/config/FileTypeConfig";
1✔
8
import fundingOptions from "@/config/FundingConfig";
1✔
9
import { InitialQuestionnaire } from "@/config/InitialValues";
1✔
10
import { NotApplicableProgram, OtherProgram } from "@/config/ProgramConfig";
1✔
11
import { InitialSections } from "@/config/SectionConfig";
1✔
12
import speciesOptions from "@/config/SpeciesConfig";
1✔
13
import env from "@/env";
1✔
14
import { ListInstitutionsResp, ListOrgsInput, ListOrgsResp } from "@/graphql";
15
import { isFormulaValue, isHyperlinkValue, isRichTextValue, isSharedFormulaValue } from "@/utils";
1✔
16
import { parseReleaseVersion } from "@/utils/envUtils";
1✔
17
import { Logger } from "@/utils/logger";
1✔
18
import { parseSchemaObject } from "@/utils/zodUtils";
1✔
19

20
import { SectionA, SectionAColumns, SectionASchema } from "./Excel/A/SectionA";
1✔
21
import { SectionB, SectionBColumns, SectionBSchema } from "./Excel/B/SectionB";
1✔
22
import { SectionC, SectionCColumns, SectionCSchema } from "./Excel/C/SectionC";
1✔
23
import { SectionD, SectionDColumns, SectionDSchema } from "./Excel/D/SectionD";
1✔
24
import { InstructionsSection } from "./Excel/Instructions/InstructionsSection";
1✔
25
import { MetaKeys } from "./Excel/Metadata/Columns";
26
import { MetadataColumns, MetadataSection } from "./Excel/Metadata/MetadataSection";
1✔
27
import { SectionCtxBase } from "./Excel/SectionBase";
28

29
/**
30
 * An internal template version identifier.
31
 */
32
export const TEMPLATE_VERSION = "1.0";
1✔
33

34
/**
35
 * The names of the HIDDEN sheets used in the Excel workbook.
36
 * Primarily used for hidden lists.
37
 */
38
export const HIDDEN_SHEET_NAMES = {
1✔
39
  institutions: "InstitutionList",
1✔
40
  programs: "ProgramList",
1✔
41
  fileTypes: "FileTypeList",
1✔
42
  cancerTypes: "CancerTypeList",
1✔
43
  speciesOptions: "SubjectSpeciesList",
1✔
44
  fundingAgencies: "FundingAgencyList",
1✔
45
  repositoryDataTypes: "RepositoryDataTypeList",
1✔
46
} as const;
1✔
47

48
/**
49
 * The required dependencies to import or export a Submission Request.
50
 */
51
export type MiddlewareDependencies = {
52
  application?: Omit<Application, "QuestionnaireData">;
53
  getPrograms?: LazyQueryExecFunction<ListOrgsResp, ListOrgsInput>;
54
  getInstitutions?: LazyQueryExecFunction<ListInstitutionsResp, unknown>;
55
};
56

57
/**
58
 * A specialized class to handle importing and exporting of a Submission Request.
59
 * This class is designed to work with QuestionnaireData, allowing for the import and export of data
60
 * via Excel files.
61
 */
62
export class QuestionnaireExcelMiddleware {
1✔
63
  /**
64
   * The internal ExcelJS Workbook object.
65
   * This object is used to create and manipulate the Excel file during the import/export process.
66
   */
67
  private workbook: ExcelJS.Workbook;
76✔
68

69
  /**
70
   * The internal QuestionnaireData object.
71
   * This object is mutated during the import process, but remains immutable for export.
72
   */
73
  private data: QuestionnaireData | RecursivePartial<QuestionnaireData> | null;
76✔
74

75
  /**
76
   * The dependencies required for exporting or importing,
77
   * such as fetching Programs or Institutions.
78
   */
79
  private dependencies: MiddlewareDependencies;
76✔
80

81
  /**
82
   * Creates an instance of the QuestionnaireExcelMiddleware.
83
   *
84
   * @param data The QuestionnaireData object, if applicable, to be exported.
85
   * @param dependencies The import/export dependencies
86
   */
87
  constructor(data: QuestionnaireData | null, dependencies: MiddlewareDependencies) {
76✔
88
    this.workbook = new ExcelJS.Workbook();
76✔
89
    this.data = data ? cloneDeep(data) : null;
76✔
90
    this.dependencies = dependencies;
76✔
91
  }
76✔
92

93
  /**
94
   * A method to serialize the QuestionnaireData object to an Excel file.
95
   * If no `QuestionnaireData` is provided, the Excel file will be generated with no pre-filled values.
96
   *
97
   * @throws Will throw an error if the serialization fails significantly.
98
   * @returns A Promise that resolves to an ArrayBuffer containing the Excel file data.
99
   */
100
  public async serialize(): Promise<ArrayBuffer> {
76✔
NEW
101
    this.setMetadataProperties();
×
102

NEW
103
    await this.serializeMetadata();
×
NEW
104
    await this.serializeInstructions();
×
NEW
105
    await this.serializeSectionA();
×
NEW
106
    await this.serializeSectionB();
×
NEW
107
    await this.serializeSectionC();
×
NEW
108
    await this.serializeSectionD();
×
109

NEW
110
    return this.workbook.xlsx.writeBuffer();
×
NEW
111
  }
×
112

113
  /**
114
   * A static method to parse the input file and return a QuestionnaireExcelMiddleware instance.
115
   * This method is the inverse of the `serialize` method.
116
   *
117
   * @param fileBuffer The Excel file data to be parsed.
118
   * @param dependencies The dependencies required for parsing the data.
119
   * @throws Will throw an error if the data cannot be parsed or is invalid.
120
   * @returns A new instance of QuestionnaireExcelMiddleware with the parsed data.
121
   */
122
  public static async parse(
76✔
NEW
123
    fileBuffer: ArrayBuffer,
×
NEW
124
    dependencies: MiddlewareDependencies
×
NEW
125
  ): Promise<QuestionnaireExcelMiddleware["data"]> {
×
126
    // Load the workbook from the ArrayBuffer
NEW
127
    const workbook = new ExcelJS.Workbook();
×
NEW
128
    await workbook.xlsx.load(fileBuffer);
×
129

130
    // Create an instance with null data, then assign the loaded workbook
NEW
131
    const middleware = new QuestionnaireExcelMiddleware(null, dependencies);
×
NEW
132
    middleware.workbook = workbook;
×
NEW
133
    middleware.data = { ...InitialQuestionnaire, sections: [...InitialSections] };
×
134

NEW
135
    await middleware.parseMetadata();
×
NEW
136
    await middleware.parseSectionA();
×
NEW
137
    await middleware.parseSectionB();
×
NEW
138
    await middleware.parseSectionC();
×
NEW
139
    await middleware.parseSectionD();
×
140

NEW
141
    return middleware.data;
×
NEW
142
  }
×
143

144
  /**
145
   * Sets the metadata properties for the Excel workbook.
146
   *
147
   * @returns A Promise that resolves when the properties have been set.
148
   */
149
  private async setMetadataProperties(): Promise<void> {
76✔
150
    this.workbook.creator = "crdc-datahub-ui";
1✔
151
    this.workbook.lastModifiedBy = "crdc-datahub-ui";
1✔
152
    this.workbook.title = "CRDC Submission Request";
1✔
153
    this.workbook.subject = "CRDC Submission Request Template";
1✔
154
    this.workbook.company = "National Cancer Institute";
1✔
155
    this.workbook.created = new Date();
1✔
156
    this.workbook.modified = new Date();
1✔
157
  }
1✔
158

159
  /**
160
   * Builds the metadata page for the Excel workbook.
161
   *
162
   * @returns A readonly reference to the created worksheet.
163
   */
164
  private async serializeMetadata(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
165
    const ctx: SectionCtxBase = {
6✔
166
      workbook: this.workbook,
6✔
167
      u: {
6✔
168
        header: (ws: ExcelJS.Worksheet, color?: string) => {
6✔
169
          const r1 = ws.getRow(1);
6✔
170
          r1.font = { bold: true };
6✔
171
          r1.alignment = { horizontal: "center" };
6✔
172
          r1.fill = { type: "pattern", pattern: "solid", fgColor: { argb: color } };
6✔
173
          ws.state = "veryHidden";
6✔
174
        },
6✔
175
      },
6✔
176
    };
6✔
177

178
    const metaSection = new MetadataSection({
6✔
179
      application: this.dependencies.application,
6✔
180
      templateVersion: TEMPLATE_VERSION,
6✔
181
      devTier: env.VITE_DEV_TIER || "N/A",
6!
182
      appVersion: parseReleaseVersion(),
6✔
183
    });
6✔
184

185
    return metaSection.serialize(ctx);
6✔
186
  }
6✔
187

188
  /**
189
   * Serializes the instructions section into the Excel workbook.
190
   *
191
   * @returns A readonly reference to the created worksheet.
192
   */
193
  private async serializeInstructions(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
194
    const ctx: SectionCtxBase = {
1✔
195
      workbook: this.workbook,
1✔
196
      u: {
1✔
197
        header: (ws: ExcelJS.Worksheet, color?: string) => {
1✔
NEW
198
          const r1 = ws.getRow(1);
×
NEW
199
          r1.font = { bold: true };
×
NEW
200
          r1.alignment = { horizontal: "center" };
×
NEW
201
          r1.fill = { type: "pattern", pattern: "solid", fgColor: { argb: color } };
×
NEW
202
        },
×
203
      },
1✔
204
    };
1✔
205

206
    const instructionsSection = new InstructionsSection();
1✔
207

208
    return instructionsSection.serialize(ctx);
1✔
209
  }
1✔
210

211
  /**
212
   * Adds the form section A to the Excel workbook.
213
   *
214
   * @returns A readonly reference to the created worksheet.
215
   */
216
  private async serializeSectionA(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
217
    const ctx: SectionCtxBase = {
15✔
218
      workbook: this.workbook,
15✔
219
      u: {
15✔
220
        header: (ws: ExcelJS.Worksheet, color?: string) => {
15✔
221
          const r1 = ws.getRow(1);
15✔
222
          r1.font = { bold: true };
15✔
223
          r1.alignment = { horizontal: "center" };
15✔
224
          r1.fill = { type: "pattern", pattern: "solid", fgColor: { argb: color } };
15✔
225
        },
15✔
226
      },
15✔
227
    };
15✔
228

229
    const sectionA = new SectionA({
15✔
230
      data: this.data as QuestionnaireData,
15✔
231
      institutionSheet: await this.createInstitutionSheet(),
15✔
232
    });
15✔
233

234
    return sectionA.serialize(ctx);
15✔
235
  }
15✔
236

237
  /**
238
   * Serializes Section B data into the Excel workbook.
239
   *
240
   * @returns The serialized worksheet for Section B.
241
   */
242
  private async serializeSectionB(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
243
    const ctx: SectionCtxBase = {
20✔
244
      workbook: this.workbook,
20✔
245
      u: {
20✔
246
        header: (ws: ExcelJS.Worksheet, color?: string) => {
20✔
247
          const r1 = ws.getRow(1);
20✔
248
          r1.font = { bold: true };
20✔
249
          r1.alignment = { horizontal: "center" };
20✔
250
          r1.fill = { type: "pattern", pattern: "solid", fgColor: { argb: color } };
20✔
251
        },
20✔
252
      },
20✔
253
    };
20✔
254

255
    const sectionB = new SectionB({
20✔
256
      data: this.data as QuestionnaireData,
20✔
257
      programSheet: await this.createProgramsSheet(),
20✔
258
      fundingAgenciesSheet: await this.createFundingAgencySheet(),
20✔
259
      repositoryDataTypesSheet: await this.createRepositoryDataTypesSheet(),
20✔
260
    });
20✔
261

262
    const sheet = await sectionB.serialize(ctx);
20✔
263

264
    return sheet;
20✔
265
  }
20✔
266

267
  /**
268
   * Adds the form section C to the Excel workbook.
269
   *
270
   * @returns A readonly reference to the created worksheet.
271
   */
272
  private async serializeSectionC(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
273
    const ctx: SectionCtxBase = {
14✔
274
      workbook: this.workbook,
14✔
275
      u: {
14✔
276
        header: (ws: ExcelJS.Worksheet, color?: string) => {
14✔
277
          const r1 = ws.getRow(1);
14✔
278
          r1.font = { bold: true };
14✔
279
          r1.alignment = { horizontal: "center" };
14✔
280
          r1.fill = { type: "pattern", pattern: "solid", fgColor: { argb: color } };
14✔
281
        },
14✔
282
      },
14✔
283
    };
14✔
284

285
    const sectionC = new SectionC({
14✔
286
      data: this.data as QuestionnaireData,
14✔
287
      cancerTypesSheet: await this.createCancerTypesSheet(),
14✔
288
      speciesSheet: await this.createSpeciesListSheet(),
14✔
289
    });
14✔
290

291
    return sectionC.serialize(ctx);
14✔
292
  }
14✔
293

294
  private async serializeSectionD(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
295
    const ctx: SectionCtxBase = {
16✔
296
      workbook: this.workbook,
16✔
297
      u: {
16✔
298
        header: (ws: ExcelJS.Worksheet, color?: string) => {
16✔
299
          const r1 = ws.getRow(1);
16✔
300
          r1.font = { bold: true };
16✔
301
          r1.alignment = { horizontal: "center" };
16✔
302
          r1.fill = { type: "pattern", pattern: "solid", fgColor: { argb: color } };
16✔
303
        },
16✔
304
      },
16✔
305
    };
16✔
306

307
    const sectionD = new SectionD({
16✔
308
      data: this.data as QuestionnaireData,
16✔
309
      programSheet: await this.createProgramsSheet(),
16✔
310
      fileTypesSheet: await this.createFileTypesSheet(),
16✔
311
    });
16✔
312

313
    const sheet = await sectionD.serialize(ctx);
16✔
314

315
    return sheet;
16✔
316
  }
16✔
317

318
  /**
319
   * Parses the data from the metadata section.
320
   *
321
   * @returns A Promise that resolves to a boolean indicating success or failure of the parsing.
322
   */
323
  private async parseMetadata(): Promise<boolean> {
76✔
324
    const sheet = this.workbook.getWorksheet(MetadataSection.SHEET_NAME);
4✔
325
    if (!sheet) {
4✔
326
      return false;
1✔
327
    }
1✔
328

329
    const data = await this.extractValuesFromWorksheet(sheet);
3✔
330
    const newData = new Map<MetaKeys, Array<unknown>>();
3✔
331

332
    data.forEach((values, key) => {
3✔
333
      const colKey = MetadataColumns.find((col) => col.header === key)?.key;
33✔
334
      newData.set(
33✔
335
        colKey,
33✔
336
        values.map((value) => toString(value).trim())
33✔
337
      );
33✔
338
    });
3✔
339

340
    if (newData?.get("devTier")?.[0] !== env.VITE_DEV_TIER) {
4✔
341
      Logger.info("QuestionnaireExcelMiddleware: Received mismatched devTier.", {
1✔
342
        expected: env.VITE_DEV_TIER,
1✔
343
        received: newData?.get("devTier")?.[0],
1✔
344
      });
1✔
345
    }
1✔
346
    if (newData?.get("templateVersion")?.[0] !== TEMPLATE_VERSION) {
4✔
347
      Logger.info("QuestionnaireExcelMiddleware: Received mismatched templateVersion.", {
1✔
348
        expected: TEMPLATE_VERSION,
1✔
349
        received: newData?.get("templateVersion")?.[0],
1✔
350
      });
1✔
351
    }
1✔
352
    if (
3✔
353
      newData?.get("submissionId")?.[0] &&
4✔
354
      newData?.get("submissionId")?.[0] !== this.dependencies?.application?._id
1✔
355
    ) {
4✔
356
      Logger.info("QuestionnaireExcelMiddleware: Received mismatched submissionId.", {
1✔
357
        expected: this.dependencies?.application?._id,
1✔
358
        received: newData?.get("submissionId")?.[0],
1✔
359
      });
1✔
360
    }
1✔
361
    if (
3✔
362
      newData?.get("appVersion")?.[0] &&
4✔
363
      newData?.get("appVersion")?.[0] !== parseReleaseVersion()
3✔
364
    ) {
4✔
365
      Logger.info("QuestionnaireExcelMiddleware: Received mismatched appVersion.", {
1✔
366
        expected: parseReleaseVersion(),
1✔
367
        received: newData?.get("appVersion")?.[0],
1✔
368
      });
1✔
369
    }
1✔
370

371
    return true;
3✔
372
  }
4✔
373

374
  /**
375
   * Parses the data from Section A of the Excel workbook.
376
   *
377
   * @returns A Promise that resolves to a boolean indicating success or failure of the parsing.
378
   */
379
  private async parseSectionA(): Promise<boolean> {
76✔
380
    const sheet = this.workbook.getWorksheet(SectionA.SHEET_NAME);
9✔
381
    if (!sheet) {
9✔
382
      Logger.info(`parseSectionA: No sheet found for ${SectionA.SHEET_NAME}. Skipping`);
1✔
383
      return false;
1✔
384
    }
1✔
385

386
    const data = await this.extractValuesFromWorksheet(sheet);
8✔
387
    const newData = new Map();
8✔
388

389
    data.forEach((values, key) => {
8✔
390
      const colKey = SectionAColumns.find((col) => col.header === key)?.key;
160✔
391
      newData.set(
160✔
392
        colKey,
160✔
393
        values.map((value) => toString(value).trim())
160✔
394
      );
160✔
395
    });
8✔
396

397
    const newMapping = SectionA.mapValues(newData, {
8✔
398
      institutionSheet: this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.institutions),
8✔
399
    });
8✔
400

401
    const result: RecursivePartial<QuestionnaireData> = parseSchemaObject(
8✔
402
      SectionASchema,
8✔
403
      newMapping
8✔
404
    );
8✔
405

406
    const hasPIFields = some(values(result?.pi), (v) => typeof v === "string" && v.trim() !== "");
9✔
407
    const hasPrimaryContactFields = some(
9✔
408
      values(result?.primaryContact),
9✔
409
      (v) => typeof v === "string" && v.trim() !== ""
9✔
410
    );
9✔
411
    const hasAdditionalContactFields = some(result?.additionalContacts || [], (contact) =>
9!
412
      some(values(contact), (v) => typeof v === "string" && v.trim() !== "")
3✔
413
    );
9✔
414

415
    this.data = merge({}, this.data, result);
9✔
416

417
    const isStarted = hasPIFields || hasPrimaryContactFields || hasAdditionalContactFields;
9✔
418
    this.data.sections.find((s) => s.name === "A").status = isStarted
9✔
419
      ? "In Progress"
5✔
420
      : "Not Started";
3✔
421

422
    return true;
9✔
423
  }
9✔
424

425
  /**
426
   * Parses the data from Section B of the Excel workbook.
427
   *
428
   * @returns A Promise that resolves to a boolean indicating success or failure of the parsing.
429
   */
430
  private async parseSectionB(): Promise<boolean> {
76✔
431
    const ws = this.workbook.getWorksheet(SectionB.SHEET_NAME);
10✔
432
    if (!ws) {
10✔
433
      Logger.info("parseSectionB: No sheet found for Section B. Skipping");
1✔
434
      return false;
1✔
435
    }
1✔
436

437
    const data = await this.extractValuesFromWorksheet(ws);
9✔
438
    const newData = new Map();
9✔
439
    // Swap the column headers for the column keys in the mapping
440
    data.forEach((values, key) => {
9✔
441
      const colKey = SectionBColumns.find((col) => col.header === key)?.key;
171✔
442
      newData.set(
171✔
443
        colKey,
171✔
444
        values.map((value) => toString(value).trim())
171✔
445
      );
171✔
446
    });
9✔
447
    const newMapping = SectionB.mapValues(newData, {
9✔
448
      programSheet: this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.programs),
9✔
449
    });
9✔
450

451
    const result: RecursivePartial<QuestionnaireData> = parseSchemaObject(
9✔
452
      SectionBSchema,
9✔
453
      newMapping
9✔
454
    );
9✔
455

456
    this.data = merge({}, this.data, result);
9✔
457

458
    const hasProgramId = result?.program?._id?.length > 0;
10✔
459
    const hasProgramName = result?.program?.name?.length > 0;
10✔
460
    const hasProgramAbbreviation = result?.program?.abbreviation?.length > 0;
10✔
461
    const hasStudyName = result?.study?.name?.length > 0;
10✔
462
    const hasStudyAbbreviation = result?.study?.abbreviation?.length > 0;
10✔
463
    const hasStudyDescription = result?.study?.description?.length > 0;
10✔
464
    // SR form creates one funding entry by default
465
    const hasFundingAgency = result?.study?.funding?.[0]?.agency;
10✔
466
    const hasFundingGrantNumbers = result?.study?.funding?.[0]?.grantNumbers;
10✔
467
    const hasFundingNciProgramOfficer = result?.study?.funding?.[0]?.nciProgramOfficer;
10✔
468
    const hasPublications = result?.study?.publications?.length > 0;
10✔
469
    const hasPlannedPublications = result?.study?.plannedPublications?.length > 0;
10✔
470
    const hasRepositories = result?.study?.repositories?.length > 0;
10✔
471

472
    const isStarted =
10✔
473
      hasProgramId ||
10✔
474
      hasProgramName ||
2✔
475
      hasProgramAbbreviation ||
2✔
476
      hasStudyName ||
2✔
477
      hasStudyAbbreviation ||
2✔
478
      hasStudyDescription ||
2✔
479
      hasFundingAgency ||
2✔
480
      hasFundingGrantNumbers ||
2✔
481
      hasFundingNciProgramOfficer ||
2✔
482
      hasPublications ||
2✔
483
      hasPlannedPublications ||
2✔
484
      hasRepositories;
2✔
485
    this.data.sections.find((s) => s.name === "B").status = isStarted
10✔
486
      ? "In Progress"
7✔
487
      : "Not Started";
2✔
488

489
    return true;
10✔
490
  }
10✔
491

492
  /**
493
   * Parses the data from Section C of the Excel workbook.
494
   *
495
   * @returns A Promise that resolves to a boolean indicating success or failure of the parsing.
496
   */
497
  private async parseSectionC(): Promise<boolean> {
76✔
498
    const sheet = this.workbook.getWorksheet(SectionC.SHEET_NAME);
10✔
499
    if (!sheet) {
10✔
500
      Logger.info(`parseSectionC: No sheet found for ${SectionC.SHEET_NAME}. Skipping`);
1✔
501
      return false;
1✔
502
    }
1✔
503

504
    const data = await this.extractValuesFromWorksheet(sheet);
9✔
505
    const newData = new Map();
9✔
506

507
    data.forEach((values, key) => {
9✔
508
      const colKey = SectionCColumns.find((col) => col.header === key)?.key;
99✔
509
      newData.set(
99✔
510
        colKey,
99✔
511
        values.map((value) => toString(value).trim())
99✔
512
      );
99✔
513
    });
9✔
514

515
    const newMapping = SectionC.mapValues(newData, {
9✔
516
      cancerTypesSheet: this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.cancerTypes),
9✔
517
      speciesSheet: this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.speciesOptions),
9✔
518
    });
9✔
519

520
    const result: RecursivePartial<QuestionnaireData> = parseSchemaObject(
9✔
521
      SectionCSchema,
9✔
522
      newMapping
9✔
523
    );
9✔
524

525
    const hasAccessTypes = result?.accessTypes?.length > 0;
10✔
526
    const hasStudyFields = some(
10✔
527
      values(result?.study || {}),
10!
528
      (v) => typeof v === "string" && v.trim() !== ""
10✔
529
    );
10✔
530
    const hasCancerTypes = result?.cancerTypes?.length > 0;
10✔
531
    const hasOtherCancerTypes = result?.otherCancerTypes?.length > 0;
10✔
532
    const hasPreCancerTypes = result?.preCancerTypes?.length > 0;
10✔
533
    const hasSpecies = result?.species?.length > 0;
10✔
534
    const hasOtherSpecies = result?.otherSpeciesOfSubjects?.length > 0;
10✔
535
    const hasNumberOfParticipants = !!result?.numberOfParticipants;
10✔
536

537
    this.data = merge({}, this.data, result);
10✔
538

539
    const isStarted =
10✔
540
      hasAccessTypes ||
10✔
541
      hasStudyFields ||
8✔
542
      hasCancerTypes ||
7✔
543
      hasOtherCancerTypes ||
5✔
544
      hasPreCancerTypes ||
5✔
545
      hasSpecies ||
5✔
546
      hasOtherSpecies ||
4✔
547
      hasNumberOfParticipants;
4✔
548
    this.data.sections.find((s) => s.name === "C").status = isStarted
10✔
549
      ? "In Progress"
6✔
550
      : "Not Started";
3✔
551

552
    return true;
10✔
553
  }
10✔
554

555
  private async parseSectionD(): Promise<boolean> {
76✔
556
    const ws = this.workbook.getWorksheet(SectionD.SHEET_NAME);
6✔
557
    if (!ws) {
6✔
558
      Logger.info("parseSectionD: No sheet found for Section D. Skipping");
1✔
559
      return false;
1✔
560
    }
1✔
561

562
    const data = await this.extractValuesFromWorksheet(ws);
5✔
563
    const newData = new Map();
5✔
564
    // Swap the column headers for the column keys in the mapping
565
    data.forEach((values, key) => {
5✔
566
      const colKey = SectionDColumns.find((col) => col.header === key)?.key;
120✔
567
      newData.set(
120✔
568
        colKey,
120✔
569
        values.map((value) => toString(value).trim())
120✔
570
      );
120✔
571
    });
5✔
572
    const newMapping = SectionD.mapValues(newData, {
5✔
573
      programSheet: this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.programs),
5✔
574
      fileTypesSheet: this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.fileTypes),
5✔
575
    });
5✔
576

577
    const result: RecursivePartial<QuestionnaireData> = parseSchemaObject(
5✔
578
      SectionDSchema,
5✔
579
      newMapping
5✔
580
    );
5✔
581

582
    this.data = merge({}, this.data, result);
5✔
583

584
    const hasTargetedSubmissionDate = result?.targetedSubmissionDate?.length > 0;
6✔
585
    const hasTargetedReleaseDate = result?.targetedReleaseDate?.length > 0;
6✔
586
    const hasDataTypes = result?.dataTypes?.length > 0;
6✔
587
    const hasOtherDataTypes = result?.otherDataTypes?.length > 0;
6✔
588
    const hasFiles = result?.files?.length > 0;
6✔
589
    const hasDataDeIdentified = !!result?.dataDeIdentified;
6✔
590
    const hasSubmitterComment = result?.submitterComment?.length > 0;
6✔
591
    const hasCellLines = !!result?.cellLines;
6✔
592
    const hasModelSystems = !!result?.modelSystems;
6✔
593

594
    const isStarted =
6✔
595
      hasTargetedSubmissionDate ||
6✔
596
      hasTargetedReleaseDate ||
3✔
597
      hasDataTypes ||
3!
NEW
598
      hasOtherDataTypes ||
×
NEW
599
      hasFiles ||
×
NEW
600
      hasDataDeIdentified ||
×
NEW
601
      hasSubmitterComment ||
×
NEW
602
      hasCellLines ||
×
NEW
603
      hasModelSystems;
×
604
    this.data.sections.find((s) => s.name === "D").status = isStarted
6✔
605
      ? "In Progress"
5!
NEW
606
      : "Not Started";
×
607

608
    return true;
6✔
609
  }
6✔
610

611
  /**
612
   * Adds a hidden sheet which contains the full list of API provided institutions at function call.
613
   *
614
   * Columns:
615
   * - `A` - Institution ID
616
   * - `B` - Institution Name
617
   *
618
   * @returns An immutable internal reference to the sheet.
619
   */
620
  private async createInstitutionSheet(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
621
    let sheet = this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.institutions);
15✔
622
    if (!sheet) {
15✔
623
      sheet = this.workbook.addWorksheet(HIDDEN_SHEET_NAMES.institutions, { state: "veryHidden" });
15✔
624

625
      const institutions = await this.getAPIInstitutions();
15✔
626
      institutions?.forEach((institution, index) => {
15✔
627
        sheet.getCell(`A${index + 1}`).value = institution._id;
3✔
628
        sheet.getCell(`B${index + 1}`).value = institution.name;
3✔
629
      });
15✔
630
    }
15✔
631

632
    return sheet;
15✔
633
  }
15✔
634

635
  /**
636
   * Adds a hidden sheet which contains the full list of API provided programs at function call.
637
   *
638
   * Columns:
639
   * - `A` - Program ID
640
   * - `B` - Program Name
641
   *
642
   * @returns The created worksheet.
643
   */
644
  private async createProgramsSheet(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
645
    let sheet = this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.programs);
35✔
646
    if (!sheet) {
35✔
647
      sheet = this.workbook.addWorksheet(HIDDEN_SHEET_NAMES.programs, { state: "veryHidden" });
35✔
648

649
      const programs = await this.getAPIPrograms();
35✔
650
      const fullPrograms: ProgramInput[] = [NotApplicableProgram, ...programs, OtherProgram];
35✔
651

652
      fullPrograms.forEach((program, index) => {
35✔
653
        const row = index + 1;
71✔
654

655
        sheet.getCell(`A${row}`).value = program._id || "";
71!
656
        sheet.getCell(`B${row}`).value = program.name || "";
71✔
657
        sheet.getCell(`C${row}`).value = program.abbreviation || "";
71✔
658
        sheet.getCell(`D${row}`).value = program.description || "";
71✔
659

660
        // Set the formula for the Program name to default to Program ID if empty
661
        sheet.getCell(`E${row}`).value = {
71✔
662
          formula: `IF(LEN(TRIM(B${row}))>0,B${row},A${row})`,
71✔
663
        };
71✔
664
      });
35✔
665
    }
35✔
666

667
    return sheet;
35✔
668
  }
35✔
669

670
  /**
671
   * Creates a hidden sheet containing the file types.
672
   *
673
   * @returns The created worksheet.
674
   */
675
  private async createFileTypesSheet(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
676
    let sheet = this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.fileTypes);
16✔
677
    if (!sheet) {
16✔
678
      sheet = this.workbook.addWorksheet(HIDDEN_SHEET_NAMES.fileTypes, { state: "veryHidden" });
16✔
679

680
      Object.keys(fileTypeExtensions)?.forEach((file, index) => {
16✔
681
        sheet.getCell(`A${index + 1}`).value = file;
80✔
682
      });
16✔
683

684
      const allExtensions = union(...Object.values(fileTypeExtensions));
16✔
685
      allExtensions?.forEach((extension, index) => {
16✔
686
        sheet.getCell(`B${index + 1}`).value = extension;
144✔
687
      });
16✔
688
    }
16✔
689

690
    return sheet;
16✔
691
  }
16✔
692

693
  private async createFundingAgencySheet(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
694
    let sheet = this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.fundingAgencies);
20✔
695
    if (!sheet) {
20✔
696
      sheet = this.workbook.addWorksheet(HIDDEN_SHEET_NAMES.fundingAgencies, {
20✔
697
        state: "veryHidden",
20✔
698
      });
20✔
699

700
      fundingOptions?.forEach((agency, index) => {
20✔
701
        sheet.getCell(`A${index + 1}`).value = agency;
60✔
702
      });
20✔
703
    }
20✔
704

705
    return sheet;
20✔
706
  }
20✔
707

708
  /**
709
   * Creates a sheet with the full list of Cancer Types
710
   *
711
   * @returns A readonly reference to the worksheet.
712
   */
713
  private async createCancerTypesSheet(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
714
    let sheet = this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.cancerTypes);
14✔
715
    if (!sheet) {
14✔
716
      sheet = this.workbook.addWorksheet(HIDDEN_SHEET_NAMES.cancerTypes, { state: "veryHidden" });
14✔
717

718
      cancerTypeOptions.forEach((file, index) => {
14✔
719
        sheet.getCell(`A${index + 1}`).value = file;
742✔
720
      });
14✔
721
    }
14✔
722

723
    return sheet;
14✔
724
  }
14✔
725

726
  /**
727
   * Creates a hidden sheet containing the species options.
728
   *
729
   * @returns A readonly reference to the worksheet.
730
   */
731
  private async createSpeciesListSheet(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
732
    let sheet = this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.speciesOptions);
14✔
733
    if (!sheet) {
14✔
734
      sheet = this.workbook.addWorksheet(HIDDEN_SHEET_NAMES.speciesOptions, {
14✔
735
        state: "veryHidden",
14✔
736
      });
14✔
737

738
      speciesOptions.forEach((file, index) => {
14✔
739
        sheet.getCell(`A${index + 1}`).value = file;
56✔
740
      });
14✔
741
    }
14✔
742

743
    return sheet;
14✔
744
  }
14✔
745

746
  /**
747
   * Creates a hidden sheet containing the repository data types.
748
   *
749
   * @returns The created worksheet.
750
   */
751
  private async createRepositoryDataTypesSheet(): Promise<Readonly<ExcelJS.Worksheet>> {
76✔
752
    let sheet = this.workbook.getWorksheet(HIDDEN_SHEET_NAMES.repositoryDataTypes);
20✔
753
    if (!sheet) {
20✔
754
      sheet = this.workbook.addWorksheet(HIDDEN_SHEET_NAMES.repositoryDataTypes, {
20✔
755
        state: "veryHidden",
20✔
756
      });
20✔
757

758
      const repositoryDataTypeOptions = [
20✔
759
        DataTypes.clinicalTrial.name,
20✔
760
        DataTypes.genomics.name,
20✔
761
        DataTypes.imaging.name,
20✔
762
        DataTypes.proteomics.name,
20✔
763
      ];
764
      repositoryDataTypeOptions.forEach((file, index) => {
20✔
765
        sheet.getCell(`A${index + 1}`).value = file;
80✔
766
      });
20✔
767
    }
20✔
768

769
    return sheet;
20✔
770
  }
20✔
771

772
  /**
773
   * An internal utility to retrieve the Institution List from the dependencies
774
   *
775
   * @note This is error-safe
776
   * @returns The array of institutions
777
   */
778
  private async getAPIInstitutions(): Promise<Institution[]> {
76✔
779
    try {
15✔
780
      const { data } = (await this.dependencies.getInstitutions?.()) || {};
15✔
781
      return data?.listInstitutions?.institutions || [];
15✔
782
    } catch (error) {
15!
NEW
783
      return [];
×
NEW
784
    }
×
785
  }
15✔
786

787
  /**
788
   * Retrieves the list of programs from the dependencies.
789
   *
790
   * @note This excludes 'readOnly' programs from the program list.
791
   * @returns The array of programs.
792
   */
793
  private async getAPIPrograms(): Promise<Organization[]> {
76✔
794
    try {
35✔
795
      const { data } = (await this.dependencies.getPrograms?.()) || {};
35✔
796
      return (
35✔
797
        (data?.listPrograms?.programs as Organization[])?.filter((program) => !program.readOnly) ||
35✔
798
        []
34✔
799
      );
800
    } catch (error) {
35!
NEW
801
      return [];
×
NEW
802
    }
×
803
  }
35✔
804

805
  /**
806
   * Normalizes the cell value for consistent processing.
807
   *
808
   * @param value The cell value to normalize.
809
   * @returns The normalized value.
810
   */
811
  private normalizeCellValue(value: ExcelJS.CellValue | undefined): unknown {
76✔
812
    if (value === undefined || value === null) {
101,545✔
813
      return null;
50,475✔
814
    }
50,475✔
815

816
    if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
101,545✔
817
      return value;
51,057✔
818
    }
51,057✔
819
    if (value instanceof Date) {
101,545✔
820
      return new Intl.DateTimeFormat("en-US", { timeZone: "UTC" }).format(value);
3✔
821
    }
3✔
822

823
    if (isHyperlinkValue(value)) {
101,545✔
824
      return value.text;
2✔
825
    }
2✔
826
    if (isFormulaValue(value)) {
101,545✔
827
      return value.result !== undefined ? this.normalizeCellValue(value.result) : null;
2✔
828
    }
2✔
829
    if (isSharedFormulaValue(value)) {
101,545✔
830
      return value.result !== undefined ? this.normalizeCellValue(value.result) : null;
2✔
831
    }
2✔
832
    if (isRichTextValue(value)) {
101,545✔
833
      return value.richText.map((seg) => seg.text).join("");
2✔
834
    }
2✔
835

836
    Logger.error(
2✔
837
      "QuestionnaireExcelMiddleware: Found unknown value while normalizing data:",
2✔
838
      value
2✔
839
    );
2✔
840
    return null;
2✔
841
  }
101,545✔
842

843
  /**
844
   * Extracts the header key from a cell value.
845
   *
846
   * @param value The cell value to extract the header key from.
847
   * @returns The extracted header key or null if not found.
848
   */
849
  private headerKey(value: ExcelJS.CellValue | undefined): string | null {
76✔
850
    const normalized = this.normalizeCellValue(value);
50,768✔
851
    if (normalized === null || normalized === undefined) {
50,768✔
852
      return null;
3✔
853
    }
3✔
854

855
    return toString(normalized);
50,765✔
856
  }
50,768✔
857

858
  /**
859
   * Extracts values from the specified worksheet.
860
   *
861
   * @param ws The worksheet to extract values from.
862
   * @returns A map of header names to their corresponding cell values.
863
   */
864
  private async extractValuesFromWorksheet(
76✔
865
    ws: ExcelJS.Worksheet
35✔
866
  ): Promise<Map<string, Array<unknown>>> {
35✔
867
    const data = new Map<string, Array<unknown>>();
35✔
868
    const headerRow = ws.getRow(1);
35✔
869
    const headers = headerRow.values;
35✔
870

871
    ws.eachRow({ includeEmpty: true }, (row, rowNumber) => {
35✔
872
      if (rowNumber === 1) {
3,108✔
873
        return;
35✔
874
      }
35✔
875

876
      row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
3,073✔
877
        const header = this.headerKey(headers[colNumber]);
50,762✔
878
        if (!header) {
50,762✔
879
          // Invalid data, ignore
880
          return;
1✔
881
        }
1✔
882

883
        const allValues = data.get(header) || [];
50,762✔
884
        const normalizedValue = this.normalizeCellValue(cell.value);
50,762✔
885

886
        allValues[rowNumber - 2] = normalizedValue;
50,762✔
887

888
        data.set(header, allValues);
50,762✔
889
      });
3,073✔
890
    });
35✔
891

892
    return data;
35✔
893
  }
35✔
894
}
76✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc