diff --git a/.changeset/true-foxes-happen.md b/.changeset/true-foxes-happen.md
new file mode 100644
index 00000000000..af866ca4baf
--- /dev/null
+++ b/.changeset/true-foxes-happen.md
@@ -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
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 7d47d3dd8d2..7ae4055f91f 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -36,6 +36,30 @@ jobs:
uploadJavaScriptArtifacts: true
secrets: inherit
+ laboratory-static-preview:
+ name: laboratory static preview
+ runs-on: ubuntu-22.04
+ steps:
+ - name: checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+
+ - name: setup environment
+ uses: ./.github/actions/setup
+ with:
+ actor: laboratory-static-preview
+ codegen: false
+
+ - name: build static laboratory preview
+ run: pnpm --filter @graphql-hive/laboratory build:static-preview
+
+ - name: upload static laboratory preview artifact
+ uses: actions/upload-artifact@v7.0.0
+ with:
+ name: laboratory-static-preview
+ path: packages/libraries/laboratory/dist/hive-laboratory.static-preview.html
+ if-no-files-found: error
+ archive: false
+
# Run db migrations tests
db-migration-tests:
name: test
diff --git a/packages/libraries/laboratory/components.json b/packages/libraries/laboratory/components.json
index b266af2d175..824b2fdc8d2 100644
--- a/packages/libraries/laboratory/components.json
+++ b/packages/libraries/laboratory/components.json
@@ -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": {}
}
diff --git a/packages/libraries/laboratory/package.json b/packages/libraries/laboratory/package.json
index 06e9d51d239..02e27686842 100644
--- a/packages/libraries/laboratory/package.json
+++ b/packages/libraries/laboratory/package.json
@@ -21,6 +21,7 @@
],
"scripts": {
"build": "vite build --config vite.lib.config.ts && vite build --config vite.umd.config.ts",
+ "build:static-preview": "pnpm run build && node scripts/build-static-preview.mjs",
"dev": "vite",
"dev:electron": "VITE_TARGET=electron concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"lint": "eslint .",
@@ -38,11 +39,12 @@
"zod": "^4.1.12"
},
"dependencies": {
+ "@base-ui/react": "^1.1.0",
"radix-ui": "^1.4.3",
"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",
diff --git a/packages/libraries/laboratory/scripts/build-static-preview.mjs b/packages/libraries/laboratory/scripts/build-static-preview.mjs
new file mode 100644
index 00000000000..b062d71cf53
--- /dev/null
+++ b/packages/libraries/laboratory/scripts/build-static-preview.mjs
@@ -0,0 +1,120 @@
+import { promises as fs } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
+const packageDirectory = path.resolve(scriptDirectory, '..');
+const distDirectory = path.resolve(packageDirectory, 'dist');
+
+const resolveDistPath = relativePath => path.resolve(distDirectory, relativePath);
+
+const readRequiredText = relativePath => fs.readFile(resolveDistPath(relativePath), 'utf8');
+
+const readOptionalText = async relativePath => {
+ try {
+ return await fs.readFile(resolveDistPath(relativePath), 'utf8');
+ } catch {
+ return '';
+ }
+};
+
+const [
+ bundleSource,
+ cssSource,
+ editorWorkerSource,
+ graphqlWorkerSource,
+ jsonWorkerSource,
+ tsWorkerSource,
+] = await Promise.all([
+ readRequiredText('hive-laboratory.umd.js'),
+ readOptionalText('laboratory.css'),
+ readRequiredText('monacoeditorwork/editor.worker.bundle.js'),
+ readRequiredText('monacoeditorwork/graphql.worker.bundle.js'),
+ readRequiredText('monacoeditorwork/json.worker.bundle.js'),
+ readRequiredText('monacoeditorwork/ts.worker.bundle.js'),
+]);
+
+const serializeForScriptTag = value =>
+ JSON.stringify(value)
+ .replace(//g, '\\u003e')
+ .replace(/&/g, '\\u0026')
+ .replace(/\u2028/g, '\\u2028')
+ .replace(/\u2029/g, '\\u2029');
+
+const payload = {
+ cssSource,
+ bundleSource,
+ workers: {
+ editorWorkerService: editorWorkerSource,
+ graphql: graphqlWorkerSource,
+ json: jsonWorkerSource,
+ typescript: tsWorkerSource,
+ },
+};
+
+const html = `
+
+
+
+
+ Hive Laboratory Static Preview
+
+
+
+
+
+
+
+`;
+
+const outputPath = resolveDistPath('hive-laboratory.static-preview.html');
+await fs.writeFile(outputPath, html, 'utf8');
+
+console.log(`Created static preview: ${outputPath}`);
diff --git a/packages/libraries/laboratory/src/components/flow.tsx b/packages/libraries/laboratory/src/components/flow.tsx
new file mode 100644
index 00000000000..0c8c49fdc2f
--- /dev/null
+++ b/packages/libraries/laboratory/src/components/flow.tsx
@@ -0,0 +1,332 @@
+import { useCallback, useMemo, useState } from 'react';
+import { LucideProps } from 'lucide-react';
+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;
+ parent?: string;
+}
+
+export interface FlowGraphInternal extends FlowNode {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ isCluster: boolean;
+}
+
+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(' ');
+}
+
+export const Flow = (props: { nodes: FlowNode[]; graph?: Record }) => {
+ const [hoveredNodeId, setHoveredNodeId] = useState(null);
+ const [nodeSizes, setNodeSizes] = useState>({});
+ 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',
+ nodesep: 32,
+ ranksep: 32,
+ marginx: 32,
+ marginy: 32,
+ graph: 'tight-tree',
+ })
+ .setDefaultEdgeLabel(() => ({}));
+
+ const groups = [...new Set(props.nodes.map(node => node.parent))].filter(Boolean);
+
+ for (const node of props.nodes) {
+ if (!groups.includes(node.id)) {
+ result.setNode(node.id, {
+ width: nodeSizes[node.id]?.width,
+ height: nodeSizes[node.id]?.height,
+ });
+ }
+ }
+
+ for (const group of groups) {
+ result.setNode(group!, {
+ // clusterLabelPos: group!,
+ });
+ }
+
+ for (const node of props.nodes) {
+ if (node.parent) {
+ result.setParent(node.id, node.parent);
+ }
+ }
+
+ for (const node of props.nodes) {
+ if (node.next) {
+ for (const next of node.next) {
+ if (groups.includes(next)) {
+ const nextNode = props.nodes.find(node => node.id === next);
+
+ for (const childNext of nextNode?.next ?? []) {
+ result.setEdge(node.id, childNext);
+ }
+ } else if (!groups.includes(node.id)) {
+ result.setEdge(node.id, next);
+ }
+ }
+ }
+ }
+
+ dagre.layout(result);
+
+ const graph = result.graph();
+
+ return [
+ props.nodes.map(node => {
+ const graphNode = result.node(node.id);
+
+ return {
+ ...node,
+ isCluster: groups.includes(node.id),
+ 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.graph]);
+
+ const findFollowers = useCallback(
+ (nodeId: string): FlowNode[] => {
+ const node = nodes.find(node => node.id === nodeId);
+
+ if (!node) {
+ return [] as FlowNode[];
+ }
+
+ return (
+ (node.next
+ ?.map(next => {
+ return [nodes.find(node => node.id === next), ...findFollowers(next)].filter(Boolean);
+ })
+ .flat(Infinity) as FlowNode[]) ?? []
+ );
+ },
+ [nodes],
+ );
+
+ const hoveredNodeFollowers = useMemo(() => {
+ if (!hoveredNodeId) {
+ return [];
+ }
+
+ return findFollowers(hoveredNodeId);
+ }, [hoveredNodeId, findFollowers]);
+
+ return (
+
+
+
+ {nodes.map(node => {
+ const isHovered = hoveredNodeId === node.id;
+ const isFollowingHoveredNode = hoveredNodeFollowers.some(
+ follower => follower.id === node.id,
+ );
+ const hasFollowers = !!node.next?.length;
+ const hasPrevious = nodes.some(n => n.next?.includes(node.id));
+
+ return (
+
{
+ if (ref && !nodeSizes[node.id]) {
+ setNodeSizes(prev => ({
+ ...prev,
+ [node.id]: { width: ref.clientWidth, height: ref.clientHeight },
+ }));
+ }
+ }}
+ className={cn(
+ 'bg-card absolute z-20 flex w-64 flex-col justify-start gap-2 rounded-lg border p-2 text-sm shadow-sm transition-all',
+ {
+ 'border-primary shadow-primary/5 shadow-xl':
+ (isHovered || isFollowingHoveredNode) && !node.isCluster,
+ 'bg-card/50 pointer-events-none z-10 -mt-[10px] w-auto rounded-2xl border-dashed':
+ node.isCluster,
+ },
+ )}
+ style={{
+ left: node.x - node.width / 2,
+ top: node.y - node.height / 2,
+ width: node.isCluster ? node.width : undefined,
+ height: node.isCluster ? node.height : undefined,
+ }}
+ onMouseEnter={() => setHoveredNodeId(node.id)}
+ onMouseLeave={() => setHoveredNodeId(null)}
+ >
+
+ {node.icon ? node.icon({ className: 'size-4 text-secondary-foreground' }) : null}
+ {node.title}
+
+
+ {node.content ? node.content({ node }) : null}
+
+ {hasFollowers && !node.isCluster && (
+
+ )}
+ {hasPrevious && !node.isCluster && (
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/packages/libraries/laboratory/src/components/laboratory/builder.tsx b/packages/libraries/laboratory/src/components/laboratory/builder.tsx
index bdfe305a0c4..62697611146 100644
--- a/packages/libraries/laboratory/src/components/laboratory/builder.tsx
+++ b/packages/libraries/laboratory/src/components/laboratory/builder.tsx
@@ -21,7 +21,6 @@ import {
SearchIcon,
TextAlignStartIcon,
} from 'lucide-react';
-import { ToggleGroup, ToggleGroupItem } from '@/laboratory/components/ui/toggle-group';
import type { LaboratoryOperation } from '../../lib/operations';
import {
getFieldByPath,
@@ -40,6 +39,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';
@@ -791,8 +791,6 @@ export const Builder = (props: {
});
}, [schema, deferredSearchValue, isSearchActive, tabValue]);
- console.log(searchResult);
-
const visiblePaths = isSearchActive ? (searchResult?.visiblePaths ?? null) : null;
const forcedOpenPaths =
isSearchActive && deferredSearchValue.includes('.')
diff --git a/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx
index 019a60a7da3..e232b787fd6 100644
--- a/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx
+++ b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx
@@ -250,7 +250,7 @@ const LaboratoryContent = () => {
}
return (
-
+
@@ -344,7 +344,7 @@ const LaboratoryContent = () => {
{
>
Preflight Script
-
+ {/*
{
const tab =
@@ -416,7 +416,7 @@ const LaboratoryContent = () => {
}}
>
Settings
-
+ */}
Settings
@@ -433,7 +433,7 @@ const LaboratoryContent = () => {
-
{contentNode}
+
{contentNode}
diff --git a/packages/libraries/laboratory/src/components/laboratory/operation.tsx b/packages/libraries/laboratory/src/components/laboratory/operation.tsx
index 7d1d2da5c1c..5412aaa1532 100644
--- a/packages/libraries/laboratory/src/components/laboratory/operation.tsx
+++ b/packages/libraries/laboratory/src/components/laboratory/operation.tsx
@@ -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';
@@ -178,6 +186,52 @@ export const ResponsePreflight = ({ historyItem }: { historyItem?: LaboratoryHis
);
};
+export const ResponseQueryPlan = ({ historyItem }: { historyItem?: LaboratoryHistory | null }) => {
+ const [mode, setMode] = useState<'text' | 'visual'>('text');
+
+ const queryPlan = useMemo(() => {
+ const queryPlan =
+ JSON.parse((historyItem as LaboratoryHistoryRequest)?.response ?? '{}').extensions
+ ?.queryPlan ?? {};
+
+ if (!queryPlan) {
+ return null;
+ }
+
+ return QueryPlanSchema.safeParse(queryPlan).success ? queryPlan : null;
+ }, [historyItem]);
+
+ return (
+
+
setMode(value as 'text' | 'visual')}
+ >
+
+
+ Text
+
+
+
+ Visual
+
+
+ {mode === 'visual' ? (
+
+ ) : (
+
+ )}
+
+ );
+};
export const ResponseSubscription = ({
historyItem,
@@ -245,6 +299,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 +317,67 @@ 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;
+ }
+
+ console.log(QueryPlanSchema.safeParse(queryPlan).error?.message);
+
+ return QueryPlanSchema.safeParse(queryPlan).success;
+ }, [historyItem?.response]);
+
return (
-
-
+
+
+ {isFullScreen ? (
+
+
+
+
+ Minimize panel
+
+ ) : (
+
+
+
+
+ Maximize panel
+
+ )}
Response
+ {hasValidQueryPlan && (
+
+ Query Plan
+
+ )}
Headers
@@ -315,6 +426,9 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
+
+
+
@@ -735,7 +849,7 @@ export const Operation = (props: {
}, [props.historyItem]);
return (
-
+
diff --git a/packages/libraries/laboratory/src/components/laboratory/settings.tsx b/packages/libraries/laboratory/src/components/laboratory/settings.tsx
index 852f170dc20..020297ac586 100644
--- a/packages/libraries/laboratory/src/components/laboratory/settings.tsx
+++ b/packages/libraries/laboratory/src/components/laboratory/settings.tsx
@@ -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 (
-