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

mozilla / fx-private-relay / 6b43dd79-8f94-4235-acd1-94524bacef54

02 Jul 2025 04:04PM UTC coverage: 86.642% (+1.3%) from 85.326%
6b43dd79-8f94-4235-acd1-94524bacef54

Pull #5647

circleci

vpremamozilla
MPP-4153 - test(mask-management): increase frontend test coverage for Mask Management pages and components
Pull Request #5647: MPP-4153 - increase frontend test coverage for mask management

2586 of 3657 branches covered (70.71%)

Branch coverage included in aggregate %.

17846 of 19925 relevant lines covered (89.57%)

10.18 hits per line

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

60.53
/frontend/src/components/layout/navigation/UserMenu.tsx
1
import {
2
  useMenuTriggerState,
3
  useTreeState,
4
  TreeProps,
5
  TreeState,
6
  Item,
7
} from "react-stately";
8✔
8
import {
9
  useMenuTrigger,
10
  useButton,
11
  useOverlay,
12
  FocusScope,
13
  DismissButton,
14
  mergeProps,
15
  useMenuItem,
16
  useFocus,
17
} from "react-aria";
8✔
18
import { Key, ReactNode, useRef, useState } from "react";
8✔
19
import { AriaMenuItemProps } from "@react-aria/menu";
20
import Link from "next/link";
8✔
21
import styles from "./UserMenu.module.scss";
8✔
22
import {
23
  Cogwheel,
24
  ContactIcon,
25
  NewTabIcon,
26
  SignOutIcon,
27
  SupportIcon,
28
} from "../../Icons";
8✔
29
import { useUsers } from "../../../hooks/api/user";
8✔
30
import { useProfiles } from "../../../hooks/api/profile";
8✔
31
import { getRuntimeConfig } from "../../../config";
8✔
32
import { getCsrfToken } from "../../../functions/cookies";
8✔
33
import { useRuntimeData } from "../../../hooks/api/runtimeData";
8✔
34
import { setCookie } from "../../../functions/cookies";
35
import { useGaEvent } from "../../../hooks/gaEvent";
8✔
36
import { useL10n } from "../../../hooks/l10n";
8✔
37
import { MenuPopupProps, useMenu } from "../../../hooks/menu";
8✔
38
import React from "react";
39

40
export type Props = {
41
  style: string;
42
};
43
/**
44
 * Display the user's avatar, which can open a menu allowing the user to log out or go to their settings page.
45
 */
46
export const UserMenu = (props: Props) => {
97✔
47
  const runtimeData = useRuntimeData();
97✔
48
  const profileData = useProfiles();
97✔
49
  const usersData = useUsers();
97✔
50
  const l10n = useL10n();
97✔
51
  const gaEvent = useGaEvent();
97✔
52

53
  const itemKeys = {
97✔
54
    account: "account",
55
    settings: "settings",
56
    contact: "contact",
57
    help: "help",
58
    signout: "signout",
59
  };
60
  const accountLinkRef = useRef<HTMLAnchorElement>(null);
97✔
61
  const settingsLinkRef = useRef<HTMLAnchorElement>(null);
97✔
62
  const contactLinkRef = useRef<HTMLAnchorElement>(null);
97✔
63
  const helpLinkRef = useRef<HTMLAnchorElement>(null);
97✔
64
  const logoutFormRef = useRef<HTMLFormElement>(null);
97✔
65

66
  if (
97✔
67
    !Array.isArray(usersData.data) ||
217✔
68
    usersData.data.length !== 1 ||
69
    !runtimeData.data
70
  ) {
71
    // Still fetching the user's account data...
72
    return null;
37✔
73
  }
74

75
  const onSelect = (itemKey: Key) => {
60✔
76
    if (itemKey === itemKeys.account) {
×
77
      accountLinkRef.current?.click();
×
78
    }
79
    if (itemKey === itemKeys.settings) {
×
80
      settingsLinkRef.current?.click();
×
81
    }
82
    if (itemKey === itemKeys.contact) {
×
83
      contactLinkRef.current?.click();
×
84
    }
85
    if (itemKey === itemKeys.help) {
×
86
      helpLinkRef.current?.click();
×
87
    }
88
    if (itemKey === itemKeys.signout) {
×
89
      gaEvent({
×
90
        category: "Sign Out",
91
        action: "Click",
92
        label: "Website Sign Out",
93
      });
94
      setCookie("user-sign-out", "true", { maxAgeInSeconds: 60 * 60 });
×
95
      logoutFormRef.current?.submit();
×
96
    }
97
  };
98

99
  const contactLink =
100
    profileData.data?.[0]?.has_premium === true ? (
60!
101
      <Item
102
        key={itemKeys.contact}
103
        textValue={l10n.getString("nav-profile-contact")}
104
      >
105
        <a
106
          ref={contactLinkRef}
107
          href={`${runtimeData.data.FXA_ORIGIN}/support/?utm_source=${
108
            getRuntimeConfig().frontendOrigin
109
          }`}
110
          title={l10n.getString("nav-profile-contact-tooltip")}
111
          className={styles["menu-link"]}
112
          target="_blank"
113
          rel="noopener noreferrer"
114
        >
115
          <ContactIcon width={20} height={20} alt="" />
116
          {l10n.getString("nav-profile-contact")}
117
        </a>
118
      </Item>
119
    ) : null;
120

121
  return (
122
    <UserMenuTrigger
123
      style={props.style}
124
      label={
125
        <img
126
          src={profileData.data?.[0].avatar}
127
          alt={l10n.getString("label-open-menu")}
128
          width={42}
129
          height={42}
130
        />
131
      }
132
      onAction={onSelect}
133
    >
134
      <Item
135
        key={itemKeys.account}
136
        textValue={l10n.getString("nav-profile-manage-account")}
137
      >
138
        <span className={styles["account-menu-item"]}>
139
          <b className={styles["user-email"]}>{usersData.data[0].email}</b>
140
          <a
141
            href={`${runtimeData.data.FXA_ORIGIN}/settings/`}
142
            ref={accountLinkRef}
143
            target="_blank"
144
            rel="noopener noreferrer"
145
            className={styles["settings-link"]}
146
          >
147
            {l10n.getString("nav-profile-manage-account")}
148
            <NewTabIcon alt="" />
149
          </a>
150
        </span>
151
      </Item>
152
      <Item
153
        key={itemKeys.settings}
154
        textValue={l10n.getString("nav-profile-settings")}
155
      >
156
        <Link
157
          href="/accounts/settings"
158
          ref={settingsLinkRef}
159
          title={l10n.getString("nav-profile-settings-tooltip")}
160
          className={styles["menu-link"]}
161
        >
162
          <Cogwheel width={20} height={20} alt="" />
163
          {l10n.getString("nav-profile-settings")}
164
        </Link>
165
      </Item>
166
      {contactLink as React.JSX.Element}
167
      <Item key={itemKeys.help} textValue={l10n.getString("nav-profile-help")}>
168
        <a
169
          ref={helpLinkRef}
170
          href={`${getRuntimeConfig().supportUrl}?utm_source=${
171
            getRuntimeConfig().frontendOrigin
172
          }`}
173
          title={l10n.getString("nav-profile-help-tooltip")}
174
          className={styles["menu-link"]}
175
          target="_blank"
176
          rel="noopener noreferrer"
177
        >
178
          <SupportIcon width={20} height={20} alt="" />
179
          {l10n.getString("nav-profile-help")}
180
        </a>
181
      </Item>
182
      <Item
183
        key={itemKeys.signout}
184
        textValue={l10n.getString("nav-profile-sign-out")}
185
      >
186
        <form
187
          method="POST"
188
          action={getRuntimeConfig().fxaLogoutUrl}
189
          ref={logoutFormRef}
190
        >
191
          <input
192
            type="hidden"
193
            name="csrfmiddlewaretoken"
194
            value={getCsrfToken()}
195
          />
196
          <button type="submit" className={styles["menu-button"]}>
197
            <SignOutIcon alt="" />
198
            {l10n.getString("nav-profile-sign-out")}
199
          </button>
200
        </form>
201
      </Item>
202
    </UserMenuTrigger>
203
  );
204
};
205

206
type UserMenuTriggerProps = Parameters<typeof useMenuTriggerState>[0] & {
207
  label: ReactNode;
208
  style: string;
209
  children: TreeProps<Record<string, never>>["children"];
210
  onAction: AriaMenuItemProps["onAction"];
211
};
212
const UserMenuTrigger = ({
8✔
213
  label,
214
  style,
215
  ...otherProps
216
}: UserMenuTriggerProps) => {
217
  const l10n = useL10n();
60✔
218
  const userMenuTriggerState = useMenuTriggerState(otherProps);
60✔
219

220
  const triggerButtonRef = useRef<HTMLButtonElement>(null);
60✔
221
  const { menuTriggerProps, menuProps } = useMenuTrigger(
60✔
222
    {},
223
    userMenuTriggerState,
224
    triggerButtonRef,
225
  );
226
  // `menuProps` has an `autoFocus` property that is not compatible with the
227
  // `autoFocus` property for HTMLElements, because it can also be of type
228
  // `FocusStrategy` (i.e. the string "first" or "last") at the time of writing.
229
  // Since its values get spread onto an HTMLUListElement, we ignore those
230
  // values. See
231
  // https://github.com/mozilla/fx-private-relay/pull/3261#issuecomment-1493840024
232
  const menuPropsWithoutAutofocus = {
60✔
233
    ...menuProps,
234
    autoFocus:
235
      typeof menuProps.autoFocus === "boolean"
60!
236
        ? menuProps.autoFocus
237
        : undefined,
238
  };
239

240
  const triggerButtonProps = useButton(
60✔
241
    menuTriggerProps,
242
    triggerButtonRef,
243
  ).buttonProps;
244

245
  return (
246
    <div className={`${styles.wrapper} ${style}`}>
247
      <button
248
        {...triggerButtonProps}
249
        ref={triggerButtonRef}
250
        title={l10n.getString("avatar-tooltip")}
251
        className={styles.trigger}
252
      >
253
        {label}
254
      </button>
255
      {userMenuTriggerState.isOpen && (
60✔
256
        <UserMenuPopup
257
          {...otherProps}
258
          aria-label={l10n.getString("avatar-tooltip")}
259
          domProps={menuPropsWithoutAutofocus}
260
          autoFocus={userMenuTriggerState.focusStrategy}
261
          onClose={() => userMenuTriggerState.close()}
×
262
        />
263
      )}
264
    </div>
265
  );
266
};
267

268
type UserMenuPopupProps = MenuPopupProps<Record<string, never>>;
269
const UserMenuPopup = (props: UserMenuPopupProps) => {
8✔
270
  const popupState = useTreeState({ ...props, selectionMode: "none" });
×
271

272
  const popupRef = useRef<HTMLUListElement>(null);
×
273
  const popupProps = useMenu(props, popupState, popupRef).menuProps;
×
274

275
  const overlayRef = useRef<HTMLDivElement>(null);
×
276
  const { overlayProps } = useOverlay(
×
277
    {
278
      onClose: props.onClose,
279
      shouldCloseOnBlur: true,
280
      isOpen: true,
281
      isDismissable: true,
282
    },
283
    overlayRef,
284
  );
285

286
  // <FocusScope> ensures that focus is restored back to the
287
  // trigger when the menu is closed.
288
  // The <DismissButton> components allow screen reader users
289
  // to dismiss the popup easily.
290
  return (
291
    <FocusScope restoreFocus>
292
      <div {...overlayProps} ref={overlayRef}>
293
        <DismissButton onDismiss={props.onClose} />
294
        <ul
295
          {...mergeProps(popupProps, props.domProps)}
296
          ref={popupRef}
297
          className={styles.popup}
298
        >
299
          {Array.from(popupState.collection).map((item) => (
300
            <UserMenuItem
×
301
              key={item.key}
302
              // TODO: Fix the typing (likely: report to react-aria that the type does not include an isDisabled prop)
303
              item={item as unknown as UserMenuItemProps["item"]}
304
              state={popupState}
305
              onAction={props.onAction}
306
              onClose={props.onClose}
307
            />
308
          ))}
309
        </ul>
310
        <DismissButton onDismiss={props.onClose} />
311
      </div>
312
    </FocusScope>
313
  );
314
};
315

316
type UserMenuItemProps = {
317
  // TODO: Figure out correct type:
318
  item: {
319
    key: AriaMenuItemProps["key"];
320
    isDisabled: AriaMenuItemProps["isDisabled"];
321
    rendered?: ReactNode;
322
  };
323
  state: TreeState<unknown>;
324
  onAction: AriaMenuItemProps["onAction"];
325
  onClose: AriaMenuItemProps["onClose"];
326
};
327

328
const UserMenuItem = (props: UserMenuItemProps) => {
8✔
329
  const menuItemRef = useRef<HTMLLIElement>(null);
×
330
  const menuItemProps = useMenuItem(
×
331
    {
332
      key: props.item.key,
333
      isDisabled: props.item.isDisabled,
334
      onAction: props.onAction,
335
      onClose: props.onClose,
336
    },
337
    props.state,
338
    menuItemRef,
339
  ).menuItemProps;
340

341
  const [_isFocused, setIsFocused] = useState(false);
×
342
  const focusProps = useFocus({ onFocusChange: setIsFocused }).focusProps;
×
343

344
  return (
345
    <li
346
      {...mergeProps(menuItemProps, focusProps)}
347
      ref={menuItemRef}
348
      className={styles["menu-item-wrapper"]}
349
    >
350
      {props.item.rendered}
351
    </li>
352
  );
353
};
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