Feat/lab query plan (#7892)

Co-authored-by: Laurin <laurinquast@googlemail.com>
This commit is contained in:
Michael Skorokhodov 2026-04-14 14:47:09 +02:00 committed by GitHub
parent ed9ab34c70
commit fab4b03ace
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2296 additions and 155 deletions

View file

@ -0,0 +1,6 @@
---
'@graphql-hive/laboratory': patch
'@graphql-hive/render-laboratory': patch
---
Hive Laboratory renders Hive Router query plan if included in response extensions

View file

@ -12,11 +12,11 @@
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/laboratory/components",
"utils": "@/laboratory/lib/utils",
"ui": "@/laboratory/components/ui",
"lib": "@/laboratory/lib",
"hooks": "@/laboratory/hooks"
"components": "src/components",
"utils": "src/lib/utils",
"ui": "src/components/ui",
"lib": "src/lib",
"hooks": "src/hooks"
},
"registries": {}
}

View file

@ -38,11 +38,13 @@
"zod": "^4.1.12"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
"radix-ui": "^1.4.3",
"react-zoom-pan-pinch": "^3.7.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@dagrejs/dagre": "^1.1.8",
"@dagrejs/dagre": "^2.0.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",

View file

@ -0,0 +1,519 @@
import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
import { LucideProps, MaximizeIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Slider } from '@/components/ui/slider';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import dagre from '@dagrejs/dagre';
export interface FlowNode {
id: string;
title: string;
next?: string[];
icon?: (props: LucideProps) => React.ReactNode;
content?: (props: { node: FlowNode }) => React.ReactNode;
headerSuffix?: (props: { node: FlowNode }) => React.ReactNode;
children?: FlowNode[];
maxWidth?: number;
}
export interface FlowGraphInternal extends FlowNode {
x: number;
y: number;
width: number;
height: number;
}
export type Point = {
x: number;
y: number;
};
export function orthogonalPoints(from: Point, to: Point, t = 0.5): [Point, Point, Point, Point] {
const midX = from.x + (to.x - from.x) * t;
return [from, { x: midX, y: from.y }, { x: midX, y: to.y }, to];
}
export function roundedOrthogonalPath(
[p0, p1, p2, p3]: [Point, Point, Point, Point],
radius = 12,
): string {
const r1 = Math.min(radius, Math.abs(p1.x - p0.x), Math.abs(p2.y - p1.y));
const r2 = Math.min(radius, Math.abs(p2.y - p1.y), Math.abs(p3.x - p2.x));
const p1a = {
x: p1.x - Math.sign(p1.x - p0.x) * r1,
y: p1.y,
};
const p1b = {
x: p1.x,
y: p1.y + Math.sign(p2.y - p1.y) * r1,
};
const p2a = {
x: p2.x,
y: p2.y - Math.sign(p2.y - p1.y) * r2,
};
const p2b = {
x: p2.x + Math.sign(p3.x - p2.x) * r2,
y: p2.y,
};
return [
`M ${p0.x} ${p0.y}`,
`L ${p1a.x} ${p1a.y}`,
`Q ${p1.x} ${p1.y} ${p1b.x} ${p1b.y}`,
`L ${p2a.x} ${p2a.y}`,
`Q ${p2.x} ${p2.y} ${p2b.x} ${p2b.y}`,
`L ${p3.x} ${p3.y}`,
].join(' ');
}
const MIN_SCALE = 0.2;
const MAX_SCALE = 3;
const ZOOM_STEP = 0.02;
export const Flow = (props: {
nodes: FlowNode[];
margin?: number;
gapX?: number;
gapY?: number;
onGraphLayout?: (graph: dagre.graphlib.Graph) => void;
disableBackground?: boolean;
disableGestures?: boolean;
className?: string;
containerClassName?: string;
isChild?: boolean;
}) => {
const [isCanvasActive, setIsCanvasActive] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const panStartRef = useRef<Point | null>(null);
const [view, setView] = useState<{ x: number; y: number; scale: number }>({
x: 0,
y: 0,
scale: 1,
});
const [nodeSizes, setNodeSizes] = useState<Record<string, { width: number; height: number }>>({});
const [nodes, edges, graphSize] = useMemo(() => {
if (Object.keys(nodeSizes).length === 0) {
return [
props.nodes.map(node => ({ ...node, x: 0, y: 0, width: 0, height: 0, isCluster: false })),
[],
{ width: 0, height: 0 },
];
}
const result = new dagre.graphlib.Graph({
compound: true,
})
.setGraph({
rankdir: 'LR',
align: 'UL',
ranksep: props.gapX ?? 48,
nodesep: props.gapY ?? 48,
marginx: props.margin ?? 32,
marginy: props.margin ?? 64,
graph: 'tight-tree',
})
.setDefaultEdgeLabel(() => ({}));
for (const node of props.nodes) {
result.setNode(node.id, {
width: nodeSizes[node.id]?.width,
height: nodeSizes[node.id]?.height,
});
}
for (const node of props.nodes) {
if (node.next) {
for (const next of node.next) {
result.setEdge(node.id, next);
}
}
}
dagre.layout(result);
props.onGraphLayout?.(result);
const graph = result.graph();
return [
props.nodes.map(node => {
const graphNode = result.node(node.id);
return {
...node,
x: graphNode?.x ?? 0,
y: graphNode?.y ?? 0,
width: graphNode?.width ?? 0,
height: graphNode?.height ?? 0,
};
}),
result.edges().map(edge => {
return {
from: edge.v,
to: edge.w,
};
}),
{ width: graph.width, height: graph.height },
];
}, [nodeSizes, props.nodes, props.margin, props.gapX]);
const handleWheel = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
if (props.disableGestures) {
return;
}
if (event.ctrlKey || event.metaKey) {
const bounds = event.currentTarget.getBoundingClientRect();
const pointerX = event.clientX - bounds.left;
const pointerY = event.clientY - bounds.top;
setView(prev => {
const zoomFactor = Math.exp(-event.deltaY * ZOOM_STEP);
const scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * zoomFactor));
const ratio = scale / prev.scale;
const x = pointerX - (pointerX - prev.x) * ratio;
const y = pointerY - (pointerY - prev.y) * ratio;
return { x, y, scale };
});
return;
}
},
[props.disableGestures],
);
const stopPanning = useCallback(() => {
setIsPanning(false);
panStartRef.current = null;
}, []);
const handleMouseDown = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) {
return;
}
if (!event.nativeEvent.composedPath().includes(containerRef.current)) {
return;
}
if (props.disableGestures) {
return;
}
event.preventDefault();
setIsPanning(true);
panStartRef.current = { x: event.clientX, y: event.clientY };
function handleMouseUp() {
stopPanning();
setIsCanvasActive(false);
}
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
};
},
[props.disableGestures],
);
const handleMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if (props.disableGestures || !isPanning || !panStartRef.current) {
return;
}
const deltaX = event.clientX - panStartRef.current.x;
const deltaY = event.clientY - panStartRef.current.y;
panStartRef.current = { x: event.clientX, y: event.clientY };
setView(prev => ({
...prev,
x: prev.x + deltaX,
y: prev.y + deltaY,
}));
},
[isPanning, props.disableGestures],
);
const fitInView = useCallback(() => {
const { width, height } = graphSize;
const container = containerRef.current;
if (!container) {
return;
}
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect();
console.log({
container,
containerWidth,
containerHeight,
width,
height,
});
const scale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, Math.min(containerWidth / width, containerHeight / height)),
);
setView(prev => ({
...prev,
scale,
x: containerWidth / 2 - (width * scale) / 2,
y: containerHeight / 2 - (height * scale) / 2,
}));
}, [graphSize]);
useEffect(() => {
if (props.disableGestures || !containerRef.current) {
return;
}
const element = containerRef.current;
const preventNativeGesture = (event: Event) => {
event.preventDefault();
};
element.addEventListener('gesturestart', preventNativeGesture, { passive: false });
element.addEventListener('gesturechange', preventNativeGesture, { passive: false });
element.addEventListener('gestureend', preventNativeGesture, { passive: false });
element.addEventListener('wheel', preventNativeGesture, { passive: false });
return () => {
element.removeEventListener('gesturestart', preventNativeGesture);
element.removeEventListener('gesturechange', preventNativeGesture);
element.removeEventListener('gestureend', preventNativeGesture);
element.removeEventListener('wheel', preventNativeGesture);
};
}, [props.disableGestures]);
useEffect(() => {
if (props.disableGestures) {
return;
}
const preventBrowserZoomHotkeys = (event: KeyboardEvent) => {
if (!isCanvasActive || (!event.metaKey && !event.ctrlKey)) {
return;
}
if (['+', '-', '=', '0'].includes(event.key)) {
event.preventDefault();
}
};
window.addEventListener('keydown', preventBrowserZoomHotkeys);
return () => {
window.removeEventListener('keydown', preventBrowserZoomHotkeys);
};
}, [isCanvasActive, props.disableGestures]);
return (
<div
ref={containerRef}
className={cn(
'bg-background relative h-full w-full touch-none',
{
'cursor-grab': !props.disableGestures && !isPanning,
'cursor-grabbing': !props.disableGestures && isPanning,
},
props.containerClassName,
)}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={() => {
stopPanning();
setIsCanvasActive(false);
}}
onMouseEnter={() => setIsCanvasActive(true)}
>
{!props.disableBackground && (
<div className="bg-size-[16px_16px] absolute inset-0 h-full w-full bg-[radial-gradient(hsl(var(--border))_1px,transparent_1px)] opacity-50" />
)}
<div
className={cn('relative', props.className)}
style={{
width: graphSize.width,
height: graphSize.height,
transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
transformOrigin: '0 0',
}}
>
{nodes.map(node => {
const hasFollowers = !!node.next?.length;
const hasPrevious = nodes.some(n => n.next?.includes(node.id));
const content = node.content ? node.content({ node }) : null;
const hasContent = !!content;
const hasChildren = !!node.children?.length;
return (
<div
key={node.id}
ref={ref => {
if (ref && !nodeSizes[node.id]) {
setNodeSizes(prev => ({
...prev,
[node.id]: { width: ref.clientWidth, height: ref.clientHeight },
}));
}
}}
className={cn(
'bg-card transition-color absolute flex grid min-w-72 grid-cols-1 grid-rows-1 justify-start gap-2 rounded-lg border p-2 text-sm shadow-sm',
{
'w-72': !hasChildren,
'grid-rows-[auto_1fr]': hasContent || hasChildren,
'grid-rows-[auto_auto_1fr]': hasContent && hasChildren,
'rounded-xl border-dashed bg-transparent shadow-none': hasChildren,
},
)}
style={{
left: node.x - node.width / 2,
top: node.y - node.height / 2,
minWidth: Math.min(Math.max(node.width, 256), node.maxWidth ?? Infinity),
minHeight: node.height,
maxWidth: node.maxWidth,
}}
>
<div className="flex w-full items-center gap-2">
{node.icon ? node.icon({ className: 'size-4 text-secondary-foreground' }) : null}
<span className="font-medium">{node.title}</span>
<div className="ml-auto">
{node.headerSuffix ? node.headerSuffix({ node }) : null}
</div>
</div>
<div className="bg-secondary w-full rounded-sm p-2 empty:hidden">
{node.content ? node.content({ node }) : null}
</div>
{!!node.children?.length && (
<div className="size-full rounded-sm">
<Flow
nodes={node.children}
margin={0}
gapX={24}
gapY={16}
onGraphLayout={graph => {
const { width, height } = graph.graph();
setNodeSizes(prev => ({
...prev,
[node.id]: {
width: width + 20,
height: node.height + height + 4,
},
}));
}}
disableBackground
disableGestures
className="bg-transparent"
isChild
/>
</div>
)}
{hasFollowers && (
<div className="border-border bg-background absolute left-full top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-all" />
)}
{hasPrevious && (
<div className="border-border bg-background absolute left-0 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-all" />
)}
</div>
);
})}
<svg
className="pointer-events-none absolute left-0 top-0 -z-10"
style={{ width: graphSize.width, height: graphSize.height }}
>
{edges.filter(Boolean).map(edge => {
const fromNode = nodes.find(node => node.id === edge.from);
const toNode = nodes.find(node => node.id === edge.to);
if (!fromNode || !toNode) {
return null;
}
return (
<path
key={edge.from + edge.to}
className="stroke-border animate-dash transition-color animate-[dash_500ms_linear_infinite] fill-none stroke-2 [stroke-dasharray:12_8]"
d={roundedOrthogonalPath(
orthogonalPoints(
{
x: fromNode.x + fromNode.width / 2,
y: fromNode.y,
},
{
x: toNode.x - toNode.width / 2,
y: toNode.y,
},
),
4,
)}
/>
);
})}
</svg>
</div>
{!props.isChild && (
<div className="absolute left-4 top-4 z-10 flex items-center gap-2">
<div className="bg-card grid w-96 grid-cols-[1fr_auto_auto] items-center gap-2 rounded-lg border p-2 shadow-sm">
<div className="flex flex-1 items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView(prev => ({ ...prev, scale: prev.scale - ZOOM_STEP }))}
>
<ZoomOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom out</TooltipContent>
</Tooltip>
<Slider
value={[view.scale]}
onValueChange={value => setView(prev => ({ ...prev, scale: value[0] }))}
min={MIN_SCALE}
max={MAX_SCALE}
step={ZOOM_STEP}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setView(prev => ({ ...prev, scale: prev.scale + ZOOM_STEP }))}
>
<ZoomInIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom in</TooltipContent>
</Tooltip>
</div>
<Separator orientation="vertical" className="h-6!" />
<Button variant="ghost" size="sm" onClick={fitInView}>
<MaximizeIcon className="size-4" />
Fit in view
</Button>
</div>
</div>
)}
</div>
);
};

View file

@ -21,7 +21,7 @@ import {
SearchIcon,
TextAlignStartIcon,
} from 'lucide-react';
import { ToggleGroup, ToggleGroupItem } from '@/laboratory/components/ui/toggle-group';
import { toast } from 'sonner';
import type { LaboratoryOperation } from '../../lib/operations';
import {
getFieldByPath,
@ -40,6 +40,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '..
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group';
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useLaboratory } from './context';
@ -816,6 +817,8 @@ export const Builder = (props: {
const restoreEndpoint = useCallback(() => {
setEndpointValue(endpoint ?? '');
setEndpoint(defaultEndpoint ?? '');
toast.success('Endpoint restored to default');
}, [defaultEndpoint, setEndpointValue]);
return (
@ -851,14 +854,18 @@ export const Builder = (props: {
</InputGroupAddon>
{defaultEndpoint && (
<InputGroupAddon align="inline-end">
<InputGroupButton className="rounded-full" size="icon-xs" onClick={restoreEndpoint}>
<Tooltip>
<TooltipTrigger>
<Tooltip>
<TooltipTrigger>
<InputGroupButton
className="rounded-full"
size="icon-xs"
onClick={restoreEndpoint}
>
<RotateCcwIcon className="size-4" />
</TooltipTrigger>
<TooltipContent>Restore default endpoint</TooltipContent>
</Tooltip>
</InputGroupButton>
</InputGroupButton>
</TooltipTrigger>
<TooltipContent>Restore default endpoint</TooltipContent>
</Tooltip>
</InputGroupAddon>
)}
</InputGroup>

View file

@ -128,7 +128,11 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
const id = useId();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const { introspection, endpoint, theme } = useLaboratory();
const wantsJson = props.language === 'json' || props.defaultLanguage === 'json';
const [typescriptReady, setTypescriptReady] = useState(!!monaco.languages.typescript);
const [jsonReady, setJsonReady] = useState(
monaco.languages.getLanguages().some(language => language.id === 'json'),
);
const apiRef = useRef<MonacoGraphQLAPI | null>(null);
useEffect(() => {
@ -170,8 +174,9 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
useEffect(() => {
void (async function () {
if (!props.extraLibs?.length) {
return;
if (wantsJson && !jsonReady) {
await import('monaco-editor/esm/vs/language/json/monaco.contribution');
setJsonReady(true);
}
if (!monaco.languages.typescript) {
@ -179,6 +184,10 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
setTypescriptReady(true);
}
if (!props.extraLibs?.length) {
return;
}
const ts = monaco.languages.typescript;
if (!ts) {
@ -208,7 +217,7 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
})),
);
})();
}, [id, props.extraLibs]);
}, [id, jsonReady, props.extraLibs, wantsJson]);
useImperativeHandle(
ref,
@ -226,6 +235,10 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
return null;
}
if (!jsonReady && wantsJson) {
return null;
}
return (
<div className="size-full overflow-hidden">
<MonacoEditor

View file

@ -402,7 +402,7 @@ const LaboratoryContent = () => {
>
Preflight Script
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* <DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
const tab =
@ -416,7 +416,7 @@ const LaboratoryContent = () => {
}}
>
Settings
</DropdownMenuItem>
</DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="right">Settings</TooltipContent>
@ -433,7 +433,7 @@ const LaboratoryContent = () => {
<div className="w-full">
<Tabs />
</div>
<div className="bg-card flex-1 overflow-hidden">{contentNode}</div>
<div className="bg-card relative flex-1 overflow-hidden">{contentNode}</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
@ -623,7 +623,7 @@ export const Laboratory = (
[],
);
const containerRef = useRef<HTMLDivElement>(null);
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
@ -642,7 +642,7 @@ export const Laboratory = (
className={cn('hive-laboratory bg-background size-full', props.theme, {
'fixed inset-0 z-50': isFullScreen,
})}
ref={containerRef}
ref={setContainer}
>
<Toaster richColors closeButton position="top-right" theme={props.theme} />
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
@ -809,7 +809,7 @@ export const Laboratory = (
{...collectionsApi}
{...operationsApi}
{...historyApi}
container={containerRef.current}
container={container}
openAddCollectionDialog={openAddCollectionDialog}
openUpdateEndpointDialog={openUpdateEndpointDialog}
openAddTestDialog={openAddTestDialog}

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AlignLeftIcon,
BookmarkIcon,
CircleCheckIcon,
CircleXIcon,
@ -7,6 +8,9 @@ import {
FileTextIcon,
HistoryIcon,
MoreHorizontalIcon,
NetworkIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlayIcon,
PowerIcon,
PowerOffIcon,
@ -16,6 +20,8 @@ import { compressToEncodedURIComponent } from 'lz-string';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { toast } from 'sonner';
import { z } from 'zod';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { QueryPlanSchema } from '@/lib/query-plan/schema';
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
import { useForm } from '@tanstack/react-form';
import type {
@ -24,6 +30,7 @@ import type {
LaboratoryHistorySubscription,
} from '../../lib/history';
import type { LaboratoryOperation } from '../../lib/operations';
import { QueryPlanTree, renderQueryPlan } from '../../lib/query-plan/utils';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
@ -45,6 +52,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { Spinner } from '../ui/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Toggle } from '../ui/toggle';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import { Builder } from './builder';
import { useLaboratory } from './context';
import { Editor } from './editor';
@ -86,6 +94,7 @@ const Headers = (props: { operation?: LaboratoryOperation | null; isReadOnly?: b
<Editor
uri={monaco.Uri.file('headers.json')}
value={operation?.headers ?? ''}
language="json"
onChange={value => {
updateActiveOperation({
headers: value ?? '',
@ -109,6 +118,7 @@ const Extensions = (props: { operation?: LaboratoryOperation | null; isReadOnly?
<Editor
uri={monaco.Uri.file('extensions.json')}
value={operation?.extensions ?? ''}
language="json"
onChange={value => {
updateActiveOperation({
extensions: value ?? '',
@ -178,6 +188,56 @@ export const ResponsePreflight = ({ historyItem }: { historyItem?: LaboratoryHis
</ScrollArea>
);
};
export const ResponseQueryPlan = ({ historyItem }: { historyItem?: LaboratoryHistory | null }) => {
const [mode, setMode] = useState<'text' | 'visual'>('text');
const queryPlan = useMemo(() => {
try {
const queryPlan =
JSON.parse((historyItem as LaboratoryHistoryRequest)?.response ?? '{}').extensions
?.queryPlan ?? {};
if (!queryPlan) {
return null;
}
return QueryPlanSchema.safeParse(queryPlan).success ? queryPlan : null;
} catch {
return null;
}
}, [historyItem]);
return (
<div className="relative size-full">
<ToggleGroup
className="bg-card absolute right-4 top-4 z-10 shadow-sm"
type="single"
variant="outline"
value={mode}
onValueChange={value => setMode(value as 'text' | 'visual')}
>
<ToggleGroupItem value="text">
<AlignLeftIcon className="size-4" />
Text
</ToggleGroupItem>
<ToggleGroupItem value="visual">
<NetworkIcon className="size-4" />
Visual
</ToggleGroupItem>
</ToggleGroup>
{mode === 'visual' ? (
<QueryPlanTree key={historyItem?.id} plan={queryPlan} />
) : (
<Editor
value={renderQueryPlan(queryPlan)}
defaultLanguage="graphql"
theme="hive-laboratory"
options={{ readOnly: true }}
/>
)}
</div>
);
};
export const ResponseSubscription = ({
historyItem,
@ -245,6 +305,8 @@ export const ResponseSubscription = ({
};
export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryRequest | null }) => {
const [isFullScreen, setIsFullScreen] = useState(false);
const isError = useMemo(() => {
if (!historyItem) {
return false;
@ -261,12 +323,65 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
);
}, [historyItem]);
const hasValidQueryPlan = useMemo(() => {
if (!historyItem) {
return false;
}
const queryPlan = JSON.parse(historyItem.response).extensions?.queryPlan;
if (!queryPlan) {
return false;
}
return QueryPlanSchema.safeParse(queryPlan).success;
}, [historyItem?.response]);
return (
<Tabs defaultValue="response" className="grid size-full grid-rows-[auto_1fr]">
<TabsList className="h-[49.5px] w-full justify-start rounded-none border-b bg-transparent p-3">
<Tabs
defaultValue="response"
className={cn('bg-card grid size-full grid-rows-[auto_1fr]', {
'z-100 absolute inset-0 size-full': isFullScreen,
})}
>
<TabsList className="h-[50px] w-full items-center justify-start rounded-none border-b bg-transparent p-3">
{isFullScreen ? (
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="mr-2 mt-0.5 h-6 w-6"
onClick={() => setIsFullScreen(false)}
>
<PanelLeftOpenIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Minimize panel</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="sm"
className="mr-2 mt-0.5 h-6 w-6"
onClick={() => setIsFullScreen(true)}
>
<PanelLeftCloseIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Maximize panel</TooltipContent>
</Tooltip>
)}
<TabsTrigger value="response" className="grow-0 rounded-sm">
Response
</TabsTrigger>
{hasValidQueryPlan && (
<TabsTrigger value="query-plan" className="grow-0 rounded-sm">
Query Plan
</TabsTrigger>
)}
<TabsTrigger value="headers" className="grow-0 rounded-sm">
Headers
</TabsTrigger>
@ -315,6 +430,9 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
<TabsContent value="response" className="overflow-hidden">
<ResponseBody historyItem={historyItem} />
</TabsContent>
<TabsContent value="query-plan" className="overflow-hidden">
<ResponseQueryPlan historyItem={historyItem} />
</TabsContent>
<TabsContent value="headers" className="overflow-hidden">
<ResponseHeaders historyItem={historyItem} />
</TabsContent>
@ -735,7 +853,7 @@ export const Operation = (props: {
}, [props.historyItem]);
return (
<div className="bg-card size-full">
<div className="bg-card relative size-full">
<ResizablePanelGroup direction="horizontal" className="size-full">
<ResizablePanel defaultSize={25}>
<Builder operation={operation} isReadOnly={isReadOnly} />

View file

@ -1,4 +1,6 @@
import { z } from 'zod';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useForm } from '@tanstack/react-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldGroup, FieldLabel } from '../ui/field';
@ -8,6 +10,17 @@ import { useLaboratory } from './context';
const settingsFormSchema = z.object({
fetch: z.object({
credentials: z.enum(['include', 'omit', 'same-origin']),
timeout: z.number().optional(),
retry: z.number().optional(),
useGETForQueries: z.boolean().optional(),
}),
subscriptions: z.object({
protocol: z.enum(['SSE', 'GRAPHQL_SSE', 'WS', 'LEGACY_WS']),
}),
introspection: z.object({
queryName: z.string().optional(),
method: z.enum(['GET', 'POST']).optional(),
schemaDescription: z.boolean().optional(),
}),
});
@ -25,12 +38,12 @@ export const Settings = () => {
});
return (
<div className="bg-card size-full p-3">
<div className="bg-card size-full overflow-y-auto p-3">
<form
id="settings-form"
onSubmit={form.handleSubmit}
onChange={form.handleSubmit}
className="mx-auto max-w-2xl"
className="mx-auto flex max-w-2xl flex-col gap-4"
>
<Card>
<CardHeader>
@ -66,6 +79,154 @@ export const Settings = () => {
);
}}
</form.Field>
<form.Field name="fetch.timeout">
{field => {
return (
<Field>
<FieldLabel htmlFor={field.name}>Timeout</FieldLabel>
<Input
type="number"
name={field.name}
value={field.state.value}
onChange={e => field.handleChange(Number(e.target.value))}
/>
</Field>
);
}}
</form.Field>
<form.Field name="fetch.retry">
{field => {
return (
<Field>
<FieldLabel htmlFor={field.name}>Retry</FieldLabel>
<Input
type="number"
name={field.name}
value={field.state.value}
onChange={e => field.handleChange(Number(e.target.value))}
/>
</Field>
);
}}
</form.Field>
<form.Field name="fetch.useGETForQueries">
{field => {
return (
<Field className="flex-row items-center">
<Switch
className="!w-8"
checked={field.state.value}
onCheckedChange={field.handleChange}
/>
<FieldLabel htmlFor={field.name}>Use GET for queries</FieldLabel>
</Field>
);
}}
</form.Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Subscriptions</CardTitle>
<CardDescription>
Configure the subscriptions options for the laboratory.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<form.Field name="subscriptions.protocol">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Protocol</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={value =>
field.handleChange(value as 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS')
}
>
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
<SelectValue placeholder="Select protocol" />
</SelectTrigger>
<SelectContent>
<SelectItem value="SSE">SSE</SelectItem>
<SelectItem value="GRAPHQL_SSE">GRAPHQL_SSE</SelectItem>
<SelectItem value="WS">WS</SelectItem>
<SelectItem value="LEGACY_WS">LEGACY_WS</SelectItem>
</SelectContent>
</Select>
</Field>
);
}}
</form.Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Introspection</CardTitle>
<CardDescription>
Configure the introspection options for the laboratory.
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<form.Field name="introspection.queryName">
{field => {
return (
<Field>
<FieldLabel htmlFor={field.name}>Query name</FieldLabel>
<Input
name={field.name}
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
/>
</Field>
);
}}
</form.Field>
<form.Field name="introspection.method">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field>
<FieldLabel htmlFor={field.name}>Method</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={value => field.handleChange(value as 'GET' | 'POST')}
>
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
</SelectContent>
</Select>
</Field>
);
}}
</form.Field>
<form.Field name="introspection.schemaDescription">
{field => {
return (
<Field className="flex-row items-center">
<Switch
className="!w-8"
checked={field.state.value}
onCheckedChange={field.handleChange}
/>
<FieldLabel htmlFor={field.name}>Schema description</FieldLabel>
</Field>
);
}}
</form.Field>
</FieldGroup>
</CardContent>
</Card>

View file

@ -0,0 +1,275 @@
'use client';
import * as React from 'react';
import { CheckIcon, XIcon } from 'lucide-react';
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
import { cn } from '../../lib/utils';
import { useLaboratory } from '../laboratory/context';
import { Button } from './button';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './input-group';
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn('w-auto', className)}>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = 'bottom',
sideOffset = 6,
align = 'start',
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
>) {
const { container } = useLaboratory();
return (
<ComboboxPrimitive.Portal container={container}>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
'group/combobox-content w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) bg-popover text-popover-foreground ring-foreground/10 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative max-h-96 min-w-[calc(var(--anchor-width)+--spacing(7))] overflow-hidden rounded-md shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none',
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
'data-empty:p-0 max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1',
className,
)}
{...props}
/>
);
}
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"outline-hidden data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
data-slot="combobox-item-indicator"
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-coarse:size-5 pointer-events-none size-4" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />
);
}
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn(
'text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm px-2 py-1.5 text-xs',
className,
)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
'text-muted-foreground group-data-empty/combobox-content:flex hidden w-full justify-center py-2 text-center text-sm',
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
'border-input shadow-xs focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm transition-[color,box-shadow] focus-within:ring-[3px]',
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
'bg-muted text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0 flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 whitespace-nowrap rounded-sm px-1.5 text-xs font-medium',
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-sm" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn('min-w-16 flex-1 outline-none', className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { XIcon } from 'lucide-react';
import { useLaboratory } from '@/components/laboratory/context';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '../../lib/utils';
@ -12,14 +12,9 @@ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const { container } = useLaboratory();
return (
<>
<DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />
<div ref={setContainer} style={{ display: 'contents' }} />
</>
);
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
@ -34,7 +29,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-150 fixed inset-0 bg-black/50',
className,
)}
{...props}
@ -50,13 +45,15 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const { container } = useLaboratory();
return (
<DialogPortal data-slot="dialog-portal">
<DialogPortal data-slot="dialog-portal" container={container}>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-150 fixed left-[50%] top-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}

View file

@ -0,0 +1,55 @@
import * as React from 'react';
import { Slider as SliderPrimitive } from 'radix-ui';
import { cn } from '../../lib/utils';
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50',
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5',
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View file

@ -3,7 +3,7 @@
import * as React from 'react';
import { type VariantProps } from 'class-variance-authority';
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
import { cn } from '../../../lib/utils';
import { cn } from '../../lib/utils';
import { toggleVariants } from './toggle';
const ToggleGroupContext = React.createContext<

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useLaboratory } from '@/components/laboratory/context';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '../../lib/utils';
@ -33,7 +33,7 @@ function TooltipContent({
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const { container } = useLaboratory();
return (
<>
@ -42,7 +42,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-150 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
className,
)}
{...props}
@ -51,7 +51,6 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
<div ref={setContainer} style={{ display: 'contents' }} />
</>
);
}

View file

@ -57,14 +57,14 @@
--color-neutral-12: 175 23% 10%;
--color-accent: 206 96% 35%;
--color-ring: 216 58% 49%;
--color-destructive: 357 96% 58%;
--radius: var(--hive-laboratory-radius, 0.5rem);
--background: var(--hive-laboratory-background, var(--color-neutral-2));
--foreground: var(--hive-laboratory-foreground, var(--color-neutral-11));
--muted: var(--hive-laboratory-muted, 24 9.8% 10%);
--muted: var(--hive-laboratory-muted, var(--color-neutral-3));
--muted-foreground: var(--hive-laboratory-muted-foreground, var(--color-neutral-11));
--popover: var(--hive-laboratory-popover, var(--color-neutral-3));
@ -80,12 +80,12 @@
--primary-foreground: var(--hive-laboratory-primary-foreground, var(--color-neutral-1));
--secondary: var(--hive-laboratory-secondary, var(--color-neutral-3));
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-11));
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-8));
--accent: var(--hive-laboratory-accent, var(--color-neutral-4));
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
--destructive: var(--hive-laboratory-destructive, var(--red-500));
--destructive: var(--hive-laboratory-destructive, var(--color-destructive));
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
--ring: var(--hive-laboratory-ring, var(--color-ring));
@ -106,6 +106,7 @@
--color-neutral-12: 204 14% 93%;
--color-accent: 48 100% 83%;
--color-destructive: 358.75 100% 70%;
--radius: var(--hive-laboratory-radius, 0.5rem);
--background: var(--hive-laboratory-background, var(--color-neutral-1));
@ -132,7 +133,7 @@
--accent: var(--hive-laboratory-accent, var(--color-neutral-6));
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
--destructive: var(--hive-laboratory-destructive, var(--red-500));
--destructive: var(--hive-laboratory-destructive, var(--color-destructive));
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
--ring: var(--hive-laboratory-ring, var(--color-ring));
@ -159,3 +160,9 @@
@apply bg-background text-foreground;
}
}
@keyframes dash {
to {
stroke-dashoffset: -20;
}
}

View file

@ -1,5 +1,5 @@
import ReactDOM from 'react-dom/client';
import { Laboratory } from './components/laboratory/laboratory';
import { Laboratory, LaboratoryProps } from './components/laboratory/laboratory';
export * from './components/laboratory/laboratory';
export * from './components/laboratory/context';
@ -17,7 +17,7 @@ export * from './lib/tabs';
export * from './lib/tests';
export * from './lib/plugins';
export const renderLaboratory = (el: HTMLElement) => {
export const renderLaboratory = (el: HTMLElement, props: LaboratoryProps) => {
const prefix = 'hive-laboratory';
const getLocalStorage = (key: string) => {
@ -74,6 +74,7 @@ export const renderLaboratory = (el: HTMLElement) => {
onHistoryChange={history => {
setLocalStorage('history', history);
}}
{...props}
/>,
);
};

View file

@ -1,43 +0,0 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Toggle as TogglePrimitive } from 'radix-ui';
import { cn } from '../../../lib/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 min-w-9 px-2',
sm: 'h-8 min-w-8 px-1.5',
lg: 'h-10 min-w-10 px-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View file

@ -0,0 +1,225 @@
import { z } from 'zod';
export const FlattenNodePathSegmentSchema = z.union([
z.object({ Field: z.string() }),
z.object({ TypeCondition: z.array(z.string()) }),
z.literal('@'),
]);
export type FlattenNodePathSegment = z.infer<typeof FlattenNodePathSegmentSchema>;
export const FlattenNodePathSchema = z.array(FlattenNodePathSegmentSchema);
export type FlattenNodePath = z.infer<typeof FlattenNodePathSchema>;
export const FlattenNodePathsSchema = z.array(FlattenNodePathSchema);
export type FlattenNodePaths = z.infer<typeof FlattenNodePathsSchema>;
export const FetchNodePathSegmentSchema = z.union([
z.object({ Key: z.string() }),
z.object({ TypenameEquals: z.array(z.string()) }),
]);
export type FetchNodePathSegment = z.infer<typeof FetchNodePathSegmentSchema>;
export const ValueSetterSchema = z.object({
path: z.array(FetchNodePathSegmentSchema),
setValueTo: z.string(),
});
export const KeyRenamerSchema = z.object({
path: z.array(FetchNodePathSegmentSchema),
renameKeyTo: z.string(),
});
export const FetchRewriteSchema = z.union([ValueSetterSchema, KeyRenamerSchema]);
export type FetchRewrite = z.infer<typeof FetchRewriteSchema>;
export const SelectionFieldSchema = z.object({
kind: z.literal('Field'),
name: z.string(),
});
export const SelectionInlineFragmentSchema = z.object({
kind: z.literal('InlineFragment'),
typeCondition: z.string().nullish(),
selections: z.array(SelectionFieldSchema).nullish(),
});
export type SelectionInlineFragment = z.infer<typeof SelectionInlineFragmentSchema>;
export const SelectionFragmentSpreadSchema = z.object({
kind: z.literal('FragmentSpread'),
name: z.string(),
});
export const SelectionItemSchema = z.union([
SelectionInlineFragmentSchema,
SelectionFragmentSpreadSchema,
SelectionFieldSchema,
]);
export type SelectionItem = z.infer<typeof SelectionItemSchema>;
export const SelectionSetSchema = z.array(SelectionItemSchema);
export type SelectionSet = z.infer<typeof SelectionSetSchema>;
export interface SequenceNodePlan {
kind: 'Sequence';
nodes: PlanNode[];
}
export interface ParallelNodePlan {
kind: 'Parallel';
nodes: PlanNode[];
}
export interface FlattenNodePlan {
kind: 'Flatten';
path: FlattenNodePath;
node: PlanNode;
}
export interface ConditionNodePlan {
kind: 'Condition';
condition: string;
ifClause?: PlanNode | null;
elseClause?: PlanNode | null;
}
export interface SubscriptionNodePlan {
kind: 'Subscription';
primary: PlanNode;
}
export interface DeferNodePlan {
kind: 'Defer';
primary: DeferPrimary;
deferred: DeferredNode[];
}
export interface DeferPrimary {
subselection?: string | null;
node?: PlanNode | null;
}
export interface DeferredNode {
depends: DeferDependency[];
label?: string | null;
queryPath: string[];
subselection?: string | null;
node?: PlanNode | null;
}
export type FetchNodePlan = z.infer<typeof FetchNodePlanSchema>;
export type BatchFetchNodePlan = z.infer<typeof BatchFetchNodePlanSchema>;
export type PlanNode =
| FetchNodePlan
| BatchFetchNodePlan
| SequenceNodePlan
| ParallelNodePlan
| FlattenNodePlan
| ConditionNodePlan
| SubscriptionNodePlan
| DeferNodePlan;
export const PlanNodeSchema: z.ZodType<PlanNode> = z.lazy(() =>
z.discriminatedUnion('kind', [
FetchNodePlanSchema,
BatchFetchNodePlanSchema,
SequenceNodePlanSchema,
ParallelNodePlanSchema,
FlattenNodePlanSchema,
ConditionNodePlanSchema,
SubscriptionNodePlanSchema,
DeferNodePlanSchema,
]),
);
export const FetchNodePlanSchema = z.object({
kind: z.literal('Fetch'),
serviceName: z.string(),
operationKind: z.string().nullish(),
operationName: z.string().nullish(),
operation: z.string(),
variableUsages: z.array(z.string()).nullish(),
requires: SelectionSetSchema.nullish(),
inputRewrites: z.array(FetchRewriteSchema).nullish(),
outputRewrites: z.array(FetchRewriteSchema).nullish(),
});
export const EntityBatchAliasSchema = z.object({
alias: z.string(),
representationsVariableName: z.string(),
paths: FlattenNodePathsSchema,
requires: SelectionSetSchema,
inputRewrites: z.array(FetchRewriteSchema).nullish(),
outputRewrites: z.array(FetchRewriteSchema).nullish(),
});
export type EntityBatchAlias = z.infer<typeof EntityBatchAliasSchema>;
export const EntityBatchSchema = z.object({
aliases: z.array(EntityBatchAliasSchema),
});
export type EntityBatch = z.infer<typeof EntityBatchSchema>;
export const BatchFetchNodePlanSchema = z.object({
kind: z.literal('BatchFetch'),
serviceName: z.string(),
operationKind: z.string().nullish(),
operationName: z.string().nullish(),
operation: z.string(),
variableUsages: z.array(z.string()).nullish(),
entityBatch: EntityBatchSchema,
});
export const SequenceNodePlanSchema = z.object({
kind: z.literal('Sequence'),
nodes: z.array(PlanNodeSchema),
});
export const ParallelNodePlanSchema = z.object({
kind: z.literal('Parallel'),
nodes: z.array(PlanNodeSchema),
});
export const FlattenNodePlanSchema = z.object({
kind: z.literal('Flatten'),
path: FlattenNodePathSchema,
node: PlanNodeSchema,
});
export const ConditionNodePlanSchema = z.object({
kind: z.literal('Condition'),
condition: z.string(),
ifClause: PlanNodeSchema.nullish(),
elseClause: PlanNodeSchema.nullish(),
});
export const SubscriptionNodePlanSchema = z.object({
kind: z.literal('Subscription'),
primary: PlanNodeSchema,
});
export const DeferDependencySchema = z.object({
id: z.string(),
deferLabel: z.string().nullish(),
});
export type DeferDependency = z.infer<typeof DeferDependencySchema>;
export const DeferPrimarySchema = z.object({
subselection: z.string().nullish(),
node: PlanNodeSchema.nullish(),
});
export const DeferredNodeSchema = z.object({
depends: z.array(DeferDependencySchema),
label: z.string().nullish(),
queryPath: z.array(z.string()),
subselection: z.string().nullish(),
node: PlanNodeSchema.nullish(),
});
export const DeferNodePlanSchema = z.object({
kind: z.literal('Defer'),
primary: DeferPrimarySchema,
deferred: z.array(DeferredNodeSchema),
});
export const QueryPlanSchema = z.object({
kind: z.literal('QueryPlan'),
node: PlanNodeSchema.nullish(),
});
export type QueryPlan = z.infer<typeof QueryPlanSchema>;

View file

@ -0,0 +1,795 @@
import { useMemo } from 'react';
import { parse, print } from 'graphql';
import { isArray } from 'lodash';
import {
Box,
Boxes,
ClockIcon,
GitForkIcon,
Layers2Icon,
ListOrderedIcon,
LucideProps,
NetworkIcon,
UnlinkIcon,
} from 'lucide-react';
import { v4 as uuidv4 } from 'uuid';
import { Flow, FlowNode } from '@/components/flow';
import { GraphQLIcon } from '@/components/icons';
import { Editor } from '@/components/laboratory/editor';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import {
BatchFetchNodePlan,
ConditionNodePlan,
DeferNodePlan,
FetchNodePlan,
FlattenNodePath,
FlattenNodePathSegment,
FlattenNodePlan,
ParallelNodePlan,
PlanNode,
QueryPlan,
SelectionInlineFragment,
SelectionSet,
SequenceNodePlan,
SubscriptionNodePlan,
} from './schema';
function indent(depth: number): string {
return ' '.repeat(depth);
}
function normalizeStringSet(value: string[] | Set<string> | null | undefined): string[] {
if (!value) return [];
return Array.isArray(value) ? value : Array.from(value);
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function renderFlattenPathSegment(seg: FlattenNodePathSegment): string {
if (seg === '@') return '@';
if (isObject(seg) && 'Field' in seg) {
return String(seg.Field);
}
if (isObject(seg) && 'TypeCondition' in seg) {
const names = normalizeStringSet(seg.TypeCondition as string[] | Set<string>);
return `|[${names.join('|')}]`;
}
if (isArray(seg)) {
return renderFlattenPath(seg);
}
return String(seg);
}
export function renderFlattenPath(path: FlattenNodePath): string {
let out = '';
for (let i = 0; i < path.length; i++) {
const current = path[i];
const next = path[i + 1];
out += renderFlattenPathSegment(current);
if (next !== undefined) {
const nextIsTypeCondition = isObject(next) && 'TypeCondition' in next;
if (!nextIsTypeCondition) out += '.';
}
}
return out;
}
export function renderSelectionSet(
selectionSet: SelectionSet | null | undefined,
depth = 0,
): string {
if (!selectionSet?.length) return '';
const lines: string[] = [];
for (const item of selectionSet) {
if (item.kind === 'InlineFragment') {
lines.push(`${indent(depth)}... on ${item.typeCondition} {`);
for (const property of item.selections ?? []) {
lines.push(`${indent(depth + 1)}${property.name}`);
}
lines.push(`${indent(depth)}}`);
}
}
return lines.join('\n');
}
export function renderQueryPlan(plan: QueryPlan): string {
const lines: string[] = [];
lines.push('QueryPlan {');
if (plan.node) {
lines.push(renderPlanNode(plan.node, 1));
} else {
lines.push(`${indent(1)}None`);
}
lines.push('}');
return lines.join('\n');
}
export function renderPlanNode(node: PlanNode, depth = 0): string {
switch (node.kind) {
case 'Fetch':
return renderFetchNode(node, depth);
case 'BatchFetch':
return renderBatchFetchNode(node, depth);
case 'Flatten':
return renderFlattenNode(node, depth);
case 'Sequence':
return renderSequenceNode(node, depth);
case 'Parallel':
return renderParallelNode(node, depth);
case 'Condition':
return renderConditionNode(node, depth);
case 'Subscription':
return renderSubscriptionNode(node, depth);
case 'Defer':
return renderDeferNode(node, depth);
default:
return `${indent(depth)}<UnknownNode kind="${(node as { kind?: string }).kind ?? 'unknown'}">`;
}
}
export function renderFetchNode(node: FetchNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}Fetch(service: "${node.serviceName}") {`);
if (node.requires) {
lines.push(`${indent(depth + 1)}{`);
const requires = renderSelectionSet(node.requires, depth + 2);
if (requires) lines.push(requires);
lines.push(`${indent(depth + 1)}} =>`);
}
const slice = node.operation.includes('_entities') ? 2 : 1;
try {
lines.push(
`${indent(depth + 1)}{`,
renderMultilineBlock(
print(parse(node.operation))
.split('\n')
.slice(slice, slice * -1)
.join('\n'),
depth + 2 - slice,
),
`${indent(depth + 1)}}`,
);
} catch {
lines.push(`${indent(depth + 1)}${node.operation}`);
}
lines.push(`${indent(depth)}}`);
return lines.join('\n');
}
export function renderBatchFetchNode(node: BatchFetchNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}BatchFetch(service: "${node.serviceName}") {`);
for (let i = 0; i < node.entityBatch.aliases.length; i++) {
const alias = node.entityBatch.aliases[i];
lines.push(
`${indent(depth + 1)}${alias.alias} {`,
`${indent(depth + 2)}paths: [`,
...alias.paths.map(path => `${indent(depth + 3)}"${renderFlattenPath(path)}"`),
`${indent(depth + 2)}]`,
);
const requires = renderSelectionSet(alias.requires, depth + 3);
if (requires) {
lines.push(`${indent(depth + 2)}{`, requires, `${indent(depth + 2)}}`);
}
if (i < node.entityBatch.aliases.length - 1) {
lines.push(`${indent(depth + 1)}}`);
}
}
try {
lines.push(
`${indent(depth + 1)}}`,
`${indent(depth + 1)}{`,
renderMultilineBlock(
print(parse(node.operation)).split('\n').slice(1, -1).join('\n'),
depth + 1,
),
`${indent(depth + 1)}}`,
`${indent(depth)}}`,
);
} catch {
lines.push(`${indent(depth + 1)}${node.operation}`);
}
return lines.join('\n');
}
export function renderFlattenNode(node: FlattenNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(
`${indent(depth)}Flatten(path: "${renderFlattenPath(node.path)}") {`,
renderPlanNode(node.node, depth + 1),
`${indent(depth)}}`,
);
return lines.join('\n');
}
export function renderSequenceNode(node: SequenceNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}Sequence {`);
for (const child of node.nodes) {
lines.push(renderPlanNode(child, depth + 1));
}
lines.push(`${indent(depth)}}`);
return lines.join('\n');
}
export function renderParallelNode(node: ParallelNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}Parallel {`);
for (const child of node.nodes) {
lines.push(renderPlanNode(child, depth + 1));
}
lines.push(`${indent(depth)}}`);
return lines.join('\n');
}
export function renderConditionNode(node: ConditionNodePlan, depth = 0): string {
const lines: string[] = [];
if (node.ifClause && !node.elseClause) {
lines.push(
`${indent(depth)}Include(if: $${node.condition}) {`,
renderPlanNode(node.ifClause, depth + 1),
`${indent(depth)}}`,
);
return lines.join('\n');
}
if (!node.ifClause && node.elseClause) {
lines.push(
`${indent(depth)}Skip(if: $${node.condition}) {`,
renderPlanNode(node.elseClause, depth + 1),
`${indent(depth)}}`,
);
return lines.join('\n');
}
if (node.ifClause && node.elseClause) {
lines.push(
`${indent(depth)}Condition(if: $${node.condition}) {`,
`${indent(depth + 1)}if {`,
renderPlanNode(node.ifClause, depth + 2),
`${indent(depth + 1)}}`,
`${indent(depth + 1)}else {`,
renderPlanNode(node.elseClause, depth + 2),
`${indent(depth + 1)}}`,
`${indent(depth)}}`,
);
return lines.join('\n');
}
return `${indent(depth)}Condition(if: $${node.condition}) {}`;
}
export function renderSubscriptionNode(node: SubscriptionNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(
`${indent(depth)}Subscription {`,
`${indent(depth + 1)}primary {`,
renderPlanNode(node.primary, depth + 2),
`${indent(depth + 1)}}`,
`${indent(depth)}}`,
);
return lines.join('\n');
}
export function renderDeferNode(node: DeferNodePlan, depth = 0): string {
const lines: string[] = [];
lines.push(`${indent(depth)}Defer {`, `${indent(depth + 1)}primary {`);
if (node.primary.subselection) {
lines.push(`${indent(depth + 2)}subselection: ${JSON.stringify(node.primary.subselection)}`);
}
if (node.primary.node) {
lines.push(renderPlanNode(node.primary.node, depth + 2));
}
lines.push(`${indent(depth + 1)}}`);
if (node.deferred.length > 0) {
lines.push(`${indent(depth + 1)}deferred {`);
for (const d of node.deferred) {
lines.push(`${indent(depth + 2)}item {`);
if (d.label) lines.push(`${indent(depth + 3)}label: ${JSON.stringify(d.label)}`);
lines.push(`${indent(depth + 3)}queryPath: [${d.queryPath.join(', ')}]`);
if (d.subselection) {
lines.push(`${indent(depth + 3)}subselection: ${JSON.stringify(d.subselection)}`);
}
if (d.depends.length) {
lines.push(
`${indent(depth + 3)}depends: [${d.depends
.map(x => (x.deferLabel ? `${x.id}:${x.deferLabel}` : x.id))
.join(', ')}]`,
);
}
if (d.node) {
lines.push(renderPlanNode(d.node, depth + 3));
}
lines.push(`${indent(depth + 2)}}`);
}
lines.push(`${indent(depth + 1)}}`);
}
lines.push(`${indent(depth)}}`);
return lines.join('\n');
}
function renderMultilineBlock(value: string, depth = 0): string {
return value
.split('\n')
.map(line => `${indent(depth)}${line}`)
.join('\n');
}
export type ExtractedOperation = {
path: string;
nodeKind: 'Fetch' | 'BatchFetch';
serviceName: string;
operationKind?: string | null;
operationName?: string | null;
graphql: string;
};
export function extractOperations(plan: QueryPlan): ExtractedOperation[] {
if (!plan.node) return [];
return extractOperationsFromNode(plan.node, 'root');
}
function extractOperationsFromNode(node: PlanNode, path: string): ExtractedOperation[] {
switch (node.kind) {
case 'Fetch':
return [
{
path,
nodeKind: 'Fetch',
serviceName: node.serviceName,
operationKind: node.operationKind ?? null,
operationName: node.operationName ?? null,
graphql: node.operation,
},
];
case 'BatchFetch':
return [
{
path,
nodeKind: 'BatchFetch',
serviceName: node.serviceName,
operationKind: node.operationKind ?? null,
operationName: node.operationName ?? null,
graphql: node.operation,
},
];
case 'Flatten':
return extractOperationsFromNode(
node.node,
`${path}.flatten(${renderFlattenPath(node.path)})`,
);
case 'Sequence':
return node.nodes.flatMap((child, i) =>
extractOperationsFromNode(child, `${path}.sequence[${i}]`),
);
case 'Parallel':
return node.nodes.flatMap((child, i) =>
extractOperationsFromNode(child, `${path}.parallel[${i}]`),
);
case 'Condition': {
const out: ExtractedOperation[] = [];
if (node.ifClause) {
out.push(...extractOperationsFromNode(node.ifClause, `${path}.if($${node.condition})`));
}
if (node.elseClause) {
out.push(...extractOperationsFromNode(node.elseClause, `${path}.else($${node.condition})`));
}
return out;
}
case 'Subscription':
return extractOperationsFromNode(node.primary, `${path}.subscription.primary`);
case 'Defer': {
const out: ExtractedOperation[] = [];
if (node.primary.node) {
out.push(...extractOperationsFromNode(node.primary.node, `${path}.defer.primary`));
}
node.deferred.forEach((d, i) => {
if (d.node) {
out.push(
...extractOperationsFromNode(
d.node,
`${path}.defer.deferred[${i}]${d.label ? `(${d.label})` : ''}`,
),
);
}
});
return out;
}
default:
return [];
}
}
export interface QueryPlanNode extends FlowNode {
kind: PlanNode['kind'] | 'Root';
children?: QueryPlanNode[];
}
function visitNode(
node: PlanNode,
parentNode: QueryPlanNode | null,
nodes: QueryPlanNode[],
contentPrefix?: React.ReactNode,
detailsContent?: string,
): QueryPlanNode {
let result: QueryPlanNode | null = {
id: uuidv4(),
title: node.kind,
kind: node.kind as QueryPlanNode['kind'],
};
switch (node.kind) {
case 'Fetch':
result.content = () => {
const entity = (node.requires?.[0] as SelectionInlineFragment)?.typeCondition;
return (
<div className="flex flex-col gap-2">
{contentPrefix}
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Service</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{node.serviceName}
</span>
</div>
{entity && (
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Entity</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{entity}
</span>
</div>
)}
<div className="border-t border-dashed">
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="link" className="h-auto w-full pt-2 text-xs">
Show details
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl! max-h-150 h-full w-full">
<DialogHeader>
<DialogTitle>Fetch</DialogTitle>
</DialogHeader>
<div className="h-full overflow-hidden rounded-sm border">
<Editor value={detailsContent ?? renderFetchNode(node)} language="graphql" />
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
};
break;
case 'BatchFetch':
result.headerSuffix = () => {
const totalPaths = node.entityBatch.aliases.reduce(
(acc, alias) => acc + alias.paths.length,
0,
);
return (
<div className="bg-muted flex items-center gap-1 rounded-md p-0.5 pl-1 font-mono text-xs leading-none">
Total paths:
<div className="bg-primary/10 border-primary text-primary rounded-sm border px-1 text-xs font-medium leading-none">
{totalPaths}
</div>
</div>
);
};
result.content = () => {
return (
<div className="*:border-border flex flex-col gap-2 *:border-b *:border-dashed *:pb-2 *:last:border-b-0 *:last:pb-0">
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Service</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{node.serviceName}
</span>
</div>
{node.entityBatch.aliases.map(alias => {
return (
<div key={alias.alias} className="grid gap-2">
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Path</span>
{alias.paths.length > 1 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
<div className="bg-card rounded-sm border px-1 text-xs font-medium">
{alias.paths.length}
</div>
</span>
</TooltipTrigger>
<TooltipContent className="whitespace-pre-wrap font-mono">
{alias.paths.map(path => renderFlattenPath(path)).join(',\n')}
</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
{renderFlattenPath(alias.paths[0])}
</span>
</TooltipTrigger>
<TooltipContent>{renderFlattenPath(alias.paths[0])}</TooltipContent>
</Tooltip>
)}
</div>
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Enitity</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
{(alias.requires[0] as SelectionInlineFragment).typeCondition}
</span>
</div>
</div>
);
})}
<div className="-mt-2">
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="link" className="h-auto w-full pt-2 text-xs">
Show details
</Button>
</DialogTrigger>
<DialogContent
className="max-w-2xl! max-h-150 h-full w-full"
onMouseDown={e => {
e.preventDefault();
e.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>BatchFetch</DialogTitle>
</DialogHeader>
<div className="h-full overflow-hidden rounded-sm border">
<Editor value={renderBatchFetchNode(node)} language="graphql" />
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
};
break;
case 'Flatten':
result = null;
visitNode(
node.node,
result,
nodes,
<>
{contentPrefix}
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Path</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
{renderFlattenPath(node.path)}
</span>
</TooltipTrigger>
<TooltipContent>{renderFlattenPath(node.path)}</TooltipContent>
</Tooltip>
</div>
</>,
detailsContent ?? renderFlattenNode(node),
);
break;
case 'Sequence': {
result = null;
let prevChild: QueryPlanNode | null = null;
for (let i = 0; i < node.nodes.length; i++) {
const child = node.nodes[i];
const childNode = visitNode(
child,
prevChild,
i === 0 ? [] : nodes,
contentPrefix,
detailsContent,
);
if (i === 0) {
result = childNode;
}
if (prevChild) {
prevChild.next = [childNode.id];
}
prevChild = childNode;
}
break;
}
case 'Parallel':
result.children = [];
for (const child of node.nodes) {
visitNode(child, result, result.children, contentPrefix, detailsContent);
}
break;
case 'Condition':
result = null;
if (node.ifClause) {
visitNode(
node.ifClause,
result,
nodes,
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Include</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
if: ${node.condition}
</span>
</div>,
renderConditionNode(node),
);
}
if (node.elseClause) {
visitNode(
node.elseClause,
result,
nodes,
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
<span className="font-medium">Skip</span>
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
if: ${node.condition}
</span>
</div>,
renderConditionNode(node),
);
}
break;
case 'Subscription':
visitNode(node.primary, result, nodes, contentPrefix, detailsContent);
break;
case 'Defer':
if (node.primary.node) {
visitNode(node.primary.node, result, nodes, contentPrefix, detailsContent);
}
for (const deferred of node.deferred) {
if (deferred.node) {
visitNode(deferred.node, result, nodes, contentPrefix, detailsContent);
}
}
break;
default:
break;
}
if (parentNode && result) {
parentNode.next = [...(parentNode.next ?? []), result.id!];
}
if (result) {
result.icon = queryPlanNodeIcon(result.kind);
nodes.push(result as QueryPlanNode);
}
return result as QueryPlanNode;
}
export const queryPlanNodeIcon = (
kind: QueryPlanNode['kind'],
): ((props: LucideProps) => React.ReactNode) => {
return (props: LucideProps) => {
switch (kind) {
case 'Root':
return (
<GraphQLIcon {...props} className={cn(props.className, 'size-6 min-w-6 text-pink-500')} />
);
case 'Fetch':
return <Box {...props} />;
case 'BatchFetch':
return <Boxes {...props} />;
case 'Flatten':
return <Layers2Icon {...props} />;
case 'Sequence':
return <ListOrderedIcon {...props} />;
case 'Parallel':
return <NetworkIcon {...props} />;
case 'Condition':
return <GitForkIcon {...props} className={cn('rotate-90', props.className)} />;
case 'Subscription':
return <UnlinkIcon {...props} />;
case 'Defer':
return <ClockIcon {...props} />;
}
};
};
export function QueryPlanTree(props: { plan: QueryPlan }) {
const nodes = useMemo(() => {
const nodes: QueryPlanNode[] = [];
const rootNode: QueryPlanNode = {
id: uuidv4(),
title: '',
kind: 'Root',
maxWidth: 42,
};
nodes.push(rootNode);
if (props.plan.node) {
visitNode(props.plan.node, rootNode, nodes);
}
return nodes.map(node => {
return {
...node,
icon: queryPlanNodeIcon(node.kind),
} satisfies FlowNode;
});
}, [props.plan]);
return <Flow nodes={nodes} />;
}

View file

@ -3,6 +3,17 @@ import { useCallback, useState } from 'react';
export type LaboratorySettings = {
fetch: {
credentials: 'include' | 'omit' | 'same-origin';
timeout?: number;
retry?: number;
useGETForQueries?: boolean;
};
subscriptions: {
protocol: 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS';
};
introspection: {
queryName?: string;
method?: 'GET' | 'POST';
schemaDescription?: boolean;
};
};
@ -22,6 +33,17 @@ export const useSettings = (props: {
props.defaultSettings ?? {
fetch: {
credentials: 'same-origin',
timeout: 10000,
retry: 3,
useGETForQueries: false,
},
subscriptions: {
protocol: 'WS',
},
introspection: {
queryName: 'IntrospectionQuery',
method: 'POST',
schemaDescription: false,
},
},
);

View file

@ -58,11 +58,7 @@ export default {
},
},
optimizeDeps: {
include: [
'monaco-editor/esm/vs/editor/editor.api',
'monaco-editor/esm/vs/language/json/monaco.contribution',
'monaco-graphql/esm/monaco.contribution',
],
include: ['monaco-editor/esm/vs/editor/editor.api', 'monaco-graphql/esm/monaco.contribution'],
},
environments: {
client: {

View file

@ -673,16 +673,22 @@ importers:
packages/libraries/laboratory:
dependencies:
'@base-ui/react':
specifier: ^1.1.0
version: 1.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-zoom-pan-pinch:
specifier: ^3.7.0
version: 3.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
uuid:
specifier: ^13.0.0
version: 13.0.0
devDependencies:
'@dagrejs/dagre':
specifier: ^1.1.8
version: 1.1.8
specifier: ^2.0.4
version: 2.0.4
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -3510,12 +3516,11 @@ packages:
'@cypress/xvfb@1.2.4':
resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==}
'@dagrejs/dagre@1.1.8':
resolution: {integrity: sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==}
'@dagrejs/dagre@2.0.4':
resolution: {integrity: sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==}
'@dagrejs/graphlib@2.2.4':
resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==}
engines: {node: '>17.0.0'}
'@dagrejs/graphlib@3.0.4':
resolution: {integrity: sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==}
'@date-fns/utc@2.1.1':
resolution: {integrity: sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA==}
@ -4083,12 +4088,6 @@ packages:
'@floating-ui/dom@1.7.5':
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
'@floating-ui/react-dom@2.1.0':
resolution: {integrity: sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react-dom@2.1.7':
resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==}
peerDependencies:
@ -4104,9 +4103,6 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/utils@0.2.2':
resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==}
'@gar/promise-retry@1.0.2':
resolution: {integrity: sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==}
engines: {node: ^20.17.0 || >=22.9.0}
@ -16922,6 +16918,13 @@ packages:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-zoom-pan-pinch@3.7.0:
resolution: {integrity: sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==}
engines: {node: '>=8', npm: '>=5'}
peerDependencies:
react: '*'
react-dom: '*'
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@ -17917,9 +17920,6 @@ packages:
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
engines: {node: ^14.18.0 || >=16.0.0}
tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
@ -18642,11 +18642,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
use-sync-external-store@1.5.0:
resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@ -21400,11 +21395,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@dagrejs/dagre@1.1.8':
'@dagrejs/dagre@2.0.4':
dependencies:
'@dagrejs/graphlib': 2.2.4
'@dagrejs/graphlib': 3.0.4
'@dagrejs/graphlib@2.2.4': {}
'@dagrejs/graphlib@3.0.4': {}
'@date-fns/utc@2.1.1': {}
@ -21532,7 +21527,7 @@ snapshots:
'@emotion/react@11.10.5(@babel/core@7.28.5)(@types/react@18.3.18)(react@18.3.1)':
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
'@emotion/babel-plugin': 11.10.5(@babel/core@7.28.5)
'@emotion/cache': 11.10.5
'@emotion/serialize': 1.1.1
@ -22089,12 +22084,6 @@ snapshots:
'@floating-ui/core': 1.7.4
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/dom': 1.2.9
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/react-dom@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/dom': 1.7.5
@ -22103,16 +22092,14 @@ snapshots:
'@floating-ui/react@0.26.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@floating-ui/utils': 0.2.2
'@floating-ui/react-dom': 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@floating-ui/utils': 0.2.10
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tabbable: 6.2.0
tabbable: 6.4.0
'@floating-ui/utils@0.2.10': {}
'@floating-ui/utils@0.2.2': {}
'@gar/promise-retry@1.0.2':
dependencies:
retry: 0.13.1
@ -27739,7 +27726,7 @@ snapshots:
'@radix-ui/react-dialog@1.0.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.3.1)
'@radix-ui/react-context': 1.0.0(react@18.3.1)
@ -28205,7 +28192,7 @@ snapshots:
'@radix-ui/react-popper@1.2.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@floating-ui/react-dom': 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-arrow': 1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1)
@ -30708,7 +30695,7 @@ snapshots:
'@tanstack/store': 0.1.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.5.0(react@18.3.1)
use-sync-external-store: 1.6.0(react@18.3.1)
'@tanstack/react-store@0.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
@ -33343,7 +33330,7 @@ snapshots:
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
date-fns@4.1.0: {}
@ -37667,7 +37654,7 @@ snapshots:
mjml-cli@4.14.0(encoding@0.1.13):
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
chokidar: 3.6.0
glob: 7.2.3
js-beautify: 1.14.6
@ -37690,7 +37677,7 @@ snapshots:
mjml-core@4.14.0(patch_hash=52f1e476e154edea0222aa95e55676888525198d882becc3b362511b77fd7e7f)(encoding@0.1.13):
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
cheerio: 1.0.0-rc.10
detect-node: 2.0.4
js-beautify: 1.14.6
@ -37800,7 +37787,7 @@ snapshots:
mjml-migrate@4.14.0(encoding@0.1.13):
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
js-beautify: 1.14.6
lodash: 4.18.1
mjml-core: 4.14.0(patch_hash=52f1e476e154edea0222aa95e55676888525198d882becc3b362511b77fd7e7f)(encoding@0.1.13)
@ -37826,7 +37813,7 @@ snapshots:
mjml-preset-core@4.14.0(encoding@0.1.13):
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
mjml-accordion: 4.14.0(encoding@0.1.13)
mjml-body: 4.14.0(encoding@0.1.13)
mjml-button: 4.14.0(encoding@0.1.13)
@ -37905,7 +37892,7 @@ snapshots:
mjml-validator@4.13.0:
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
mjml-wrapper@4.14.0(encoding@0.1.13):
dependencies:
@ -39382,7 +39369,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@ -39394,6 +39381,11 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-zoom-pan-pinch@3.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react@18.3.1:
dependencies:
loose-envify: 1.4.0
@ -40594,8 +40586,6 @@ snapshots:
'@pkgr/core': 0.1.2
tslib: 2.8.1
tabbable@6.2.0: {}
tabbable@6.4.0: {}
tagged-tag@1.0.0: {}
@ -41354,10 +41344,6 @@ snapshots:
dependencies:
react: 18.3.1
use-sync-external-store@1.5.0(react@18.3.1):
dependencies:
react: 18.3.1
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
@ -41943,7 +41929,7 @@ snapshots:
yup@0.29.3:
dependencies:
'@babel/runtime': 7.26.10
'@babel/runtime': 7.28.6
fn-name: 3.0.0
lodash: 4.18.1
lodash-es: 4.18.1