Skip to content

Commit 00c9160

Browse files
authored
Merge pull request #844 from objectstack-ai/copilot/add-url-driven-debug-panel
2 parents a27c3c7 + 0e84e99 commit 00c9160

File tree

17 files changed

+1096
-12
lines changed

17 files changed

+1096
-12
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
865865
- [x] **P2: Full Examples Metadata Audit** — Systematic spec compliance audit across all 4 examples: added `type: 'dashboard'` + `description` to todo/kitchen-sink dashboards, refactored msw-todo to use `ObjectSchema.create` + `Field.*` with snake_case field names, added explicit views to kitchen-sink and msw-todo, added missing `successMessage` on CRM opportunity action, 21 automated compliance tests
866866
- [x] **P2: CRM Dashboard Full provider:'object' Adaptation** — Converted all chart and table widgets in CRM dashboard from static `provider: 'value'` to dynamic `provider: 'object'` with aggregation configs. 12 widgets total: 4 KPI metrics (static), 7 charts (sum/count/avg/max aggregation across opportunity, product, order objects), 1 table (dynamic fetch). Cross-object coverage (order), diverse aggregate functions (sum, count, avg, max). Fixed table `close_date` field alignment. Added i18n for 2 new widgets (10 locales). 9 new CRM metadata tests, 6 new DashboardRenderer rendering tests (area/donut/line/cross-object + edge cases). All provider:'object' paths covered.
867867
- [x] **P1: Dashboard provider:'object' Crash & Blank Rendering Fixes** — Fixed 3 critical bugs causing all charts to be blank and tables to crash on provider:'object' dashboards: (1) DashboardRenderer `...options` spread was leaking provider config objects as `data` in data-table and pivot schemas — fixed by destructuring `data` out before spread, (2) DataTableRenderer and PivotTable now guard with `Array.isArray()` for graceful degradation when non-array data arrives, (3) ObjectChart now shows visible loading/warning messages instead of silently rendering blank when `dataSource` is missing. Also added provider:'object' support to DashboardGridLayout (charts, tables, pivots). 2 new regression tests.
868+
- [x] **P1: URL-Driven Debug/Developer Panel** — Universal debug mode activated via `?__debug` URL parameter (amis devtools-style). `@object-ui/core`: exported `DebugFlags`, `DebugCollector` (perf/expr/event data collection, tree-shakeable), `parseDebugFlags()`, enhanced `isDebugEnabled()` (URL → globalThis → env resolution, SSR-safe). `@object-ui/react`: `useDebugMode` hook with URL detection, Ctrl+Shift+D shortcut, manual toggle; `SchemaRendererContext` extended with `debugFlags`; `SchemaRenderer` injects `data-debug-type`/`data-debug-id` attrs + reports render perf to `DebugCollector` when debug enabled. `@object-ui/components`: floating `DebugPanel` with 7 built-in tabs (Schema, Data, Perf, Expr, Events, Registry, Flags), plugin-extensible via `extraTabs`. Console `MetadataInspector` auto-opens when `?__debug` detected. Fine-grained sub-flags: `?__debug_schema`, `?__debug_perf`, `?__debug_data`, `?__debug_expr`, `?__debug_events`, `?__debug_registry`. 48 new tests.
868869

869870
### Ecosystem & Marketplace
870871
- Plugin marketplace website with search, ratings, and install count

apps/console/src/components/MetadataInspector.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
* Used across ObjectView, DashboardView, PageView, ReportView, and RecordDetailView.
66
*/
77

8-
import { useState } from 'react';
8+
import { useState, useMemo } from 'react';
99
import { Button } from '@object-ui/components';
1010
import { Code2, Copy, Check, ChevronDown, ChevronRight } from 'lucide-react';
11+
import { parseDebugFlags } from '@object-ui/core';
1112

1213
interface MetadataSection {
1314
title: string;
@@ -128,9 +129,19 @@ export function MetadataPanel({ sections, open }: Omit<MetadataInspectorProps, '
128129

129130
/**
130131
* Hook to manage MetadataInspector open/close state.
132+
* Automatically opens when `?__debug` URL parameter is present.
131133
*/
132134
export function useMetadataInspector() {
133-
const [showDebug, setShowDebug] = useState(false);
135+
const autoOpen = useMemo(() => {
136+
try {
137+
return typeof window !== 'undefined'
138+
? parseDebugFlags(window.location.search).enabled
139+
: false;
140+
} catch {
141+
return false;
142+
}
143+
}, []);
144+
const [showDebug, setShowDebug] = useState(autoOpen);
134145
return {
135146
showDebug,
136147
toggleDebug: () => setShowDebug(prev => !prev),
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React, { useState, useCallback, useMemo } from 'react';
10+
import type { DebugFlags } from '@object-ui/core';
11+
import { ComponentRegistry, DebugCollector } from '@object-ui/core';
12+
import type { PerfEntry, ExprEntry, EventEntry, DebugEntry } from '@object-ui/core';
13+
import { cn } from '../lib/utils';
14+
15+
/* ------------------------------------------------------------------ */
16+
/* Types */
17+
/* ------------------------------------------------------------------ */
18+
19+
export interface DebugPanelTab {
20+
id: string;
21+
label: string;
22+
icon?: React.ReactNode;
23+
render: () => React.ReactNode;
24+
}
25+
26+
export interface DebugPanelProps {
27+
/** Whether the panel is open */
28+
open: boolean;
29+
/** Toggle callback */
30+
onClose: () => void;
31+
/** Debug flags from the URL / hook */
32+
flags?: DebugFlags;
33+
/** Current schema being rendered (for the Schema tab) */
34+
schema?: unknown;
35+
/** Current data context (for the Data tab) */
36+
dataContext?: unknown;
37+
/** Extra tabs provided by plugins */
38+
extraTabs?: DebugPanelTab[];
39+
/** CSS class override */
40+
className?: string;
41+
}
42+
43+
/* ------------------------------------------------------------------ */
44+
/* Built-in tab renderers */
45+
/* ------------------------------------------------------------------ */
46+
47+
function SchemaTab({ schema }: { schema?: unknown }) {
48+
if (!schema) {
49+
return <p className="text-xs text-muted-foreground italic">No schema available</p>;
50+
}
51+
return (
52+
<pre className="text-[11px] leading-relaxed font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap break-all">
53+
{JSON.stringify(schema, null, 2)}
54+
</pre>
55+
);
56+
}
57+
58+
function DataTab({ dataContext }: { dataContext?: unknown }) {
59+
if (!dataContext) {
60+
return <p className="text-xs text-muted-foreground italic">No data context available</p>;
61+
}
62+
return (
63+
<pre className="text-[11px] leading-relaxed font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap break-all">
64+
{JSON.stringify(dataContext, null, 2)}
65+
</pre>
66+
);
67+
}
68+
69+
function RegistryTab() {
70+
const entries = useMemo(() => {
71+
try {
72+
return ComponentRegistry.getAllTypes();
73+
} catch {
74+
return [];
75+
}
76+
}, []);
77+
78+
if (entries.length === 0) {
79+
return <p className="text-xs text-muted-foreground italic">No registered components</p>;
80+
}
81+
return (
82+
<div className="space-y-1 max-h-[60vh] overflow-auto">
83+
{entries.map((name: string) => (
84+
<div
85+
key={name}
86+
className="flex items-center gap-2 px-2 py-1 rounded text-xs font-mono bg-muted/30"
87+
>
88+
<span className="inline-block w-2 h-2 rounded-full bg-green-500 shrink-0" />
89+
{name}
90+
</div>
91+
))}
92+
<p className="text-[10px] text-muted-foreground mt-2">
93+
{entries.length} component{entries.length !== 1 ? 's' : ''} registered
94+
</p>
95+
</div>
96+
);
97+
}
98+
99+
function FlagsTab({ flags }: { flags?: DebugFlags }) {
100+
if (!flags) {
101+
return <p className="text-xs text-muted-foreground italic">No debug flags</p>;
102+
}
103+
return (
104+
<pre className="text-[11px] leading-relaxed font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap break-all">
105+
{JSON.stringify(flags, null, 2)}
106+
</pre>
107+
);
108+
}
109+
110+
/* ------------------------------------------------------------------ */
111+
/* Collector-backed tabs (Perf / Expr / Events) */
112+
/* ------------------------------------------------------------------ */
113+
114+
function useCollectorEntries(kind?: DebugEntry['kind']): DebugEntry[] {
115+
const collector = DebugCollector.getInstance();
116+
const [entries, setEntries] = useState<DebugEntry[]>(() => collector.getEntries(kind));
117+
118+
React.useEffect(() => {
119+
// Sync on mount in case entries were added before subscribe
120+
setEntries(collector.getEntries(kind));
121+
const unsub = collector.subscribe(() => {
122+
setEntries(collector.getEntries(kind));
123+
});
124+
return unsub;
125+
}, [collector, kind]);
126+
127+
return entries;
128+
}
129+
130+
function PerfTab() {
131+
const entries = useCollectorEntries('perf');
132+
const perfItems = entries.map((e) => e.data as PerfEntry);
133+
134+
if (perfItems.length === 0) {
135+
return <p className="text-xs text-muted-foreground italic">No performance data collected yet</p>;
136+
}
137+
return (
138+
<div className="space-y-1 max-h-[60vh] overflow-auto">
139+
{perfItems.map((p, i) => (
140+
<div
141+
key={i}
142+
className={cn(
143+
'flex items-center justify-between px-2 py-1 rounded text-xs font-mono',
144+
p.durationMs > 16 ? 'bg-red-50 text-red-700' : 'bg-muted/30',
145+
)}
146+
>
147+
<span className="truncate mr-2">{p.type}{p.id ? `:${p.id}` : ''}</span>
148+
<span className="shrink-0 tabular-nums">{p.durationMs.toFixed(2)}ms</span>
149+
</div>
150+
))}
151+
<p className="text-[10px] text-muted-foreground mt-2">
152+
{perfItems.length} render{perfItems.length !== 1 ? 's' : ''} tracked
153+
</p>
154+
</div>
155+
);
156+
}
157+
158+
function ExprTab() {
159+
const entries = useCollectorEntries('expr');
160+
const exprItems = entries.map((e) => e.data as ExprEntry);
161+
162+
if (exprItems.length === 0) {
163+
return <p className="text-xs text-muted-foreground italic">No expression evaluations tracked yet</p>;
164+
}
165+
return (
166+
<div className="space-y-1.5 max-h-[60vh] overflow-auto">
167+
{exprItems.map((ex, i) => (
168+
<div key={i} className="px-2 py-1.5 rounded bg-muted/30 text-xs font-mono">
169+
<div className="text-muted-foreground truncate">{ex.expression}</div>
170+
<div className="mt-0.5">{JSON.stringify(ex.result)}</div>
171+
</div>
172+
))}
173+
<p className="text-[10px] text-muted-foreground mt-2">
174+
{exprItems.length} evaluation{exprItems.length !== 1 ? 's' : ''} tracked
175+
</p>
176+
</div>
177+
);
178+
}
179+
180+
function EventsTab() {
181+
const entries = useCollectorEntries('event');
182+
const eventItems = entries.map((e) => e.data as EventEntry);
183+
184+
if (eventItems.length === 0) {
185+
return <p className="text-xs text-muted-foreground italic">No events captured yet</p>;
186+
}
187+
return (
188+
<div className="space-y-1.5 max-h-[60vh] overflow-auto">
189+
{eventItems.map((ev, i) => (
190+
<div key={i} className="px-2 py-1.5 rounded bg-muted/30 text-xs font-mono">
191+
<div className="flex items-center justify-between">
192+
<span className="font-semibold">{ev.action}</span>
193+
<span className="text-[10px] text-muted-foreground tabular-nums">
194+
{new Date(ev.timestamp).toLocaleTimeString()}
195+
</span>
196+
</div>
197+
{ev.payload !== undefined && (
198+
<pre className="mt-0.5 text-[10px] text-muted-foreground truncate">
199+
{JSON.stringify(ev.payload)}
200+
</pre>
201+
)}
202+
</div>
203+
))}
204+
<p className="text-[10px] text-muted-foreground mt-2">
205+
{eventItems.length} event{eventItems.length !== 1 ? 's' : ''} captured
206+
</p>
207+
</div>
208+
);
209+
}
210+
211+
/* ------------------------------------------------------------------ */
212+
/* DebugPanel */
213+
/* ------------------------------------------------------------------ */
214+
215+
/**
216+
* A floating developer debug panel activated via URL parameters or manual toggle.
217+
*
218+
* Built-in tabs:
219+
* - **Schema** — current rendered JSON schema
220+
* - **Data** — active data context
221+
* - **Perf** — component render timing (highlights slow renders >16ms)
222+
* - **Expr** — expression evaluation trace
223+
* - **Events** — action/event timeline
224+
* - **Registry** — all registered component types
225+
* - **Flags** — current debug flags
226+
*
227+
* Plugins can inject additional tabs via the `extraTabs` prop.
228+
*/
229+
export function DebugPanel({
230+
open,
231+
onClose,
232+
flags,
233+
schema,
234+
dataContext,
235+
extraTabs = [],
236+
className,
237+
}: DebugPanelProps) {
238+
const builtInTabs: DebugPanelTab[] = useMemo(() => [
239+
{ id: 'schema', label: 'Schema', render: () => <SchemaTab schema={schema} /> },
240+
{ id: 'data', label: 'Data', render: () => <DataTab dataContext={dataContext} /> },
241+
{ id: 'perf', label: 'Perf', render: () => <PerfTab /> },
242+
{ id: 'expr', label: 'Expr', render: () => <ExprTab /> },
243+
{ id: 'events', label: 'Events', render: () => <EventsTab /> },
244+
{ id: 'registry', label: 'Registry', render: () => <RegistryTab /> },
245+
{ id: 'flags', label: 'Flags', render: () => <FlagsTab flags={flags} /> },
246+
], [schema, dataContext, flags]);
247+
248+
const allTabs = useMemo(() => [...builtInTabs, ...extraTabs], [builtInTabs, extraTabs]);
249+
const [activeTabId, setActiveTabId] = useState(allTabs[0]?.id ?? 'schema');
250+
251+
const activeTab = allTabs.find((t) => t.id === activeTabId) ?? allTabs[0];
252+
253+
const handleTabChange = useCallback((id: string) => {
254+
setActiveTabId(id);
255+
}, []);
256+
257+
if (!open) return null;
258+
259+
return (
260+
<div
261+
className={cn(
262+
'fixed bottom-4 right-4 z-[9999] w-[420px] max-w-[95vw] rounded-lg border bg-background shadow-2xl',
263+
'flex flex-col overflow-hidden',
264+
className,
265+
)}
266+
data-testid="debug-panel"
267+
role="dialog"
268+
aria-label="Developer Debug Panel"
269+
>
270+
{/* Header */}
271+
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
272+
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
273+
🛠 Debug Panel
274+
</span>
275+
<button
276+
onClick={onClose}
277+
className="text-muted-foreground hover:text-foreground text-sm leading-none px-1"
278+
aria-label="Close debug panel"
279+
data-testid="debug-panel-close"
280+
>
281+
282+
</button>
283+
</div>
284+
285+
{/* Tabs */}
286+
<div className="flex border-b overflow-x-auto" role="tablist">
287+
{allTabs.map((tab) => (
288+
<button
289+
key={tab.id}
290+
role="tab"
291+
aria-selected={tab.id === activeTab?.id}
292+
onClick={() => handleTabChange(tab.id)}
293+
className={cn(
294+
'px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors',
295+
tab.id === activeTab?.id
296+
? 'border-b-2 border-primary text-foreground'
297+
: 'text-muted-foreground hover:text-foreground',
298+
)}
299+
data-testid={`debug-tab-${tab.id}`}
300+
>
301+
{tab.icon && <span className="mr-1">{tab.icon}</span>}
302+
{tab.label}
303+
</button>
304+
))}
305+
</div>
306+
307+
{/* Content */}
308+
<div className="p-3 overflow-auto max-h-[50vh]" data-testid="debug-panel-content">
309+
{activeTab?.render()}
310+
</div>
311+
</div>
312+
);
313+
}

0 commit comments

Comments
 (0)