Files
supabase/apps/studio/components/interfaces/LocalDropdown.test.tsx
Mert YEREKAPAN da81b2f14d feat(studio): add click tracking for top bar buttons (#45414)
## Summary

Adds PostHog click/open tracking for every interactive element in the
Studio top bar. Previously only 5 of ~16 surfaces were tracked.

### New events (16)

| Event | Surface |
|---|---|
| `home_logo_clicked` | Supabase logo |
| `header_back_to_dashboard_clicked` | Mobile back chevron |
| `header_exceeding_usage_badge_clicked` | "Exceeding usage limits"
badge |
| `organization_dropdown_opened` | Org dropdown trigger |
| `project_dropdown_opened` | Project dropdown trigger |
| `branch_dropdown_opened` | Branch dropdown trigger |
| `merge_request_button_clicked` | MR trigger (separate from existing
success event) |
| `connect_button_clicked` | Connect CTA |
| `feedback_dropdown_opened` | Feedback dropdown trigger |
| `advisor_button_clicked` | Advisor toggle |
| `inline_editor_button_clicked` | SQL editor toggle |
| `assistant_button_clicked` | AI Assistant toggle |
| `user_dropdown_opened` | Account dropdown |
| `local_dropdown_opened` | Local-dev settings dropdown |
| `local_version_popover_opened` | CLI version popover |

### Notes
- Uses `useTrack` (per `telemetry-standards`), all event names use
approved `_clicked` / `_opened` verbs.
- Dropdown `onOpenChange` handlers guard against Radix's double-fire by
only tracking when `open === true`.
- `merge_request_button_clicked` fires on the trigger click; the
existing `branch_create_merge_request_button_clicked` continues to fire
on successful MR creation.
- Pre-existing tracked surfaces (`command_menu_opened`,
`help_button_clicked`, `header_upgrade_cta_clicked`,
`send_feedback_button_clicked`) are unchanged.

## Test plan

- [x] Spot-check each event fires once per interaction in PostHog Live
Events
- [x] Verify no double-fire on dropdown close

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Added telemetry tracking for many header/navigation interactions
(logo, back-to-dashboard, usage badge,
connect/merge/advisor/assistant/inline-editor buttons, and multiple
dropdowns/popovers).
* **Tests**
* Updated tests to stub telemetry calls so UI tests remain stable and
deterministic.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-05 16:12:51 +00:00

159 lines
4.5 KiB
TypeScript

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { MouseEventHandler, ReactElement, ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { LocalDropdown } from './LocalDropdown'
const { mockRouter, mockSetTheme, mockSetLastRoute, mockToggleFeaturePreviewModal } = vi.hoisted(
() => ({
mockRouter: {
pathname: '/project/[ref]/editor',
asPath: '/project/default/editor',
},
mockSetTheme: vi.fn(),
mockSetLastRoute: vi.fn(),
mockToggleFeaturePreviewModal: vi.fn(),
})
)
vi.mock('next/router', () => ({
useRouter: () => mockRouter,
}))
vi.mock('next/link', () => ({
default: ({
href,
children,
onClick,
}: {
href: string
children: ReactNode
onClick?: MouseEventHandler<HTMLAnchorElement>
}) => (
<a href={href} onClick={onClick}>
{children}
</a>
),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({
theme: 'dark',
setTheme: mockSetTheme,
}),
}))
vi.mock('@/state/app-state', () => ({
useAppStateSnapshot: () => ({
setLastRouteBeforeVisitingAccountPage: mockSetLastRoute,
}),
}))
vi.mock('@/components/ui/ProfileImage', () => ({
ProfileImage: () => <div>Avatar</div>,
}))
vi.mock('./App/FeaturePreview/FeaturePreviewContext', () => ({
useFeaturePreviewModal: () => ({
toggleFeaturePreviewModal: mockToggleFeaturePreviewModal,
}),
}))
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => vi.fn() }))
vi.mock('ui', async () => {
const React = await import('react')
return {
Button: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { children?: ReactNode }) => (
<button {...props}>{children}</button>
),
cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({
children,
asChild,
onClick,
onSelect,
}: {
children: ReactNode
asChild?: boolean
onClick?: () => void
onSelect?: () => void
}) =>
asChild ? (
<div>{children}</div>
) : (
<button
onClick={() => {
onClick?.()
onSelect?.()
}}
>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuSeparator: () => <hr />,
DropdownMenuRadioGroup: ({
children,
onValueChange,
}: {
children: ReactNode
onValueChange: (value: string) => void
}) => (
<div>
{React.Children.map(children, (child: ReactNode) =>
React.isValidElement<{ value: string; onClick?: () => void }>(child)
? React.cloneElement(child, {
onClick: () => onValueChange(child.props.value),
})
: (child as ReactElement)
)}
</div>
),
DropdownMenuRadioItem: ({
children,
onClick,
}: {
children: ReactNode
onClick?: () => void
}) => <button onClick={onClick}>{children}</button>,
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
singleThemes: [
{ value: 'dark', name: 'Dark' },
{ value: 'light', name: 'Light' },
],
}
})
describe('LocalDropdown', () => {
it('shows Preferences, removes Command menu, and keeps theme controls wired', async () => {
const user = userEvent.setup()
render(<LocalDropdown />)
expect(screen.getByText('Preferences')).toBeInTheDocument()
expect(screen.queryByText('Command menu')).not.toBeInTheDocument()
expect(screen.getByText('Theme')).toBeInTheDocument()
await user.click(screen.getByText('Preferences'))
expect(mockSetLastRoute).toHaveBeenCalledWith('/project/default/editor')
await user.click(screen.getByText('Feature previews'))
expect(mockToggleFeaturePreviewModal).toHaveBeenCalledWith(true)
await user.click(screen.getByText('Light'))
expect(mockSetTheme).toHaveBeenCalledWith('light')
})
})