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

CBIIT / crdc-datahub-ui / 12380199839

17 Dec 2024 07:40PM UTC coverage: 56.554% (-0.2%) from 56.77%
12380199839

Pull #566

github

web-flow
Merge f6e0da767 into ef123ee2c
Pull Request #566: CRDCDH-2123

2632 of 5112 branches covered (51.49%)

Branch coverage included in aggregate %.

1 of 32 new or added lines in 2 files covered. (3.13%)

2 existing lines in 1 file now uncovered.

3805 of 6270 relevant lines covered (60.69%)

141.6 hits per line

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

0.0
/src/content/users/ProfileView.tsx
1
import { FC, useEffect, useMemo, useRef, useState } from "react";
2
import { useLazyQuery, useMutation, useQuery } from "@apollo/client";
3
import { LoadingButton } from "@mui/lab";
4
import { Box, Container, MenuItem, Stack, TextField, Typography, styled } from "@mui/material";
5
import { Controller, ControllerRenderProps, useForm } from "react-hook-form";
6
import { useNavigate } from "react-router-dom";
7
import { useSnackbar } from "notistack";
8
import bannerSvg from "../../assets/banner/profile_banner.png";
9
import profileIcon from "../../assets/icons/profile_icon.svg";
10
import { useAuthContext, Status as AuthStatus } from "../../components/Contexts/AuthContext";
11
import SuspenseLoader from "../../components/SuspenseLoader";
12
import { Roles } from "../../config/AuthRoles";
13
import {
14
  EDIT_USER,
15
  EditUserInput,
16
  EditUserResp,
17
  GET_USER,
18
  GetUserInput,
19
  GetUserResp,
20
  LIST_APPROVED_STUDIES,
21
  ListApprovedStudiesInput,
22
  ListApprovedStudiesResp,
23
  UPDATE_MY_USER,
24
  UpdateMyUserInput,
25
  UpdateMyUserResp,
26
} from "../../graphql";
27
import { formatFullStudyName, formatIDP, formatStudySelectionValue } from "../../utils";
28
import { DataCommons } from "../../config/DataCommons";
29
import usePageTitle from "../../hooks/usePageTitle";
30
import { useSearchParamsContext } from "../../components/Contexts/SearchParamsContext";
31
import BaseSelect from "../../components/StyledFormComponents/StyledSelect";
32
import BaseOutlinedInput from "../../components/StyledFormComponents/StyledOutlinedInput";
33
import BaseAutocomplete from "../../components/StyledFormComponents/StyledAutocomplete";
34
import useProfileFields, { VisibleFieldState } from "../../hooks/useProfileFields";
35
import AccessRequest from "../../components/AccessRequest";
36

37
type Props = {
38
  _id: User["_id"];
39
  viewType: "users" | "profile";
40
};
41

42
type FormInput = UpdateMyUserInput["userInfo"] | EditUserInput;
43

44
const StyledContainer = styled(Container)({
×
45
  marginBottom: "90px",
46
});
47

48
const StyledBanner = styled("div")({
×
49
  background: `url(${bannerSvg})`,
50
  backgroundBlendMode: "luminosity, normal",
51
  backgroundSize: "cover",
52
  backgroundPosition: "center",
53
  width: "100%",
54
  height: "153px",
55
});
56

57
const StyledPageTitle = styled(Typography)({
×
58
  fontFamily: "Nunito Sans",
59
  fontSize: "45px",
60
  fontWeight: 800,
61
  letterSpacing: "-1.5px",
62
  color: "#fff",
63
});
64

65
const StyledProfileIcon = styled("div")({
×
66
  position: "relative",
67
  transform: "translate(-218px, -75px)",
68
  "& img": {
69
    position: "absolute",
70
  },
71
  "& img:nth-of-type(1)": {
72
    zIndex: 2,
73
    filter: "drop-shadow(10px 13px 9px rgba(0, 0, 0, 0.35))",
74
  },
75
});
76

77
const StyledHeader = styled("div")({
×
78
  textAlign: "left",
79
  width: "100%",
80
  marginTop: "-34px !important",
81
  marginBottom: "41px !important",
82
});
83

84
const StyledHeaderText = styled(Typography)({
×
85
  fontSize: "26px",
86
  lineHeight: "35px",
87
  color: "#083A50",
88
  fontWeight: 700,
89
});
90

91
const StyledField = styled("div", { shouldForwardProp: (p) => p !== "visible" })<{
×
92
  visible?: boolean;
93
}>(({ visible = true }) => ({
×
94
  marginBottom: "10px",
95
  minHeight: "41px",
96
  display: visible ? "flex" : "none",
×
97
  alignItems: "center",
98
  justifyContent: "flex-start",
99
  fontSize: "18px",
100
}));
101

102
const StyledLabel = styled("span")({
×
103
  color: "#356AAD",
104
  fontWeight: "700",
105
  marginRight: "40px",
106
  minWidth: "127px",
107
});
108

109
const BaseInputStyling = {
×
110
  width: "363px",
111
};
112

113
const StyledAutocomplete = styled(BaseAutocomplete)(BaseInputStyling);
×
114
const StyledTextField = styled(BaseOutlinedInput)(BaseInputStyling);
×
115
const StyledSelect = styled(BaseSelect)(BaseInputStyling);
×
116

117
const StyledButtonStack = styled(Stack)({
×
118
  marginTop: "50px",
119
});
120

121
const StyledButton = styled(LoadingButton)(({ txt, border }: { txt: string; border: string }) => ({
×
122
  borderRadius: "8px",
123
  border: `2px solid ${border}`,
124
  color: `${txt} !important`,
125
  width: "101px",
126
  height: "51px",
127
  textTransform: "none",
128
  fontWeight: 700,
129
  fontSize: "17px",
130
  padding: "6px 8px",
131
}));
132

133
const StyledContentStack = styled(Stack)({
×
134
  marginLeft: "2px !important",
135
});
136

137
const StyledTitleBox = styled(Box)({
×
138
  marginTop: "-86px",
139
  marginBottom: "88px",
140
  width: "100%",
141
});
142

143
const StyledTag = styled("div")({
×
144
  position: "absolute",
145
  paddingLeft: "12px",
146
});
147

148
/**
149
 * User Profile View Component
150
 *
151
 * @param {Props} props
152
 * @returns {JSX.Element}
153
 */
154
const ProfileView: FC<Props> = ({ _id, viewType }: Props) => {
×
155
  usePageTitle(viewType === "profile" ? "User Profile" : `Edit User ${_id}`);
×
156

157
  const navigate = useNavigate();
×
158
  const { enqueueSnackbar } = useSnackbar();
×
159
  const { user: currentUser, setData, logout, status: authStatus } = useAuthContext();
×
160
  const { lastSearchParams } = useSearchParamsContext();
×
NEW
161
  const { handleSubmit, register, reset, watch, setValue, control } = useForm<FormInput>();
×
162

NEW
163
  const ALL_STUDIES_OPTION = "All";
×
NEW
164
  const manageUsersPageUrl = `/users${lastSearchParams?.["/users"] ?? ""}`;
×
165
  const isSelf = _id === currentUser._id;
×
166
  const [user, setUser] = useState<User | null>(
×
167
    isSelf && viewType === "profile" ? { ...currentUser } : null
×
168
  );
169
  const [saving, setSaving] = useState<boolean>(false);
×
170
  const [studyOptions, setStudyOptions] = useState<string[]>([]);
×
171

172
  const roleField = watch("role");
×
NEW
173
  const prevRoleRef = useRef<UserRole>(roleField);
×
NEW
174
  const studiesField = watch("studies");
×
NEW
175
  const prevStudiesRef = useRef<string[]>(studiesField);
×
176
  const fieldset = useProfileFields({ _id: user?._id, role: roleField }, viewType);
×
177

UNCOV
178
  const canRequestRole: boolean = useMemo<boolean>(() => {
×
179
    if (viewType !== "profile" || _id !== currentUser._id) {
×
180
      return false;
×
181
    }
182

183
    return true;
×
184
  }, [user, _id, currentUser, viewType]);
185

186
  const [getUser] = useLazyQuery<GetUserResp, GetUserInput>(GET_USER, {
×
187
    context: { clientName: "backend" },
188
    fetchPolicy: "no-cache",
189
  });
190

191
  const [updateMyUser] = useMutation<UpdateMyUserResp, UpdateMyUserInput>(UPDATE_MY_USER, {
×
192
    context: { clientName: "backend" },
193
    fetchPolicy: "no-cache",
194
  });
195

196
  const [editUser] = useMutation<EditUserResp, EditUserInput>(EDIT_USER, {
×
197
    context: { clientName: "backend" },
198
    fetchPolicy: "no-cache",
199
  });
200

201
  const { data: approvedStudies, loading: approvedStudiesLoading } = useQuery<
×
202
    ListApprovedStudiesResp,
203
    ListApprovedStudiesInput
204
  >(LIST_APPROVED_STUDIES, {
205
    variables: { first: -1, orderBy: "studyName", sortDirection: "asc" },
206
    context: { clientName: "backend" },
207
    fetchPolicy: "cache-and-network",
208
    skip: fieldset.studies !== "UNLOCKED",
209
  });
210

211
  const formattedStudyMap = useMemo<Record<string, string>>(() => {
×
212
    if (!approvedStudies?.listApprovedStudies?.studies) {
×
213
      return {};
×
214
    }
215

NEW
216
    const studyIdMap = approvedStudies.listApprovedStudies.studies.reduce(
×
217
      (acc, { _id, studyName, studyAbbreviation }) => ({
×
218
        ...acc,
219
        [_id]: formatFullStudyName(studyName, studyAbbreviation),
220
      }),
221
      {}
222
    );
223

NEW
224
    if (roleField === "Federal Lead") {
×
NEW
225
      studyIdMap[ALL_STUDIES_OPTION] = ALL_STUDIES_OPTION;
×
226
    }
227

NEW
228
    return studyIdMap;
×
229
  }, [approvedStudies?.listApprovedStudies?.studies, roleField]);
230

231
  const onSubmit = async (data: FormInput) => {
×
232
    setSaving(true);
×
233

234
    // Save profile changes
235
    if (isSelf && viewType === "profile" && "firstName" in data && "lastName" in data) {
×
236
      const { data: d, errors } = await updateMyUser({
×
237
        variables: {
238
          userInfo: {
239
            firstName: data.firstName,
240
            lastName: data.lastName,
241
          },
242
        },
243
      }).catch((e) => ({ errors: e?.message, data: null }));
×
244
      setSaving(false);
×
245

246
      if (errors || !d?.updateMyUser) {
×
247
        enqueueSnackbar(errors || "Unable to save profile changes", { variant: "error" });
×
248
        return;
×
249
      }
250

251
      setData(d.updateMyUser);
×
252
      // Save user changes
253
    } else if (viewType === "users" && "role" in data) {
×
254
      const { data: d, errors } = await editUser({
×
255
        variables: {
256
          userID: _id,
257
          role: data.role,
258
          userStatus: data.userStatus,
259
          studies: fieldset.studies !== "HIDDEN" ? data.studies : null,
×
260
          dataCommons: fieldset.dataCommons !== "HIDDEN" ? data.dataCommons : null,
×
261
        },
262
      }).catch((e) => ({ errors: e?.message, data: null }));
×
263
      setSaving(false);
×
264

265
      if (errors || !d?.editUser) {
×
266
        enqueueSnackbar(errors || "Unable to save user profile changes", { variant: "error" });
×
267
        return;
×
268
      }
269

270
      if (isSelf) {
×
271
        setData(d.editUser);
×
272
        if (d.editUser.userStatus === "Inactive") {
×
273
          logout();
×
274
        }
275
      }
276
    }
277

278
    enqueueSnackbar("All changes have been saved", { variant: "success" });
×
279
    if (viewType === "users") {
×
280
      navigate(manageUsersPageUrl);
×
281
    }
282
  };
283

284
  const sortStudyOptions = () => {
×
285
    const options = Object.keys(formattedStudyMap);
×
286

NEW
287
    const selectedOptions = studiesField
×
288
      .filter((v) => options.includes(v))
×
289
      .sort((a, b) => formattedStudyMap[a]?.localeCompare(formattedStudyMap?.[b]));
×
290
    const unselectedOptions = options
×
291
      .filter((o) => !selectedOptions.includes(o))
×
292
      .sort((a, b) =>
NEW
293
        a === ALL_STUDIES_OPTION ? -1 : formattedStudyMap[a]?.localeCompare(formattedStudyMap?.[b])
×
294
      );
295

296
    setStudyOptions([...selectedOptions, ...unselectedOptions]);
×
297
  };
298

NEW
299
  const handleStudyChange = (
×
300
    field: ControllerRenderProps<FormInput, "studies">,
301
    data: string[]
302
  ) => {
NEW
303
    let updatedData = [...data];
×
304

305
    // Previous studies included all but the user selected other studies
NEW
306
    if (prevStudiesRef.current?.includes(ALL_STUDIES_OPTION)) {
×
NEW
307
      updatedData = updatedData.filter((v) => v !== ALL_STUDIES_OPTION);
×
308
      // User selected all studies, remove all other studies
NEW
309
    } else if (data.includes(ALL_STUDIES_OPTION)) {
×
NEW
310
      updatedData = [ALL_STUDIES_OPTION];
×
311
    }
312

NEW
313
    field.onChange(updatedData);
×
NEW
314
    prevStudiesRef.current = updatedData;
×
315
  };
316

NEW
317
  const handleRoleChange = (field: ControllerRenderProps<FormInput, "role">, value: UserRole) => {
×
NEW
318
    if (prevRoleRef.current === "Federal Lead") {
×
NEW
319
      setValue(
×
320
        "studies",
NEW
321
        studiesField.filter((v) => v !== ALL_STUDIES_OPTION)
×
322
      );
NEW
323
    } else if (value === "Federal Lead") {
×
NEW
324
      setValue("studies", [ALL_STUDIES_OPTION]);
×
325
    }
326

NEW
327
    field.onChange(value);
×
328
  };
329

UNCOV
330
  useEffect(() => {
×
331
    // No action needed if viewing own profile, using cached data
332
    if (isSelf && viewType === "profile") {
×
333
      setUser({ ...currentUser });
×
334
      reset({
×
335
        ...currentUser,
336
        studies: currentUser.studies?.map((s: ApprovedStudy) => s?._id) || [],
×
337
      });
338
      return;
×
339
    }
340

341
    (async () => {
×
342
      const { data, error } = await getUser({ variables: { userID: _id } });
×
343

344
      if (error || !data?.getUser) {
×
345
        navigate(manageUsersPageUrl, {
×
346
          state: { error: "Unable to fetch user data" },
347
        });
348
        return;
×
349
      }
350

351
      setUser({ ...data?.getUser });
×
352
      reset({
×
353
        ...data?.getUser,
354
        studies: data?.getUser?.studies?.map((s: ApprovedStudy) => s?._id) || [],
×
355
      });
356
    })();
357
  }, [_id]);
358

359
  useEffect(() => {
×
360
    if (fieldset.studies === "UNLOCKED") {
×
361
      sortStudyOptions();
×
362
    }
363
  }, [formattedStudyMap]);
364

NEW
365
  useEffect(() => {
×
NEW
366
    prevRoleRef.current = roleField;
×
367
  }, [roleField]);
368

369
  if (!user || authStatus === AuthStatus.LOADING) {
×
370
    return <SuspenseLoader />;
×
371
  }
372

373
  return (
×
374
    <>
375
      <StyledBanner />
376
      <StyledContainer maxWidth="lg">
377
        <Stack direction="row" justifyContent="center" alignItems="flex-start" spacing={2}>
378
          <StyledProfileIcon>
379
            <img src={profileIcon} alt="profile icon" />
380
          </StyledProfileIcon>
381

382
          <StyledContentStack
383
            direction="column"
384
            justifyContent="center"
385
            alignItems="center"
386
            spacing={2}
387
          >
388
            <StyledTitleBox>
389
              <StyledPageTitle variant="h1">
390
                {viewType === "profile" ? "User Profile" : "Edit User"}
×
391
              </StyledPageTitle>
392
            </StyledTitleBox>
393
            <StyledHeader>
394
              <StyledHeaderText variant="h2">{user.email}</StyledHeaderText>
395
            </StyledHeader>
396

397
            <form onSubmit={handleSubmit(onSubmit)}>
398
              <StyledField>
399
                <StyledLabel>Account Type</StyledLabel>
400
                {formatIDP(user.IDP)}
401
              </StyledField>
402
              <StyledField>
403
                <StyledLabel>Email</StyledLabel>
404
                {user.email}
405
              </StyledField>
406
              <StyledField>
407
                <StyledLabel id="firstNameLabel">First name</StyledLabel>
408
                {VisibleFieldState.includes(fieldset.firstName) ? (
×
409
                  <StyledTextField
410
                    {...register("firstName", {
411
                      required: true,
412
                      maxLength: 30,
413
                      setValueAs: (v: string) => v?.trim(),
×
414
                    })}
415
                    inputProps={{ "aria-labelledby": "firstNameLabel", maxLength: 30 }}
416
                    size="small"
417
                    required
418
                  />
419
                ) : (
420
                  user.firstName
421
                )}
422
              </StyledField>
423
              <StyledField>
424
                <StyledLabel id="lastNameLabel">Last name</StyledLabel>
425
                {VisibleFieldState.includes(fieldset.lastName) ? (
×
426
                  <StyledTextField
427
                    {...register("lastName", {
428
                      required: true,
429
                      maxLength: 30,
430
                      setValueAs: (v: string) => v?.trim(),
×
431
                    })}
432
                    inputProps={{ "aria-labelledby": "lastNameLabel", maxLength: 30 }}
433
                    size="small"
434
                    required
435
                  />
436
                ) : (
437
                  user.lastName
438
                )}
439
              </StyledField>
440
              <StyledField>
441
                <StyledLabel id="userRoleLabel">Role</StyledLabel>
442
                {VisibleFieldState.includes(fieldset.role) ? (
×
443
                  <Controller
444
                    name="role"
445
                    control={control}
446
                    rules={{ required: true }}
447
                    render={({ field }) => (
448
                      <StyledSelect
×
449
                        {...field}
450
                        size="small"
NEW
451
                        onChange={(e) => handleRoleChange(field, e?.target?.value as UserRole)}
×
452
                        MenuProps={{ disablePortal: true }}
453
                        inputProps={{ "aria-labelledby": "userRoleLabel" }}
454
                      >
455
                        {Roles.map((role) => (
456
                          <MenuItem key={role} value={role}>
×
457
                            {role}
458
                          </MenuItem>
459
                        ))}
460
                      </StyledSelect>
461
                    )}
462
                  />
463
                ) : (
464
                  <>
465
                    {user?.role}
466
                    {canRequestRole && <AccessRequest />}
×
467
                  </>
468
                )}
469
              </StyledField>
470
              <StyledField visible={fieldset.studies !== "HIDDEN"}>
471
                <StyledLabel id="userStudies">Studies</StyledLabel>
472
                {VisibleFieldState.includes(fieldset.studies) ? (
×
473
                  <Controller
474
                    name="studies"
475
                    control={control}
476
                    rules={{ required: true }}
477
                    render={({ field }) => (
478
                      <StyledAutocomplete
×
479
                        {...field}
480
                        renderInput={({ inputProps, ...params }) => (
481
                          <TextField
×
482
                            {...params}
483
                            placeholder={studiesField?.length > 0 ? undefined : "Select studies"}
×
484
                            inputProps={{ "aria-labelledby": "userStudies", ...inputProps }}
485
                            onBlur={sortStudyOptions}
486
                          />
487
                        )}
488
                        renderTags={(value: string[], _, state) => {
489
                          if (value?.length === 0 || state.focused) {
×
490
                            return null;
×
491
                          }
492

493
                          return (
×
494
                            <StyledTag>
495
                              {formatStudySelectionValue(value, formattedStudyMap)}
496
                            </StyledTag>
497
                          );
498
                        }}
499
                        options={
500
                          roleField === "Federal Lead" &&
×
501
                          !studyOptions?.includes(ALL_STUDIES_OPTION)
502
                            ? [ALL_STUDIES_OPTION, ...studyOptions]
503
                            : studyOptions
504
                        }
505
                        getOptionLabel={(option: string) => formattedStudyMap[option]}
×
NEW
506
                        onChange={(_, data: string[]) => handleStudyChange(field, data)}
×
507
                        disabled={fieldset.studies === "DISABLED"}
508
                        loading={approvedStudiesLoading}
509
                        disableCloseOnSelect
510
                        multiple
511
                      />
512
                    )}
513
                  />
514
                ) : null}
515
              </StyledField>
516
              <StyledField>
517
                <StyledLabel id="userStatusLabel">Account Status</StyledLabel>
518
                {VisibleFieldState.includes(fieldset.userStatus) ? (
×
519
                  <Controller
520
                    name="userStatus"
521
                    control={control}
522
                    rules={{ required: true }}
523
                    render={({ field }) => (
524
                      <StyledSelect
×
525
                        {...field}
526
                        size="small"
527
                        MenuProps={{ disablePortal: true }}
528
                        inputProps={{ "aria-labelledby": "userStatusLabel" }}
529
                      >
530
                        <MenuItem value="Active">Active</MenuItem>
531
                        <MenuItem value="Inactive">Inactive</MenuItem>
532
                      </StyledSelect>
533
                    )}
534
                  />
535
                ) : (
536
                  user.userStatus
537
                )}
538
              </StyledField>
539
              <StyledField visible={fieldset.dataCommons !== "HIDDEN"}>
540
                <StyledLabel id="userDataCommons">Data Commons</StyledLabel>
541
                {VisibleFieldState.includes(fieldset.dataCommons) ? (
×
542
                  <Controller
543
                    name="dataCommons"
544
                    control={control}
545
                    rules={{ required: false }}
546
                    render={({ field }) => (
547
                      <StyledSelect
×
548
                        {...field}
549
                        size="small"
550
                        value={field.value || []}
×
551
                        disabled={fieldset.dataCommons === "DISABLED"}
552
                        MenuProps={{ disablePortal: true }}
553
                        inputProps={{ "aria-labelledby": "userDataCommons" }}
554
                        multiple
555
                      >
556
                        {DataCommons.map((dc) => (
557
                          <MenuItem key={dc.name} value={dc.name}>
×
558
                            {dc.name}
559
                          </MenuItem>
560
                        ))}
561
                      </StyledSelect>
562
                    )}
563
                  />
564
                ) : (
565
                  user.dataCommons?.join(", ")
566
                )}
567
              </StyledField>
568

569
              <StyledButtonStack
570
                direction="row"
571
                justifyContent="center"
572
                alignItems="center"
573
                spacing={1}
574
              >
575
                {Object.values(fieldset).some((fieldState) => fieldState === "UNLOCKED") && (
×
576
                  <StyledButton type="submit" loading={saving} txt="#14634F" border="#26B893">
577
                    Save
578
                  </StyledButton>
579
                )}
580
                {viewType === "users" && (
×
581
                  <StyledButton
582
                    type="button"
583
                    onClick={() => navigate(manageUsersPageUrl)}
×
584
                    txt="#666666"
585
                    border="#828282"
586
                  >
587
                    Cancel
588
                  </StyledButton>
589
                )}
590
              </StyledButtonStack>
591
            </form>
592
          </StyledContentStack>
593
        </Stack>
594
      </StyledContainer>
595
    </>
596
  );
597
};
598

599
export default ProfileView;
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