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

CBIIT / crdc-datahub-ui / 16006182009

01 Jul 2025 05:32PM UTC coverage: 62.703% (-8.6%) from 71.278%
16006182009

Pull #756

github

web-flow
Merge pull request #755 from CBIIT/revert-omb-date

revert: OMB expiration update
Pull Request #756: Sync 3.4.0 with 3.3.0

3560 of 6102 branches covered (58.34%)

Branch coverage included in aggregate %.

4920 of 7422 relevant lines covered (66.29%)

227.7 hits per line

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

67.29
/src/content/dataSubmissions/QualityControl.tsx
1
import React, { FC, MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
2
import { isEqual } from "lodash";
3
import { Box, Button, Stack, styled } from "@mui/material";
4
import { useSnackbar } from "notistack";
5
import { useLazyQuery } from "@apollo/client";
6
import GenericTable, { Column } from "../../components/GenericTable";
7
import { FormatDate, Logger, titleCase } from "../../utils";
8
import ErrorDetailsDialog from "../../components/ErrorDetailsDialog";
9
import QCResultsContext from "./Contexts/QCResultsContext";
10
import { ExportValidationButton } from "../../components/DataSubmissions/ExportValidationButton";
11
import { useSubmissionContext } from "../../components/Contexts/SubmissionContext";
12
import StyledTooltip from "../../components/StyledFormComponents/StyledTooltip";
13
import TruncatedText from "../../components/TruncatedText";
14
import DoubleLabelSwitch from "../../components/DoubleLabelSwitch";
15
import {
16
  AGGREGATED_SUBMISSION_QC_RESULTS,
17
  SUBMISSION_QC_RESULTS,
18
  AggregatedSubmissionQCResultsInput,
19
  AggregatedSubmissionQCResultsResp,
20
  SubmissionQCResultsInput,
21
  SubmissionQCResultsResp,
22
} from "../../graphql";
23
import QualityControlFilters from "../../components/DataSubmissions/QualityControlFilters";
24
import { NodeComparisonProps } from "../../components/NodeComparison";
25
import { ValidationErrorCodes } from "../../config/ValidationErrors";
26

27
type FilterForm = {
28
  issueType: string;
29
  /**
30
   * The node type to filter by.
31
   *
32
   * @default "All"
33
   */
34
  nodeType: string;
35
  batchID: number | "All";
36
  severity: QCResult["severity"] | "All";
37
};
38

39
const StyledErrorDetailsButton = styled(Button)({
2✔
40
  display: "inline",
41
  color: "#0B6CB1",
42
  fontFamily: "'Nunito', 'Rubik', sans-serif",
43
  fontSize: "16px",
44
  fontStyle: "normal",
45
  fontWeight: 600,
46
  lineHeight: "19px",
47
  padding: 0,
48
  textDecorationLine: "underline",
49
  textTransform: "none",
50
  "&:hover": {
51
    background: "transparent",
52
    textDecorationLine: "underline",
53
  },
54
});
55

56
const StyledNodeType = styled(Box)({
2✔
57
  display: "flex",
58
  alignItems: "center",
59
  textTransform: "capitalize",
60
});
61

62
const StyledSeverity = styled(Box)({
2✔
63
  display: "flex",
64
  alignItems: "center",
65
});
66

67
const StyledBreakAll = styled(Box)({
2✔
68
  wordBreak: "break-all",
69
});
70

71
const StyledIssuesTextWrapper = styled(Box)({
2✔
72
  whiteSpace: "nowrap",
73
  wordBreak: "break-word",
74
});
75

76
const StyledDateTooltip = styled(StyledTooltip)(() => ({
4✔
77
  cursor: "pointer",
78
}));
79

80
type RowData = QCResult | AggregatedQCResult;
81

82
const aggregatedColumns: Column<AggregatedQCResult>[] = [
2✔
83
  {
84
    label: "Issue Type",
85
    renderValue: (data) => <TruncatedText text={data.title} maxCharacters={50} />,
×
86
    field: "title",
87
  },
88
  {
89
    label: "Severity",
90
    renderValue: (data) => (
91
      <StyledSeverity color={data?.severity === "Error" ? "#B54717" : "#8D5809"}>
×
92
        {data?.severity}
93
      </StyledSeverity>
94
    ),
95
    field: "severity",
96
  },
97
  {
98
    label: "Count",
99
    renderValue: (data) =>
100
      Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(data.count || 0),
×
101
    field: "count",
102
    default: true,
103
  },
104
  {
105
    label: "Expand",
106
    renderValue: (data) => (
107
      <QCResultsContext.Consumer>
×
108
        {({ handleExpandClick }) => (
109
          <StyledErrorDetailsButton
×
110
            onClick={() => handleExpandClick?.(data)}
×
111
            variant="text"
112
            disableRipple
113
            disableTouchRipple
114
            disableFocusRipple
115
          >
116
            Expand
117
          </StyledErrorDetailsButton>
118
        )}
119
      </QCResultsContext.Consumer>
120
    ),
121
    fieldKey: "expand",
122
    sortDisabled: true,
123
    sx: {
124
      width: "104px",
125
      textAlign: "center",
126
    },
127
  },
128
];
129

130
const expandedColumns: Column<QCResult>[] = [
2✔
131
  {
132
    label: "Batch ID",
133
    renderValue: (data) => <StyledBreakAll>{data?.displayID}</StyledBreakAll>,
4✔
134
    field: "displayID",
135
    default: true,
136
    sx: {
137
      width: "122px",
138
    },
139
  },
140
  {
141
    label: "Node Type",
142
    renderValue: (data) => (
143
      <StyledNodeType>
4✔
144
        <TruncatedText text={data?.type} maxCharacters={15} disableInteractiveTooltip={false} />
145
      </StyledNodeType>
146
    ),
147
    field: "type",
148
  },
149
  {
150
    label: "Submitted Identifier",
151
    renderValue: (data) => (
152
      <TruncatedText
4✔
153
        text={data?.submittedID}
154
        maxCharacters={15}
155
        disableInteractiveTooltip={false}
156
      />
157
    ),
158
    field: "submittedID",
159
  },
160
  {
161
    label: "Severity",
162
    renderValue: (data) => (
163
      <StyledSeverity color={data?.severity === "Error" ? "#B54717" : "#8D5809"}>
4✔
164
        {data?.severity}
165
      </StyledSeverity>
166
    ),
167
    field: "severity",
168
    sx: {
169
      width: "148px",
170
    },
171
  },
172
  {
173
    label: "Validated Date",
174
    renderValue: (data) =>
175
      data.validatedDate ? (
4!
176
        <StyledDateTooltip
177
          title={FormatDate(data.validatedDate, "M/D/YYYY h:mm A")}
178
          placement="top"
179
        >
180
          <span>{FormatDate(data.validatedDate, "M/D/YYYY")}</span>
181
        </StyledDateTooltip>
182
      ) : (
183
        ""
184
      ),
185
    field: "validatedDate",
186
    sx: {
187
      width: "193px",
188
    },
189
  },
190
  {
191
    label: "Issues",
192
    renderValue: (data) =>
193
      (data?.errors?.length > 0 || data?.warnings?.length > 0) && (
4✔
194
        <QCResultsContext.Consumer>
195
          {({ handleOpenErrorDialog }) => (
196
            <Stack direction="row">
4✔
197
              <StyledIssuesTextWrapper>
198
                <TruncatedText
199
                  text={`${data.errors?.[0]?.title || data.warnings?.[0]?.title}.`}
3✔
200
                  maxCharacters={15}
201
                  wrapperSx={{ display: "inline" }}
202
                  labelSx={{ display: "inline" }}
203
                  disableInteractiveTooltip={false}
204
                />{" "}
205
                <StyledErrorDetailsButton
206
                  onClick={() => handleOpenErrorDialog && handleOpenErrorDialog(data)}
×
207
                  variant="text"
208
                  disableRipple
209
                  disableTouchRipple
210
                  disableFocusRipple
211
                >
212
                  See details.
213
                </StyledErrorDetailsButton>
214
              </StyledIssuesTextWrapper>
215
            </Stack>
216
          )}
217
        </QCResultsContext.Consumer>
218
      ),
219
    sortDisabled: true,
220
  },
221
];
222

223
// CSV columns used for exporting table data
224
export const csvColumns = {
2✔
225
  "Batch ID": (d: QCResult) => d.displayID,
×
226
  "Node Type": (d: QCResult) => d.type,
×
227
  "Submitted Identifier": (d: QCResult) => d.submittedID,
×
228
  Severity: (d: QCResult) => d.severity,
×
229
  "Validated Date": (d: QCResult) => FormatDate(d?.validatedDate, "MM-DD-YYYY [at] hh:mm A", ""),
×
230
  Issues: (d: QCResult) => {
231
    const value = d.errors[0]?.description ?? d.warnings[0]?.description;
×
232

233
    // NOTE: The ErrorMessage descriptions contain non-standard double quotes
234
    // that don't render correctly in Excel. This replaces them with standard double quotes.
235
    return value.replaceAll(/[“”‟〞"]/g, `"`);
×
236
  },
237
};
238

239
export const aggregatedCSVColumns = {
2✔
240
  "Issue Type": (d: AggregatedQCResult) => d.title,
×
241
  Severity: (d: AggregatedQCResult) => d.severity,
×
242
  Count: (d: AggregatedQCResult) => d.count,
×
243
};
244

245
const QualityControl: FC = () => {
2✔
246
  const { enqueueSnackbar } = useSnackbar();
184✔
247
  const { data: submissionData } = useSubmissionContext();
184✔
248
  const {
249
    _id: submissionId,
250
    status: submissionStatus,
251
    metadataValidationStatus,
252
    fileValidationStatus,
253
  } = submissionData?.getSubmission || {};
184!
254

255
  const [loading, setLoading] = useState<boolean>(false);
184✔
256
  const [data, setData] = useState<RowData[]>([]);
184✔
257
  const [prevData, setPrevData] = useState<FetchListing<RowData>>(null);
184✔
258
  const [totalData, setTotalData] = useState(0);
184✔
259
  const [openErrorDialog, setOpenErrorDialog] = useState<boolean>(false);
184✔
260
  const [selectedRow, setSelectedRow] = useState<RowData | null>(null);
184✔
261
  const [isAggregated, setIsAggregated] = useState<boolean>(true);
184✔
262
  const [issueType, setIssueType] = useState<string | null>("All");
184✔
263
  const filtersRef: MutableRefObject<FilterForm> = useRef({
184✔
264
    issueType: "All",
265
    batchID: "All",
266
    nodeType: "All",
267
    severity: "All",
268
  });
269
  const tableRef = useRef<TableMethods>(null);
184✔
270

271
  const errorDescriptions = useMemo(() => {
184✔
272
    if (selectedRow && "errors" in selectedRow) {
28!
273
      return selectedRow.errors?.map((error) => `(Error) ${error.description}`) ?? [];
×
274
    }
275
    return [];
28✔
276
  }, [selectedRow]);
277

278
  const warningDescriptions = useMemo(() => {
184✔
279
    if (selectedRow && "warnings" in selectedRow) {
28!
280
      return (
×
281
        (selectedRow as QCResult).warnings?.map((warning) => `(Warning) ${warning.description}`) ??
×
282
        []
283
      );
284
    }
285
    return [];
28✔
286
  }, [selectedRow]);
287

288
  const allDescriptions = useMemo(
184✔
289
    () => [...errorDescriptions, ...warningDescriptions],
28✔
290
    [errorDescriptions, warningDescriptions]
291
  );
292

293
  const [submissionQCResults] = useLazyQuery<SubmissionQCResultsResp, SubmissionQCResultsInput>(
184✔
294
    SUBMISSION_QC_RESULTS,
295
    {
296
      context: { clientName: "backend" },
297
      fetchPolicy: "cache-and-network",
298
    }
299
  );
300

301
  const [aggregatedSubmissionQCResults] = useLazyQuery<
184✔
302
    AggregatedSubmissionQCResultsResp,
303
    AggregatedSubmissionQCResultsInput
304
  >(AGGREGATED_SUBMISSION_QC_RESULTS, {
305
    context: { clientName: "backend" },
306
    fetchPolicy: "cache-and-network",
307
  });
308

309
  useEffect(() => {
184✔
310
    tableRef.current?.refresh();
28✔
311
  }, [metadataValidationStatus, fileValidationStatus]);
312

313
  const handleFetchQCResults = async (fetchListing: FetchListing<QCResult>, force: boolean) => {
184✔
314
    const { first, offset, sortDirection, orderBy } = fetchListing || {};
26!
315
    if (!force && data?.length > 0 && isEqual(fetchListing, prevData)) {
26!
316
      return;
×
317
    }
318

319
    setPrevData(fetchListing);
26✔
320

321
    try {
26✔
322
      setLoading(true);
26✔
323

324
      const { data: d, error } = await submissionQCResults({
26✔
325
        variables: {
326
          id: submissionId,
327
          first,
328
          offset,
329
          sortDirection,
330
          orderBy,
331
          issueCode:
332
            !filtersRef.current.issueType || filtersRef.current.issueType === "All"
39!
333
              ? undefined
334
              : filtersRef.current.issueType,
335
          nodeTypes:
336
            !filtersRef.current.nodeType || filtersRef.current.nodeType === "All"
39✔
337
              ? undefined
338
              : [filtersRef.current.nodeType],
339
          batchIDs:
340
            !filtersRef.current.batchID || filtersRef.current.batchID === "All"
39✔
341
              ? undefined
342
              : [filtersRef.current.batchID],
343
          severities: filtersRef.current.severity || "All",
13!
344
        },
345
        context: { clientName: "backend" },
346
        fetchPolicy: "no-cache",
347
      });
348
      if (error || !d?.submissionQCResults) {
26✔
349
        throw new Error("Unable to retrieve submission quality control results.");
4✔
350
      }
351
      setData(d.submissionQCResults.results);
22✔
352
      setTotalData(d.submissionQCResults.total);
22✔
353
    } catch (err) {
354
      enqueueSnackbar(err?.toString(), { variant: "error" });
4✔
355
    } finally {
356
      setLoading(false);
26✔
357
    }
358
  };
359

360
  const handleFetchAggQCResults = async (
184✔
361
    fetchListing: FetchListing<AggregatedQCResult>,
362
    force: boolean
363
  ) => {
364
    const { first, offset, sortDirection, orderBy } = fetchListing || {};
52!
365

366
    if (!force && data?.length > 0 && isEqual(fetchListing, prevData)) {
52!
367
      return;
×
368
    }
369

370
    setPrevData(fetchListing);
52✔
371

372
    try {
52✔
373
      setLoading(true);
52✔
374

375
      const { data: d, error } = await aggregatedSubmissionQCResults({
52✔
376
        variables: {
377
          submissionID: submissionId,
378
          severity: filtersRef.current.severity?.toLowerCase() || "all",
26!
379
          first,
380
          offset,
381
          sortDirection,
382
          orderBy,
383
        },
384
        context: { clientName: "backend" },
385
        fetchPolicy: "no-cache",
386
      });
387
      if (error || !d?.aggregatedSubmissionQCResults) {
48✔
388
        throw new Error("Unable to retrieve submission aggregated quality control results.");
8✔
389
      }
390
      setData(d.aggregatedSubmissionQCResults.results);
40✔
391
      setTotalData(d.aggregatedSubmissionQCResults.total);
40✔
392
    } catch (err) {
393
      Logger.error(`QualityControl: ${err?.toString()}`);
8✔
394
      enqueueSnackbar(err?.toString(), { variant: "error" });
8✔
395
    } finally {
396
      setLoading(false);
48✔
397
    }
398
  };
399

400
  const handleFetchData = (fetchListing: FetchListing<RowData>, force: boolean) => {
184✔
401
    if (!submissionId || !filtersRef.current) {
82✔
402
      return;
4✔
403
    }
404

405
    isAggregated
78✔
406
      ? handleFetchAggQCResults(fetchListing, force)
407
      : handleFetchQCResults(fetchListing, force);
408
  };
409

410
  const handleOpenErrorDialog = (data: QCResult) => {
184✔
411
    setOpenErrorDialog(true);
×
412
    setSelectedRow(data);
×
413
  };
414

415
  const Actions = useMemo<React.ReactNode>(
184✔
416
    () => (
417
      <Stack direction="row" alignItems="center" gap="8px" marginRight="37px">
52✔
418
        <ExportValidationButton
419
          submission={submissionData?.getSubmission}
420
          fields={isAggregated ? aggregatedCSVColumns : csvColumns}
26✔
421
          isAggregated={isAggregated}
422
          disabled={totalData <= 0}
423
        />
424
      </Stack>
425
    ),
426
    [submissionData?.getSubmission, totalData, isAggregated]
427
  );
428

429
  const handleOnFiltersChange = (data: FilterForm) => {
184✔
430
    filtersRef.current = data;
50✔
431
    tableRef.current?.setPage(0, true);
50✔
432
  };
433

434
  const onSwitchToggle = () => {
184✔
435
    setIsAggregated((prev) => {
18✔
436
      const newVal = !prev;
18✔
437
      // Reset to 'All' when in Aggregated view
438
      if (newVal === true) {
18!
439
        setIssueType("All");
×
440
      }
441

442
      return newVal;
18✔
443
    });
444
  };
445

446
  const currentColumns = useMemo(
184✔
447
    () => (isAggregated ? aggregatedColumns : expandedColumns),
46✔
448
    [isAggregated]
449
  ) as Column<RowData>[];
450

451
  const handleExpandClick = (issue: AggregatedQCResult) => {
184✔
452
    if (!issue?.code) {
×
453
      Logger.error("QualityControl: Unable to expand invalid issue.");
×
454
      return;
×
455
    }
456

457
    setIssueType(issue?.code);
×
458
    setIsAggregated(false);
×
459
  };
460

461
  const providerValue = useMemo(
184✔
462
    () => ({
184✔
463
      handleOpenErrorDialog,
464
      handleExpandClick,
465
    }),
466
    [handleOpenErrorDialog, handleExpandClick]
467
  );
468

469
  const comparisonData = useMemo<NodeComparisonProps | undefined>(() => {
184✔
470
    if (submissionStatus === "Completed") {
28!
471
      return undefined;
×
472
    }
473
    if (!selectedRow || !("submittedID" in selectedRow && "type" in selectedRow)) {
28!
474
      return undefined;
28✔
475
    }
476
    if (
×
477
      !selectedRow?.errors?.some((error) => error.code === ValidationErrorCodes.UPDATING_DATA) &&
×
478
      !selectedRow?.warnings?.some((warning) => warning.code === ValidationErrorCodes.UPDATING_DATA)
×
479
    ) {
480
      return undefined;
×
481
    }
482

483
    return {
×
484
      submissionID: submissionId,
485
      nodeType: selectedRow.type,
486
      submittedID: selectedRow.submittedID,
487
    };
488
  }, [submissionStatus, submissionId, selectedRow]);
489

490
  return (
184✔
491
    <>
492
      <QualityControlFilters
493
        onChange={handleOnFiltersChange}
494
        issueType={issueType}
495
        isAggregated={isAggregated}
496
      />
497

498
      <QCResultsContext.Provider value={providerValue}>
499
        <GenericTable
500
          ref={tableRef}
501
          columns={currentColumns}
502
          data={data || []}
92!
503
          total={totalData || 0}
179✔
504
          loading={loading}
505
          defaultRowsPerPage={20}
506
          defaultOrder="desc"
507
          position="both"
508
          noContentText="No validation issues found. Either no validation has been conducted yet, or all issues have been resolved."
509
          setItemKey={(item, idx) => `${idx}_${"title" in item ? item?.title : item?.batchID}`}
4!
510
          onFetchData={handleFetchData}
511
          AdditionalActions={{
512
            top: {
513
              before: (
514
                <DoubleLabelSwitch
515
                  leftLabel="Aggregated"
516
                  rightLabel="Expanded"
517
                  id="table-state-switch"
518
                  data-testid="table-view-switch"
519
                  checked={!isAggregated}
520
                  onChange={onSwitchToggle}
521
                  inputProps={{ "aria-label": "Aggregated or Expanded table view switch" }}
522
                />
523
              ),
524
              after: Actions,
525
            },
526
            bottom: {
527
              after: Actions,
528
            },
529
          }}
530
          containerProps={{ sx: { marginBottom: "8px" } }}
531
        />
532
      </QCResultsContext.Provider>
533
      {!isAggregated && (
139✔
534
        <ErrorDetailsDialog
535
          open={openErrorDialog}
536
          onClose={() => setOpenErrorDialog(false)}
×
537
          header={null}
538
          title="Validation Issues"
539
          nodeInfo={`For ${titleCase((selectedRow as QCResult)?.type)}${
540
            (selectedRow as QCResult)?.type?.toLocaleLowerCase() !== "data file" ? " Node" : ""
47!
541
          } ID ${(selectedRow as QCResult)?.submittedID}`}
542
          errors={allDescriptions}
543
          errorCount={`${allDescriptions?.length || 0} ${
94✔
544
            allDescriptions?.length === 1 ? "ISSUE" : "ISSUES"
47!
545
          }`}
546
          comparisonData={comparisonData}
547
        />
548
      )}
549
    </>
550
  );
551
};
552

553
export default React.memo(QualityControl);
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