mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): prototype for telemetry entry point (#44720)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Some small styling brush ups and experimental for internal telemetry tools. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Developer toolbar redesigned with compact event/flag lists, “Copy JSON” per event, and a fixed draggable trigger that snaps and remembers its position. Toolbar is now available in staging and local environments. * **Bug Fixes** * ConfigCat readiness wait ensures flags load correctly. * Feature flag loading made resilient so one provider’s failure won’t block the other. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com>
This commit is contained in:
1 parent
848d1a3754
commit
12989ba7fe
15 files changed
+682
-330
No files matched your search
@@ -1,6 +1,5 @@
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { DevToolbarTrigger } from 'dev-tools'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -224,7 +223,6 @@ export const LayoutHeader = ({
|
||||
{customHeaderComponents && customHeaderComponents}
|
||||
{IS_PLATFORM ? (
|
||||
<>
|
||||
<DevToolbarTrigger />
|
||||
<FeedbackDropdown />
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
|
||||
@@ -36,7 +36,7 @@ import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { DevToolbar, DevToolbarProvider } from 'dev-tools'
|
||||
import { DevToolbar, DevToolbarProvider, DevToolbarTrigger } from 'dev-tools'
|
||||
import Head from 'next/head'
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
|
||||
import { ErrorInfo, useCallback, type ComponentProps } from 'react'
|
||||
@@ -191,6 +191,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
</CommandProvider>
|
||||
</AiAssistantStateContextProvider>
|
||||
<DevToolbar />
|
||||
<DevToolbarTrigger />
|
||||
</DevToolbarProvider>
|
||||
</ThemeProvider>
|
||||
</RouteValidationWrapper>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 244 B |
@@ -73,7 +73,11 @@ export async function getFlags(userEmail: string = '', customAttributes?: Record
|
||||
|
||||
if (!client) {
|
||||
return []
|
||||
} else if (userEmail) {
|
||||
}
|
||||
|
||||
await client.waitForReady()
|
||||
|
||||
if (userEmail) {
|
||||
return client.getAllValuesAsync(
|
||||
new configcat.User(userEmail, undefined, undefined, _customAttributes)
|
||||
)
|
||||
|
||||
@@ -145,8 +145,8 @@ export const FeatureFlagProvider = ({
|
||||
|
||||
let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} }
|
||||
|
||||
// Run both async operations in parallel
|
||||
const [flags, flagValues] = await Promise.all([
|
||||
// Run both async operations in parallel — allSettled so a failure in one doesn't block the other
|
||||
const [phResult, ccResult] = await Promise.allSettled([
|
||||
loadPHFlags
|
||||
? (async () => {
|
||||
await ensureGroupContext()
|
||||
@@ -163,7 +163,17 @@ export const FeatureFlagProvider = ({
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
const isLocalDev = process.env.NODE_ENV === 'development'
|
||||
const flags = phResult.status === 'fulfilled' ? phResult.value : {}
|
||||
if (phResult.status === 'rejected') {
|
||||
console.warn('[FeatureFlags] PostHog flags failed', phResult.reason)
|
||||
}
|
||||
const flagValues = ccResult.status === 'fulfilled' ? ccResult.value : []
|
||||
|
||||
// Dev toolbar flag overrides are available in local dev and staging.
|
||||
// Duplicated for tree-shaking — bundler must see literal process.env reference.
|
||||
// Keep in sync: dev-tools/index.ts, DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx
|
||||
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
|
||||
const isDevToolsEnabled = env === 'local' || env === 'staging'
|
||||
|
||||
const safeParse = (value: string | undefined): Record<string, boolean | number | string> => {
|
||||
if (!value) return {}
|
||||
@@ -176,8 +186,8 @@ export const FeatureFlagProvider = ({
|
||||
|
||||
// Process PostHog flags if loaded
|
||||
if (Object.keys(flags).length > 0) {
|
||||
// Apply local dev overrides for PostHog flags
|
||||
if (isLocalDev) {
|
||||
// Apply dev toolbar overrides for PostHog flags
|
||||
if (isDevToolsEnabled) {
|
||||
try {
|
||||
const cookies = getCookies()
|
||||
const phOverrides = safeParse(cookies['x-ph-flag-overrides'])
|
||||
@@ -196,10 +206,10 @@ export const FeatureFlagProvider = ({
|
||||
|
||||
try {
|
||||
const cookies = getCookies()
|
||||
// Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (local only)
|
||||
// x-cc-flag-overrides takes precedence in local dev
|
||||
// Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (dev toolbar only)
|
||||
// x-cc-flag-overrides takes precedence when dev tools are enabled
|
||||
const vercelOverrides = safeParse(cookies['vercel-flag-overrides'])
|
||||
const ccOverrides = isLocalDev ? safeParse(cookies['x-cc-flag-overrides']) : {}
|
||||
const ccOverrides = isDevToolsEnabled ? safeParse(cookies['x-cc-flag-overrides']) : {}
|
||||
|
||||
overridesCookieValue = {
|
||||
...vercelOverrides,
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock('common', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const originalEnv = process.env.NODE_ENV
|
||||
const originalEnv = process.env.NEXT_PUBLIC_ENVIRONMENT
|
||||
|
||||
/**
|
||||
* Helper to render the full component tree as used in production.
|
||||
@@ -35,12 +35,15 @@ async function renderFullToolbar() {
|
||||
const { DevToolbarProvider } = await import('./DevToolbarContext')
|
||||
const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
|
||||
const { DevToolbar } = await import('./DevToolbar')
|
||||
const { TooltipProvider } = await import('ui')
|
||||
|
||||
return render(
|
||||
<DevToolbarProvider apiUrl="http://localhost:3000">
|
||||
<DevToolbarTrigger />
|
||||
<DevToolbar />
|
||||
</DevToolbarProvider>
|
||||
<TooltipProvider>
|
||||
<DevToolbarProvider apiUrl="http://localhost:3000">
|
||||
<DevToolbarTrigger />
|
||||
<DevToolbar />
|
||||
</DevToolbarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,14 +79,18 @@ describe('DevToolbar', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.NEXT_PUBLIC_ENVIRONMENT
|
||||
} else {
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = originalEnv
|
||||
}
|
||||
vi.resetModules()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('when not in local development', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'prod'
|
||||
})
|
||||
|
||||
it('returns null and does not render anything', async () => {
|
||||
@@ -104,7 +111,7 @@ describe('DevToolbar', () => {
|
||||
|
||||
describe('when in local development but not enabled', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
})
|
||||
|
||||
it('does not render trigger when toolbar is not enabled', async () => {
|
||||
@@ -126,7 +133,7 @@ describe('DevToolbar', () => {
|
||||
|
||||
describe('when in local development and enabled', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
|
||||
})
|
||||
|
||||
@@ -149,7 +156,7 @@ describe('DevToolbar', () => {
|
||||
|
||||
// Sheet should open with the title
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dev Telemetry')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dev Toolbar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,8 +173,22 @@ describe('DevToolbar', () => {
|
||||
expect(screen.getByRole('tab', { name: /Flags/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows "Local Only" badge in toolbar header', async () => {
|
||||
describe('when in staging environment and enabled', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'staging'
|
||||
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
|
||||
})
|
||||
|
||||
it('renders the toolbar trigger', async () => {
|
||||
vi.resetModules()
|
||||
await renderFullToolbar()
|
||||
const triggerButton = screen.getByRole('button')
|
||||
expect(triggerButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows server events notice in events tab', async () => {
|
||||
vi.resetModules()
|
||||
const user = userEvent.setup()
|
||||
await renderFullToolbar()
|
||||
@@ -176,14 +197,30 @@ describe('DevToolbar', () => {
|
||||
await user.click(triggerButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Local Only')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Server-side events are only visible when using the toolbar in local development'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not connect to SSE in staging', async () => {
|
||||
const EventSourceSpy = vi.fn()
|
||||
vi.stubGlobal('EventSource', EventSourceSpy)
|
||||
|
||||
vi.resetModules()
|
||||
await renderFullToolbar()
|
||||
|
||||
expect(EventSourceSpy).not.toHaveBeenCalled()
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('window.devTelemetry function', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
})
|
||||
|
||||
it('enables toolbar when called', async () => {
|
||||
@@ -203,12 +240,15 @@ describe('DevToolbar', () => {
|
||||
const { DevToolbarProvider } = await import('./DevToolbarContext')
|
||||
const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
|
||||
const { DevToolbar } = await import('./DevToolbar')
|
||||
const { TooltipProvider } = await import('ui')
|
||||
|
||||
rerender(
|
||||
<DevToolbarProvider apiUrl="http://localhost:3000">
|
||||
<DevToolbarTrigger />
|
||||
<DevToolbar />
|
||||
</DevToolbarProvider>
|
||||
<TooltipProvider>
|
||||
<DevToolbarProvider apiUrl="http://localhost:3000">
|
||||
<DevToolbarTrigger />
|
||||
<DevToolbar />
|
||||
</DevToolbarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(localStorage.getItem('dev-telemetry-toolbar-enabled')).toBe('true')
|
||||
@@ -217,7 +257,7 @@ describe('DevToolbar', () => {
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('removes window.devTelemetry on unmount', async () => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
|
||||
vi.resetModules()
|
||||
const result = await renderFullToolbar()
|
||||
@@ -232,7 +272,7 @@ describe('DevToolbar', () => {
|
||||
|
||||
describe('EventCard keyboard accessibility', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
|
||||
})
|
||||
|
||||
@@ -246,7 +286,7 @@ describe('DevToolbar', () => {
|
||||
await user.click(triggerButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dev Telemetry')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dev Toolbar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Events tab should be active by default and show empty state
|
||||
@@ -256,7 +296,7 @@ describe('DevToolbar', () => {
|
||||
|
||||
describe('Flag override UI', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'development'
|
||||
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
|
||||
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
|
||||
})
|
||||
|
||||
@@ -277,8 +317,8 @@ describe('DevToolbar', () => {
|
||||
await user.click(flagsTab)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: /PostHog/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: /ConfigCat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /PostHog/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /ConfigCat/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+307
-184
@@ -1,40 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useFeatureFlags } from 'common'
|
||||
import { Activity, ChevronDown, ChevronUp, Flag } from 'lucide-react'
|
||||
import { Copy, EyeOff, Search, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input_Shadcn_ as Input,
|
||||
cn,
|
||||
Input,
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
Switch,
|
||||
Tabs_Shadcn_ as Tabs,
|
||||
TabsContent_Shadcn_ as TabsContent,
|
||||
TabsList_Shadcn_ as TabsList,
|
||||
TabsTrigger_Shadcn_ as TabsTrigger,
|
||||
cn,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { useDevToolbar } from './DevToolbarContext'
|
||||
import type { DevTelemetryEvent } from './types'
|
||||
import {
|
||||
CC_ORIGINALS_KEY,
|
||||
PH_ORIGINALS_KEY,
|
||||
deleteCookie,
|
||||
getCookie,
|
||||
parseOverrideValue,
|
||||
PH_ORIGINALS_KEY,
|
||||
readOriginals,
|
||||
safeJsonParse,
|
||||
setCookie,
|
||||
@@ -42,51 +46,88 @@ import {
|
||||
writeOriginals,
|
||||
} from './utils'
|
||||
|
||||
const IS_LOCAL_DEV = process.env.NODE_ENV === 'development'
|
||||
// Duplicated for tree-shaking — bundler must see literal process.env reference.
|
||||
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
|
||||
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
|
||||
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
|
||||
const IS_LOCAL_DEV = env === 'local'
|
||||
|
||||
function EventCard({ event }: { event: DevTelemetryEvent }) {
|
||||
function EventRow({ event }: { event: DevTelemetryEvent }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const handleToggle = () => setIsExpanded((prev) => !prev)
|
||||
const time = new Date(event.timestamp).toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard
|
||||
.writeText(JSON.stringify({ name: event.eventName, properties: event.properties }, null, 2))
|
||||
.catch((error) => console.warn('Copy failed', error))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-3 bg-surface-100">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${event.eventName} event details`}
|
||||
className="flex items-start justify-between cursor-pointer gap-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm w-full text-left"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<Badge variant={event.source === 'client' ? 'default' : 'success'} className="shrink-0">
|
||||
{event.source}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{event.eventType}
|
||||
</Badge>
|
||||
<span className="font-mono text-sm break-all">{event.eventName}</span>
|
||||
</div>
|
||||
{event.distinctId && (
|
||||
<div
|
||||
className="text-xs text-foreground-muted font-mono truncate"
|
||||
title={event.distinctId}
|
||||
<div className="group last:border-b-0">
|
||||
<div className="relative flex items-center h-9 hover:bg-surface-100">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
className="flex items-center flex-1 min-w-0 h-full px-6 gap-5 cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-2 shrink-0 w-16">
|
||||
<span
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-[2px] shrink-0',
|
||||
event.source === 'client' ? 'bg-brand' : 'bg-foreground-lighter'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-xs uppercase',
|
||||
event.source === 'client' ? 'text-brand' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
ID: {event.distinctId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-foreground-muted shrink-0">
|
||||
<span className="text-xs whitespace-nowrap">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
{event.source}
|
||||
</span>
|
||||
</span>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<span className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
|
||||
<span className="font-mono text-xs text-foreground-lighter shrink-0 w-20">{time}</span>
|
||||
<span className="font-mono text-xs text-foreground-lighter shrink-0 w-28 truncate uppercase text-left">
|
||||
{event.eventType}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-foreground truncate shrink-0">
|
||||
{event.eventName}
|
||||
</span>
|
||||
{event.distinctId && (
|
||||
<span className="font-mono text-xs text-foreground-muted truncate">
|
||||
{event.distinctId}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="shrink-0 pr-6">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy JSON"
|
||||
className="p-1 rounded hover:bg-surface-200 text-foreground-muted hover:text-foreground-light"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Copy JSON</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<pre className="mt-3 p-2 bg-surface-200 rounded text-xs overflow-x-auto max-h-[300px] overflow-y-auto">
|
||||
<pre className="px-8 py-2 bg-surface-100 border-b text-xs font-mono overflow-x-auto max-h-[200px] overflow-y-auto text-foreground-light">
|
||||
{JSON.stringify(event.properties, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
@@ -94,7 +135,7 @@ function EventCard({ event }: { event: DevTelemetryEvent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function FlagCard({
|
||||
function FlagRow({
|
||||
flagName,
|
||||
currentValue,
|
||||
originalValue,
|
||||
@@ -110,30 +151,44 @@ function FlagCard({
|
||||
const valueType = typeof originalValue
|
||||
const isNull = originalValue === null
|
||||
const inputProps = {
|
||||
size: 'tiny' as const,
|
||||
value: String(currentValue),
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => onToggle(event.target.value),
|
||||
className: 'w-32',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-md p-3', isOverridden && 'border-warning bg-warning/5')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="font-mono text-sm truncate">{flagName}</span>
|
||||
{isOverridden && (
|
||||
<Badge variant="warning" className="shrink-0">
|
||||
Overridden
|
||||
</Badge>
|
||||
)}
|
||||
{isNull && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
null
|
||||
</Badge>
|
||||
)}
|
||||
<div className={cn('px-4 py-3 flex flex-col gap-0.5', isOverridden && 'bg-warning/5')}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex gap-2 min-w-0 h-4">
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-xs truncate',
|
||||
isOverridden ? 'text-warning' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{flagName}
|
||||
</span>
|
||||
{isOverridden && (
|
||||
<Badge variant="warning" className="shrink-0">
|
||||
Overridden
|
||||
</Badge>
|
||||
)}
|
||||
{isNull && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
null
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-foreground-muted uppercase font-mono">
|
||||
Original:{' '}
|
||||
<code className="text-foreground-lighter">{JSON.stringify(originalValue)}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isNull ? (
|
||||
<Input value="null" disabled className="w-32 opacity-50" />
|
||||
<Input size="tiny" value="null" disabled className="w-32 opacity-50" />
|
||||
) : valueType === 'boolean' ? (
|
||||
<Switch
|
||||
checked={currentValue as boolean}
|
||||
@@ -145,19 +200,12 @@ function FlagCard({
|
||||
<Input {...inputProps} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOverridden && (
|
||||
<div className="mt-2 text-xs text-foreground-muted">
|
||||
Original:{' '}
|
||||
<code className="bg-surface-200 px-1 rounded">{JSON.stringify(originalValue)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DevToolbar() {
|
||||
const { isEnabled, isOpen, setIsOpen, events, setEvents } = useDevToolbar()
|
||||
const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar()
|
||||
const [activeTab, setActiveTab] = useState<string>('events')
|
||||
const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog')
|
||||
const [eventFilter, setEventFilter] = useState<string>('')
|
||||
@@ -218,6 +266,24 @@ export function DevToolbar() {
|
||||
setCcFlagOriginals(readOriginals(CC_ORIGINALS_KEY))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const STYLE_ID = 'dev-toolbar-hide-native-devtools'
|
||||
const existing = document.getElementById(STYLE_ID)
|
||||
|
||||
if (isOpen) {
|
||||
if (!existing) {
|
||||
const style = document.createElement('style')
|
||||
style.id = STYLE_ID
|
||||
style.textContent = `
|
||||
.tsqd-open-btn, .tsqd-open-btn-container { display: none !important; }
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
} else {
|
||||
existing?.remove()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const updatePhOriginals = updateOriginals(PH_ORIGINALS_KEY, setPhFlagOriginals)
|
||||
const updateCcOriginals = updateOriginals(CC_ORIGINALS_KEY, setCcFlagOriginals)
|
||||
|
||||
@@ -284,7 +350,7 @@ export function DevToolbar() {
|
||||
const ccOverrideCount = Object.keys(ccFlagOverrides).length
|
||||
const totalOverrideCount = phOverrideCount + ccOverrideCount
|
||||
|
||||
if (!IS_LOCAL_DEV || !isEnabled) return null
|
||||
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -292,146 +358,203 @@ export function DevToolbar() {
|
||||
side="bottom"
|
||||
size="lg"
|
||||
className="flex flex-col p-0 gap-0 overflow-hidden"
|
||||
showClose={false}
|
||||
hasOverlay={false}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<SheetHeader className="px-6 py-4 border-b shrink-0 space-y-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="w-5 h-5 text-brand-500" />
|
||||
<SheetTitle className="text-lg font-semibold">Dev Telemetry</SheetTitle>
|
||||
<Badge variant="secondary">Local Only</Badge>
|
||||
</div>
|
||||
<SheetDescription className="sr-only">
|
||||
View telemetry events and feature flags for local development
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<SheetHeader className="border-b shrink-0 space-y-0 p-0">
|
||||
<SheetTitle className="sr-only">Dev Toolbar</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
View telemetry events and feature flags for local development
|
||||
</SheetDescription>
|
||||
<div className="flex items-center px-6">
|
||||
<Image
|
||||
src="/img/logo-pixel-small-light.png"
|
||||
alt="Dev Toolbar"
|
||||
width={16}
|
||||
height={16}
|
||||
style={{
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
className="shrink-0 mr-4"
|
||||
/>
|
||||
<TabsList className="flex gap-x-4 rounded-none !border-none h-auto">
|
||||
<TabsTrigger
|
||||
value="events"
|
||||
className="text-xs py-3 border-b-[1px] font-mono uppercase"
|
||||
>
|
||||
Events ({filteredEvents.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="flags"
|
||||
className="text-xs py-3 border-b-[1px] font-mono uppercase"
|
||||
>
|
||||
Flags {totalOverrideCount > 0 && `(${totalOverrideCount})`}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="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>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X className="w-4 h-4" />}
|
||||
className="text-foreground-light hover:text-foreground p-1"
|
||||
/>
|
||||
</SheetClose>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Close</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden px-6 pt-4">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col min-h-0 overflow-hidden"
|
||||
>
|
||||
<TabsList className="shrink-0 mb-4">
|
||||
<TabsTrigger value="events" className="flex items-center gap-2 px-4">
|
||||
<Activity className="w-4 h-4" />
|
||||
Events ({filteredEvents.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="flags" className="flex items-center gap-2 px-4">
|
||||
<Flag className="w-4 h-4" />
|
||||
Flags {totalOverrideCount > 0 && `(${totalOverrideCount} overrides)`}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{activeTab === 'events' && (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<div className="flex items-center gap-4 pb-4 shrink-0">
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between border-b shrink-0 px-6 py-2">
|
||||
<Input
|
||||
size="tiny"
|
||||
placeholder="Filter events..."
|
||||
value={eventFilter}
|
||||
onChange={(e) => setEventFilter(e.target.value)}
|
||||
className="flex-1"
|
||||
icon={<Search size={14} className="text-foreground-lighter" />}
|
||||
className="flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 max-w-96"
|
||||
/>
|
||||
<Button type="outline" onClick={() => setEvents([])}>
|
||||
Clear
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setEvents([])}
|
||||
className="text-foreground-lighter hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2 pb-6">
|
||||
{!IS_LOCAL_DEV && (
|
||||
<div className="px-6 py-2 text-xs text-foreground-muted border-b bg-surface-100">
|
||||
Server-side events are only visible when using the toolbar in local development
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pb-4">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="text-center text-foreground-muted py-8">
|
||||
<div className="text-center text-foreground-lighter py-8 text-sm">
|
||||
No events yet. Interact with the app to see telemetry events.
|
||||
</div>
|
||||
) : (
|
||||
filteredEvents.map((event) => (
|
||||
<EventCard key={`${event.source}-${event.id}`} event={event} />
|
||||
))
|
||||
<div className="overflow-hidden">
|
||||
{filteredEvents.map((event) => (
|
||||
<EventRow key={`${event.source}-${event.id}`} event={event} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'flags' && (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
{totalOverrideCount > 0 && (
|
||||
<div className="flex items-center justify-between p-3 bg-warning/10 rounded-md mb-4 shrink-0">
|
||||
<span className="text-sm text-warning">
|
||||
{totalOverrideCount} flag(s) overridden
|
||||
{phOverrideCount > 0 && ccOverrideCount > 0
|
||||
? ` (${phOverrideCount} PostHog, ${ccOverrideCount} ConfigCat)`
|
||||
: ''}
|
||||
</span>
|
||||
<Button type="outline" onClick={clearAllOverrides}>
|
||||
Clear & Reload
|
||||
</Button>
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex">
|
||||
{/* Sidebar */}
|
||||
<div className="w-44 border-r shrink-0 flex flex-col">
|
||||
<nav className="flex flex-col p-3 gap-0.5">
|
||||
{(
|
||||
[
|
||||
{ id: 'posthog', label: 'PostHog', count: phOverrideCount },
|
||||
{ id: 'configcat', label: 'ConfigCat', count: ccOverrideCount },
|
||||
] as const
|
||||
).map(({ id, label, count }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setFlagsSubTab(id)}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-1.5 rounded text-sm text-left uppercase font-mono tracking-wide',
|
||||
flagsSubTab === id
|
||||
? 'bg-surface-300 text-foreground'
|
||||
: 'text-foreground-light hover:bg-surface-200'
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-xs">{label}</span>
|
||||
{count > 0 && (
|
||||
<span className="text-xs text-foreground-lighter">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{totalOverrideCount > 0 && (
|
||||
<div className="mt-auto p-2 border-t">
|
||||
<Button type="outline" size="tiny" block onClick={clearAllOverrides}>
|
||||
Reset & Reload
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flag list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
|
||||
<div className="divide-y">
|
||||
{flagsSubTab === 'posthog' &&
|
||||
(Object.keys(posthogFlags).length === 0 ? (
|
||||
<div className="text-center text-foreground-lighter py-8 text-sm">
|
||||
No PostHog feature flags loaded yet.
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(posthogFlags).map(([flagName, flagValue]) => (
|
||||
<FlagRow
|
||||
key={flagName}
|
||||
flagName={flagName}
|
||||
currentValue={
|
||||
phFlagOverrides[flagName] ?? phFlagOriginals[flagName] ?? flagValue
|
||||
}
|
||||
originalValue={phFlagOriginals[flagName] ?? flagValue}
|
||||
isOverridden={flagName in phFlagOverrides}
|
||||
onToggle={(value) => togglePhFlagOverride(flagName, value)}
|
||||
/>
|
||||
))
|
||||
))}
|
||||
{flagsSubTab === 'configcat' &&
|
||||
(Object.keys(configcatFlags).length === 0 ? (
|
||||
<div className="text-center text-foreground-lighter py-8 text-sm">
|
||||
No ConfigCat feature flags loaded yet.
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(configcatFlags).map(([flagName, flagValue]) => (
|
||||
<FlagRow
|
||||
key={flagName}
|
||||
flagName={flagName}
|
||||
currentValue={
|
||||
ccFlagOverrides[flagName] ?? ccFlagOriginals[flagName] ?? flagValue
|
||||
}
|
||||
originalValue={ccFlagOriginals[flagName] ?? flagValue}
|
||||
isOverridden={flagName in ccFlagOverrides}
|
||||
onToggle={(value) => toggleCcFlagOverride(flagName, value)}
|
||||
/>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={flagsSubTab}
|
||||
onValueChange={(v) => setFlagsSubTab(v as 'posthog' | 'configcat')}
|
||||
className="flex-1 flex flex-col min-h-0 overflow-hidden"
|
||||
>
|
||||
<TabsList className="shrink-0 mb-4">
|
||||
<TabsTrigger value="posthog" className="px-4">
|
||||
PostHog {phOverrideCount > 0 && `(${phOverrideCount})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="configcat" className="px-4">
|
||||
ConfigCat {ccOverrideCount > 0 && `(${ccOverrideCount})`}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{flagsSubTab === 'posthog' && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
|
||||
<div className="space-y-4">
|
||||
{Object.keys(posthogFlags).length === 0 ? (
|
||||
<div className="text-center text-foreground-muted py-8">
|
||||
No PostHog feature flags loaded yet.
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(posthogFlags).map(([flagName, flagValue]) => (
|
||||
<FlagCard
|
||||
key={flagName}
|
||||
flagName={flagName}
|
||||
currentValue={
|
||||
phFlagOverrides[flagName] ?? phFlagOriginals[flagName] ?? flagValue
|
||||
}
|
||||
originalValue={phFlagOriginals[flagName] ?? flagValue}
|
||||
isOverridden={flagName in phFlagOverrides}
|
||||
onToggle={(value) => togglePhFlagOverride(flagName, value)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{flagsSubTab === 'configcat' && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
|
||||
<div className="space-y-4">
|
||||
{Object.keys(configcatFlags).length === 0 ? (
|
||||
<div className="text-center text-foreground-muted py-8">
|
||||
No ConfigCat feature flags loaded yet.
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(configcatFlags).map(([flagName, flagValue]) => (
|
||||
<FlagCard
|
||||
key={flagName}
|
||||
flagName={flagName}
|
||||
currentValue={
|
||||
ccFlagOverrides[flagName] ?? ccFlagOriginals[flagName] ?? flagValue
|
||||
}
|
||||
originalValue={ccFlagOriginals[flagName] ?? flagValue}
|
||||
isOverridden={flagName in ccFlagOverrides}
|
||||
onToggle={(value) => toggleCcFlagOverride(flagName, value)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { type ClientTelemetryEvent, ensurePlatformSuffix, posthogClient } from 'common'
|
||||
import { ensurePlatformSuffix, posthogClient, type ClientTelemetryEvent } from 'common'
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
import type {
|
||||
@@ -18,7 +18,11 @@ import type {
|
||||
} from './types'
|
||||
import { getCookie } from './utils'
|
||||
|
||||
const IS_LOCAL_DEV = process.env.NODE_ENV === 'development'
|
||||
// Duplicated for tree-shaking — bundler must see literal process.env reference.
|
||||
// Keep in sync: index.ts, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
|
||||
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
|
||||
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
|
||||
const IS_LOCAL_DEV = env === 'local'
|
||||
const MAX_EVENTS = 200
|
||||
const STORAGE_KEY = 'dev-telemetry-toolbar-enabled'
|
||||
|
||||
@@ -56,7 +60,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LOCAL_DEV) return
|
||||
if (!IS_TOOLBAR_ENABLED) return
|
||||
|
||||
let stored: string | null = null
|
||||
try {
|
||||
@@ -105,7 +109,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
|
||||
}, [appendEvent, isEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled || typeof EventSource === 'undefined') return
|
||||
if (!IS_LOCAL_DEV || !isEnabled || typeof EventSource === 'undefined') return
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let isMounted = true
|
||||
@@ -176,7 +180,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
|
||||
}
|
||||
}, [apiUrl, appendEvent, isEnabled])
|
||||
|
||||
if (!IS_LOCAL_DEV) {
|
||||
if (!IS_TOOLBAR_ENABLED) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,113 +1,258 @@
|
||||
'use client'
|
||||
|
||||
import { Activity, EyeOff } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, PointerEvent } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
import { useDevToolbar } from './DevToolbarContext'
|
||||
|
||||
const IS_LOCAL_DEV = process.env.NODE_ENV === 'development'
|
||||
// Duplicated for tree-shaking — bundler must see literal process.env reference.
|
||||
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx
|
||||
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
|
||||
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
|
||||
const POSITION_STORAGE_KEY = 'dev-telemetry-toolbar-position'
|
||||
const DRAG_THRESHOLD = 4
|
||||
const MARGIN = 24
|
||||
const BUTTON_SIZE = 40 // h-10 w-10
|
||||
|
||||
// Spring easing: slight overshoot then settle
|
||||
const SNAP_TRANSITION =
|
||||
'top 380ms cubic-bezier(0.34, 1.56, 0.64, 1), left 380ms cubic-bezier(0.34, 1.56, 0.64, 1)'
|
||||
|
||||
type SnapPosition =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'middle-left'
|
||||
| 'middle-center'
|
||||
| 'middle-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right'
|
||||
|
||||
// All positions expressed as pixel top+left so transitions interpolate cleanly
|
||||
function getSnapCoords(
|
||||
position: SnapPosition,
|
||||
vw: number,
|
||||
vh: number
|
||||
): { top: number; left: number } {
|
||||
const [row, col] = position.split('-')
|
||||
const top =
|
||||
row === 'top'
|
||||
? MARGIN
|
||||
: row === 'bottom'
|
||||
? vh - MARGIN - BUTTON_SIZE
|
||||
: Math.round(vh / 2 - BUTTON_SIZE / 2)
|
||||
const left =
|
||||
col === 'left'
|
||||
? MARGIN
|
||||
: col === 'right'
|
||||
? vw - MARGIN - BUTTON_SIZE
|
||||
: Math.round(vw / 2 - BUTTON_SIZE / 2)
|
||||
return { top, left }
|
||||
}
|
||||
|
||||
function getNearestSnapPosition(cx: number, cy: number): SnapPosition {
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const row = cy < vh / 3 ? 'top' : cy > (2 * vh) / 3 ? 'bottom' : 'middle'
|
||||
const col = cx < vw / 3 ? 'left' : cx > (2 * vw) / 3 ? 'right' : 'center'
|
||||
return `${row}-${col}` as SnapPosition
|
||||
}
|
||||
|
||||
function readStoredPosition(): SnapPosition {
|
||||
if (typeof window === 'undefined') return 'bottom-right'
|
||||
return (localStorage.getItem(POSITION_STORAGE_KEY) as SnapPosition) ?? 'bottom-right'
|
||||
}
|
||||
|
||||
export function DevToolbarTrigger() {
|
||||
const { isEnabled, isOpen, setIsOpen, events, dismissToolbar } = useDevToolbar()
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const { isEnabled, isOpen, setIsOpen, events } = useDevToolbar()
|
||||
const [snapPosition, setSnapPosition] = useState<SnapPosition>('bottom-right')
|
||||
const [hasHydrated, setHasHydrated] = useState(false)
|
||||
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null)
|
||||
// Holds the last drag pixel position for one RAF to prime the CSS transition
|
||||
const [releasedAt, setReleasedAt] = useState<{ x: number; y: number } | null>(null)
|
||||
const [viewport, setViewport] = useState(() => ({
|
||||
w: typeof window !== 'undefined' ? window.innerWidth : 1920,
|
||||
h: typeof window !== 'undefined' ? window.innerHeight : 1080,
|
||||
}))
|
||||
|
||||
const dragRef = useRef<{
|
||||
startPointerX: number
|
||||
startPointerY: number
|
||||
startButtonX: number
|
||||
startButtonY: number
|
||||
hasDragged: boolean
|
||||
} | null>(null)
|
||||
const wasDraggingRef = useRef(false)
|
||||
|
||||
// Restore persisted position after mount to avoid SSR hydration mismatch.
|
||||
// hasHydrated is set via RAF so the correct position is painted before transitions are enabled,
|
||||
// preventing the spring animation from firing on initial load.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current)
|
||||
closeTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
const stored = readStoredPosition()
|
||||
if (stored !== 'bottom-right') setSnapPosition(stored)
|
||||
const id = requestAnimationFrame(() => setHasHydrated(true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [])
|
||||
|
||||
if (!IS_LOCAL_DEV || !isEnabled) {
|
||||
return null
|
||||
}
|
||||
// Keep snap coords accurate on resize
|
||||
useEffect(() => {
|
||||
const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight })
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
// Two-phase snap: hold last drag position for one frame (primes the transition),
|
||||
// then clear it so the spring fires from that position to the snap target
|
||||
useEffect(() => {
|
||||
if (releasedAt === null) return
|
||||
const id = requestAnimationFrame(() => setReleasedAt(null))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [releasedAt])
|
||||
|
||||
const handlePointerDown = useCallback((e: PointerEvent<HTMLButtonElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
dragRef.current = {
|
||||
startPointerX: e.clientX,
|
||||
startPointerY: e.clientY,
|
||||
startButtonX: rect.left,
|
||||
startButtonY: rect.top,
|
||||
hasDragged: false,
|
||||
}
|
||||
wasDraggingRef.current = false
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const handlePointerMove = useCallback((e: PointerEvent<HTMLButtonElement>) => {
|
||||
if (!dragRef.current) return
|
||||
const dx = e.clientX - dragRef.current.startPointerX
|
||||
const dy = e.clientY - dragRef.current.startPointerY
|
||||
if (!dragRef.current.hasDragged && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
|
||||
dragRef.current.hasDragged = true
|
||||
setDragPos({
|
||||
x: dragRef.current.startButtonX + dx,
|
||||
y: dragRef.current.startButtonY + dy,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePointerUp = useCallback((e: PointerEvent<HTMLButtonElement>) => {
|
||||
if (!dragRef.current) return
|
||||
const { hasDragged } = dragRef.current
|
||||
dragRef.current = null
|
||||
wasDraggingRef.current = hasDragged
|
||||
if (!hasDragged) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 2
|
||||
const newPosition = getNearestSnapPosition(cx, cy)
|
||||
setSnapPosition(newPosition)
|
||||
localStorage.setItem(POSITION_STORAGE_KEY, newPosition)
|
||||
// Phase 1: park at last drag position with transition primed
|
||||
setReleasedAt({ x: rect.left, y: rect.top })
|
||||
setDragPos(null)
|
||||
}, [])
|
||||
|
||||
const handlePointerCancel = useCallback(() => {
|
||||
dragRef.current = null
|
||||
wasDraggingRef.current = false
|
||||
setDragPos(null)
|
||||
setReleasedAt(null)
|
||||
}, [])
|
||||
|
||||
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
|
||||
|
||||
const eventCount = events.length
|
||||
const isDragging = dragPos !== null
|
||||
const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h)
|
||||
const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease`
|
||||
|
||||
const containerStyle: CSSProperties =
|
||||
dragPos !== null
|
||||
? {
|
||||
position: 'fixed',
|
||||
zIndex: 50,
|
||||
left: dragPos.x,
|
||||
top: dragPos.y,
|
||||
transition: 'none',
|
||||
opacity: 1,
|
||||
}
|
||||
: releasedAt !== null
|
||||
? // Phase 1: same pixel position as drag end, transition now defined
|
||||
{
|
||||
position: 'fixed',
|
||||
zIndex: 50,
|
||||
left: releasedAt.x,
|
||||
top: releasedAt.y,
|
||||
transition: FULL_TRANSITION,
|
||||
opacity: isOpen ? 0 : 1,
|
||||
pointerEvents: isOpen ? 'none' : undefined,
|
||||
}
|
||||
: // Phase 2: spring fires from releasedAt → snapCoords
|
||||
{
|
||||
position: 'fixed',
|
||||
zIndex: 50,
|
||||
...snapCoords,
|
||||
transition: hasHydrated ? FULL_TRANSITION : 'none',
|
||||
opacity: isOpen ? 0 : 1,
|
||||
pointerEvents: isOpen ? 'none' : undefined,
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setPopoverOpen(false)
|
||||
if (wasDraggingRef.current) {
|
||||
wasDraggingRef.current = false
|
||||
return
|
||||
}
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setPopoverOpen(false)
|
||||
dismissToolbar()
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current)
|
||||
closeTimeoutRef.current = null
|
||||
}
|
||||
setPopoverOpen(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setPopoverOpen(false)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleTriggerMouseLeave = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
handleMouseLeave()
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover_Shadcn_ open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type="text"
|
||||
className={cn(
|
||||
'relative rounded-full h-[32px] px-2',
|
||||
'text-foreground-light hover:text-foreground',
|
||||
'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
|
||||
isOpen && 'text-foreground bg-surface-300'
|
||||
)}
|
||||
aria-label="Open dev toolbar"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleTriggerMouseLeave}
|
||||
>
|
||||
<Activity className="w-4 h-4" />
|
||||
{eventCount > 0 && (
|
||||
<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-white',
|
||||
'text-[10px] font-medium leading-none'
|
||||
)}
|
||||
>
|
||||
{eventCount > 99 ? '99+' : eventCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
side="left"
|
||||
align="center"
|
||||
sideOffset={4}
|
||||
className="w-auto p-1"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
<div style={containerStyle}>
|
||||
<Button
|
||||
type="text"
|
||||
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',
|
||||
'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
|
||||
'select-none touch-none',
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
)}
|
||||
aria-label="Open dev toolbar"
|
||||
onClick={handleClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
title="Dev Toolbar"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-surface-200 text-foreground-light hover:text-foreground whitespace-nowrap"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<EyeOff className="w-4 h-4 shrink-0" />
|
||||
<span>Hide</span>
|
||||
</button>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
<Image
|
||||
src="/img/logo-pixel-small-light.png"
|
||||
alt="Dev Toolbar"
|
||||
width={16}
|
||||
height={16}
|
||||
style={{
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
{eventCount > 0 && (
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
{eventCount > 99 ? '99+' : eventCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
function MockImage(props: Record<string, unknown>) {
|
||||
// eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
|
||||
return <img {...(props as React.ImgHTMLAttributes<HTMLImageElement>)} />
|
||||
}
|
||||
|
||||
export default MockImage
|
||||
+18
-14
@@ -5,10 +5,14 @@ import * as DevToolbarContextModule from './DevToolbarContext'
|
||||
import * as DevToolbarTriggerModule from './DevToolbarTrigger'
|
||||
import type { DevTelemetryToolbarContextType } from './types'
|
||||
|
||||
// Tree-shaking pattern: conditionally export stubs in production
|
||||
// The bundler replaces process.env.NODE_ENV at build time, making the
|
||||
// ternary static. Combined with sideEffects: false, the implementation
|
||||
// modules are eliminated from the production bundle.
|
||||
// Tree-shaking pattern: conditionally export stubs outside local/staging.
|
||||
// The bundler replaces NEXT_PUBLIC_ENVIRONMENT at build time, making the
|
||||
// ternary static. In production builds (env === 'prod'), the implementation
|
||||
// modules are eliminated from the bundle.
|
||||
// Duplicated for tree-shaking — bundler must see literal process.env reference.
|
||||
// Keep in sync: DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
|
||||
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
|
||||
const isToolbarEnabled = env === 'local' || env === 'staging'
|
||||
|
||||
const noopContext: DevTelemetryToolbarContextType = {
|
||||
isEnabled: false,
|
||||
@@ -19,18 +23,18 @@ const noopContext: DevTelemetryToolbarContextType = {
|
||||
dismissToolbar: () => {},
|
||||
}
|
||||
|
||||
export const DevToolbarProvider =
|
||||
process.env.NODE_ENV !== 'development'
|
||||
? ({ children }: { children: ReactNode; apiUrl?: string }) => children
|
||||
: DevToolbarContextModule.DevToolbarProvider
|
||||
export const DevToolbarProvider = !isToolbarEnabled
|
||||
? ({ children }: { children: ReactNode; apiUrl?: string }) => children
|
||||
: DevToolbarContextModule.DevToolbarProvider
|
||||
|
||||
export const useDevToolbar =
|
||||
process.env.NODE_ENV !== 'development' ? () => noopContext : DevToolbarContextModule.useDevToolbar
|
||||
export const useDevToolbar = !isToolbarEnabled
|
||||
? () => noopContext
|
||||
: DevToolbarContextModule.useDevToolbar
|
||||
|
||||
export const DevToolbar =
|
||||
process.env.NODE_ENV !== 'development' ? () => null : DevToolbarModule.DevToolbar
|
||||
export const DevToolbar = !isToolbarEnabled ? () => null : DevToolbarModule.DevToolbar
|
||||
|
||||
export const DevToolbarTrigger =
|
||||
process.env.NODE_ENV !== 'development' ? () => null : DevToolbarTriggerModule.DevToolbarTrigger
|
||||
export const DevToolbarTrigger = !isToolbarEnabled
|
||||
? () => null
|
||||
: DevToolbarTriggerModule.DevToolbarTrigger
|
||||
|
||||
export type { DevTelemetryEvent, DevToolbarConfig } from './types'
|
||||
@@ -33,6 +33,7 @@
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'next/image': path.resolve(__dirname, './__mocks__/next-image.tsx'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference types="@testing-library/jest-dom" />
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
|
||||
@@ -20,6 +21,10 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
|
||||
vi.mock('next/navigation', () => require('next-router-mock/navigation'))
|
||||
|
||||
// jsdom doesn't implement pointer capture methods
|
||||
Element.prototype.setPointerCapture = vi.fn()
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
Generated
+3
@@ -2253,6 +2253,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: '*'
|
||||
version: 0.511.0(react@18.3.1)
|
||||
next:
|
||||
specifier: 'catalog:'
|
||||
version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 18.3.1
|
||||
|
||||
Reference in new issue
Block a user