@object-ui/components 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +12 -12
- package/CHANGELOG.md +8 -0
- package/dist/index.css +1 -1
- package/dist/index.js +24701 -22929
- package/dist/index.umd.cjs +37 -37
- package/dist/src/custom/config-field-renderer.d.ts +21 -0
- package/dist/src/custom/config-panel-renderer.d.ts +81 -0
- package/dist/src/custom/config-row.d.ts +27 -0
- package/dist/src/custom/index.d.ts +5 -0
- package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
- package/dist/src/custom/navigation-overlay.d.ts +8 -0
- package/dist/src/custom/section-header.d.ts +31 -0
- package/dist/src/debug/DebugPanel.d.ts +39 -0
- package/dist/src/debug/index.d.ts +9 -0
- package/dist/src/hooks/use-config-draft.d.ts +46 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/renderers/action/action-bar.d.ts +23 -0
- package/dist/src/types/config-panel.d.ts +92 -0
- package/dist/src/ui/sheet.d.ts +2 -0
- package/dist/src/ui/sidebar.d.ts +4 -0
- package/package.json +17 -17
- package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
- package/src/__tests__/action-bar.test.tsx +172 -0
- package/src/__tests__/config-field-renderer.test.tsx +307 -0
- package/src/__tests__/config-panel-renderer.test.tsx +580 -0
- package/src/__tests__/config-primitives.test.tsx +106 -0
- package/src/__tests__/mobile-accessibility.test.tsx +120 -0
- package/src/__tests__/navigation-overlay.test.tsx +97 -0
- package/src/__tests__/use-config-draft.test.tsx +295 -0
- package/src/custom/config-field-renderer.tsx +276 -0
- package/src/custom/config-panel-renderer.tsx +306 -0
- package/src/custom/config-row.tsx +50 -0
- package/src/custom/index.ts +5 -0
- package/src/custom/mobile-dialog-content.tsx +67 -0
- package/src/custom/navigation-overlay.tsx +42 -4
- package/src/custom/section-header.tsx +68 -0
- package/src/debug/DebugPanel.tsx +313 -0
- package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
- package/src/{index.test.ts → debug/index.ts} +2 -7
- package/src/hooks/use-config-draft.ts +127 -0
- package/src/index.css +4 -0
- package/src/index.ts +15 -0
- package/src/renderers/action/action-bar.tsx +202 -0
- package/src/renderers/action/index.ts +1 -0
- package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
- package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
- package/src/renderers/complex/data-table.tsx +346 -43
- package/src/renderers/data-display/breadcrumb.tsx +3 -2
- package/src/renderers/form/form.tsx +4 -4
- package/src/renderers/navigation/header-bar.tsx +69 -10
- package/src/stories/ConfigPanel.stories.tsx +232 -0
- package/src/types/config-panel.ts +101 -0
- package/src/ui/dialog.tsx +20 -3
- package/src/ui/sheet.tsx +6 -3
- package/src/ui/sidebar.tsx +93 -9
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* MobileDialogContent
|
|
11
|
+
*
|
|
12
|
+
* A mobile-optimized wrapper around the upstream Shadcn DialogContent.
|
|
13
|
+
* On mobile (< sm breakpoint), the dialog is full-screen with a larger
|
|
14
|
+
* close-button touch target (≥ 44×44px per WCAG 2.5.5).
|
|
15
|
+
* On tablet+ (≥ sm), it falls back to the standard centered dialog.
|
|
16
|
+
*
|
|
17
|
+
* This lives in `custom/` to avoid modifying the Shadcn-synced `ui/dialog.tsx`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
22
|
+
import { X } from 'lucide-react';
|
|
23
|
+
import { cn } from '../lib/utils';
|
|
24
|
+
import { DialogOverlay, DialogPortal } from '../ui/dialog';
|
|
25
|
+
|
|
26
|
+
export const MobileDialogContent = React.forwardRef<
|
|
27
|
+
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
28
|
+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
29
|
+
>(({ className, children, ...props }, ref) => (
|
|
30
|
+
<DialogPortal>
|
|
31
|
+
<DialogOverlay />
|
|
32
|
+
<DialogPrimitive.Content
|
|
33
|
+
ref={ref}
|
|
34
|
+
className={cn(
|
|
35
|
+
// Mobile-first: full-screen
|
|
36
|
+
'fixed inset-0 z-50 w-full bg-background p-4 shadow-lg duration-200',
|
|
37
|
+
'h-[100dvh]',
|
|
38
|
+
// Desktop (sm+): centered dialog with border + rounded corners
|
|
39
|
+
'sm:inset-auto sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%]',
|
|
40
|
+
'sm:max-w-lg sm:h-auto sm:max-h-[90vh] sm:rounded-lg sm:border sm:p-6',
|
|
41
|
+
// Animations
|
|
42
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
43
|
+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
44
|
+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
45
|
+
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
|
46
|
+
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
{...props}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
<DialogPrimitive.Close
|
|
53
|
+
className={cn(
|
|
54
|
+
'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity',
|
|
55
|
+
'hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
56
|
+
'disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground',
|
|
57
|
+
// Mobile touch target ≥ 44×44px (WCAG 2.5.5)
|
|
58
|
+
'min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 flex items-center justify-center',
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<X className="h-5 w-5 sm:h-4 sm:w-4" />
|
|
62
|
+
<span className="sr-only">Close</span>
|
|
63
|
+
</DialogPrimitive.Close>
|
|
64
|
+
</DialogPrimitive.Content>
|
|
65
|
+
</DialogPortal>
|
|
66
|
+
));
|
|
67
|
+
MobileDialogContent.displayName = 'MobileDialogContent';
|
|
@@ -89,6 +89,8 @@ export interface NavigationOverlayProps {
|
|
|
89
89
|
width?: string | number;
|
|
90
90
|
/** Whether navigation is an overlay mode */
|
|
91
91
|
isOverlay: boolean;
|
|
92
|
+
/** Target view/form name from NavigationConfig */
|
|
93
|
+
view?: string;
|
|
92
94
|
/** Title for the overlay header */
|
|
93
95
|
title?: string;
|
|
94
96
|
/** Description for the overlay header */
|
|
@@ -100,6 +102,12 @@ export interface NavigationOverlayProps {
|
|
|
100
102
|
* Receives the selected record.
|
|
101
103
|
*/
|
|
102
104
|
children: (record: Record<string, unknown>) => React.ReactNode;
|
|
105
|
+
/**
|
|
106
|
+
* Optional render function for a specific view/form based on `view` prop.
|
|
107
|
+
* When provided, this takes priority over `children` for rendering overlay content.
|
|
108
|
+
* Receives the selected record and the view name.
|
|
109
|
+
*/
|
|
110
|
+
renderView?: (record: Record<string, unknown>, viewName: string) => React.ReactNode;
|
|
103
111
|
/**
|
|
104
112
|
* The main content to wrap (for split mode only).
|
|
105
113
|
* In split mode, the main content is rendered in the left panel.
|
|
@@ -146,10 +154,12 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
|
|
|
146
154
|
close,
|
|
147
155
|
setIsOpen,
|
|
148
156
|
width,
|
|
157
|
+
view,
|
|
149
158
|
title,
|
|
150
159
|
description,
|
|
151
160
|
className,
|
|
152
161
|
children,
|
|
162
|
+
renderView,
|
|
153
163
|
mainContent,
|
|
154
164
|
popoverTrigger,
|
|
155
165
|
}) => {
|
|
@@ -165,6 +175,14 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
|
|
|
165
175
|
const widthStyle = getWidthStyle(width);
|
|
166
176
|
const resolvedTitle = title || 'Record Detail';
|
|
167
177
|
|
|
178
|
+
// Use renderView when both renderView and view are provided; otherwise fallback to children
|
|
179
|
+
const renderContent = (record: Record<string, unknown>) => {
|
|
180
|
+
if (renderView && view) {
|
|
181
|
+
return renderView(record, view);
|
|
182
|
+
}
|
|
183
|
+
return children(record);
|
|
184
|
+
};
|
|
185
|
+
|
|
168
186
|
// --- Drawer Mode (Sheet) ---
|
|
169
187
|
if (mode === 'drawer') {
|
|
170
188
|
return (
|
|
@@ -179,7 +197,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
|
|
|
179
197
|
{description && <SheetDescription>{description}</SheetDescription>}
|
|
180
198
|
</SheetHeader>
|
|
181
199
|
<div className="mt-4">
|
|
182
|
-
{
|
|
200
|
+
{renderContent(selectedRecord)}
|
|
183
201
|
</div>
|
|
184
202
|
</SheetContent>
|
|
185
203
|
</Sheet>
|
|
@@ -199,7 +217,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
|
|
|
199
217
|
{description && <DialogDescription>{description}</DialogDescription>}
|
|
200
218
|
</DialogHeader>
|
|
201
219
|
<div className="mt-4">
|
|
202
|
-
{
|
|
220
|
+
{renderContent(selectedRecord)}
|
|
203
221
|
</div>
|
|
204
222
|
</DialogContent>
|
|
205
223
|
</Dialog>
|
|
@@ -258,7 +276,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
|
|
|
258
276
|
{description && (
|
|
259
277
|
<p className="text-sm text-muted-foreground mb-4">{description}</p>
|
|
260
278
|
)}
|
|
261
|
-
{
|
|
279
|
+
{renderContent(selectedRecord)}
|
|
262
280
|
</div>
|
|
263
281
|
</ResizablePanel>
|
|
264
282
|
</PanelGroup>
|
|
@@ -267,6 +285,26 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
|
|
|
267
285
|
|
|
268
286
|
// --- Popover Mode ---
|
|
269
287
|
if (mode === 'popover') {
|
|
288
|
+
if (!popoverTrigger) {
|
|
289
|
+
// Fallback: render as a compact floating card when no trigger element is provided
|
|
290
|
+
if (!isOpen) return null;
|
|
291
|
+
return (
|
|
292
|
+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
293
|
+
<DialogContent
|
|
294
|
+
className={cn('w-96 max-h-[80vh] overflow-y-auto p-4', className)}
|
|
295
|
+
style={widthStyle}
|
|
296
|
+
>
|
|
297
|
+
<DialogHeader>
|
|
298
|
+
<DialogTitle className="text-sm">{resolvedTitle}</DialogTitle>
|
|
299
|
+
{description && <DialogDescription className="text-xs">{description}</DialogDescription>}
|
|
300
|
+
</DialogHeader>
|
|
301
|
+
<div className="mt-2">
|
|
302
|
+
{renderContent(selectedRecord)}
|
|
303
|
+
</div>
|
|
304
|
+
</DialogContent>
|
|
305
|
+
</Dialog>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
270
308
|
return (
|
|
271
309
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
272
310
|
{popoverTrigger && (
|
|
@@ -283,7 +321,7 @@ export const NavigationOverlay: React.FC<NavigationOverlayProps> = ({
|
|
|
283
321
|
{description && (
|
|
284
322
|
<p className="text-xs text-muted-foreground">{description}</p>
|
|
285
323
|
)}
|
|
286
|
-
{
|
|
324
|
+
{renderContent(selectedRecord)}
|
|
287
325
|
</div>
|
|
288
326
|
</PopoverContent>
|
|
289
327
|
</Popover>
|
|
@@ -0,0 +1,68 @@
|
|
|
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 { ChevronDown, ChevronRight } from "lucide-react"
|
|
10
|
+
|
|
11
|
+
import { cn } from "../lib/utils"
|
|
12
|
+
|
|
13
|
+
export interface SectionHeaderProps {
|
|
14
|
+
/** Section heading text */
|
|
15
|
+
title: string
|
|
16
|
+
/** Icon rendered before the title */
|
|
17
|
+
icon?: React.ReactNode
|
|
18
|
+
/** Enable collapse/expand toggle */
|
|
19
|
+
collapsible?: boolean
|
|
20
|
+
/** Current collapsed state */
|
|
21
|
+
collapsed?: boolean
|
|
22
|
+
/** Callback when toggling collapse/expand */
|
|
23
|
+
onToggle?: () => void
|
|
24
|
+
/** Data-testid attribute */
|
|
25
|
+
testId?: string
|
|
26
|
+
/** Additional CSS class name */
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Section heading with optional collapse/expand support.
|
|
32
|
+
*
|
|
33
|
+
* Renders as a `<button>` when collapsible, with a chevron icon
|
|
34
|
+
* indicating the expand/collapse state. Uses `aria-expanded` for accessibility.
|
|
35
|
+
*/
|
|
36
|
+
function SectionHeader({ title, icon, collapsible, collapsed, onToggle, testId, className }: SectionHeaderProps) {
|
|
37
|
+
const titleContent = (
|
|
38
|
+
<h3 className="text-xs font-semibold text-foreground uppercase tracking-wider flex items-center gap-1.5">
|
|
39
|
+
{icon && <span className="text-muted-foreground shrink-0" aria-hidden="true">{icon}</span>}
|
|
40
|
+
{title}
|
|
41
|
+
</h3>
|
|
42
|
+
)
|
|
43
|
+
if (collapsible) {
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
data-testid={testId}
|
|
47
|
+
className={cn("flex items-center justify-between pt-4 pb-1.5 first:pt-0 w-full text-left", className)}
|
|
48
|
+
onClick={onToggle}
|
|
49
|
+
type="button"
|
|
50
|
+
aria-expanded={!collapsed}
|
|
51
|
+
>
|
|
52
|
+
{titleContent}
|
|
53
|
+
{collapsed ? (
|
|
54
|
+
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
55
|
+
) : (
|
|
56
|
+
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
57
|
+
)}
|
|
58
|
+
</button>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
return (
|
|
62
|
+
<div className={cn("pt-4 pb-1.5 first:pt-0", className)} data-testid={testId}>
|
|
63
|
+
{titleContent}
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { SectionHeader }
|
|
@@ -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,0 +1,134 @@
|
|
|
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 { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import { DebugPanel } from '../DebugPanel';
|
|
12
|
+
|
|
13
|
+
describe('DebugPanel', () => {
|
|
14
|
+
it('should render nothing when open is false', () => {
|
|
15
|
+
const { container } = render(
|
|
16
|
+
<DebugPanel open={false} onClose={vi.fn()} />,
|
|
17
|
+
);
|
|
18
|
+
expect(container.innerHTML).toBe('');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should render the panel when open is true', () => {
|
|
22
|
+
render(<DebugPanel open={true} onClose={vi.fn()} />);
|
|
23
|
+
expect(screen.getByTestId('debug-panel')).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText('🛠 Debug Panel')).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should render built-in tabs', () => {
|
|
28
|
+
render(<DebugPanel open={true} onClose={vi.fn()} />);
|
|
29
|
+
expect(screen.getByTestId('debug-tab-schema')).toBeInTheDocument();
|
|
30
|
+
expect(screen.getByTestId('debug-tab-data')).toBeInTheDocument();
|
|
31
|
+
expect(screen.getByTestId('debug-tab-perf')).toBeInTheDocument();
|
|
32
|
+
expect(screen.getByTestId('debug-tab-expr')).toBeInTheDocument();
|
|
33
|
+
expect(screen.getByTestId('debug-tab-events')).toBeInTheDocument();
|
|
34
|
+
expect(screen.getByTestId('debug-tab-registry')).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByTestId('debug-tab-flags')).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should call onClose when close button is clicked', () => {
|
|
39
|
+
const onClose = vi.fn();
|
|
40
|
+
render(<DebugPanel open={true} onClose={onClose} />);
|
|
41
|
+
fireEvent.click(screen.getByTestId('debug-panel-close'));
|
|
42
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should switch tabs on click', () => {
|
|
46
|
+
render(
|
|
47
|
+
<DebugPanel
|
|
48
|
+
open={true}
|
|
49
|
+
onClose={vi.fn()}
|
|
50
|
+
schema={{ type: 'text', content: 'Hello' }}
|
|
51
|
+
dataContext={{ name: 'Test' }}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
// Default tab is Schema
|
|
55
|
+
expect(screen.getByTestId('debug-tab-schema')).toHaveAttribute('aria-selected', 'true');
|
|
56
|
+
|
|
57
|
+
// Click Data tab
|
|
58
|
+
fireEvent.click(screen.getByTestId('debug-tab-data'));
|
|
59
|
+
expect(screen.getByTestId('debug-tab-data')).toHaveAttribute('aria-selected', 'true');
|
|
60
|
+
expect(screen.getByTestId('debug-tab-schema')).toHaveAttribute('aria-selected', 'false');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should display schema JSON in Schema tab', () => {
|
|
64
|
+
const testSchema = { type: 'button', content: 'Click' };
|
|
65
|
+
render(<DebugPanel open={true} onClose={vi.fn()} schema={testSchema} />);
|
|
66
|
+
const content = screen.getByTestId('debug-panel-content');
|
|
67
|
+
expect(content.textContent).toContain('"type": "button"');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should display data JSON in Data tab', () => {
|
|
71
|
+
render(
|
|
72
|
+
<DebugPanel
|
|
73
|
+
open={true}
|
|
74
|
+
onClose={vi.fn()}
|
|
75
|
+
dataContext={{ user: 'Alice' }}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
fireEvent.click(screen.getByTestId('debug-tab-data'));
|
|
79
|
+
const content = screen.getByTestId('debug-panel-content');
|
|
80
|
+
expect(content.textContent).toContain('"user": "Alice"');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should display debug flags in Flags tab', () => {
|
|
84
|
+
render(
|
|
85
|
+
<DebugPanel
|
|
86
|
+
open={true}
|
|
87
|
+
onClose={vi.fn()}
|
|
88
|
+
flags={{ enabled: true, schema: true }}
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
fireEvent.click(screen.getByTestId('debug-tab-flags'));
|
|
92
|
+
const content = screen.getByTestId('debug-panel-content');
|
|
93
|
+
expect(content.textContent).toContain('"enabled": true');
|
|
94
|
+
expect(content.textContent).toContain('"schema": true');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should render extra tabs from plugins', () => {
|
|
98
|
+
const extraTab = {
|
|
99
|
+
id: 'custom',
|
|
100
|
+
label: 'Custom',
|
|
101
|
+
render: () => <div data-testid="custom-content">Custom Content</div>,
|
|
102
|
+
};
|
|
103
|
+
render(<DebugPanel open={true} onClose={vi.fn()} extraTabs={[extraTab]} />);
|
|
104
|
+
expect(screen.getByTestId('debug-tab-custom')).toBeInTheDocument();
|
|
105
|
+
|
|
106
|
+
fireEvent.click(screen.getByTestId('debug-tab-custom'));
|
|
107
|
+
expect(screen.getByTestId('custom-content')).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should have proper accessibility attributes', () => {
|
|
111
|
+
render(<DebugPanel open={true} onClose={vi.fn()} />);
|
|
112
|
+
const panel = screen.getByTestId('debug-panel');
|
|
113
|
+
expect(panel).toHaveAttribute('role', 'dialog');
|
|
114
|
+
expect(panel).toHaveAttribute('aria-label', 'Developer Debug Panel');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should show empty state for Perf tab', () => {
|
|
118
|
+
render(<DebugPanel open={true} onClose={vi.fn()} />);
|
|
119
|
+
fireEvent.click(screen.getByTestId('debug-tab-perf'));
|
|
120
|
+
expect(screen.getByTestId('debug-panel-content').textContent).toContain('No performance data');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should show empty state for Expr tab', () => {
|
|
124
|
+
render(<DebugPanel open={true} onClose={vi.fn()} />);
|
|
125
|
+
fireEvent.click(screen.getByTestId('debug-tab-expr'));
|
|
126
|
+
expect(screen.getByTestId('debug-panel-content').textContent).toContain('No expression evaluations');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should show empty state for Events tab', () => {
|
|
130
|
+
render(<DebugPanel open={true} onClose={vi.fn()} />);
|
|
131
|
+
fireEvent.click(screen.getByTestId('debug-tab-events'));
|
|
132
|
+
expect(screen.getByTestId('debug-panel-content').textContent).toContain('No events captured');
|
|
133
|
+
});
|
|
134
|
+
});
|