diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx new file mode 100644 index 0000000000..91bef2c8bb --- /dev/null +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx @@ -0,0 +1,250 @@ +import { render, waitFor } from "@testing-library/react"; +import React from "react"; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; + +vi.mock("next-auth/react", () => ({ + signOut: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/settings", +})); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: () => ({ + t: (key: string) => key, + }), +})); + +const mockUseMeQuery = vi.fn(); +vi.mock("@calcom/trpc/react/hooks/useMeQuery", () => ({ + default: () => mockUseMeQuery(), +})); + +vi.mock("@calcom/web/components/settings/platform/hooks/useGetUserAttributes", () => ({ + useGetUserAttributes: () => ({ + isPlatformUser: false, + }), +})); + +vi.mock("@calcom/web/modules/ee/support/lib/freshchat/FreshChatProvider", () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@calcom/ui/components/avatar", () => ({ + Avatar: () =>
Avatar
, +})); + +vi.mock("@calcom/ui/components/icon", () => ({ + Icon: ({ name }: { name: string }) => {name}, +})); + +vi.mock("@calcom/ui/components/dropdown", () => ({ + Dropdown: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@calcom/ui/classNames", () => ({ + default: (...args: string[]) => args.filter(Boolean).join(" "), +})); + +describe("UserDropdown", () => { + let mockBeacon: ReturnType; + let mockSupportOpen: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockBeacon = vi.fn(); + mockSupportOpen = vi.fn(); + + Object.defineProperty(window, "Beacon", { + value: mockBeacon, + writable: true, + configurable: true, + }); + + Object.defineProperty(window, "Support", { + value: { open: mockSupportOpen, shouldShowTriggerButton: vi.fn() }, + writable: true, + configurable: true, + }); + + Object.defineProperty(window, "screen", { + value: { width: 1920, height: 1080 }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + delete window.Beacon; + delete window.Support; + }); + + describe("Beacon session-data functionality", () => { + it("should call Beacon with session-data when Beacon is available and user has username", async () => { + mockUseMeQuery.mockReturnValue({ + data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null }, + isPending: false, + }); + + const { UserDropdown } = await import("./UserDropdown"); + render(); + + await waitFor(() => { + expect(mockBeacon).toHaveBeenCalledWith("session-data", { + username: "testuser", + screenResolution: "1920x1080", + }); + }); + }); + + it("should call Beacon with 'Unknown' username when user has no username", async () => { + mockUseMeQuery.mockReturnValue({ + data: { username: null, name: "Test User", avatarUrl: null, avatar: null }, + isPending: false, + }); + + const { UserDropdown } = await import("./UserDropdown"); + render(); + + await waitFor(() => { + expect(mockBeacon).toHaveBeenCalledWith("session-data", { + username: "Unknown", + screenResolution: "1920x1080", + }); + }); + }); + + it("should not throw error when Beacon is undefined", async () => { + delete window.Beacon; + + mockUseMeQuery.mockReturnValue({ + data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null }, + isPending: false, + }); + + const { UserDropdown } = await import("./UserDropdown"); + + // Should not throw + expect(() => render()).not.toThrow(); + }); + + it("should call Beacon when it loads lazily after mount", async () => { + // Start with Beacon undefined (simulating lazy load) + delete window.Beacon; + + mockUseMeQuery.mockReturnValue({ + data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null }, + isPending: false, + }); + + const { UserDropdown } = await import("./UserDropdown"); + render(); + + // Beacon should not have been called yet + expect(mockBeacon).not.toHaveBeenCalled(); + + // Simulate Beacon loading after mount + Object.defineProperty(window, "Beacon", { + value: mockBeacon, + writable: true, + configurable: true, + }); + + // Wait for the polling interval to detect Beacon + await waitFor( + () => { + expect(mockBeacon).toHaveBeenCalledWith("session-data", { + username: "testuser", + screenResolution: "1920x1080", + }); + }, + { timeout: 2000 } + ); + }); + + it("should update Beacon session-data when username changes", async () => { + const { rerender } = render(
); + + // First render with initial username + mockUseMeQuery.mockReturnValue({ + data: { username: "user1", name: "User One", avatarUrl: null, avatar: null }, + isPending: false, + }); + + const { UserDropdown } = await import("./UserDropdown"); + const { rerender: rerenderComponent } = render(); + + await waitFor(() => { + expect(mockBeacon).toHaveBeenCalledWith("session-data", { + username: "user1", + screenResolution: "1920x1080", + }); + }); + + // Clear mock to track new calls + mockBeacon.mockClear(); + + // Update username + mockUseMeQuery.mockReturnValue({ + data: { username: "user2", name: "User Two", avatarUrl: null, avatar: null }, + isPending: false, + }); + + rerenderComponent(); + + await waitFor(() => { + expect(mockBeacon).toHaveBeenCalledWith("session-data", { + username: "user2", + screenResolution: "1920x1080", + }); + }); + }); + }); + + describe("Component rendering", () => { + it("should return null when user is not available and not pending", async () => { + mockUseMeQuery.mockReturnValue({ + data: null, + isPending: false, + }); + + const { UserDropdown } = await import("./UserDropdown"); + const { container } = render(); + + // Component should return null + expect(container.firstChild).toBeNull(); + }); + + it("should render dropdown when user is available", async () => { + mockUseMeQuery.mockReturnValue({ + data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null }, + isPending: false, + }); + + const { UserDropdown } = await import("./UserDropdown"); + const { getByTestId } = render(); + + expect(getByTestId("dropdown")).toBeInTheDocument(); + }); + + it("should render dropdown when isPending is true (loading state)", async () => { + mockUseMeQuery.mockReturnValue({ + data: null, + isPending: true, + }); + + const { UserDropdown } = await import("./UserDropdown"); + const { getByTestId } = render(); + + expect(getByTestId("dropdown")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx index de6fd2d062..65f509db6a 100644 --- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx +++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx @@ -28,9 +28,17 @@ declare global { 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): void; + // Catch-all for other methods - add explicit types above if using new commands + (...args: unknown[]): void; +}; + interface UserDropdownProps { small?: boolean; } @@ -43,16 +51,31 @@ export function UserDropdown({ small }: UserDropdownProps) { const isPlatformPages = pathname?.startsWith("/settings/platform"); useEffect(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - const Beacon = window.Beacon; - // window.Beacon is defined when user actually opens up HelpScout and username is available here. On every re-render update session info, so that it is always latest. - Beacon && - Beacon("session-data", { - username: user?.username || "Unknown", - screenResolution: `${screen.width}x${screen.height}`, - }); - }); + 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); @@ -66,6 +89,7 @@ export function UserDropdown({ small }: UserDropdownProps) { }; useEffect(() => { + if (typeof window === "undefined") return; if (!menuOpen && openSupportAfterClose) { setTimeout(() => { window.Support?.open();