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

mozilla / fx-private-relay / e6596aaf-157d-4581-acf2-f4e4f97a1130

21 Sep 2023 08:17PM CUT coverage: 74.433% (-0.1%) from 74.552%
e6596aaf-157d-4581-acf2-f4e4f97a1130

push

circleci

web-flow
Merge pull request #3907 from mozilla/fix-ga-events-MPP-3422

Fix ga events mpp 3422

1895 of 2761 branches covered (0.0%)

Branch coverage included in aggregate %.

21 of 21 new or added lines in 5 files covered. (100.0%)

5977 of 7815 relevant lines covered (76.48%)

18.39 hits per line

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

78.26
/frontend/src/components/dashboard/aliases/AliasGenerationButton.tsx
1
import {
2
  FocusScope,
3
  useOverlay,
4
  useMenuTrigger,
5
  useMenu,
6
  DismissButton,
7
  mergeProps,
8
  useMenuItem,
9
  useFocus,
10
  useButton,
11
  AriaOverlayProps,
12
} from "react-aria";
3✔
13
import { AriaMenuItemProps } from "@react-aria/menu";
14
import { event as gaEvent } from "react-ga";
3✔
15
import {
16
  Item,
17
  MenuTriggerState,
18
  TreeProps,
19
  TreeState,
20
  useMenuTriggerState,
21
  useOverlayTriggerState,
22
  useTreeState,
23
} from "react-stately";
3✔
24
import { HTMLAttributes, Key, ReactNode, useRef, useState } from "react";
3✔
25
import styles from "./AliasGenerationButton.module.scss";
3✔
26
import { ArrowDownIcon, PlusIcon } from "../../Icons";
3✔
27
import { ProfileData } from "../../../hooks/api/profile";
28
import { Button, LinkButton } from "../../Button";
3✔
29
import { AliasData } from "../../../hooks/api/aliases";
30
import { getRuntimeConfig } from "../../../config";
3✔
31
import { RuntimeData } from "../../../hooks/api/runtimeData";
32
import { isPeriodicalPremiumAvailableInCountry } from "../../../functions/getPlan";
3✔
33
import { useGaViewPing } from "../../../hooks/gaViewPing";
3✔
34
import { AddressPickerModal } from "./AddressPickerModal";
3✔
35
import { useL10n } from "../../../hooks/l10n";
3✔
36

37
export type Props = {
38
  aliases: AliasData[];
39
  profile: ProfileData;
40
  runtimeData?: RuntimeData;
41
  onCreate: (
42
    options:
43
      | { mask_type: "random" }
44
      | { mask_type: "custom"; address: string; blockPromotionals: boolean },
45
  ) => void;
46
};
47

48
/**
49
 * A button to initiate the different flows for creating an alias.
50
 *
51
 * Usually, this will be a simple button to generate a new random alias,
52
 * but it adapts to the situation to e.g. prompt the user to upgrade to Premium
53
 * when they run out of aliases, or to allow generating a custom alias if the
54
 * user is able to.
55
 */
56
export const AliasGenerationButton = (props: Props) => {
163✔
57
  const l10n = useL10n();
159✔
58
  const getUnlimitedButtonRef = useGaViewPing({
159✔
59
    category: "Purchase Button",
60
    label: "profile-create-alias-upgrade-promo",
61
  });
62

63
  const maxAliases = getRuntimeConfig().maxFreeAliases;
159✔
64
  if (!props.profile.has_premium && props.aliases.length >= maxAliases) {
159✔
65
    // If the user does not have Premium, has reached the alias limit,
66
    // and Premium is not available to them, show a greyed-out button:
67
    if (!isPeriodicalPremiumAvailableInCountry(props.runtimeData)) {
6✔
68
      return (
69
        <Button disabled>
70
          <PlusIcon alt="" width={16} height={16} />
71
          {l10n.getString("profile-label-generate-new-alias-2")}
72
        </Button>
73
      );
74
    }
75

76
    // If the user does not have Premium, has reached the alias limit,
77
    // and Premium is available to them, prompt them to upgrade:
78
    return (
79
      <LinkButton
80
        href="/premium#pricing"
81
        ref={getUnlimitedButtonRef}
82
        onClick={() => {
83
          gaEvent({
×
84
            category: "Purchase Button",
85
            action: "Engage",
86
            label: "profile-create-alias-upgrade-promo",
87
          });
88
        }}
89
      >
90
        {l10n.getString("profile-label-upgrade-2")}
91
      </LinkButton>
92
    );
93
  }
94

95
  if (
153✔
96
    props.profile.has_premium &&
177✔
97
    typeof props.profile.subdomain === "string"
98
  ) {
99
    return (
100
      <AliasTypeMenu
101
        onCreate={props.onCreate}
102
        subdomain={props.profile.subdomain}
103
      />
104
    );
105
  }
106

107
  return (
108
    <Button
109
      onClick={() => props.onCreate({ mask_type: "random" })}
×
110
      title={l10n.getString("profile-label-generate-new-alias-2")}
111
    >
112
      <PlusIcon alt="" width={16} height={16} />
113
      {l10n.getString("profile-label-generate-new-alias-2")}
114
    </Button>
115
  );
116
};
117

118
type AliasTypeMenuProps = {
119
  subdomain: string;
120
  onCreate: (
121
    options:
122
      | { mask_type: "random" }
123
      | { mask_type: "custom"; address: string; blockPromotionals: boolean },
124
  ) => void;
125
};
126
const AliasTypeMenu = (props: AliasTypeMenuProps) => {
3✔
127
  const l10n = useL10n();
3✔
128
  const modalState = useOverlayTriggerState({});
3✔
129

130
  const onAction = (key: Key) => {
3✔
131
    if (key === "random") {
×
132
      props.onCreate({ mask_type: "random" });
×
133
      return;
×
134
    }
135
    if (key === "custom") {
×
136
      modalState.open();
×
137
    }
138
  };
139

140
  const onPick = (
3✔
141
    address: string,
142
    settings: { blockPromotionals: boolean },
143
  ) => {
144
    props.onCreate({
×
145
      mask_type: "custom",
146
      address: address,
147
      blockPromotionals: settings.blockPromotionals,
148
    });
149
    modalState.close();
×
150
  };
151

152
  const dialog = modalState.isOpen ? (
3!
153
    <AddressPickerModal
154
      isOpen={modalState.isOpen}
155
      onClose={() => modalState.close()}
×
156
      onPick={onPick}
157
      subdomain={props.subdomain}
158
    />
159
  ) : null;
160

161
  return (
162
    <>
163
      <AliasTypeMenuButton onAction={onAction}>
164
        <Item key="random">
165
          {l10n.getString("profile-label-generate-new-alias-menu-random-2")}
166
        </Item>
167
        <Item key="custom">
168
          {l10n.getString("profile-label-generate-new-alias-menu-custom-2", {
169
            subdomain: props.subdomain,
170
          })}
171
        </Item>
172
      </AliasTypeMenuButton>
173
      {dialog}
174
    </>
175
  );
176
};
177

178
type AliasTypeMenuButtonProps = Parameters<typeof useMenuTriggerState>[0] & {
179
  children: TreeProps<Record<string, never>>["children"];
180
  onAction: AriaMenuItemProps["onAction"];
181
};
182
const AliasTypeMenuButton = (props: AliasTypeMenuButtonProps) => {
3✔
183
  const l10n = useL10n();
7✔
184
  const triggerState = useMenuTriggerState(props);
7✔
185
  const triggerRef = useRef<HTMLButtonElement>(null);
7✔
186
  const { menuTriggerProps, menuProps } = useMenuTrigger(
7✔
187
    {},
188
    triggerState,
189
    triggerRef,
190
  );
191
  // `menuProps` has an `autoFocus` property that is not compatible with the
192
  // `autoFocus` property for HTMLElements, because it can also be of type
193
  // `FocusStrategy` (i.e. the string "first" or "last") at the time of writing.
194
  // Since its values get spread onto an HTMLUListElement, we ignore those
195
  // values. See
196
  // https://github.com/mozilla/fx-private-relay/pull/3261#issuecomment-1493840024
197
  const menuPropsWithoutAutofocus = {
7✔
198
    ...menuProps,
199
    autoFocus:
200
      typeof menuProps.autoFocus === "boolean"
7!
201
        ? menuProps.autoFocus
202
        : undefined,
203
  };
204

205
  const triggerButtonProps = useButton(
7✔
206
    menuTriggerProps,
207
    triggerRef,
208
  ).buttonProps;
209

210
  return (
211
    <div className={styles["button-wrapper"]}>
212
      <Button ref={triggerRef} {...triggerButtonProps}>
213
        {l10n.getString("profile-label-generate-new-alias-2")}
214
        <ArrowDownIcon alt="" width={16} height={16} />
215
      </Button>
216
      {triggerState.isOpen && (
7✔
217
        <AliasTypeMenuPopup
218
          {...props}
219
          aria-label={l10n.getString("profile-label-generate-new-alias-2")}
220
          domProps={menuPropsWithoutAutofocus}
221
          autoFocus={triggerState.focusStrategy}
222
          onClose={() => triggerState.close()}
×
223
        />
224
      )}
225
    </div>
226
  );
227
};
228

229
type AliasTypeMenuPopupProps = TreeProps<Record<string, never>> & {
230
  onAction: AriaMenuItemProps["onAction"];
231
  domProps: HTMLAttributes<HTMLElement>;
232
  onClose?: AriaOverlayProps["onClose"];
233
  autoFocus?: MenuTriggerState["focusStrategy"];
234
};
235
const AliasTypeMenuPopup = (props: AliasTypeMenuPopupProps) => {
3✔
236
  const popupState = useTreeState({ ...props, selectionMode: "none" });
4✔
237

238
  const popupRef = useRef<HTMLUListElement>(null);
4✔
239
  const popupProps = useMenu(props, popupState, popupRef).menuProps;
4✔
240

241
  const overlayRef = useRef<HTMLDivElement>(null);
4✔
242
  const { overlayProps } = useOverlay(
4✔
243
    {
244
      onClose: props.onClose,
245
      shouldCloseOnBlur: true,
246
      isOpen: true,
247
      isDismissable: true,
248
    },
249
    overlayRef,
250
  );
251

252
  // <FocusScope> ensures that focus is restored back to the
253
  // trigger when the menu is closed.
254
  // The <DismissButton> components allow screen reader users
255
  // to dismiss the popup easily.
256
  return (
257
    <FocusScope restoreFocus>
258
      <div {...overlayProps} ref={overlayRef}>
259
        <DismissButton onDismiss={props.onClose} />
260
        <ul
261
          {...mergeProps(popupProps, props.domProps)}
262
          ref={popupRef}
263
          className={styles.popup}
264
        >
265
          {Array.from(popupState.collection).map((item) => (
266
            <AliasTypeMenuItem
8✔
267
              key={item.key}
268
              // TODO: Fix the typing (likely: report to react-aria that the type does not include an isDisabled prop)
269
              item={item as unknown as AliasTypeMenuItemProps["item"]}
270
              state={popupState}
271
              onAction={props.onAction}
272
              onClose={props.onClose}
273
            />
274
          ))}
275
        </ul>
276
        <DismissButton onDismiss={props.onClose} />
277
      </div>
278
    </FocusScope>
279
  );
280
};
281

282
type AliasTypeMenuItemProps = {
283
  // TODO: Figure out correct type:
284
  item: {
285
    key: AriaMenuItemProps["key"];
286
    isDisabled: AriaMenuItemProps["isDisabled"];
287
    rendered?: ReactNode;
288
  };
289
  state: TreeState<unknown>;
290
  onAction: AriaMenuItemProps["onAction"];
291
  onClose: AriaMenuItemProps["onClose"];
292
};
293

294
const AliasTypeMenuItem = (props: AliasTypeMenuItemProps) => {
3✔
295
  const menuItemRef = useRef<HTMLLIElement>(null);
12✔
296
  const menuItemProps = useMenuItem(
12✔
297
    {
298
      key: props.item.key,
299
      isDisabled: props.item.isDisabled,
300
      onAction: props.onAction,
301
      onClose: props.onClose,
302
    },
303
    props.state,
304
    menuItemRef,
305
  ).menuItemProps;
306

307
  const [_isFocused, setIsFocused] = useState(false);
12✔
308
  const focusProps = useFocus({ onFocusChange: setIsFocused }).focusProps;
12✔
309

310
  return (
311
    <li
312
      {...mergeProps(menuItemProps, focusProps)}
313
      ref={menuItemRef}
314
      className={styles["menu-item-wrapper"]}
315
    >
316
      {props.item.rendered}
317
    </li>
318
  );
319
};
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