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

CBIIT / crdc-datahub-ui / 11074479132

27 Sep 2024 04:44PM UTC coverage: 44.982% (+26.5%) from 18.435%
11074479132

Pull #479

github

web-flow
Merge a0867d25a into 3d8b55818
Pull Request #479: 3.0.0 Release

1727 of 4418 branches covered (39.09%)

Branch coverage included in aggregate %.

2612 of 5228 relevant lines covered (49.96%)

128.96 hits per line

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

83.23
/src/content/dataSubmissions/QualityControl.tsx
1
import React, { FC, useEffect, useMemo, useRef, useState } from "react";
2
import { useLazyQuery, useQuery } from "@apollo/client";
3
import { cloneDeep, isEqual } from "lodash";
4
import { Box, Button, FormControl, MenuItem, Stack, styled } from "@mui/material";
5
import { Controller, useForm } from "react-hook-form";
6
import { useSnackbar } from "notistack";
7
import {
8
  LIST_BATCHES,
9
  ListBatchesInput,
10
  ListBatchesResp,
11
  SUBMISSION_QC_RESULTS,
12
  SUBMISSION_STATS,
13
  SubmissionQCResultsResp,
14
  SubmissionStatsResp,
15
} from "../../graphql";
16
import GenericTable, { Column } from "../../components/GenericTable";
17
import { FormatDate, compareNodeStats, titleCase } from "../../utils";
18
import ErrorDetailsDialog from "../../components/ErrorDetailsDialog";
19
import QCResultsContext from "./Contexts/QCResultsContext";
20
import { ExportValidationButton } from "../../components/DataSubmissions/ExportValidationButton";
21
import StyledSelect from "../../components/StyledFormComponents/StyledSelect";
22
import { useSubmissionContext } from "../../components/Contexts/SubmissionContext";
23

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

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

52
const StyledNodeType = styled(Box)({
2✔
53
  display: "flex",
54
  alignItems: "center",
55
  textTransform: "capitalize",
56
});
57

58
const StyledSeverity = styled(Box)({
2✔
59
  minHeight: 76.5,
60
  display: "flex",
61
  alignItems: "center",
62
});
63

64
const StyledBreakAll = styled(Box)({
2✔
65
  wordBreak: "break-all",
66
});
67

68
const StyledFilterContainer = styled(Box)({
2✔
69
  display: "flex",
70
  alignItems: "center",
71
  justifyContent: "space-between",
72
  marginBottom: "21px",
73
  paddingLeft: "26px",
74
  paddingRight: "35px",
75
});
76

77
const StyledFormControl = styled(FormControl)({
2✔
78
  minWidth: "231px",
79
});
80

81
const StyledInlineLabel = styled("label")({
2✔
82
  color: "#083A50",
83
  fontFamily: "'Nunito', 'Rubik', sans-serif",
84
  fontWeight: 700,
85
  fontSize: "16px",
86
  fontStyle: "normal",
87
  lineHeight: "19.6px",
88
  paddingRight: "10px",
89
});
90

91
const StyledIssuesTextWrapper = styled(Box)({
2✔
92
  whiteSpace: "nowrap",
93
  wordBreak: "break-word",
94
});
95

96
type TouchedState = { [K in keyof FilterForm]: boolean };
97

98
const initialTouchedFields: TouchedState = {
2✔
99
  nodeType: false,
100
  batchID: false,
101
  severity: false,
102
};
103

104
const columns: Column<QCResult>[] = [
2✔
105
  {
106
    label: "Batch ID",
107
    renderValue: (data) => <StyledBreakAll>{data?.displayID}</StyledBreakAll>,
4✔
108
    field: "displayID",
109
    default: true,
110
  },
111
  {
112
    label: "Node Type",
113
    renderValue: (data) => <StyledNodeType>{data?.type}</StyledNodeType>,
4✔
114
    field: "type",
115
  },
116
  {
117
    label: "Submitted Identifier",
118
    renderValue: (data) => <StyledBreakAll>{data?.submittedID}</StyledBreakAll>,
4✔
119
    field: "submittedID",
120
    sx: {
121
      width: "20%",
122
    },
123
  },
124
  {
125
    label: "Severity",
126
    renderValue: (data) => (
127
      <StyledSeverity color={data?.severity === "Error" ? "#B54717" : "#8D5809"}>
4✔
128
        {data?.severity}
129
      </StyledSeverity>
130
    ),
131
    field: "severity",
132
  },
133
  {
134
    label: "Validated Date",
135
    renderValue: (data) =>
136
      data?.validatedDate ? `${FormatDate(data?.validatedDate, "MM-DD-YYYY [at] hh:mm A")}` : "",
4!
137
    field: "validatedDate",
138
  },
139
  {
140
    label: "Issues",
141
    renderValue: (data) =>
142
      (data?.errors?.length > 0 || data?.warnings?.length > 0) && (
4✔
143
        <QCResultsContext.Consumer>
144
          {({ handleOpenErrorDialog }) => (
145
            <Stack direction="row">
4✔
146
              <StyledIssuesTextWrapper>
147
                <span>
148
                  {data.errors?.length > 0 ? data.errors[0].title : data.warnings[0]?.title}.
2✔
149
                </span>{" "}
150
                <StyledErrorDetailsButton
151
                  onClick={() => handleOpenErrorDialog && handleOpenErrorDialog(data)}
×
152
                  variant="text"
153
                  disableRipple
154
                  disableTouchRipple
155
                  disableFocusRipple
156
                >
157
                  See details.
158
                </StyledErrorDetailsButton>
159
              </StyledIssuesTextWrapper>
160
            </Stack>
161
          )}
162
        </QCResultsContext.Consumer>
163
      ),
164
    sortDisabled: true,
165
    sx: {
166
      width: "38%",
167
    },
168
  },
169
];
170

171
// CSV columns used for exporting table data
172
export const csvColumns = {
2✔
173
  "Batch ID": (d: QCResult) => d.displayID,
×
174
  "Node Type": (d: QCResult) => d.type,
×
175
  "Submitted Identifier": (d: QCResult) => d.submittedID,
×
176
  Severity: (d: QCResult) => d.severity,
×
177
  "Validated Date": (d: QCResult) => FormatDate(d?.validatedDate, "MM-DD-YYYY [at] hh:mm A", ""),
×
178
  Issues: (d: QCResult) => {
179
    const value = d.errors[0]?.description ?? d.warnings[0]?.description;
×
180

181
    // NOTE: The ErrorMessage descriptions contain non-standard double quotes
182
    // that don't render correctly in Excel. This replaces them with standard double quotes.
183
    return value.replaceAll(/[“”‟〞"]/g, `"`);
×
184
  },
185
};
186

187
const QualityControl: FC = () => {
2✔
188
  const { enqueueSnackbar } = useSnackbar();
142✔
189
  const { data: submissionData } = useSubmissionContext();
142✔
190
  const { watch, control } = useForm<FilterForm>({
142✔
191
    defaultValues: {
192
      batchID: "All",
193
      nodeType: "All",
194
      severity: "All",
195
    },
196
  });
197
  const {
198
    _id: submissionId,
199
    metadataValidationStatus,
200
    fileValidationStatus,
201
  } = submissionData?.getSubmission || {};
142!
202

203
  const [loading, setLoading] = useState<boolean>(false);
142✔
204
  const [data, setData] = useState<QCResult[]>([]);
142✔
205
  const [prevData, setPrevData] = useState<FetchListing<QCResult>>(null);
142✔
206
  const [totalData, setTotalData] = useState(0);
142✔
207
  const [openErrorDialog, setOpenErrorDialog] = useState<boolean>(false);
142✔
208
  const [selectedRow, setSelectedRow] = useState<QCResult | null>(null);
142✔
209
  const [touchedFilters, setTouchedFilters] = useState<TouchedState>(initialTouchedFields);
142✔
210
  const nodeTypeFilter = watch("nodeType");
142✔
211
  const batchIDFilter = watch("batchID");
142✔
212
  const severityFilter = watch("severity");
142✔
213
  const tableRef = useRef<TableMethods>(null);
142✔
214

215
  const errorDescriptions =
216
    selectedRow?.errors?.map((error) => `(Error) ${error.description}`) ?? [];
142✔
217
  const warningDescriptions =
218
    selectedRow?.warnings?.map((warning) => `(Warning) ${warning.description}`) ?? [];
142✔
219
  const allDescriptions = [...errorDescriptions, ...warningDescriptions];
142✔
220

221
  const [submissionQCResults] = useLazyQuery<SubmissionQCResultsResp>(SUBMISSION_QC_RESULTS, {
142✔
222
    variables: { id: submissionId },
223
    context: { clientName: "backend" },
224
    fetchPolicy: "cache-and-network",
225
  });
226

227
  const { data: batchData } = useQuery<ListBatchesResp<true>, ListBatchesInput>(LIST_BATCHES, {
142✔
228
    variables: {
229
      submissionID: submissionId,
230
      first: -1,
231
      offset: 0,
232
      partial: true,
233
      orderBy: "displayID",
234
      sortDirection: "asc",
235
    },
236
    context: { clientName: "backend" },
237
    skip: !submissionId,
238
    fetchPolicy: "cache-and-network",
239
  });
240

241
  const { data: submissionStats } = useQuery<SubmissionStatsResp>(SUBMISSION_STATS, {
142✔
242
    variables: { id: submissionId },
243
    context: { clientName: "backend" },
244
    skip: !submissionId,
245
    fetchPolicy: "cache-and-network",
246
  });
247

248
  const nodeTypes = useMemo(
142✔
249
    () =>
250
      cloneDeep(submissionStats?.submissionStats?.stats)
44✔
251
        ?.filter((stat) => stat.error > 0 || stat.warning > 0)
24✔
252
        ?.sort(compareNodeStats)
253
        ?.reverse()
254
        ?.map((stat) => stat.nodeName),
20✔
255
    [submissionStats?.submissionStats?.stats]
256
  );
257

258
  const handleFetchQCResults = async (fetchListing: FetchListing<QCResult>, force: boolean) => {
142✔
259
    const { first, offset, sortDirection, orderBy } = fetchListing || {};
32!
260
    if (!submissionId) {
32✔
261
      return;
2✔
262
    }
263
    if (!force && data?.length > 0 && isEqual(fetchListing, prevData)) {
30!
264
      return;
×
265
    }
266

267
    setPrevData(fetchListing);
30✔
268

269
    try {
30✔
270
      setLoading(true);
30✔
271

272
      const { data: d, error } = await submissionQCResults({
30✔
273
        variables: {
274
          submissionID: submissionId,
275
          first,
276
          offset,
277
          sortDirection,
278
          orderBy,
279
          nodeTypes: !nodeTypeFilter || nodeTypeFilter === "All" ? undefined : [nodeTypeFilter],
45✔
280
          batchIDs: !batchIDFilter || batchIDFilter === "All" ? undefined : [batchIDFilter],
45✔
281
          severities: watch("severity") || "All",
15!
282
        },
283
        context: { clientName: "backend" },
284
        fetchPolicy: "no-cache",
285
      });
286
      if (error || !d?.submissionQCResults) {
28✔
287
        throw new Error("Unable to retrieve submission quality control results.");
4✔
288
      }
289
      setData(d.submissionQCResults.results);
24✔
290
      setTotalData(d.submissionQCResults.total);
24✔
291
    } catch (err) {
292
      enqueueSnackbar(err?.toString(), { variant: "error" });
4✔
293
    } finally {
294
      setLoading(false);
28✔
295
    }
296
  };
297

298
  const handleOpenErrorDialog = (data: QCResult) => {
142✔
299
    setOpenErrorDialog(true);
×
300
    setSelectedRow(data);
×
301
  };
302

303
  const handleFilterChange = (field: keyof FilterForm) => {
142✔
304
    setTouchedFilters((prev) => ({ ...prev, [field]: true }));
4✔
305
  };
306

307
  const Actions = useMemo<React.ReactNode>(
142✔
308
    () => (
309
      <Stack direction="row" alignItems="center" gap="8px" marginRight="37px">
32✔
310
        <ExportValidationButton
311
          submission={submissionData?.getSubmission}
312
          fields={csvColumns}
313
          disabled={totalData <= 0}
314
        />
315
      </Stack>
316
    ),
317
    [submissionData?.getSubmission, totalData]
318
  );
319

320
  const providerValue = useMemo(
142✔
321
    () => ({
142✔
322
      handleOpenErrorDialog,
323
    }),
324
    [handleOpenErrorDialog]
325
  );
326

327
  useEffect(() => {
142✔
328
    if (!touchedFilters.nodeType && !touchedFilters.batchID && !touchedFilters.severity) {
32✔
329
      return;
28✔
330
    }
331
    tableRef.current?.setPage(0, true);
4✔
332
  }, [nodeTypeFilter, batchIDFilter, severityFilter]);
333

334
  useEffect(() => {
142✔
335
    tableRef.current?.refresh();
28✔
336
  }, [metadataValidationStatus, fileValidationStatus]);
337

338
  return (
142✔
339
    <>
340
      <StyledFilterContainer>
341
        <Stack direction="row" justifyContent="flex-start" alignItems="center">
342
          <StyledInlineLabel htmlFor="batchID-filter">Batch ID</StyledInlineLabel>
343
          <StyledFormControl>
344
            <Controller
345
              name="batchID"
346
              control={control}
347
              render={({ field }) => (
348
                <StyledSelect
142✔
349
                  {...field}
350
                  /* zIndex has to be higher than the SuspenseLoader to avoid cropping */
351
                  MenuProps={{ disablePortal: true, sx: { zIndex: 99999 } }}
352
                  inputProps={{ id: "batchID-filter" }}
353
                  data-testid="quality-control-batchID-filter"
354
                  onChange={(e) => {
355
                    field.onChange(e);
2✔
356
                    handleFilterChange("batchID");
2✔
357
                  }}
358
                >
359
                  <MenuItem value="All">All</MenuItem>
360
                  {batchData?.listBatches?.batches?.map((batch) => (
361
                    <MenuItem key={batch._id} value={batch._id} data-testid={batch._id}>
24✔
362
                      {batch.displayID}
363
                      {` (${FormatDate(batch.createdAt, "MM/DD/YYYY")})`}
364
                    </MenuItem>
365
                  ))}
366
                </StyledSelect>
367
              )}
368
            />
369
          </StyledFormControl>
370
        </Stack>
371

372
        <Stack direction="row" justifyContent="flex-start" alignItems="center">
373
          <StyledInlineLabel htmlFor="nodeType-filter">Node Type</StyledInlineLabel>
374
          <StyledFormControl>
375
            <Controller
376
              name="nodeType"
377
              control={control}
378
              render={({ field }) => (
379
                <StyledSelect
142✔
380
                  {...field}
381
                  /* zIndex has to be higher than the SuspenseLoader to avoid cropping */
382
                  MenuProps={{ disablePortal: true, sx: { zIndex: 99999 } }}
383
                  inputProps={{ id: "nodeType-filter" }}
384
                  data-testid="quality-control-nodeType-filter"
385
                  onChange={(e) => {
386
                    field.onChange(e);
2✔
387
                    handleFilterChange("nodeType");
2✔
388
                  }}
389
                >
390
                  <MenuItem value="All">All</MenuItem>
391
                  {nodeTypes?.map((nodeType) => (
392
                    <MenuItem key={nodeType} value={nodeType} data-testid={`nodeType-${nodeType}`}>
26✔
393
                      {nodeType.toLowerCase()}
394
                    </MenuItem>
395
                  ))}
396
                </StyledSelect>
397
              )}
398
            />
399
          </StyledFormControl>
400
        </Stack>
401

402
        <Stack direction="row" justifyContent="flex-start" alignItems="center">
403
          <StyledInlineLabel htmlFor="severity-filter">Severity</StyledInlineLabel>
404
          <StyledFormControl>
405
            <Controller
406
              name="severity"
407
              control={control}
408
              render={({ field }) => (
409
                <StyledSelect
142✔
410
                  {...field}
411
                  /* zIndex has to be higher than the SuspenseLoader to avoid cropping */
412
                  MenuProps={{ disablePortal: true, sx: { zIndex: 99999 } }}
413
                  inputProps={{ id: "severity-filter" }}
414
                  data-testid="quality-control-severity-filter"
415
                  onChange={(e) => {
416
                    field.onChange(e);
×
417
                    handleFilterChange("severity");
×
418
                  }}
419
                >
420
                  <MenuItem value="All">All</MenuItem>
421
                  <MenuItem value="Error">Error</MenuItem>
422
                  <MenuItem value="Warning">Warning</MenuItem>
423
                </StyledSelect>
424
              )}
425
            />
426
          </StyledFormControl>
427
        </Stack>
428
      </StyledFilterContainer>
429
      <QCResultsContext.Provider value={providerValue}>
430
        <GenericTable
431
          ref={tableRef}
432
          columns={columns}
433
          data={data || []}
71!
434
          total={totalData || 0}
140✔
435
          loading={loading}
436
          defaultRowsPerPage={20}
437
          defaultOrder="desc"
438
          position="both"
439
          noContentText="No validation issues found. Either no validation has been conducted yet, or all issues have been resolved."
440
          setItemKey={(item, idx) => `${idx}_${item.batchID}_${item.submittedID}`}
4✔
441
          onFetchData={handleFetchQCResults}
442
          AdditionalActions={Actions}
443
          containerProps={{ sx: { marginBottom: "8px" } }}
444
        />
445
      </QCResultsContext.Provider>
446
      <ErrorDetailsDialog
447
        open={openErrorDialog}
448
        onClose={() => setOpenErrorDialog(false)}
×
449
        header={null}
450
        title="Validation Issues"
451
        nodeInfo={`For ${titleCase(selectedRow?.type)}${
452
          selectedRow?.type?.toLocaleLowerCase() !== "data file" ? " Node" : ""
71!
453
        } ID ${selectedRow?.submittedID}`}
454
        errors={allDescriptions}
455
        errorCount={`${allDescriptions?.length || 0} ${
142✔
456
          allDescriptions?.length === 1 ? "ISSUE" : "ISSUES"
71!
457
        }`}
458
      />
459
    </>
460
  );
461
};
462

463
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