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

CBIIT / crdc-datahub-ui / 12378692329

17 Dec 2024 05:59PM UTC coverage: 56.594% (-0.2%) from 56.77%
12378692329

Pull #566

github

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

2632 of 5108 branches covered (51.53%)

Branch coverage included in aggregate %.

1 of 28 new or added lines in 2 files covered. (3.57%)

1 existing line in 1 file now uncovered.

3805 of 6266 relevant lines covered (60.72%)

141.69 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, 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

299
  useEffect(() => {
×
300
    // No action needed if viewing own profile, using cached data
301
    if (isSelf && viewType === "profile") {
×
302
      setUser({ ...currentUser });
×
303
      reset({
×
304
        ...currentUser,
305
        studies: currentUser.studies?.map((s: ApprovedStudy) => s?._id) || [],
×
306
      });
307
      return;
×
308
    }
309

310
    (async () => {
×
311
      const { data, error } = await getUser({ variables: { userID: _id } });
×
312

313
      if (error || !data?.getUser) {
×
314
        navigate(manageUsersPageUrl, {
×
315
          state: { error: "Unable to fetch user data" },
316
        });
317
        return;
×
318
      }
319

320
      setUser({ ...data?.getUser });
×
321
      reset({
×
322
        ...data?.getUser,
323
        studies: data?.getUser?.studies?.map((s: ApprovedStudy) => s?._id) || [],
×
324
      });
325
    })();
326
  }, [_id]);
327

328
  useEffect(() => {
×
329
    if (fieldset.studies === "UNLOCKED") {
×
330
      sortStudyOptions();
×
331
    }
332
  }, [formattedStudyMap]);
333

NEW
334
  useEffect(() => {
×
NEW
335
    prevRoleRef.current = roleField;
×
336
  }, [roleField]);
337

338
  if (!user || authStatus === AuthStatus.LOADING) {
×
339
    return <SuspenseLoader />;
×
340
  }
341

342
  return (
×
343
    <>
344
      <StyledBanner />
345
      <StyledContainer maxWidth="lg">
346
        <Stack direction="row" justifyContent="center" alignItems="flex-start" spacing={2}>
347
          <StyledProfileIcon>
348
            <img src={profileIcon} alt="profile icon" />
349
          </StyledProfileIcon>
350

351
          <StyledContentStack
352
            direction="column"
353
            justifyContent="center"
354
            alignItems="center"
355
            spacing={2}
356
          >
357
            <StyledTitleBox>
358
              <StyledPageTitle variant="h1">
359
                {viewType === "profile" ? "User Profile" : "Edit User"}
×
360
              </StyledPageTitle>
361
            </StyledTitleBox>
362
            <StyledHeader>
363
              <StyledHeaderText variant="h2">{user.email}</StyledHeaderText>
364
            </StyledHeader>
365

366
            <form onSubmit={handleSubmit(onSubmit)}>
367
              <StyledField>
368
                <StyledLabel>Account Type</StyledLabel>
369
                {formatIDP(user.IDP)}
370
              </StyledField>
371
              <StyledField>
372
                <StyledLabel>Email</StyledLabel>
373
                {user.email}
374
              </StyledField>
375
              <StyledField>
376
                <StyledLabel id="firstNameLabel">First name</StyledLabel>
377
                {VisibleFieldState.includes(fieldset.firstName) ? (
×
378
                  <StyledTextField
379
                    {...register("firstName", {
380
                      required: true,
381
                      maxLength: 30,
382
                      setValueAs: (v: string) => v?.trim(),
×
383
                    })}
384
                    inputProps={{ "aria-labelledby": "firstNameLabel", maxLength: 30 }}
385
                    size="small"
386
                    required
387
                  />
388
                ) : (
389
                  user.firstName
390
                )}
391
              </StyledField>
392
              <StyledField>
393
                <StyledLabel id="lastNameLabel">Last name</StyledLabel>
394
                {VisibleFieldState.includes(fieldset.lastName) ? (
×
395
                  <StyledTextField
396
                    {...register("lastName", {
397
                      required: true,
398
                      maxLength: 30,
399
                      setValueAs: (v: string) => v?.trim(),
×
400
                    })}
401
                    inputProps={{ "aria-labelledby": "lastNameLabel", maxLength: 30 }}
402
                    size="small"
403
                    required
404
                  />
405
                ) : (
406
                  user.lastName
407
                )}
408
              </StyledField>
409
              <StyledField>
410
                <StyledLabel id="userRoleLabel">Role</StyledLabel>
411
                {VisibleFieldState.includes(fieldset.role) ? (
×
412
                  <Controller
413
                    name="role"
414
                    control={control}
415
                    rules={{ required: true }}
416
                    render={({ field }) => (
417
                      <StyledSelect
×
418
                        {...field}
419
                        size="small"
420
                        onChange={(e) => {
NEW
421
                          if (prevRoleRef.current === "Federal Lead") {
×
NEW
422
                            setValue(
×
423
                              "studies",
NEW
424
                              studiesField.filter((v) => v !== ALL_STUDIES_OPTION)
×
425
                            );
NEW
426
                          } else if (e.target.value === "Federal Lead") {
×
NEW
427
                            setValue("studies", [ALL_STUDIES_OPTION]);
×
428
                          }
429

NEW
430
                          field.onChange(e.target.value);
×
431
                        }}
432
                        MenuProps={{ disablePortal: true }}
433
                        inputProps={{ "aria-labelledby": "userRoleLabel" }}
434
                      >
435
                        {Roles.map((role) => (
436
                          <MenuItem key={role} value={role}>
×
437
                            {role}
438
                          </MenuItem>
439
                        ))}
440
                      </StyledSelect>
441
                    )}
442
                  />
443
                ) : (
444
                  <>
445
                    {user?.role}
446
                    {canRequestRole && <AccessRequest />}
×
447
                  </>
448
                )}
449
              </StyledField>
450
              <StyledField visible={fieldset.studies !== "HIDDEN"}>
451
                <StyledLabel id="userStudies">Studies</StyledLabel>
452
                {VisibleFieldState.includes(fieldset.studies) ? (
×
453
                  <Controller
454
                    name="studies"
455
                    control={control}
456
                    rules={{ required: true }}
457
                    render={({ field }) => (
458
                      <StyledAutocomplete
×
459
                        {...field}
460
                        renderInput={({ inputProps, ...params }) => (
461
                          <TextField
×
462
                            {...params}
463
                            placeholder={studiesField?.length > 0 ? undefined : "Select studies"}
×
464
                            inputProps={{ "aria-labelledby": "userStudies", ...inputProps }}
465
                            onBlur={sortStudyOptions}
466
                          />
467
                        )}
468
                        renderTags={(value: string[], _, state) => {
469
                          if (value?.length === 0 || state.focused) {
×
470
                            return null;
×
471
                          }
472

473
                          return (
×
474
                            <StyledTag>
475
                              {formatStudySelectionValue(value, formattedStudyMap)}
476
                            </StyledTag>
477
                          );
478
                        }}
479
                        options={studyOptions}
480
                        getOptionLabel={(option: string) => formattedStudyMap[option]}
×
481
                        onChange={(_, data: string[]) => {
NEW
482
                          let updatedData = [...data];
×
483

484
                          // Previous studies included all but the user selected other studies
NEW
485
                          if (prevStudiesRef.current?.includes(ALL_STUDIES_OPTION)) {
×
NEW
486
                            updatedData = updatedData.filter((v) => v !== ALL_STUDIES_OPTION);
×
487
                            // User selected all studies, remove all other studies
NEW
488
                          } else if (data.includes(ALL_STUDIES_OPTION)) {
×
NEW
489
                            updatedData = [ALL_STUDIES_OPTION];
×
490
                          }
491

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

557
              <StyledButtonStack
558
                direction="row"
559
                justifyContent="center"
560
                alignItems="center"
561
                spacing={1}
562
              >
563
                {Object.values(fieldset).some((fieldState) => fieldState === "UNLOCKED") && (
×
564
                  <StyledButton type="submit" loading={saving} txt="#14634F" border="#26B893">
565
                    Save
566
                  </StyledButton>
567
                )}
568
                {viewType === "users" && (
×
569
                  <StyledButton
570
                    type="button"
571
                    onClick={() => navigate(manageUsersPageUrl)}
×
572
                    txt="#666666"
573
                    border="#828282"
574
                  >
575
                    Cancel
576
                  </StyledButton>
577
                )}
578
              </StyledButtonStack>
579
            </form>
580
          </StyledContentStack>
581
        </Stack>
582
      </StyledContainer>
583
    </>
584
  );
585
};
586

587
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