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

mozilla / fx-private-relay / 3bc340e2-329f-4ed2-8700-adaaac8d78c8

15 Dec 2023 06:50PM CUT coverage: 73.514% (-0.1%) from 73.614%
3bc340e2-329f-4ed2-8700-adaaac8d78c8

push

circleci

jwhitlock
Use branch database with production tests

Previously, migrations tests were run with production code, branch
requirements, and branch migrations. Now they run with production
requirements, so that third-party migrations are tested as well.

This uses pytest --reuse-db to create a test database with the branch's
migrations, and then a pip install with the production code. This more
closely emulates the mixed environment during a deploy.

1962 of 2913 branches covered (0.0%)

Branch coverage included in aggregate %.

6273 of 8289 relevant lines covered (75.68%)

19.91 hits per line

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

70.48
/frontend/src/pages/accounts/profile.page.tsx
1
import type { NextPage } from "next";
2
import Image from "next/image";
1✔
3
import {
4
  forwardRef,
5
  HTMLAttributes,
6
  ReactNode,
7
  RefObject,
8
  useRef,
9
} from "react";
1✔
10
import {
11
  FocusScope,
12
  mergeProps,
13
  useButton,
14
  useOverlay,
15
  useOverlayPosition,
16
  useOverlayTrigger,
17
  useTooltip,
18
  useTooltipTrigger,
19
} from "react-aria";
1✔
20
import { event as gaEvent } from "react-ga";
1✔
21
import { useMenuTriggerState, useTooltipTriggerState } from "react-stately";
1✔
22
import { toast } from "react-toastify";
1✔
23
import styles from "./profile.module.scss";
1✔
24
import UpsellBannerUs from "./images/upsell-banner-us.svg";
1✔
25
import UpsellBannerNonUs from "./images/upsell-banner-nonus.svg";
1✔
26
import { CheckBadgeIcon, LockIcon, PencilIcon } from "../../components/Icons";
1✔
27
import { Layout } from "../../components/layout/Layout";
1✔
28
import { useProfiles } from "../../hooks/api/profile";
1✔
29
import {
30
  AliasData,
31
  getAllAliases,
32
  getFullAddress,
33
  useAliases,
34
} from "../../hooks/api/aliases";
1✔
35
import { useUsers } from "../../hooks/api/user";
1✔
36
import { AliasList } from "../../components/dashboard/aliases/AliasList";
1✔
37
import { ProfileBanners } from "../../components/dashboard/ProfileBanners";
1✔
38
import { LinkButton } from "../../components/Button";
1✔
39
import { useRuntimeData } from "../../hooks/api/runtimeData";
1✔
40
import {
41
  isPeriodicalPremiumAvailableInCountry,
42
  isPhonesAvailableInCountry,
43
} from "../../functions/getPlan";
1✔
44
import { useGaViewPing } from "../../hooks/gaViewPing";
1✔
45
import { PremiumOnboarding } from "../../components/dashboard/PremiumOnboarding";
1✔
46
import { Onboarding } from "../../components/dashboard/Onboarding";
1✔
47
import { getRuntimeConfig } from "../../config";
1✔
48
import { Tips } from "../../components/dashboard/tips/Tips";
1✔
49
import { getLocale } from "../../functions/getLocale";
1✔
50
import { AddonData } from "../../components/dashboard/AddonData";
1✔
51
import { useAddonData } from "../../hooks/addon";
1✔
52
import { CloseIcon } from "../../components/Icons";
53
import { isFlagActive } from "../../functions/waffle";
1✔
54
import { DashboardSwitcher } from "../../components/layout/navigation/DashboardSwitcher";
1✔
55
import { usePurchaseTracker } from "../../hooks/purchaseTracker";
1✔
56
import { useL10n } from "../../hooks/l10n";
1✔
57
import { Localized } from "../../components/Localized";
1✔
58
import { clearCookie, getCookie, setCookie } from "../../functions/cookies";
1✔
59
import { SubdomainInfoTooltip } from "../../components/dashboard/subdomain/SubdomainInfoTooltip";
1✔
60
import Link from "next/link";
1✔
61
import { FreeOnboarding } from "../../components/dashboard/FreeOnboarding";
1✔
62
import Confetti from "react-confetti";
1✔
63
import { useRouter } from "next/router";
1✔
64

65
const Profile: NextPage = () => {
1✔
66
  const runtimeData = useRuntimeData();
55✔
67
  const profileData = useProfiles();
55✔
68
  const userData = useUsers();
55✔
69
  const aliasData = useAliases();
55✔
70
  const addonData = useAddonData();
55✔
71
  const router = useRouter();
55✔
72
  const l10n = useL10n();
55✔
73
  const setCustomDomainLinkRef = useGaViewPing({
55✔
74
    category: "Purchase Button",
75
    label: "profile-set-custom-domain",
76
  });
77
  const hash = getCookie("profile-location-hash");
55✔
78
  if (hash) {
55!
79
    document.location.hash = hash;
×
80
    clearCookie("profile-location-hash");
×
81
  }
82
  usePurchaseTracker(profileData.data?.[0]);
55✔
83

84
  if (!userData.isValidating && userData.error) {
54!
85
    if (document.location.hash) {
×
86
      setCookie("profile-location-hash", document.location.hash);
×
87
    }
88
    // Add url params to auth_params so django-allauth will send them to FXA
89
    const originalUrlParams = document.location.search.replace("?", "");
×
90
    const fxaLoginWithAuthParams =
91
      getRuntimeConfig().fxaLoginUrl +
×
92
      "&auth_params=" +
93
      encodeURIComponent(originalUrlParams);
94
    document.location.assign(fxaLoginWithAuthParams);
×
95
  }
96

97
  const profile = profileData.data?.[0];
54✔
98
  const user = userData.data?.[0];
54✔
99
  if (
54!
100
    !runtimeData.data ||
324✔
101
    !profile ||
102
    !user ||
103
    !router ||
104
    !aliasData.randomAliasData.data ||
105
    !aliasData.customAliasData.data
106
  ) {
107
    // TODO: Show a loading spinner?
108
    return null;
×
109
  }
110

111
  const setCustomSubdomain = async (customSubdomain: string) => {
54✔
112
    const response = await profileData.setSubdomain(customSubdomain);
×
113
    if (!response.ok) {
×
114
      toast(
×
115
        l10n.getString("error-subdomain-not-available-2", {
116
          unavailable_subdomain: customSubdomain,
117
        }),
118
        { type: "error" },
119
      );
120
    }
121
    addonData.sendEvent("subdomainClaimed", { subdomain: customSubdomain });
×
122
  };
123

124
  const allAliases = getAllAliases(
54✔
125
    aliasData.randomAliasData.data,
126
    aliasData.customAliasData.data,
127
  );
128

129
  // premium user onboarding experience
130
  if (
54✔
131
    profile.has_premium &&
81✔
132
    profile.onboarding_state < getRuntimeConfig().maxOnboardingAvailable
133
  ) {
134
    const onNextStep = (step: number) => {
8✔
135
      profileData.update(profile.id, {
2✔
136
        onboarding_state: step,
137
      });
138
    };
139

140
    return (
141
      <>
142
        <AddonData
143
          aliases={allAliases}
144
          profile={profile}
145
          runtimeData={runtimeData.data}
146
          totalBlockedEmails={profile.emails_blocked}
147
          totalForwardedEmails={profile.emails_forwarded}
148
          totalEmailTrackersRemoved={profile.level_one_trackers_blocked}
149
        />
150
        <Layout runtimeData={runtimeData.data}>
151
          {isPhonesAvailableInCountry(runtimeData.data) ? (
8!
152
            <DashboardSwitcher />
153
          ) : null}
154
          <PremiumOnboarding
155
            profile={profile}
156
            onNextStep={onNextStep}
157
            onPickSubdomain={setCustomSubdomain}
158
          />
159
        </Layout>
160
      </>
161
    );
162
  }
163

164
  const freeMaskLimit = getRuntimeConfig().maxFreeAliases;
46✔
165
  const freeMaskLimitReached =
166
    allAliases.length >= freeMaskLimit && !profile.has_premium;
46✔
167

168
  const createAlias = async (
46✔
169
    options:
170
      | { mask_type: "random" }
171
      | { mask_type: "custom"; address: string; blockPromotionals: boolean },
172
    setAliasGeneratedState?: (flag: boolean) => void,
173
  ) => {
174
    try {
1✔
175
      const response = await aliasData.create(options);
1✔
176
      if (!response.ok) {
1!
177
        throw new Error(
×
178
          "Immediately caught to land in the same code path as failed requests.",
179
        );
180
      }
181
      if (setAliasGeneratedState) {
1!
182
        setAliasGeneratedState(true);
×
183
      }
184
      addonData.sendEvent("aliasListUpdate");
1✔
185
    } catch (error) {
186
      // TODO: Refactor CustomAddressGenerationModal to remove the setAliasGeneratedState callback, and instead use a try catch block.
187
      setAliasGeneratedState
×
188
        ? setAliasGeneratedState(false)
189
        : toast(l10n.getString("error-mask-create-failed"), { type: "error" });
190

191
      // This is so we can catch the error when calling createAlias asynchronously and apply
192
      // more logic to handle when generating a mask fails.
193
      return Promise.reject("Mask generation failed");
×
194
    }
195
  };
196

197
  const updateAlias = async (
46✔
198
    alias: AliasData,
199
    updatedFields: Partial<AliasData>,
200
  ) => {
201
    try {
1✔
202
      const response = await aliasData.update(alias, updatedFields);
1✔
203
      if (!response.ok) {
1!
204
        throw new Error(
×
205
          "Immediately caught to land in the same code path as failed requests.",
206
        );
207
      }
208
    } catch (error) {
209
      toast(
1✔
210
        l10n.getString("error-mask-update-failed", {
211
          alias: getFullAddress(alias),
212
        }),
213
        { type: "error" },
214
      );
215
    }
216
  };
217

218
  const deleteAlias = async (alias: AliasData) => {
46✔
219
    try {
1✔
220
      const response = await aliasData.delete(alias);
1✔
221
      if (!response.ok) {
1!
222
        throw new Error(
×
223
          "Immediately caught to land in the same code path as failed requests.",
224
        );
225
      }
226
      addonData.sendEvent("aliasListUpdate");
1✔
227
    } catch (error: unknown) {
228
      toast(
×
229
        l10n.getString("error-mask-delete-failed", {
230
          alias: getFullAddress(alias),
231
        }),
232
        { type: "error" },
233
      );
234
    }
235
  };
236

237
  // We pull UTM parameters from query
238
  const {
239
    utm_campaign = "",
46✔
240
    utm_medium = "",
46✔
241
    utm_source = "",
46✔
242
  } = router.query || {};
46✔
243
  const isFreeUserOnboardingActive = isFlagActive(
46✔
244
    runtimeData.data,
245
    "free_user_onboarding",
246
  );
247

248
  // We validate UTM parameters to ensure they match the expected values for the onboarding campaign
249
  const isValidUtmParameters =
250
    utm_campaign === "relay-onboarding" &&
46!
251
    utm_source === "relay-onboarding" &&
252
    utm_medium === "email";
253

254
  // Determine if the user is part of the target audience for onboarding
255
  // This checks if the user does not have a premium account and has not completed all onboarding steps
256
  const isOnboarding =
257
    profile.onboarding_free_state <
46✔
258
    getRuntimeConfig().maxOnboardingFreeAvailable;
259
  const isTargetAudience = !profile.has_premium && isOnboarding;
46✔
260

261
  // Conditions: onboarding is active, UTM parameters are valid OR the user has less than or equal to
262
  // 2 masks (if in onboarding process, up to 3), and the user is part of the target audience
263
  if (
46!
264
    isFreeUserOnboardingActive &&
46!
265
    (isValidUtmParameters ||
266
      allAliases.length <= 2 ||
267
      (profile.onboarding_free_state > 0 && allAliases.length <= 3)) &&
268
    isTargetAudience
269
  ) {
270
    const onNextStep = (step: number) => {
×
271
      profileData.update(profile.id, {
×
272
        onboarding_free_state: step,
273
      });
274
    };
275

276
    return (
277
      <>
278
        <AddonData
279
          aliases={allAliases}
280
          profile={profile}
281
          runtimeData={runtimeData.data}
282
          totalBlockedEmails={profile.emails_blocked}
283
          totalForwardedEmails={profile.emails_forwarded}
284
          totalEmailTrackersRemoved={profile.level_one_trackers_blocked}
285
        />
286
        <Layout runtimeData={runtimeData.data}>
287
          {isPhonesAvailableInCountry(runtimeData.data) ? (
×
288
            <DashboardSwitcher />
289
          ) : null}
290
          <FreeOnboarding
291
            profile={profile}
292
            onNextStep={onNextStep}
293
            onPickSubdomain={setCustomSubdomain}
294
            aliases={allAliases}
295
            generateNewMask={createAlias}
296
            hasReachedFreeMaskLimit={freeMaskLimitReached}
297
            user={user}
298
            runtimeData={runtimeData.data}
299
            onUpdate={updateAlias}
300
            hasAtleastOneMask={allAliases.length >= 1}
301
          />
302
        </Layout>
303
      </>
304
    );
305
  }
306

307
  const subdomainMessage =
308
    typeof profile.subdomain === "string" ? (
309
      <>
2✔
310
        <span>{l10n.getString("profile-label-custom-domain")}</span>
311
        <span className={styles["profile-registered-domain-value"]}>
312
          @{profile.subdomain}.{getRuntimeConfig().mozmailDomain}
313
        </span>
314
      </>
315
    ) : profile.has_premium ? (
316
      <a className={styles["open-button"]} href="#mpp-choose-subdomain">
17✔
317
        {l10n.getString("profile-label-set-your-custom-domain-free-user")}
318
      </a>
319
    ) : (
320
      <Link
321
        className={styles["open-button"]}
322
        href={"/premium#pricing"}
323
        ref={setCustomDomainLinkRef}
324
        onClick={() => {
325
          gaEvent({
×
326
            category: "Purchase Button",
327
            action: "Engage",
328
            label: "profile-set-custom-domain",
329
          });
330
        }}
331
      >
332
        {l10n.getString("profile-label-set-your-custom-domain-free-user")}
333
      </Link>
334
    );
335

336
  const numberFormatter = new Intl.NumberFormat(getLocale(l10n), {
46✔
337
    notation: "compact",
338
    compactDisplay: "short",
339
  });
340

341
  type TooltipProps = {
342
    children: ReactNode;
343
  };
344

345
  const MaxedMasksTooltip = (props: TooltipProps) => {
46✔
346
    const l10n = useL10n();
8✔
347
    const triggerState = useTooltipTriggerState({ delay: 0 });
8✔
348
    const triggerRef = useRef<HTMLSpanElement>(null);
8✔
349
    const tooltipTrigger = useTooltipTrigger({}, triggerState, triggerRef);
8✔
350
    const { tooltipProps } = useTooltip({}, triggerState);
8✔
351

352
    return (
353
      <div className={styles["stat-wrapper"]}>
354
        <span
355
          ref={triggerRef}
356
          {...tooltipTrigger.triggerProps}
357
          className={`${styles.stat} ${styles["forwarded-stat"]}`}
358
        >
359
          {props.children}
360
        </span>
361
        {triggerState.isOpen && (
8✔
362
          <div
363
            {...mergeProps(tooltipTrigger.tooltipProps, tooltipProps)}
364
            className={styles.tooltip}
365
          >
366
            <p>
367
              {l10n.getString("profile-maxed-aliases-tooltip", {
368
                limit: freeMaskLimit,
369
              })}
370
            </p>
371
          </div>
372
        )}
373
      </div>
374
    );
375
  };
376

377
  // Show stats for free users and premium users
378
  const stats = (
379
    <section className={styles.header}>
380
      <div className={styles["header-wrapper"]}>
381
        <div className={styles["user-details"]}>
382
          <Localized
383
            id="profile-label-welcome-html"
384
            vars={{
385
              email: user.email,
386
            }}
387
            elems={{
388
              span: <span className={styles.lead} />,
389
            }}
390
          >
391
            <span className={styles.greeting} />
392
          </Localized>
393
          <strong className={styles.subdomain}>
394
            {/* render check badge if subdomain is set and user has premium
395
            render pencil icon if subdomain is not set but user has premium
396
            render lock icon by default */}
397
            {typeof profile.subdomain === "string" && profile.has_premium ? (
48✔
398
              <CheckBadgeIcon alt="" />
2✔
399
            ) : typeof profile.subdomain !== "string" && profile.has_premium ? (
88✔
400
              <PencilIcon alt="" className={styles["pencil-icon"]} />
17✔
401
            ) : (
402
              <LockIcon alt="" className={styles["lock-icon"]} />
403
            )}
404
            {subdomainMessage}
405
            <SubdomainInfoTooltip hasPremium={profile.has_premium} />
406
          </strong>
407
        </div>
408
        <dl className={styles["account-stats"]}>
409
          <div className={styles.stat}>
410
            <dt className={styles.label}>
411
              {l10n.getString("profile-stat-label-aliases-used-2")}
412
            </dt>
413
            {/* If premium is available in the user's country and 
414
            the user has reached their free mask limit and 
415
            they are a free user, show the maxed masks tooltip */}
416
            {isPeriodicalPremiumAvailableInCountry(runtimeData.data) &&
90✔
417
            freeMaskLimitReached ? (
418
              <dd className={`${styles.value} ${styles.maxed}`}>
7✔
419
                <MaxedMasksTooltip>
420
                  {numberFormatter.format(allAliases.length)}
421
                </MaxedMasksTooltip>
422
              </dd>
423
            ) : (
424
              <dd className={`${styles.value}`}>
425
                {numberFormatter.format(allAliases.length)}
426
              </dd>
427
            )}
428
          </div>
429
          <div className={styles.stat}>
430
            <dt className={styles.label}>
431
              {l10n.getString("profile-stat-label-blocked")}
432
            </dt>
433
            <dd className={styles.value}>
434
              {numberFormatter.format(profile.emails_blocked)}
435
            </dd>
436
          </div>
437
          <div className={styles.stat}>
438
            <dt className={styles.label}>
439
              {l10n.getString("profile-stat-label-forwarded")}
440
            </dt>
441
            <dd className={styles.value}>
442
              {numberFormatter.format(profile.emails_forwarded)}
443
            </dd>
444
          </div>
445
          {/*
446
            Only show tracker blocking stats if the back-end provides them:
447
          */}
448
          {isFlagActive(runtimeData.data, "tracker_removal") &&
46!
449
            typeof profile.level_one_trackers_blocked === "number" && (
450
              <div className={styles.stat}>
451
                <dt className={styles.label}>
452
                  {l10n.getString("profile-stat-label-trackers-removed")}
453
                </dt>
454
                <dd className={styles.value}>
455
                  {numberFormatter.format(profile.level_one_trackers_blocked)}
456
                  <StatExplainer>
457
                    <p>
458
                      {l10n.getString(
459
                        "profile-stat-label-trackers-learn-more-part1",
460
                      )}
461
                    </p>
462
                    <p>
463
                      {l10n.getString(
464
                        "profile-stat-label-trackers-learn-more-part2-2",
465
                      )}
466
                    </p>
467
                  </StatExplainer>
468
                </dd>
469
              </div>
470
            )}
471
        </dl>
472
      </div>
473
    </section>
474
  );
475

476
  const banners = (
477
    <section className={styles["banners-wrapper"]}>
478
      <ProfileBanners
479
        profile={profile}
480
        user={user}
481
        onCreateSubdomain={setCustomSubdomain}
482
        runtimeData={runtimeData.data}
483
        aliases={allAliases}
484
      />
485
    </section>
486
  );
487
  const topBanners = allAliases.length > 0 ? banners : null;
46✔
488
  const bottomBanners = allAliases.length === 0 ? banners : null;
46✔
489

490
  // Render the upsell banner when a user has reached the free mask limit
491
  const UpsellBanner = () => (
492
    <div className={styles["upsell-banner"]}>
493
      <div className={styles["upsell-banner-wrapper"]}>
494
        <div className={styles["upsell-banner-content"]}>
495
          <p className={styles["upsell-banner-header"]}>
496
            {isPhonesAvailableInCountry(runtimeData.data)
7✔
497
              ? l10n.getString("profile-maxed-aliases-with-phone-header")
498
              : l10n.getString("profile-maxed-aliases-without-phone-header")}
499
          </p>
500
          <p className={styles["upsell-banner-description"]}>
501
            {l10n.getString(
502
              isPhonesAvailableInCountry(runtimeData.data)
7✔
503
                ? "profile-maxed-aliases-with-phone-description"
504
                : "profile-maxed-aliases-without-phone-description",
505
              {
506
                limit: freeMaskLimit,
507
              },
508
            )}
509
          </p>
510
          <LinkButton
511
            href="/premium#pricing"
512
            ref={useGaViewPing({
513
              category: "Purchase Button",
514
              label: "upgrade-premium-header-mask-limit",
515
            })}
516
            onClick={() => {
517
              gaEvent({
×
518
                category: "Purchase Button",
519
                action: "Engage",
520
                label: "upgrade-premium-header-mask-limit",
521
              });
522
            }}
523
          >
524
            {l10n.getString("profile-maxed-aliases-cta")}
525
          </LinkButton>
526
        </div>
527
        <Image
528
          className={styles["upsell-banner-image"]}
529
          src={
530
            isPhonesAvailableInCountry(runtimeData.data)
7✔
531
              ? UpsellBannerUs
532
              : UpsellBannerNonUs
533
          }
534
          alt=""
535
        />
536
      </div>
537
    </div>
538
  );
539

540
  return (
541
    <>
542
      <AddonData
543
        aliases={allAliases}
544
        profile={profile}
545
        runtimeData={runtimeData.data}
546
        totalBlockedEmails={profile.emails_blocked}
547
        totalForwardedEmails={profile.emails_forwarded}
548
        totalEmailTrackersRemoved={profile.level_one_trackers_blocked}
549
      />
550
      {/* Show confetti animation when user completes last step. */}
551
      {isFlagActive(runtimeData.data, "free_user_onboarding") &&
46!
552
        !profile.has_premium &&
553
        profile.onboarding_free_state === 4 && (
554
          <Confetti
555
            tweenDuration={5000}
556
            gravity={0.2}
557
            recycle={false}
558
            onConfettiComplete={() => {
559
              // Update onboarding step to 4 - prevents animation from displaying again.
560
              profileData.update(profile.id, {
×
561
                onboarding_free_state: 5,
562
              });
563
            }}
564
          />
565
        )}
566
      <Layout runtimeData={runtimeData.data}>
567
        {/* If free user has reached their free mask limit and 
568
        premium is available in their country, show upsell banner */}
569
        {freeMaskLimitReached &&
61✔
570
          isPeriodicalPremiumAvailableInCountry(runtimeData.data) && (
571
            <UpsellBanner />
572
          )}
573
        {isPhonesAvailableInCountry(runtimeData.data) ? (
46✔
574
          <DashboardSwitcher />
575
        ) : null}
576
        <main className={styles["profile-wrapper"]}>
577
          {stats}
578
          {topBanners}
579
          <section className={styles["main-wrapper"]}>
580
            <Onboarding
581
              aliases={allAliases}
582
              onCreate={() => createAlias({ mask_type: "random" })}
1✔
583
            />
584
            <AliasList
585
              aliases={allAliases}
586
              onCreate={createAlias}
587
              onUpdate={updateAlias}
588
              onDelete={deleteAlias}
589
              profile={profile}
590
              user={user}
591
              runtimeData={runtimeData.data}
592
            />
593
            <p className={styles["size-information"]}>
594
              {l10n.getString("profile-supports-email-forwarding", {
595
                size: getRuntimeConfig().emailSizeLimitNumber,
596
                unit: getRuntimeConfig().emailSizeLimitUnit,
597
              })}
598
            </p>
599
          </section>
600
          {bottomBanners}
601
        </main>
602
        <Tips profile={profile} runtimeData={runtimeData.data} />
603
      </Layout>
604
    </>
605
  );
606
};
607

608
const StatExplainer = (props: { children: React.ReactNode }) => {
1✔
609
  const l10n = useL10n();
×
610
  const explainerState = useMenuTriggerState({});
×
611
  const overlayRef = useRef<HTMLDivElement>(null);
×
612
  const openButtonRef = useRef<HTMLButtonElement>(null);
×
613
  const closeButtonRef = useRef<HTMLButtonElement>(null);
×
614
  const { triggerProps } = useOverlayTrigger(
×
615
    { type: "dialog" },
616
    explainerState,
617
    openButtonRef,
618
  );
619

620
  const openButtonProps = useButton(triggerProps, openButtonRef).buttonProps;
×
621
  const closeButtonProps = useButton(
×
622
    { onPress: explainerState.close },
623
    closeButtonRef,
624
  ).buttonProps;
625

626
  const positionProps = useOverlayPosition({
×
627
    targetRef: openButtonRef,
628
    overlayRef: overlayRef,
629
    placement: "bottom",
630
    // $spacing-sm is 8px:
631
    offset: 8,
632
    isOpen: explainerState.isOpen,
633
  }).overlayProps;
634

635
  return (
636
    <div
637
      className={`${styles["learn-more-wrapper"]} ${
638
        explainerState.isOpen ? styles["is-open"] : styles["is-closed"]
×
639
      }`}
640
    >
641
      <button
642
        {...openButtonProps}
643
        ref={openButtonRef}
644
        className={styles["open-button"]}
645
      >
646
        {l10n.getString("profile-stat-learn-more")}
647
      </button>
648
      {explainerState.isOpen && (
×
649
        <StatExplainerTooltip
650
          ref={overlayRef}
651
          overlayProps={{
652
            isOpen: explainerState.isOpen,
653
            isDismissable: true,
654
            onClose: explainerState.close,
655
          }}
656
          positionProps={positionProps}
657
        >
658
          <button
659
            ref={closeButtonRef}
660
            {...closeButtonProps}
661
            className={styles["close-button"]}
662
          >
663
            <CloseIcon alt={l10n.getString("profile-stat-learn-more-close")} />
664
          </button>
665
          {props.children}
666
        </StatExplainerTooltip>
667
      )}
668
    </div>
669
  );
670
};
671

672
type StatExplainerTooltipProps = {
673
  children: ReactNode;
674
  overlayProps: Parameters<typeof useOverlay>[0];
675
  positionProps: HTMLAttributes<HTMLDivElement>;
676
};
677
const StatExplainerTooltip = forwardRef<
1✔
678
  HTMLDivElement,
679
  StatExplainerTooltipProps
680
>(function StatExplainerTooltipWithForwardedRef(props, overlayRef) {
681
  const { overlayProps } = useOverlay(
×
682
    props.overlayProps,
683
    overlayRef as RefObject<HTMLDivElement>,
684
  );
685

686
  return (
687
    <FocusScope restoreFocus>
688
      <div
689
        {...overlayProps}
690
        {...props.positionProps}
691
        style={{
692
          ...props.positionProps.style,
693
          // Don't let `useOverlayPosition` handle the horizontal positioning,
694
          // as it will align the tooltip with the `.stat` element, whereas we
695
          // want it to span almost the full width on mobile:
696
          left: undefined,
697
          right: undefined,
698
        }}
699
        ref={overlayRef}
700
        className={styles["learn-more-tooltip"]}
701
      >
702
        {props.children}
703
      </div>
704
    </FocusScope>
705
  );
706
});
707

708
export default Profile;
54✔
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