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

CBIIT / crdc-datahub-ui / 11689008817

05 Nov 2024 05:02PM UTC coverage: 53.702% (-0.09%) from 53.788%
11689008817

Pull #516

github

web-flow
Merge 8b6a6d9f7 into 2017902d3
Pull Request #516: CRDCDH-1738 Data Submission List - Unlock Organization dropdown

2582 of 5296 branches covered (48.75%)

Branch coverage included in aggregate %.

9 of 26 new or added lines in 3 files covered. (34.62%)

23 existing lines in 2 files now uncovered.

3707 of 6415 relevant lines covered (57.79%)

133.01 hits per line

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

93.91
/src/components/DataSubmissions/DataSubmissionListFilters.tsx
1
import { memo, useEffect, useRef, useState } from "react";
2
import { FormControl, IconButton, MenuItem, Grid, Box, styled, Stack } from "@mui/material";
3
import { debounce, isEqual } from "lodash";
4
import RefreshIcon from "@mui/icons-material/Refresh";
5
import { Controller, useForm } from "react-hook-form";
6
import StyledSelectFormComponent from "../StyledFormComponents/StyledSelect";
7
import StyledTextFieldFormComponent from "../StyledFormComponents/StyledOutlinedInput";
8
import ColumnVisibilityButton from "../GenericTable/ColumnVisibilityButton";
9
import { ListSubmissionsInput, ListSubmissionsResp } from "../../graphql";
10
import { Column } from "../GenericTable";
11
import { useSearchParamsContext } from "../Contexts/SearchParamsContext";
12
import Tooltip from "../Tooltip";
13
import { isStringLengthBetween } from "../../utils";
14

15
const StyledFilters = styled("div")({
2✔
16
  paddingTop: "19px",
17
  paddingBottom: "15px",
18
  paddingLeft: "38px",
19
  paddingRight: "26px",
20
});
21

22
const StyledFormControl = styled(FormControl)({
2✔
23
  display: "flex",
24
  flexDirection: "row",
25
  alignItems: "center",
26
  gap: "15px",
27
});
28

29
const StyledInlineLabel = styled("label")({
2✔
30
  fontFamily: "'Nunito', 'Rubik', sans-serif !important",
31
  fontWeight: 700,
32
  fontSize: "16px",
33
  lineHeight: "16px",
34
  minWidth: "100px",
35
  textAlign: "right",
36
});
37

38
const StyledSelect = styled(StyledSelectFormComponent)({
2✔
39
  width: "280px",
40
});
41

42
const StyledTextField = styled(StyledTextFieldFormComponent)({
2✔
43
  width: "280px",
44
});
45

46
const StyledRefreshIcon = styled(RefreshIcon)({
2✔
47
  transform: "scale(-1, 1)",
48
  color: "#346798",
49
  fontSize: "31px",
50
});
51

52
const StyledIconButton = styled(IconButton)({
2✔
53
  padding: 0,
54
  border: "1px solid #D0D0D0",
55
  borderRadius: "5px",
56
});
57

58
const StyledActionWrapper = styled(Box)({
2✔
59
  minWidth: "44px",
60
  width: "100%",
61
  height: "44px",
62
  display: "flex",
63
  justifyContent: "flex-start",
64
  alignItems: "center",
65
});
66

67
const ActionButtonsContainer = styled(Box)({
2✔
68
  display: "flex",
69
  flexDirection: "column",
70
  justifyContent: "center",
71
  height: "100%",
72
  gap: "9px",
73
});
74

75
const initialTouchedFields: TouchedState = {
2✔
76
  organization: false,
77
  status: false,
78
  dataCommons: false,
79
  name: false,
80
  dbGaPID: false,
81
  submitterName: false,
82
};
83

84
const statusValues: SubmissionStatus[] = [
2✔
85
  "New",
86
  "In Progress",
87
  "Submitted",
88
  "Released",
89
  "Withdrawn",
90
  "Rejected",
91
  "Completed",
92
  "Canceled",
93
  "Deleted",
94
];
95

96
const defaultValues: FilterForm = {
2✔
97
  organization: "All",
98
  status: "All",
99
  dataCommons: "All",
100
  name: "",
101
  dbGaPID: "",
102
  submitterName: "All",
103
};
104

105
type T = ListSubmissionsResp["listSubmissions"]["submissions"][0];
106

107
export type FilterForm = Pick<
108
  ListSubmissionsInput,
109
  "organization" | "status" | "dataCommons" | "name" | "dbGaPID" | "submitterName"
110
>;
111

112
type FilterFormKey = keyof FilterForm;
113

114
type TouchedState = { [K in FilterFormKey]: boolean };
115

116
type Props = {
117
  columns: Column<T>[];
118
  organizations: Organization[];
119
  submitterNames: string[];
120
  dataCommons: string[];
121
  columnVisibilityModel: ColumnVisibilityModel;
122
  onColumnVisibilityModelChange: (model: ColumnVisibilityModel) => void;
123
  onChange?: (data: FilterForm) => void;
124
};
125

126
const DataSubmissionListFilters = ({
2✔
127
  columns,
128
  organizations,
129
  submitterNames,
130
  dataCommons,
131
  columnVisibilityModel,
132
  onColumnVisibilityModelChange,
133
  onChange,
134
}: Props) => {
135
  const { searchParams, setSearchParams } = useSearchParamsContext();
326✔
136
  const { control, register, watch, reset, setValue, getValues } = useForm<FilterForm>({
326✔
137
    defaultValues,
138
  });
139
  const [
140
    statusFilter,
141
    orgFilter,
142
    dataCommonsFilter,
143
    nameFilter,
144
    dbGaPIDFilter,
145
    submitterNameFilter,
146
  ] = watch(["status", "organization", "dataCommons", "name", "dbGaPID", "submitterName"]);
326✔
147

148
  const [touchedFilters, setTouchedFilters] = useState<TouchedState>(initialTouchedFields);
326✔
149

150
  const debounceAfter3CharsInputs: FilterFormKey[] = ["name", "dbGaPID"];
326✔
151
  const debouncedOnChangeRef = useRef(
326✔
152
    debounce((form: FilterForm) => handleFormChange(form), 500)
10✔
153
  ).current;
154

155
  useEffect(() => {
326✔
156
    // Reset submitterName filter if it is no longer a valid option
157
    // due to other filters changing
158
    if (
170!
159
      submitterNameFilter !== "All" &&
94✔
160
      Object.values(touchedFilters).some((filter) => filter) &&
44✔
161
      !submitterNames?.includes(submitterNameFilter)
162
    ) {
UNCOV
163
      const newSearchParams = new URLSearchParams(searchParams);
×
UNCOV
164
      newSearchParams.delete("submitterName");
×
UNCOV
165
      setSearchParams(newSearchParams);
×
166
      setValue("submitterName", "All");
×
167
    }
168
  }, [submitterNames, submitterNameFilter, touchedFilters]);
169

170
  useEffect(() => {
326✔
171
    // Reset organization filter if it is no longer a valid option
172
    // due to other filters changing
173
    const organizationIds = organizations?.map((org) => org._id);
336✔
174
    if (
170✔
175
      orgFilter !== "All" &&
142✔
176
      Object.values(touchedFilters).some((filter) => filter) &&
82✔
177
      !organizationIds?.includes(orgFilter)
178
    ) {
NEW
179
      const newSearchParams = new URLSearchParams(searchParams);
×
NEW
180
      newSearchParams.delete("organization");
×
NEW
181
      setSearchParams(newSearchParams);
×
NEW
182
      setValue("organization", "All");
×
183
    }
184
  }, [organizations, orgFilter, touchedFilters]);
185

186
  useEffect(() => {
326✔
187
    const organizationId = searchParams.get("organization");
124✔
188
    const status = searchParams.get("status");
124✔
189
    const dataCommon = searchParams.get("dataCommons");
124✔
190
    const name = searchParams.get("name");
124✔
191
    const dbGaPID = searchParams.get("dbGaPID");
124✔
192
    const submitterName = searchParams.get("submitterName");
124✔
193

194
    handleStatusChange(status);
124✔
195

196
    if (organizationId && organizationId !== orgFilter) {
124✔
197
      setValue("organization", organizationId);
2✔
198
    }
199
    if (dataCommon && dataCommon !== dataCommonsFilter) {
124✔
200
      setValue("dataCommons", dataCommon);
2✔
201
    }
202
    if (submitterName && submitterName !== submitterNameFilter) {
124✔
203
      setValue("submitterName", submitterName);
2✔
204
    }
205
    if (name && name !== nameFilter) {
124✔
206
      setValue("name", name);
2✔
207
    }
208
    if (dbGaPID && dbGaPID !== dbGaPIDFilter) {
124✔
209
      setValue("dbGaPID", dbGaPID);
2✔
210
    }
211

212
    if (Object.values(touchedFilters).every((filter) => !filter)) {
464✔
213
      handleFormChange(getValues());
44✔
214
    }
215
  }, [organizations, submitterNames, dataCommons, searchParams?.toString()]);
216

217
  useEffect(() => {
326✔
218
    if (Object.values(touchedFilters).every((filter) => !filter)) {
636✔
219
      return;
46✔
220
    }
221

222
    const newSearchParams = new URLSearchParams(searchParams);
124✔
223

224
    if (orgFilter && orgFilter !== "All") {
124✔
225
      newSearchParams.set("organization", orgFilter);
56✔
226
    } else if (orgFilter === "All") {
68!
227
      newSearchParams.delete("organization");
68✔
228
    }
229
    if (statusFilter && statusFilter !== "All") {
124✔
230
      newSearchParams.set("status", statusFilter);
50✔
231
    } else if (statusFilter === "All") {
74!
232
      newSearchParams.delete("status");
74✔
233
    }
234
    if (dataCommonsFilter && dataCommonsFilter !== "All") {
124✔
235
      newSearchParams.set("dataCommons", dataCommonsFilter);
50✔
236
    } else if (dataCommonsFilter === "All") {
74!
237
      newSearchParams.delete("dataCommons");
74✔
238
    }
239
    if (submitterNameFilter && submitterNameFilter !== "All") {
124✔
240
      newSearchParams.set("submitterName", submitterNameFilter);
8✔
241
    } else if (submitterNameFilter === "All") {
116!
242
      newSearchParams.delete("submitterName");
116✔
243
    }
244

245
    if (nameFilter?.length >= 3) {
124✔
246
      newSearchParams.set("name", nameFilter);
56✔
247
    } else {
248
      newSearchParams.delete("name");
68✔
249
    }
250
    if (dbGaPIDFilter?.length >= 3) {
124✔
251
      newSearchParams.set("dbGaPID", dbGaPIDFilter);
18✔
252
    } else {
253
      newSearchParams.delete("dbGaPID");
106✔
254
    }
255

256
    if (newSearchParams?.toString() !== searchParams?.toString()) {
124✔
257
      setSearchParams(newSearchParams);
78✔
258
    }
259
  }, [
260
    orgFilter,
261
    statusFilter,
262
    dataCommonsFilter,
263
    nameFilter,
264
    dbGaPIDFilter,
265
    submitterNameFilter,
266
    touchedFilters,
267
  ]);
268

269
  useEffect(() => {
326✔
270
    const subscription = watch((formValue: FilterForm, { name }) => {
44✔
271
      // Add debounce for input fields
272
      const isDebounceField = debounceAfter3CharsInputs.includes(name as FilterFormKey);
124✔
273
      // Debounce if value has at least 3 characters
274
      if (isDebounceField && formValue[name]?.length >= 3) {
124✔
275
        debouncedOnChangeRef(formValue);
48✔
276
        return;
48✔
277
      }
278
      // Do nothing if values has between 0 and 3 (exclusive) characters
279
      if (isDebounceField && formValue[name]?.length > 0) {
76✔
280
        debouncedOnChangeRef.cancel();
38✔
281
        return;
38✔
282
      }
283
      // If value is cleared, call the onChange immediately
284
      if (isDebounceField && formValue[name]?.length === 0) {
38✔
285
        debouncedOnChangeRef.cancel();
20✔
286
        handleFormChange(formValue);
20✔
287
        return;
20✔
288
      }
289

290
      // Immediately call the onChange if the change is not a debounce field
291
      handleFormChange(formValue);
18✔
292
    });
293

294
    return () => {
44✔
295
      debouncedOnChangeRef.cancel();
44✔
296
      subscription.unsubscribe();
44✔
297
    };
298
  }, [watch, debouncedOnChangeRef]);
299

300
  const isStatusFilterOption = (status: string): status is FilterForm["status"] =>
326✔
301
    ["All", ...statusValues].includes(status);
84✔
302

303
  const handleStatusChange = (status: string) => {
326✔
304
    if (status === statusFilter) {
124✔
305
      return;
40✔
306
    }
307

308
    if (isStatusFilterOption(status)) {
84✔
309
      setValue("status", status);
2✔
310
    }
311
  };
312

313
  const handleFormChange = (form: FilterForm) => {
326✔
314
    if (!onChange || !form) {
92!
UNCOV
315
      return;
×
316
    }
317

318
    const newForm: FilterForm = {
92✔
319
      ...form,
320
      name: form.name?.length >= 3 ? form.name : "",
46✔
321
      dbGaPID: form.dbGaPID?.length >= 3 ? form.dbGaPID : "",
46✔
322
    };
323

324
    onChange(newForm);
92✔
325
  };
326

327
  const handleFilterChange = (field: FilterFormKey) => {
326✔
328
    setTouchedFilters((prev) => ({ ...prev, [field]: true }));
122✔
329
  };
330

331
  const handleResetFilters = () => {
326✔
332
    const newSearchParams = new URLSearchParams(searchParams);
2✔
333
    searchParams.delete("organization");
2✔
334
    searchParams.delete("status");
2✔
335
    searchParams.delete("dataCommons");
2✔
336
    searchParams.delete("name");
2✔
337
    searchParams.delete("dbGaPID");
2✔
338
    searchParams.delete("submitterName");
2✔
339
    setSearchParams(newSearchParams);
2✔
340
    reset({ ...defaultValues });
2✔
341
  };
342

343
  return (
326✔
344
    <StyledFilters data-testid="data-submission-list-filters">
345
      <Stack direction="row" alignItems="center" gap="12px">
346
        <Grid container spacing={2} rowSpacing="9px">
347
          <Grid item xs={4}>
348
            <StyledFormControl>
349
              <StyledInlineLabel htmlFor="organization-filter">Organization</StyledInlineLabel>
350
              <Controller
351
                name="organization"
352
                control={control}
353
                render={({ field }) => (
354
                  <StyledSelect
326✔
355
                    {...field}
356
                    value={
357
                      organizations?.map((org) => org._id)?.includes(field.value)
648✔
358
                        ? field.value
359
                        : "All"
360
                    }
361
                    MenuProps={{ disablePortal: true }}
362
                    inputProps={{
363
                      id: "organization-filter",
364
                      "data-testid": "organization-select-input",
365
                    }}
366
                    data-testid="organization-select"
367
                    onChange={(e) => {
368
                      field.onChange(e);
6✔
369
                      handleFilterChange("organization");
6✔
370
                    }}
371
                  >
372
                    <MenuItem value="All" data-testid="organization-option-All">
373
                      All
374
                    </MenuItem>
375
                    {organizations?.map((org) => (
376
                      <MenuItem
648✔
377
                        key={org._id}
378
                        value={org._id}
379
                        data-testid={`organization-option-${org._id}`}
380
                      >
381
                        {org.name}
382
                      </MenuItem>
383
                    ))}
384
                  </StyledSelect>
385
                )}
386
              />
387
            </StyledFormControl>
388
          </Grid>
389

390
          <Grid item xs={4}>
391
            <StyledFormControl>
392
              <StyledInlineLabel htmlFor="status-filter">Status</StyledInlineLabel>
393
              <Controller
394
                name="status"
395
                control={control}
396
                render={({ field }) => (
397
                  <StyledSelect
326✔
398
                    {...field}
399
                    value={field.value}
400
                    MenuProps={{ disablePortal: true }}
401
                    inputProps={{ id: "status-filter", "data-testid": "status-select-input" }}
402
                    data-testid="status-select"
403
                    onChange={(e) => {
404
                      field.onChange(e);
2✔
405
                      handleFilterChange("status");
2✔
406
                    }}
407
                  >
408
                    <MenuItem value="All" data-testid="status-option-All">
409
                      All
410
                    </MenuItem>
411
                    {statusValues.map((value) => (
412
                      <MenuItem
2,934✔
413
                        key={`submission_status_${value}`}
414
                        value={value}
415
                        data-testid={`status-option-${value}`}
416
                      >
417
                        {value}
418
                      </MenuItem>
419
                    ))}
420
                  </StyledSelect>
421
                )}
422
              />
423
            </StyledFormControl>
424
          </Grid>
425

426
          <Grid item xs={4}>
427
            <StyledFormControl>
428
              <StyledInlineLabel htmlFor="data-commons-filter">
429
                Data
430
                <br />
431
                Commons
432
              </StyledInlineLabel>
433
              <Controller
434
                name="dataCommons"
435
                control={control}
436
                render={({ field }) => (
437
                  <StyledSelect
326✔
438
                    {...field}
439
                    value={dataCommons?.length ? field.value : "All"}
163✔
440
                    MenuProps={{ disablePortal: true }}
441
                    inputProps={{
442
                      id: "data-commons-filter",
443
                      "data-testid": "data-commons-select-input",
444
                    }}
445
                    data-testid="data-commons-select"
446
                    onChange={(e) => {
447
                      field.onChange(e);
4✔
448
                      handleFilterChange("dataCommons");
4✔
449
                    }}
450
                  >
451
                    <MenuItem value="All" data-testid="data-commons-option-All">
452
                      All
453
                    </MenuItem>
454
                    {dataCommons?.map((dc) => (
455
                      <MenuItem key={dc} value={dc} data-testid={`data-commons-option-${dc}`}>
648✔
456
                        {dc}
457
                      </MenuItem>
458
                    ))}
459
                  </StyledSelect>
460
                )}
461
              />
462
            </StyledFormControl>
463
          </Grid>
464

465
          <Grid item xs={4}>
466
            <StyledFormControl>
467
              <StyledInlineLabel id="submission-name-filter">
468
                Submission
469
                <br />
470
                Name
471
              </StyledInlineLabel>
472
              <StyledTextField
473
                {...register("name", {
474
                  setValueAs: (val) => val?.trim(),
118✔
475
                  onChange: () => handleFilterChange("name"),
62✔
476
                  onBlur: (e) =>
477
                    isStringLengthBetween(e?.target?.value, 0, 3) && setValue("name", ""),
8!
478
                })}
479
                size="small"
480
                placeholder="Minimum 3 characters required"
481
                inputProps={{
482
                  "aria-labelledby": "submission-name-filter",
483
                  "data-testid": "submission-name-input",
484
                }}
485
                required
486
              />
487
            </StyledFormControl>
488
          </Grid>
489

490
          <Grid item xs={4}>
491
            <StyledFormControl>
492
              <StyledInlineLabel id="dbGaPID-filter">dbGaP ID</StyledInlineLabel>
493
              <StyledTextField
494
                {...register("dbGaPID", {
495
                  setValueAs: (val) => val?.trim(),
100✔
496
                  onChange: () => handleFilterChange("dbGaPID"),
44✔
497
                  onBlur: (e) =>
498
                    isStringLengthBetween(e?.target?.value, 0, 3) && setValue("dbGaPID", ""),
8!
499
                })}
500
                size="small"
501
                placeholder="Minimum 3 characters required"
502
                inputProps={{
503
                  "aria-labelledby": "dbGaPID-filter",
504
                  "data-testid": "dbGaPID-input",
505
                }}
506
                required
507
              />
508
            </StyledFormControl>
509
          </Grid>
510

511
          <Grid item xs={4}>
512
            <StyledFormControl>
513
              <StyledInlineLabel htmlFor="submitter-name-filter">Submitter</StyledInlineLabel>
514
              <Controller
515
                name="submitterName"
516
                control={control}
517
                render={({ field }) => (
518
                  <StyledSelect
326✔
519
                    {...field}
520
                    value={submitterNames?.includes(field.value) ? field.value : "All"}
163✔
521
                    MenuProps={{ disablePortal: true }}
522
                    inputProps={{
523
                      id: "submitter-name-filter",
524
                      "data-testid": "submitter-name-select-input",
525
                    }}
526
                    data-testid="submitter-name-select"
527
                    onChange={(e) => {
528
                      field.onChange(e);
4✔
529
                      handleFilterChange("submitterName");
4✔
530
                    }}
531
                  >
532
                    <MenuItem value="All" data-testid="submitter-name-option-All">
533
                      All
534
                    </MenuItem>
535
                    {submitterNames?.map((submitter) => (
536
                      <MenuItem
648✔
537
                        key={`submitter_${submitter}`}
538
                        value={submitter}
539
                        data-testid={`submitter-name-option-${submitter}`}
540
                      >
541
                        {submitter}
542
                      </MenuItem>
543
                    ))}
544
                  </StyledSelect>
545
                )}
546
              />
547
            </StyledFormControl>
548
          </Grid>
549
        </Grid>
550

551
        {/* Action Buttons */}
552
        <ActionButtonsContainer>
553
          <StyledActionWrapper>
554
            <Tooltip
555
              open={undefined}
556
              title="Reset filters."
557
              placement="top"
558
              disableHoverListener={false}
559
            >
560
              <StyledIconButton
561
                onClick={handleResetFilters}
562
                data-testid="reset-filters-button"
563
                aria-label="Reset filters button"
564
              >
565
                <StyledRefreshIcon />
566
              </StyledIconButton>
567
            </Tooltip>
568
          </StyledActionWrapper>
569
          <StyledActionWrapper>
570
            <ColumnVisibilityButton
571
              columns={columns}
572
              getColumnKey={(column) => column.fieldKey ?? column.field}
1,956✔
573
              getColumnLabel={(column) => column.label?.toString()}
1,304✔
574
              columnVisibilityModel={columnVisibilityModel}
575
              onColumnVisibilityModelChange={onColumnVisibilityModelChange}
576
              data-testid="column-visibility-button"
577
            />
578
          </StyledActionWrapper>
579
        </ActionButtonsContainer>
580
      </Stack>
581
    </StyledFilters>
582
  );
583
};
584

585
export default memo(DataSubmissionListFilters, isEqual);
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

© 2025 Coveralls, Inc