mirror of
https://github.com/graphql-hive/console
synced 2026-05-24 09:38:26 +00:00
Feat/lab query plan (#7892)
Co-authored-by: Laurin <laurinquast@googlemail.com>
This commit is contained in:
parent
ed9ab34c70
commit
fab4b03ace
22 changed files with 2296 additions and 155 deletions
6
.changeset/true-foxes-happen.md
Normal file
6
.changeset/true-foxes-happen.md
Normal 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
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
519
packages/libraries/laboratory/src/components/flow.tsx
Normal file
519
packages/libraries/laboratory/src/components/flow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
275
packages/libraries/laboratory/src/components/ui/combobox.tsx
Normal file
275
packages/libraries/laboratory/src/components/ui/combobox.tsx
Normal 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,
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
55
packages/libraries/laboratory/src/components/ui/slider.tsx
Normal file
55
packages/libraries/laboratory/src/components/ui/slider.tsx
Normal 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 };
|
||||
|
|
@ -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<
|
||||
|
|
@ -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' }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
225
packages/libraries/laboratory/src/lib/query-plan/schema.ts
Normal file
225
packages/libraries/laboratory/src/lib/query-plan/schema.ts
Normal 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>;
|
||||
795
packages/libraries/laboratory/src/lib/query-plan/utils.tsx
Normal file
795
packages/libraries/laboratory/src/lib/query-plan/utils.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue