mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): add dev toolbar launcher to account settings menu (#49285)
## What kind of change does this PR introduce? Feature ## What is the current behavior? The dev toolbar is only discoverable via `window.devToolbar()` in the browser console, or by having your email on the `devToolbarDefaultOn` ConfigCat flag. Once enabled, Studio shows a floating trigger button. ## What is the new behavior? In local and staging Studio, the account/settings dropdown (avatar menu) includes a **Local tools** section above **Theme** with a **Dev toolbar** checkbox toggle. - **On**: shows the floating orb (persists via localStorage, same as `window.devToolbar()`) - **Off**: hides the orb and dismisses the toolbar Open the panel itself via the orb once it is visible. Production builds are unchanged (`isAvailable` is false and the menu item is hidden). | After | | --- | | <img width="226" height="204" alt="CleanShot 2026-08-20 at 12 46 38@2x" src="https://github.com/user-attachments/assets/c846b119-626d-48f5-9a02-aef4d006326c" /> | | <img width="558" height="1024" alt="CleanShot 2026-08-20 at 12 47 04@2x" src="https://github.com/user-attachments/assets/4b3dd22b-537b-4874-821d-c202033c4ad7" /> | ## Manual testing Run `pnpm dev:studio` and open http://localhost:8082. 1. **Find the entry point:** top-right avatar/settings menu → **Local tools** → **Dev toolbar** (above **Theme**). Should not appear in production builds. 2. **Turn it on:** check **Dev toolbar**. A green floating orb should appear (default bottom-right). 3. **Open the panel:** click the orb. The **Dev Toolbar** sheet should open with Events and Flags tabs. 4. **Event count:** navigate around Studio (e.g. open a project, switch pages). The orb badge should increment and stay readable in light and dark mode. 5. **Turn it off:** reopen the avatar menu and uncheck **Dev toolbar**. The orb and panel should disappear. 6. **Close vs hide:** with the toolbar on, open the sheet and use **Close** (X). The orb should remain; only the sheet closes. Optional: confirm `window.devToolbar()` in the browser console still enables the orb. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a Local tools option to enable the development toolbar when available. * Toolbar activation and dismissal preferences now persist between sessions. * Added clearer event-count badges with responsive sizing for larger counts. * **Improvements** * Simplified toolbar controls by removing the separate hide option. * Improved toolbar availability handling across local and production environments. * **Tests** * Expanded coverage for activation, persistence, visibility, and event-count badges. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Danny White <dnywh@users.noreply.github.com> Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com>
This commit is contained in:
11 files changed
+357
-45
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import { useDevToolbar } from 'dev-tools'
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuGroup, DropdownMenuLabel } from 'ui'
|
||||
|
||||
export function DevToolbarMenuGroup() {
|
||||
const { isAvailable, isEnabled, enableToolbar, dismissToolbar } = useDevToolbar()
|
||||
|
||||
if (!isAvailable) return null
|
||||
|
||||
const handleToggleDevToolbar = (isChecked: boolean) => {
|
||||
if (isChecked) {
|
||||
enableToolbar()
|
||||
return
|
||||
}
|
||||
|
||||
dismissToolbar()
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Local tools</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={isEnabled}
|
||||
onCheckedChange={handleToggleDevToolbar}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Dev toolbar
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,41 @@
|
||||
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 { beforeEach, 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(),
|
||||
})
|
||||
)
|
||||
const {
|
||||
mockRouter,
|
||||
mockSetTheme,
|
||||
mockSetLastRoute,
|
||||
mockToggleFeaturePreviewModal,
|
||||
mockEnableToolbar,
|
||||
mockDismissDevToolbar,
|
||||
mockSetDevToolbarOpen,
|
||||
mockUseDevToolbar,
|
||||
} = vi.hoisted(() => ({
|
||||
mockRouter: {
|
||||
pathname: '/project/[ref]/editor',
|
||||
asPath: '/project/default/editor',
|
||||
},
|
||||
mockSetTheme: vi.fn(),
|
||||
mockSetLastRoute: vi.fn(),
|
||||
mockToggleFeaturePreviewModal: vi.fn(),
|
||||
mockEnableToolbar: vi.fn(),
|
||||
mockDismissDevToolbar: vi.fn(),
|
||||
mockSetDevToolbarOpen: vi.fn(),
|
||||
mockUseDevToolbar: vi.fn(() => ({
|
||||
isAvailable: false,
|
||||
isEnabled: false,
|
||||
isOpen: false,
|
||||
setIsOpen: mockSetDevToolbarOpen,
|
||||
enableToolbar: mockEnableToolbar,
|
||||
dismissToolbar: mockDismissDevToolbar,
|
||||
events: [],
|
||||
setEvents: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: () => mockRouter,
|
||||
@@ -62,6 +82,10 @@ vi.mock('./App/FeaturePreview/FeaturePreviewContext', () => ({
|
||||
|
||||
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => vi.fn() }))
|
||||
|
||||
vi.mock('dev-tools', () => ({
|
||||
useDevToolbar: () => mockUseDevToolbar(),
|
||||
}))
|
||||
|
||||
vi.mock('ui', async () => {
|
||||
const React = await import('react')
|
||||
|
||||
@@ -104,6 +128,19 @@ vi.mock('ui', async () => {
|
||||
</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuCheckboxItem: ({
|
||||
children,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
children: ReactNode
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}) => (
|
||||
<button tabIndex={0} aria-checked={checked} onClick={() => onCheckedChange?.(!checked)}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuRadioGroup: ({
|
||||
children,
|
||||
@@ -144,6 +181,20 @@ vi.mock('ui', async () => {
|
||||
})
|
||||
|
||||
describe('LocalDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseDevToolbar.mockReturnValue({
|
||||
isAvailable: false,
|
||||
isEnabled: false,
|
||||
isOpen: false,
|
||||
setIsOpen: mockSetDevToolbarOpen,
|
||||
enableToolbar: mockEnableToolbar,
|
||||
dismissToolbar: mockDismissDevToolbar,
|
||||
events: [],
|
||||
setEvents: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Preferences, removes Command menu, and keeps theme controls wired', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -152,6 +203,7 @@ describe('LocalDropdown', () => {
|
||||
expect(screen.getByText('Preferences')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Command menu')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Theme')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Dev toolbar')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Preferences'))
|
||||
expect(mockSetLastRoute).toHaveBeenCalledWith('/project/default/editor')
|
||||
@@ -162,4 +214,50 @@ describe('LocalDropdown', () => {
|
||||
await user.click(screen.getByText('Light'))
|
||||
expect(mockSetTheme).toHaveBeenCalledWith('light')
|
||||
})
|
||||
|
||||
it('toggles Dev toolbar visibility from the menu', async () => {
|
||||
mockUseDevToolbar.mockReturnValue({
|
||||
isAvailable: true,
|
||||
isEnabled: false,
|
||||
isOpen: false,
|
||||
setIsOpen: mockSetDevToolbarOpen,
|
||||
enableToolbar: mockEnableToolbar,
|
||||
dismissToolbar: mockDismissDevToolbar,
|
||||
events: [],
|
||||
setEvents: vi.fn(),
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<LocalDropdown />)
|
||||
|
||||
expect(screen.getByText('Local tools')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dev toolbar' }))
|
||||
|
||||
expect(mockEnableToolbar).toHaveBeenCalled()
|
||||
expect(mockDismissDevToolbar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides Dev toolbar from the menu when toggled off', async () => {
|
||||
mockUseDevToolbar.mockReturnValue({
|
||||
isAvailable: true,
|
||||
isEnabled: true,
|
||||
isOpen: false,
|
||||
setIsOpen: mockSetDevToolbarOpen,
|
||||
enableToolbar: mockEnableToolbar,
|
||||
dismissToolbar: mockDismissDevToolbar,
|
||||
events: [],
|
||||
setEvents: vi.fn(),
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<LocalDropdown />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dev toolbar' }))
|
||||
|
||||
expect(mockDismissDevToolbar).toHaveBeenCalled()
|
||||
expect(mockEnableToolbar).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
import { ButtonTooltip } from '../ui/ButtonTooltip'
|
||||
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
|
||||
import { DevToolbarMenuGroup } from './DevToolbarMenuGroup'
|
||||
import { ProfileImage } from '@/components/ui/ProfileImage'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { useAppStateSnapshot } from '@/state/app-state'
|
||||
@@ -74,6 +75,7 @@ export const LocalDropdown = ({
|
||||
Feature previews
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DevToolbarMenuGroup />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDevToolbar } from 'dev-tools'
|
||||
import { FlaskConical, Loader2, ScrollText, User2 } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
|
||||
import { ButtonTooltip } from '../ui/ButtonTooltip'
|
||||
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
|
||||
import { DevToolbarMenuGroup } from './DevToolbarMenuGroup'
|
||||
import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown'
|
||||
import { ProfileImage } from '@/components/ui/ProfileImage'
|
||||
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
|
||||
@@ -44,6 +46,8 @@ export function UserDropdown({
|
||||
|
||||
const { toggleFeaturePreviewModal } = useFeaturePreviewModal()
|
||||
const track = useTrack()
|
||||
const { isAvailable: isDevToolbarAvailable } = useDevToolbar()
|
||||
const shouldShowSectionSeparator = IS_PLATFORM || isDevToolbarAvailable
|
||||
|
||||
// The upgrade CTA is org-scoped, so only enable it on routes where an org is in scope.
|
||||
// Excludes /account/*, /organizations, /new, marketing routes, etc. Gating the hook here
|
||||
@@ -136,11 +140,14 @@ export function UserDropdown({
|
||||
Changelog
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldShowSectionSeparator && <DropdownMenuSeparator />}
|
||||
|
||||
<DevToolbarMenuGroup />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
|
||||
@@ -256,6 +256,132 @@ describe('DevToolbar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('enableToolbar', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
})
|
||||
|
||||
it('enables toolbar and exposes isAvailable in local development', async () => {
|
||||
vi.resetModules()
|
||||
const { DevToolbarProvider, useDevToolbar } = await import('./DevToolbarContext')
|
||||
const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
|
||||
const { TooltipProvider } = await import('ui')
|
||||
|
||||
function ToolbarLauncher() {
|
||||
const { isAvailable, enableToolbar } = useDevToolbar()
|
||||
return (
|
||||
<button type="button" onClick={enableToolbar}>
|
||||
{isAvailable ? 'Launch toolbar' : 'Unavailable'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DevToolbarProvider apiUrl="http://localhost:3000">
|
||||
<ToolbarLauncher />
|
||||
<DevToolbarTrigger />
|
||||
</DevToolbarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Launch toolbar' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Launch toolbar' }))
|
||||
|
||||
expect(localStorage.getItem('dev-telemetry-toolbar-enabled')).toBe('true')
|
||||
expect(screen.getByRole('button', { name: 'Open dev toolbar' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns isAvailable false in production', async () => {
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'prod'
|
||||
|
||||
vi.resetModules()
|
||||
const { DevToolbarProvider, useDevToolbar } = await import('./DevToolbarContext')
|
||||
|
||||
function ToolbarAvailability() {
|
||||
const { isAvailable } = useDevToolbar()
|
||||
return <span>{isAvailable ? 'available' : 'unavailable'}</span>
|
||||
}
|
||||
|
||||
render(
|
||||
<DevToolbarProvider apiUrl="http://localhost:3000">
|
||||
<ToolbarAvailability />
|
||||
</DevToolbarProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByText('unavailable')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissToolbar', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
})
|
||||
|
||||
async function renderDismissHarness() {
|
||||
const { DevToolbarProvider, useDevToolbar } = await import('./DevToolbarContext')
|
||||
const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
|
||||
const { TooltipProvider } = await import('ui')
|
||||
|
||||
function ToolbarDismisser() {
|
||||
const { dismissToolbar } = useDevToolbar()
|
||||
return (
|
||||
<button type="button" onClick={dismissToolbar}>
|
||||
Dismiss toolbar
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<DevToolbarProvider apiUrl="http://localhost:3000">
|
||||
<ToolbarDismisser />
|
||||
<DevToolbarTrigger />
|
||||
</DevToolbarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('persists the opt-out so it survives a remount', async () => {
|
||||
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
|
||||
|
||||
vi.resetModules()
|
||||
const { unmount } = await renderDismissHarness()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open dev toolbar' })).toBeInTheDocument()
|
||||
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Dismiss toolbar' }))
|
||||
|
||||
expect(localStorage.getItem('dev-telemetry-toolbar-enabled')).toBe('false')
|
||||
expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
await renderDismissHarness()
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('takes precedence over the devToolbarDefaultOn flag', async () => {
|
||||
flags.devToolbarDefaultOn = true
|
||||
|
||||
vi.resetModules()
|
||||
const { unmount } = await renderDismissHarness()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open dev toolbar' })).toBeInTheDocument()
|
||||
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Dismiss toolbar' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
await renderDismissHarness()
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Open dev toolbar' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('window.devToolbar function', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
@@ -461,4 +587,38 @@ describe('DevToolbar utils', () => {
|
||||
expect(valuesAreEqual('value', null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEventCountBadge', () => {
|
||||
it('returns null for zero or negative counts', async () => {
|
||||
vi.resetModules()
|
||||
const { getEventCountBadge } = await import('./utils')
|
||||
|
||||
expect(getEventCountBadge(0)).toBeNull()
|
||||
expect(getEventCountBadge(-1)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a compact circle for single-digit counts', async () => {
|
||||
vi.resetModules()
|
||||
const { getEventCountBadge } = await import('./utils')
|
||||
|
||||
expect(getEventCountBadge(7)).toEqual({ label: '7', sizeClass: 'size-3.5' })
|
||||
})
|
||||
|
||||
it('returns a larger circle for double-digit counts', async () => {
|
||||
vi.resetModules()
|
||||
const { getEventCountBadge } = await import('./utils')
|
||||
|
||||
expect(getEventCountBadge(42)).toEqual({ label: '42', sizeClass: 'size-4' })
|
||||
})
|
||||
|
||||
it('returns a capped pill for large counts', async () => {
|
||||
vi.resetModules()
|
||||
const { getEventCountBadge } = await import('./utils')
|
||||
|
||||
expect(getEventCountBadge(150)).toEqual({
|
||||
label: '99+',
|
||||
sizeClass: 'h-3.5 min-w-3.5 px-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useFeatureFlags } from 'common'
|
||||
import { Copy, EyeOff, Search, X } from 'lucide-react'
|
||||
import { Copy, Search, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
useCallback,
|
||||
@@ -208,7 +208,7 @@ function FlagRow({
|
||||
}
|
||||
|
||||
export function DevToolbar({ extraTabs = [] }: { extraTabs?: ExtraTab[] }) {
|
||||
const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar()
|
||||
const { isEnabled, isOpen, setIsOpen, events, setEvents } = useDevToolbar()
|
||||
const [activeTab, setActiveTab] = useState<string>('events')
|
||||
const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog')
|
||||
const [eventFilter, setEventFilter] = useState<string>('')
|
||||
@@ -421,17 +421,6 @@ export function DevToolbar({ extraTabs = [] }: { extraTabs?: ExtraTab[] }) {
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
icon={<EyeOff className="w-4 h-4" />}
|
||||
onClick={dismissToolbar}
|
||||
className="text-foreground-light hover:text-foreground p-1"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Hide Dev Toolbar</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SheetClose asChild>
|
||||
|
||||
@@ -53,9 +53,18 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
|
||||
const sseRetryDelayRef = useRef(SSE_INITIAL_RETRY_MS)
|
||||
const sseRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const enableToolbar = useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, 'true')
|
||||
} catch {}
|
||||
setIsEnabled(true)
|
||||
}, [])
|
||||
|
||||
// Persist 'false' rather than clearing the key, so an explicit opt-out survives
|
||||
// a reload and takes precedence over the devToolbarDefaultOn flag.
|
||||
const dismissToolbar = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.setItem(STORAGE_KEY, 'false')
|
||||
} catch {}
|
||||
setIsEnabled(false)
|
||||
setIsOpen(false)
|
||||
@@ -68,21 +77,16 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY)
|
||||
} catch {}
|
||||
if (stored === 'true' || isDefaultOn) {
|
||||
if (stored === 'true' || (stored !== 'false' && isDefaultOn)) {
|
||||
setIsEnabled(true)
|
||||
}
|
||||
|
||||
window.devToolbar = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, 'true')
|
||||
} catch {}
|
||||
setIsEnabled(true)
|
||||
}
|
||||
window.devToolbar = enableToolbar
|
||||
|
||||
return () => {
|
||||
delete window.devToolbar
|
||||
}
|
||||
}, [isDefaultOn])
|
||||
}, [enableToolbar, isDefaultOn])
|
||||
|
||||
const appendEvent = useCallback((event: DevTelemetryEvent) => {
|
||||
setEvents((prev) => {
|
||||
@@ -189,9 +193,11 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
|
||||
return (
|
||||
<DevToolbarContext.Provider
|
||||
value={{
|
||||
isAvailable: IS_TOOLBAR_ENABLED,
|
||||
isEnabled,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
enableToolbar,
|
||||
events,
|
||||
setEvents,
|
||||
dismissToolbar,
|
||||
@@ -206,9 +212,11 @@ export function useDevToolbar() {
|
||||
const context = useContext(DevToolbarContext)
|
||||
if (!context) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
isEnabled: false,
|
||||
isOpen: false,
|
||||
setIsOpen: () => {},
|
||||
enableToolbar: () => {},
|
||||
events: [],
|
||||
setEvents: () => {},
|
||||
dismissToolbar: () => {},
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { CSSProperties, PointerEvent } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
import { useDevToolbar } from './DevToolbarContext'
|
||||
import { getEventCountBadge } from './utils'
|
||||
|
||||
// Duplicated for tree-shaking — bundler must see literal process.env reference.
|
||||
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx
|
||||
@@ -163,7 +164,7 @@ export function DevToolbarTrigger() {
|
||||
|
||||
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
|
||||
|
||||
const eventCount = events.length
|
||||
const eventCountBadge = getEventCountBadge(events.length)
|
||||
const isDragging = dragPos !== null
|
||||
const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h)
|
||||
const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease`
|
||||
@@ -210,11 +211,10 @@ export function DevToolbarTrigger() {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<Button
|
||||
variant="text"
|
||||
variant="default"
|
||||
rounded
|
||||
className={cn(
|
||||
'relative rounded-full h-10 w-10 p-0',
|
||||
'bg-surface-100 border border-overlay shadow-md',
|
||||
'text-foreground-light hover:text-foreground hover:bg-surface-200',
|
||||
'relative h-10 w-10 p-0 shadow-md',
|
||||
'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
|
||||
'select-none touch-none',
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
@@ -239,17 +239,17 @@ export function DevToolbarTrigger() {
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
{eventCount > 0 && (
|
||||
{eventCountBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-1 -right-1',
|
||||
'h-4 min-w-4 px-0.5',
|
||||
'inline-flex items-center justify-center',
|
||||
'rounded-full bg-destructive text-foreground',
|
||||
'text-[10px] font-medium leading-none'
|
||||
'rounded-full bg-brand text-black',
|
||||
'text-[9px] font-medium leading-none tracking-tighter tabular-nums',
|
||||
eventCountBadge.sizeClass
|
||||
)}
|
||||
>
|
||||
{eventCount > 99 ? '99+' : eventCount}
|
||||
{eventCountBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -15,9 +15,11 @@ const env = process.env.NEXT_PUBLIC_ENVIRONMENT
|
||||
const isToolbarEnabled = env === 'local' || env === 'staging'
|
||||
|
||||
const noopContext: DevTelemetryToolbarContextType = {
|
||||
isAvailable: false,
|
||||
isEnabled: false,
|
||||
isOpen: false,
|
||||
setIsOpen: () => {},
|
||||
enableToolbar: () => {},
|
||||
events: [],
|
||||
setEvents: () => {},
|
||||
dismissToolbar: () => {},
|
||||
|
||||
@@ -32,9 +32,11 @@ export interface ExtraTab {
|
||||
}
|
||||
|
||||
export interface DevTelemetryToolbarContextType {
|
||||
isAvailable: boolean
|
||||
isEnabled: boolean
|
||||
isOpen: boolean
|
||||
setIsOpen: (open: boolean) => void
|
||||
enableToolbar: () => void
|
||||
events: DevTelemetryEvent[]
|
||||
setEvents: Dispatch<SetStateAction<DevTelemetryEvent[]>>
|
||||
dismissToolbar: () => void
|
||||
|
||||
@@ -88,3 +88,17 @@ export function parseOverrideValue(value: unknown, original: unknown): unknown {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function getEventCountBadge(count: number): { label: string; sizeClass: string } | null {
|
||||
if (count <= 0) return null
|
||||
|
||||
if (count > 99) {
|
||||
return { label: '99+', sizeClass: 'h-4 min-w-4 px-1' }
|
||||
}
|
||||
|
||||
if (count < 10) {
|
||||
return { label: String(count), sizeClass: 'size-3.5' }
|
||||
}
|
||||
|
||||
return { label: String(count), sizeClass: 'size-4' }
|
||||
}
|
||||
Reference in new issue
Block a user