* fix(user-dropdown): make SSR-safe and fix ESLint warning for Beacon * fix: added ssr guard in second useEffect and added type for beacon instead of ts-ignoring * fix: add dependency array to Beacon useEffect Address review feedback by adding [user?.username] dependency array to the Beacon session-data useEffect, improving performance by preventing unnecessary re-runs on every render. Co-authored-by: albin-baby-2002 <albinbaby2002@gmail.com> Co-Authored-By: unknown <> * test: add automated tests for UserDropdown Beacon functionality Add comprehensive tests for: - Beacon session-data calls with correct payload - Handling of undefined Beacon (no errors) - Username change triggering useEffect re-run - SSR safety (window undefined check) - Component rendering states (loading, user available, no user) Co-authored-by: albin-baby-2002 <albinbaby2002@gmail.com> Co-Authored-By: unknown <> * refactor: remove useless comments and misleading SSR test - Remove redundant comments above vi.mock calls (per keithwillcode feedback) - Remove SSR safety test that provided false coverage since jsdom always has window defined (per Cubic feedback) - The 'should not throw error when Beacon is undefined' test already covers graceful handling Co-Authored-By: unknown <> * fix: remove unused @ts-expect-error directives The Window interface is augmented with optional Beacon and Support properties, so TypeScript allows deleting them without errors. Co-Authored-By: unknown <> * fix: handle lazy-loaded Beacon with polling When Beacon loads lazily after component mount, the useEffect now polls every second until Beacon becomes available, then sends session data and clears the interval. This ensures session info is always sent even when HelpScout loads asynchronously. Added test to verify lazy loading behavior works correctly. Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: albin-baby-2002 <albinbaby2002@gmail.com>
247 lines
8.6 KiB
TypeScript
247 lines
8.6 KiB
TypeScript
import { signOut } from "next-auth/react";
|
|
import { usePathname } from "next/navigation";
|
|
import type { MouseEvent } from "react";
|
|
import { useEffect, useState } from "react";
|
|
|
|
import { ROADMAP, DESKTOP_APP_LINK } from "@calcom/lib/constants";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
|
import classNames from "@calcom/ui/classNames";
|
|
import { Avatar } from "@calcom/ui/components/avatar";
|
|
import {
|
|
Dropdown,
|
|
DropdownItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuPortal,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@calcom/ui/components/dropdown";
|
|
import { Icon } from "@calcom/ui/components/icon";
|
|
// TODO (Platform): we shouldnt be importing from web here
|
|
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
|
|
import FreshChatProvider from "@calcom/web/modules/ee/support/lib/freshchat/FreshChatProvider";
|
|
|
|
declare global {
|
|
interface Window {
|
|
Support?: {
|
|
open: () => void;
|
|
shouldShowTriggerButton: (showTrigger: boolean) => void;
|
|
};
|
|
Beacon?: BeaconFunction;
|
|
}
|
|
}
|
|
|
|
// NOTE: This interface only includes types for commands we currently use.
|
|
type BeaconFunction = {
|
|
(command: "session-data", data: Record<string, string | number>): void;
|
|
// Catch-all for other methods - add explicit types above if using new commands
|
|
(...args: unknown[]): void;
|
|
};
|
|
|
|
interface UserDropdownProps {
|
|
small?: boolean;
|
|
}
|
|
|
|
export function UserDropdown({ small }: UserDropdownProps) {
|
|
const { isPlatformUser } = useGetUserAttributes();
|
|
const { t } = useLocale();
|
|
const { data: user, isPending } = useMeQuery();
|
|
const pathname = usePathname();
|
|
const isPlatformPages = pathname?.startsWith("/settings/platform");
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const sendSessionData = () => {
|
|
const Beacon = window.Beacon;
|
|
if (Beacon) {
|
|
Beacon("session-data", {
|
|
username: user?.username || "Unknown",
|
|
screenResolution: `${screen.width}x${screen.height}`,
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Try immediately, then poll if Beacon isn't loaded yet
|
|
if (!sendSessionData()) {
|
|
const intervalId = setInterval(() => {
|
|
if (sendSessionData()) {
|
|
clearInterval(intervalId);
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(intervalId);
|
|
}
|
|
}, [user?.username]);
|
|
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [openSupportAfterClose, setOpenSupportAfterClose] = useState(false);
|
|
|
|
const handleHelpClick = (e?: MouseEvent) => {
|
|
e?.preventDefault();
|
|
e?.stopPropagation();
|
|
|
|
setOpenSupportAfterClose(true);
|
|
setMenuOpen(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
if (!menuOpen && openSupportAfterClose) {
|
|
setTimeout(() => {
|
|
window.Support?.open();
|
|
}, 0);
|
|
setOpenSupportAfterClose(false);
|
|
}
|
|
}, [menuOpen, openSupportAfterClose]);
|
|
|
|
// Prevent rendering dropdown if user isn't available.
|
|
// We don't want to show nameless user.
|
|
if (!user && !isPending) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Dropdown open={menuOpen}>
|
|
<DropdownMenuTrigger asChild onClick={() => setMenuOpen((menuOpen) => !menuOpen)} disabled={isPending}>
|
|
<button
|
|
data-testid="user-dropdown-trigger-button"
|
|
className={classNames(
|
|
"hover:bg-emphasis todesktop:!bg-transparent group mx-0 flex w-full cursor-pointer appearance-none items-center rounded-full text-left outline-none transition focus:outline-none focus:ring-0 md:rounded-none lg:rounded",
|
|
small ? "p-2" : "px-2 py-1.5"
|
|
)}>
|
|
<span
|
|
className={classNames(
|
|
small ? "h-4 w-4" : "h-5 w-5 ltr:mr-2 rtl:ml-2",
|
|
"relative shrink-0 rounded-full"
|
|
)}>
|
|
<Avatar
|
|
size={small ? "xs" : "xsm"}
|
|
imageSrc={user?.avatarUrl ?? user?.avatar}
|
|
alt={user?.username ? `${user.username} Avatar` : "Nameless User Avatar"}
|
|
className="overflow-hidden"
|
|
/>
|
|
<span
|
|
className={classNames(
|
|
"border-muted absolute -bottom-1 -right-1 rounded-full border bg-green-500",
|
|
small ? "-bottom-0.5 -right-0.5 h-2.5 w-2.5" : "-bottom-0.5 right-0 h-2 w-2"
|
|
)}
|
|
/>
|
|
</span>
|
|
{!small && (
|
|
<span className="flex grow items-center gap-2">
|
|
<span className="w-24 shrink-0 text-sm leading-none">
|
|
<span className="text-emphasis block truncate py-0.5 font-medium leading-normal">
|
|
{isPending ? "Loading..." : user?.name ?? "Nameless User"}
|
|
</span>
|
|
</span>
|
|
<Icon
|
|
name="chevron-down"
|
|
className="group-hover:text-subtle text-muted h-4 w-4 shrink-0 transition rtl:mr-4"
|
|
aria-hidden="true"
|
|
/>
|
|
</span>
|
|
)}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
|
|
<DropdownMenuPortal>
|
|
<FreshChatProvider>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
onInteractOutside={() => {
|
|
setMenuOpen(false);
|
|
}}
|
|
className="group overflow-hidden rounded-md">
|
|
<>
|
|
{!isPlatformPages && (
|
|
<>
|
|
<DropdownMenuItem>
|
|
<DropdownItem
|
|
type="button"
|
|
CustomStartIcon={
|
|
<Icon name="user" className="text-default h-4 w-4" aria-hidden="true" />
|
|
}
|
|
href="/settings/my-account/profile">
|
|
{t("my_profile")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<DropdownItem
|
|
type="button"
|
|
CustomStartIcon={
|
|
<Icon name="settings" className="text-default h-4 w-4" aria-hidden="true" />
|
|
}
|
|
href="/settings/my-account/general">
|
|
{t("my_settings")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<DropdownItem
|
|
type="button"
|
|
CustomStartIcon={
|
|
<Icon name="moon" className="text-default h-4 w-4" aria-hidden="true" />
|
|
}
|
|
href="/settings/my-account/out-of-office">
|
|
{t("out_of_office")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
|
|
<DropdownMenuItem>
|
|
<DropdownItem StartIcon="map" target="_blank" href={ROADMAP}>
|
|
{t("visit_roadmap")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<DropdownItem
|
|
type="button"
|
|
StartIcon="circle-help"
|
|
aria-hidden="true"
|
|
onClick={handleHelpClick}>
|
|
{t("help")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
{!isPlatformPages && (
|
|
<DropdownMenuItem className="todesktop:hidden hidden lg:flex">
|
|
<DropdownItem StartIcon="download" target="_blank" rel="noreferrer" href={DESKTOP_APP_LINK}>
|
|
{t("download_desktop_app")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
)}
|
|
|
|
{!isPlatformPages && isPlatformUser && (
|
|
<DropdownMenuItem className="todesktop:hidden hidden lg:flex">
|
|
<DropdownItem StartIcon="blocks" target="_blank" rel="noreferrer" href="/settings/platform">
|
|
Platform
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownMenuItem>
|
|
<DropdownItem
|
|
type="button"
|
|
color="destructive"
|
|
StartIcon="log-out"
|
|
className="rounded-t-none"
|
|
aria-hidden="true"
|
|
onClick={() => {
|
|
signOut({ callbackUrl: "/auth/logout" });
|
|
}}>
|
|
{t("sign_out")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
</>
|
|
</DropdownMenuContent>
|
|
</FreshChatProvider>
|
|
</DropdownMenuPortal>
|
|
</Dropdown>
|
|
);
|
|
}
|