DevToolbar.tsx579 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { useFeatureFlags } from 'common' |
| 4 | import { Copy, EyeOff, Search, X } from 'lucide-react' |
| 5 | import Image from 'next/image' |
| 6 | import { |
| 7 | useCallback, |
| 8 | useEffect, |
| 9 | useState, |
| 10 | type ChangeEvent, |
| 11 | type Dispatch, |
| 12 | type SetStateAction, |
| 13 | } from 'react' |
| 14 | import { |
| 15 | Badge, |
| 16 | Button, |
| 17 | cn, |
| 18 | Input, |
| 19 | InputGroup, |
| 20 | InputGroupAddon, |
| 21 | InputGroupInput, |
| 22 | Sheet, |
| 23 | SheetClose, |
| 24 | SheetContent, |
| 25 | SheetDescription, |
| 26 | SheetHeader, |
| 27 | SheetTitle, |
| 28 | Switch, |
| 29 | Tabs_Shadcn_ as Tabs, |
| 30 | TabsList_Shadcn_ as TabsList, |
| 31 | TabsTrigger_Shadcn_ as TabsTrigger, |
| 32 | Tooltip, |
| 33 | TooltipContent, |
| 34 | TooltipTrigger, |
| 35 | } from 'ui' |
| 36 | |
| 37 | import { useDevToolbar } from './DevToolbarContext' |
| 38 | import type { DevTelemetryEvent, ExtraTab } from './types' |
| 39 | import { |
| 40 | CC_ORIGINALS_KEY, |
| 41 | deleteCookie, |
| 42 | getCookie, |
| 43 | parseOverrideValue, |
| 44 | PH_ORIGINALS_KEY, |
| 45 | readOriginals, |
| 46 | safeJsonParse, |
| 47 | setCookie, |
| 48 | valuesAreEqual, |
| 49 | writeOriginals, |
| 50 | } from './utils' |
| 51 | |
| 52 | // Duplicated for tree-shaking — bundler must see literal process.env reference. |
| 53 | // Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbarTrigger.tsx, feature-flags.tsx |
| 54 | const env = process.env.NEXT_PUBLIC_ENVIRONMENT |
| 55 | const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging' |
| 56 | const IS_LOCAL_DEV = env === 'local' |
| 57 | |
| 58 | function EventRow({ event }: { event: DevTelemetryEvent }) { |
| 59 | const [isExpanded, setIsExpanded] = useState(false) |
| 60 | |
| 61 | const time = new Date(event.timestamp).toLocaleTimeString('en-US', { |
| 62 | hour12: false, |
| 63 | hour: '2-digit', |
| 64 | minute: '2-digit', |
| 65 | second: '2-digit', |
| 66 | }) |
| 67 | |
| 68 | const handleCopy = (e: React.MouseEvent) => { |
| 69 | e.stopPropagation() |
| 70 | navigator.clipboard |
| 71 | .writeText(JSON.stringify({ name: event.eventName, properties: event.properties }, null, 2)) |
| 72 | .catch((error) => console.warn('Copy failed', error)) |
| 73 | } |
| 74 | |
| 75 | return ( |
| 76 | <div className="group last:border-b-0"> |
| 77 | <div className="relative flex items-center h-9 hover:bg-surface-100"> |
| 78 | <button |
| 79 | type="button" |
| 80 | aria-expanded={isExpanded} |
| 81 | onClick={() => setIsExpanded((prev) => !prev)} |
| 82 | className="flex items-center flex-1 min-w-0 h-full px-6 gap-5 cursor-pointer" |
| 83 | > |
| 84 | <span className="flex items-center gap-2 shrink-0 w-16"> |
| 85 | <span |
| 86 | className={cn( |
| 87 | 'w-1.5 h-1.5 rounded-[2px] shrink-0', |
| 88 | event.source === 'client' ? 'bg-brand' : 'bg-foreground-lighter' |
| 89 | )} |
| 90 | /> |
| 91 | <span |
| 92 | className={cn( |
| 93 | 'font-mono text-xs uppercase', |
| 94 | event.source === 'client' ? 'text-brand' : 'text-foreground-light' |
| 95 | )} |
| 96 | > |
| 97 | {event.source} |
| 98 | </span> |
| 99 | </span> |
| 100 | |
| 101 | <span className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden"> |
| 102 | <span className="font-mono text-xs text-foreground-lighter shrink-0 w-20">{time}</span> |
| 103 | <span className="font-mono text-xs text-foreground-lighter shrink-0 w-28 truncate uppercase text-left"> |
| 104 | {event.eventType} |
| 105 | </span> |
| 106 | <span className="font-mono text-xs text-foreground truncate shrink-0"> |
| 107 | {event.eventName} |
| 108 | </span> |
| 109 | {event.distinctId && ( |
| 110 | <span className="font-mono text-xs text-foreground-muted truncate"> |
| 111 | {event.distinctId} |
| 112 | </span> |
| 113 | )} |
| 114 | </span> |
| 115 | </button> |
| 116 | <div className="shrink-0 pr-6"> |
| 117 | <Tooltip> |
| 118 | <TooltipTrigger asChild> |
| 119 | <button |
| 120 | type="button" |
| 121 | onClick={handleCopy} |
| 122 | aria-label="Copy JSON" |
| 123 | className="p-1 rounded-sm hover:bg-surface-200 text-foreground-muted hover:text-foreground-light" |
| 124 | > |
| 125 | <Copy className="w-3 h-3" /> |
| 126 | </button> |
| 127 | </TooltipTrigger> |
| 128 | <TooltipContent side="left">Copy JSON</TooltipContent> |
| 129 | </Tooltip> |
| 130 | </div> |
| 131 | </div> |
| 132 | {isExpanded && ( |
| 133 | <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"> |
| 134 | {JSON.stringify(event.properties, null, 2)} |
| 135 | </pre> |
| 136 | )} |
| 137 | </div> |
| 138 | ) |
| 139 | } |
| 140 | |
| 141 | function FlagRow({ |
| 142 | flagName, |
| 143 | currentValue, |
| 144 | originalValue, |
| 145 | isOverridden, |
| 146 | onToggle, |
| 147 | }: { |
| 148 | flagName: string |
| 149 | currentValue: unknown |
| 150 | originalValue: unknown |
| 151 | isOverridden: boolean |
| 152 | onToggle: (value: unknown) => void |
| 153 | }) { |
| 154 | const valueType = typeof originalValue |
| 155 | const isNull = originalValue === null |
| 156 | const inputProps = { |
| 157 | size: 'tiny' as const, |
| 158 | value: String(currentValue), |
| 159 | onChange: (event: ChangeEvent<HTMLInputElement>) => onToggle(event.target.value), |
| 160 | className: 'w-32', |
| 161 | } |
| 162 | |
| 163 | return ( |
| 164 | <div className={cn('px-4 py-3 flex flex-col gap-0.5', isOverridden && 'bg-warning/5')}> |
| 165 | <div className="flex items-center justify-between gap-4"> |
| 166 | <div className="flex flex-col items-start gap-1"> |
| 167 | <div className="flex gap-2 min-w-0 h-4"> |
| 168 | <span |
| 169 | className={cn( |
| 170 | 'font-mono text-xs truncate', |
| 171 | isOverridden ? 'text-warning' : 'text-foreground' |
| 172 | )} |
| 173 | > |
| 174 | {flagName} |
| 175 | </span> |
| 176 | {isOverridden && ( |
| 177 | <Badge variant="warning" className="shrink-0"> |
| 178 | Overridden |
| 179 | </Badge> |
| 180 | )} |
| 181 | {isNull && ( |
| 182 | <Badge variant="secondary" className="shrink-0"> |
| 183 | null |
| 184 | </Badge> |
| 185 | )} |
| 186 | </div> |
| 187 | <div className="text-xs text-foreground-muted uppercase font-mono"> |
| 188 | Original:{' '} |
| 189 | <code className="text-foreground-lighter">{JSON.stringify(originalValue)}</code> |
| 190 | </div> |
| 191 | </div> |
| 192 | |
| 193 | {isNull ? ( |
| 194 | <Input size="tiny" value="null" disabled className="w-32 opacity-50" /> |
| 195 | ) : valueType === 'boolean' ? ( |
| 196 | <Switch |
| 197 | checked={currentValue as boolean} |
| 198 | onCheckedChange={(checked) => onToggle(checked)} |
| 199 | /> |
| 200 | ) : valueType === 'number' ? ( |
| 201 | <Input type="number" {...inputProps} /> |
| 202 | ) : ( |
| 203 | <Input {...inputProps} /> |
| 204 | )} |
| 205 | </div> |
| 206 | </div> |
| 207 | ) |
| 208 | } |
| 209 | |
| 210 | export function DevToolbar({ extraTabs = [] }: { extraTabs?: ExtraTab[] }) { |
| 211 | const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar() |
| 212 | const [activeTab, setActiveTab] = useState<string>('events') |
| 213 | const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog') |
| 214 | const [eventFilter, setEventFilter] = useState<string>('') |
| 215 | const { posthog: posthogFlags, configcat: configcatFlags } = useFeatureFlags() |
| 216 | const [phFlagOverrides, setPhFlagOverrides] = useState<Record<string, unknown>>({}) |
| 217 | const [ccFlagOverrides, setCcFlagOverrides] = useState<Record<string, unknown>>({}) |
| 218 | const [phFlagOriginals, setPhFlagOriginals] = useState<Record<string, unknown>>({}) |
| 219 | const [ccFlagOriginals, setCcFlagOriginals] = useState<Record<string, unknown>>({}) |
| 220 | |
| 221 | const loadOverrides = useCallback( |
| 222 | (cookieName: string, label: string, setter: (value: Record<string, unknown>) => void) => { |
| 223 | const parsed = safeJsonParse<Record<string, unknown>>(getCookie(cookieName), {}, label) |
| 224 | if (Object.keys(parsed).length > 0) { |
| 225 | setter(parsed) |
| 226 | } |
| 227 | }, |
| 228 | [] |
| 229 | ) |
| 230 | |
| 231 | const saveOverrides = useCallback( |
| 232 | ( |
| 233 | cookieName: string, |
| 234 | overrides: Record<string, unknown>, |
| 235 | setter: (value: Record<string, unknown>) => void |
| 236 | ) => { |
| 237 | setter(overrides) |
| 238 | if (Object.keys(overrides).length > 0) { |
| 239 | setCookie(cookieName, JSON.stringify(overrides), '/') |
| 240 | } else { |
| 241 | deleteCookie(cookieName) |
| 242 | } |
| 243 | }, |
| 244 | [] |
| 245 | ) |
| 246 | |
| 247 | const updateOriginals = useCallback( |
| 248 | ( |
| 249 | storageKey: typeof PH_ORIGINALS_KEY | typeof CC_ORIGINALS_KEY, |
| 250 | setter: Dispatch<SetStateAction<Record<string, unknown>>> |
| 251 | ) => |
| 252 | (updater: (prev: Record<string, unknown>) => Record<string, unknown>) => { |
| 253 | setter((prev) => { |
| 254 | const next = updater(prev) |
| 255 | writeOriginals(storageKey, next) |
| 256 | return next |
| 257 | }) |
| 258 | }, |
| 259 | [] |
| 260 | ) |
| 261 | |
| 262 | useEffect(() => { |
| 263 | loadOverrides('x-ph-flag-overrides', 'PostHog flag overrides', setPhFlagOverrides) |
| 264 | loadOverrides('x-cc-flag-overrides', 'ConfigCat flag overrides', setCcFlagOverrides) |
| 265 | }, [loadOverrides]) |
| 266 | |
| 267 | useEffect(() => { |
| 268 | setPhFlagOriginals(readOriginals(PH_ORIGINALS_KEY)) |
| 269 | setCcFlagOriginals(readOriginals(CC_ORIGINALS_KEY)) |
| 270 | }, []) |
| 271 | |
| 272 | useEffect(() => { |
| 273 | const STYLE_ID = 'dev-toolbar-hide-native-devtools' |
| 274 | const existing = document.getElementById(STYLE_ID) |
| 275 | |
| 276 | if (isOpen) { |
| 277 | if (!existing) { |
| 278 | const style = document.createElement('style') |
| 279 | style.id = STYLE_ID |
| 280 | style.textContent = ` |
| 281 | .tsqd-open-btn, .tsqd-open-btn-container { display: none !important; } |
| 282 | ` |
| 283 | document.head.appendChild(style) |
| 284 | } |
| 285 | } else { |
| 286 | existing?.remove() |
| 287 | } |
| 288 | }, [isOpen]) |
| 289 | |
| 290 | const updatePhOriginals = updateOriginals(PH_ORIGINALS_KEY, setPhFlagOriginals) |
| 291 | const updateCcOriginals = updateOriginals(CC_ORIGINALS_KEY, setCcFlagOriginals) |
| 292 | |
| 293 | const togglePhFlagOverride = (flagName: string, value: unknown) => { |
| 294 | const originalValue = phFlagOriginals[flagName] ?? posthogFlags[flagName] |
| 295 | const parsedValue = parseOverrideValue(value, originalValue) |
| 296 | if (valuesAreEqual(parsedValue, originalValue)) { |
| 297 | const newOverrides = { ...phFlagOverrides } |
| 298 | delete newOverrides[flagName] |
| 299 | saveOverrides('x-ph-flag-overrides', newOverrides, setPhFlagOverrides) |
| 300 | return |
| 301 | } |
| 302 | |
| 303 | updatePhOriginals((prev) => |
| 304 | flagName in prev ? prev : { ...prev, [flagName]: posthogFlags[flagName] } |
| 305 | ) |
| 306 | const newOverrides = { ...phFlagOverrides, [flagName]: parsedValue } |
| 307 | saveOverrides('x-ph-flag-overrides', newOverrides, setPhFlagOverrides) |
| 308 | } |
| 309 | |
| 310 | const toggleCcFlagOverride = (flagName: string, value: unknown) => { |
| 311 | const originalValue = ccFlagOriginals[flagName] ?? configcatFlags[flagName] |
| 312 | const parsedValue = parseOverrideValue(value, originalValue) |
| 313 | if (valuesAreEqual(parsedValue, originalValue)) { |
| 314 | const newOverrides = { ...ccFlagOverrides } |
| 315 | delete newOverrides[flagName] |
| 316 | saveOverrides('x-cc-flag-overrides', newOverrides, setCcFlagOverrides) |
| 317 | return |
| 318 | } |
| 319 | |
| 320 | updateCcOriginals((prev) => |
| 321 | flagName in prev ? prev : { ...prev, [flagName]: configcatFlags[flagName] } |
| 322 | ) |
| 323 | const newOverrides = { ...ccFlagOverrides, [flagName]: parsedValue } |
| 324 | saveOverrides('x-cc-flag-overrides', newOverrides, setCcFlagOverrides) |
| 325 | } |
| 326 | |
| 327 | const clearAllOverrides = () => { |
| 328 | setPhFlagOverrides({}) |
| 329 | setCcFlagOverrides({}) |
| 330 | setPhFlagOriginals({}) |
| 331 | setCcFlagOriginals({}) |
| 332 | deleteCookie('x-ph-flag-overrides') |
| 333 | deleteCookie('x-cc-flag-overrides') |
| 334 | writeOriginals(PH_ORIGINALS_KEY, {}) |
| 335 | writeOriginals(CC_ORIGINALS_KEY, {}) |
| 336 | window.location.reload() |
| 337 | } |
| 338 | |
| 339 | const normalizedFilter = eventFilter.trim().toLowerCase() |
| 340 | const filteredEvents = ( |
| 341 | normalizedFilter |
| 342 | ? events.filter( |
| 343 | (e) => |
| 344 | e.eventName.toLowerCase().includes(normalizedFilter) || |
| 345 | e.eventType.toLowerCase().includes(normalizedFilter) |
| 346 | ) |
| 347 | : events |
| 348 | ) |
| 349 | .slice() |
| 350 | .sort((a, b) => b.timestamp - a.timestamp) |
| 351 | |
| 352 | const phOverrideCount = Object.keys(phFlagOverrides).length |
| 353 | const ccOverrideCount = Object.keys(ccFlagOverrides).length |
| 354 | const totalOverrideCount = phOverrideCount + ccOverrideCount |
| 355 | |
| 356 | if (!IS_TOOLBAR_ENABLED || !isEnabled) return null |
| 357 | |
| 358 | return ( |
| 359 | <Sheet open={isOpen} onOpenChange={setIsOpen}> |
| 360 | <SheetContent |
| 361 | side="bottom" |
| 362 | size="lg" |
| 363 | className="flex flex-col p-0 gap-0 overflow-hidden" |
| 364 | showClose={false} |
| 365 | hasOverlay={false} |
| 366 | onOpenAutoFocus={(event) => event.preventDefault()} |
| 367 | > |
| 368 | <Tabs |
| 369 | value={activeTab} |
| 370 | onValueChange={setActiveTab} |
| 371 | className="flex flex-col flex-1 min-h-0 overflow-hidden" |
| 372 | > |
| 373 | <SheetHeader className="border-b shrink-0 space-y-0 p-0"> |
| 374 | <SheetTitle className="sr-only">Dev Toolbar</SheetTitle> |
| 375 | <SheetDescription className="sr-only"> |
| 376 | View telemetry events and feature flags for local development |
| 377 | </SheetDescription> |
| 378 | <div className="flex items-center px-6"> |
| 379 | <Image |
| 380 | src="/img/logo-pixel-small-light.png" |
| 381 | alt="Dev Toolbar" |
| 382 | width={16} |
| 383 | height={16} |
| 384 | style={{ |
| 385 | filter: |
| 386 | 'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)', |
| 387 | }} |
| 388 | aria-hidden="true" |
| 389 | className="shrink-0 mr-4" |
| 390 | /> |
| 391 | <TabsList className="flex gap-x-4 rounded-none border-none! h-auto"> |
| 392 | <TabsTrigger value="events" className="text-xs py-3 border-b font-mono uppercase"> |
| 393 | Events ({filteredEvents.length}) |
| 394 | </TabsTrigger> |
| 395 | <TabsTrigger value="flags" className="text-xs py-3 border-b font-mono uppercase"> |
| 396 | Flags {totalOverrideCount > 0 && `(${totalOverrideCount})`} |
| 397 | </TabsTrigger> |
| 398 | {extraTabs.map((tab) => ( |
| 399 | <TabsTrigger |
| 400 | key={tab.id} |
| 401 | value={tab.id} |
| 402 | className="text-xs py-3 border-b font-mono uppercase" |
| 403 | > |
| 404 | {tab.label} |
| 405 | </TabsTrigger> |
| 406 | ))} |
| 407 | </TabsList> |
| 408 | <div className="ml-auto flex items-center gap-2"> |
| 409 | <Tooltip> |
| 410 | <TooltipTrigger asChild> |
| 411 | <Button |
| 412 | type="text" |
| 413 | icon={<EyeOff className="w-4 h-4" />} |
| 414 | onClick={dismissToolbar} |
| 415 | className="text-foreground-light hover:text-foreground p-1" |
| 416 | /> |
| 417 | </TooltipTrigger> |
| 418 | <TooltipContent side="top">Hide Dev Toolbar</TooltipContent> |
| 419 | </Tooltip> |
| 420 | <Tooltip> |
| 421 | <TooltipTrigger asChild> |
| 422 | <SheetClose asChild> |
| 423 | <Button |
| 424 | type="text" |
| 425 | icon={<X className="w-4 h-4" />} |
| 426 | className="text-foreground-light hover:text-foreground p-1" |
| 427 | /> |
| 428 | </SheetClose> |
| 429 | </TooltipTrigger> |
| 430 | <TooltipContent side="top">Close</TooltipContent> |
| 431 | </Tooltip> |
| 432 | </div> |
| 433 | </div> |
| 434 | </SheetHeader> |
| 435 | |
| 436 | <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> |
| 437 | {activeTab === 'events' && ( |
| 438 | <div className="flex-1 overflow-hidden flex flex-col"> |
| 439 | <div className="flex items-center justify-between border-b shrink-0 px-6 py-2 gap-2"> |
| 440 | <InputGroup className="w-full max-w-sm"> |
| 441 | <InputGroupInput |
| 442 | size="tiny" |
| 443 | placeholder="Filter events..." |
| 444 | value={eventFilter} |
| 445 | onChange={(e) => setEventFilter(e.target.value)} |
| 446 | /> |
| 447 | <InputGroupAddon> |
| 448 | <Search size={14} className="text-foreground-lighter" /> |
| 449 | </InputGroupAddon> |
| 450 | </InputGroup> |
| 451 | <Button |
| 452 | type="default" |
| 453 | onClick={() => setEvents([])} |
| 454 | className="text-foreground-lighter hover:text-foreground" |
| 455 | > |
| 456 | Clear all |
| 457 | </Button> |
| 458 | </div> |
| 459 | |
| 460 | {!IS_LOCAL_DEV && ( |
| 461 | <div className="px-6 py-2 text-xs text-foreground-muted border-b bg-surface-100"> |
| 462 | Server-side events are only visible when using the toolbar in local development |
| 463 | </div> |
| 464 | )} |
| 465 | |
| 466 | <div className="flex-1 min-h-0 overflow-y-auto pb-4"> |
| 467 | {filteredEvents.length === 0 ? ( |
| 468 | <div className="text-center text-foreground-lighter py-8 text-sm"> |
| 469 | No events yet. Interact with the app to see telemetry events. |
| 470 | </div> |
| 471 | ) : ( |
| 472 | <div className="overflow-hidden"> |
| 473 | {filteredEvents.map((event) => ( |
| 474 | <EventRow key={`${event.source}-${event.id}`} event={event} /> |
| 475 | ))} |
| 476 | </div> |
| 477 | )} |
| 478 | </div> |
| 479 | </div> |
| 480 | )} |
| 481 | |
| 482 | {activeTab === 'flags' && ( |
| 483 | <div className="flex-1 min-h-0 overflow-hidden flex"> |
| 484 | {/* Sidebar */} |
| 485 | <div className="w-44 border-r shrink-0 flex flex-col"> |
| 486 | <nav className="flex flex-col p-3 gap-0.5"> |
| 487 | {( |
| 488 | [ |
| 489 | { id: 'posthog', label: 'PostHog', count: phOverrideCount }, |
| 490 | { id: 'configcat', label: 'ConfigCat', count: ccOverrideCount }, |
| 491 | ] as const |
| 492 | ).map(({ id, label, count }) => ( |
| 493 | <button |
| 494 | key={id} |
| 495 | type="button" |
| 496 | onClick={() => setFlagsSubTab(id)} |
| 497 | className={cn( |
| 498 | 'flex items-center justify-between px-3 py-1.5 rounded-sm text-sm text-left uppercase font-mono tracking-wide', |
| 499 | flagsSubTab === id |
| 500 | ? 'bg-surface-300 text-foreground' |
| 501 | : 'text-foreground-light hover:bg-surface-200' |
| 502 | )} |
| 503 | > |
| 504 | <span className="font-mono text-xs">{label}</span> |
| 505 | {count > 0 && ( |
| 506 | <span className="text-xs text-foreground-lighter">{count}</span> |
| 507 | )} |
| 508 | </button> |
| 509 | ))} |
| 510 | </nav> |
| 511 | {totalOverrideCount > 0 && ( |
| 512 | <div className="mt-auto p-2 border-t"> |
| 513 | <Button type="outline" size="tiny" block onClick={clearAllOverrides}> |
| 514 | Reset & Reload |
| 515 | </Button> |
| 516 | </div> |
| 517 | )} |
| 518 | </div> |
| 519 | |
| 520 | {/* Flag list */} |
| 521 | <div className="flex-1 min-h-0 overflow-y-auto pb-6"> |
| 522 | <div className="divide-y"> |
| 523 | {flagsSubTab === 'posthog' && |
| 524 | (Object.keys(posthogFlags).length === 0 ? ( |
| 525 | <div className="text-center text-foreground-lighter py-8 text-sm"> |
| 526 | No PostHog feature flags loaded yet. |
| 527 | </div> |
| 528 | ) : ( |
| 529 | Object.entries(posthogFlags).map(([flagName, flagValue]) => ( |
| 530 | <FlagRow |
| 531 | key={flagName} |
| 532 | flagName={flagName} |
| 533 | currentValue={ |
| 534 | phFlagOverrides[flagName] ?? phFlagOriginals[flagName] ?? flagValue |
| 535 | } |
| 536 | originalValue={phFlagOriginals[flagName] ?? flagValue} |
| 537 | isOverridden={flagName in phFlagOverrides} |
| 538 | onToggle={(value) => togglePhFlagOverride(flagName, value)} |
| 539 | /> |
| 540 | )) |
| 541 | ))} |
| 542 | {flagsSubTab === 'configcat' && |
| 543 | (Object.keys(configcatFlags).length === 0 ? ( |
| 544 | <div className="text-center text-foreground-lighter py-8 text-sm"> |
| 545 | No ConfigCat feature flags loaded yet. |
| 546 | </div> |
| 547 | ) : ( |
| 548 | Object.entries(configcatFlags).map(([flagName, flagValue]) => ( |
| 549 | <FlagRow |
| 550 | key={flagName} |
| 551 | flagName={flagName} |
| 552 | currentValue={ |
| 553 | ccFlagOverrides[flagName] ?? ccFlagOriginals[flagName] ?? flagValue |
| 554 | } |
| 555 | originalValue={ccFlagOriginals[flagName] ?? flagValue} |
| 556 | isOverridden={flagName in ccFlagOverrides} |
| 557 | onToggle={(value) => toggleCcFlagOverride(flagName, value)} |
| 558 | /> |
| 559 | )) |
| 560 | ))} |
| 561 | </div> |
| 562 | </div> |
| 563 | </div> |
| 564 | )} |
| 565 | |
| 566 | {extraTabs.map((tab) => ( |
| 567 | <div |
| 568 | key={tab.id} |
| 569 | className={cn('flex-1 min-h-0 overflow-y-auto', activeTab !== tab.id && 'hidden')} |
| 570 | > |
| 571 | {tab.content} |
| 572 | </div> |
| 573 | ))} |
| 574 | </div> |
| 575 | </Tabs> |
| 576 | </SheetContent> |
| 577 | </Sheet> |
| 578 | ) |
| 579 | } |