diff --git a/.changeset/true-foxes-happen.md b/.changeset/true-foxes-happen.md new file mode 100644 index 000000000..af866ca4b --- /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/packages/libraries/laboratory/components.json b/packages/libraries/laboratory/components.json index b266af2d1..824b2fdc8 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 42476bf2f..8857c58d9 100644 --- a/packages/libraries/laboratory/package.json +++ b/packages/libraries/laboratory/package.json @@ -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", diff --git a/packages/libraries/laboratory/src/components/flow.tsx b/packages/libraries/laboratory/src/components/flow.tsx new file mode 100644 index 000000000..11562af4f --- /dev/null +++ b/packages/libraries/laboratory/src/components/flow.tsx @@ -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(null); + const panStartRef = useRef(null); + const [view, setView] = useState<{ x: number; y: number; scale: number }>({ + x: 0, + y: 0, + scale: 1, + }); + 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', + 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) => { + 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) => { + 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) => { + 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 ( +
{ + stopPanning(); + setIsCanvasActive(false); + }} + onMouseEnter={() => setIsCanvasActive(true)} + > + {!props.disableBackground && ( +
+ )} +
+ {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 ( +
{ + 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, + }} + > +
+ {node.icon ? node.icon({ className: 'size-4 text-secondary-foreground' }) : null} + {node.title} +
+ {node.headerSuffix ? node.headerSuffix({ node }) : null} +
+
+
+ {node.content ? node.content({ node }) : null} +
+ {!!node.children?.length && ( +
+ { + const { width, height } = graph.graph(); + + setNodeSizes(prev => ({ + ...prev, + [node.id]: { + width: width + 20, + height: node.height + height + 4, + }, + })); + }} + disableBackground + disableGestures + className="bg-transparent" + isChild + /> +
+ )} + {hasFollowers && ( +
+ )} + {hasPrevious && ( +
+ )} +
+ ); + })} + + {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 ( + + ); + })} + +
+ {!props.isChild && ( +
+
+
+ + + + + Zoom out + + setView(prev => ({ ...prev, scale: value[0] }))} + min={MIN_SCALE} + max={MAX_SCALE} + step={ZOOM_STEP} + /> + + + + + Zoom in + +
+ + +
+
+ )} +
+ ); +}; diff --git a/packages/libraries/laboratory/src/components/laboratory/builder.tsx b/packages/libraries/laboratory/src/components/laboratory/builder.tsx index 41eaf2e0a..e2fa3d60c 100644 --- a/packages/libraries/laboratory/src/components/laboratory/builder.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/builder.tsx @@ -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: { {defaultEndpoint && ( - - - + + + - - Restore default endpoint - - + + + Restore default endpoint + )} diff --git a/packages/libraries/laboratory/src/components/laboratory/editor.tsx b/packages/libraries/laboratory/src/components/laboratory/editor.tsx index 4c892be89..6afb34087 100644 --- a/packages/libraries/laboratory/src/components/laboratory/editor.tsx +++ b/packages/libraries/laboratory/src/components/laboratory/editor.tsx @@ -128,7 +128,11 @@ const EditorInner = forwardRef((props, ref) => { const id = useId(); const editorRef = useRef(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(null); useEffect(() => { @@ -170,8 +174,9 @@ const EditorInner = forwardRef((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((props, ref) => { setTypescriptReady(true); } + if (!props.extraLibs?.length) { + return; + } + const ts = monaco.languages.typescript; if (!ts) { @@ -208,7 +217,7 @@ const EditorInner = forwardRef((props, ref) => { })), ); })(); - }, [id, props.extraLibs]); + }, [id, jsonReady, props.extraLibs, wantsJson]); useImperativeHandle( ref, @@ -226,6 +235,10 @@ const EditorInner = forwardRef((props, ref) => { return null; } + if (!jsonReady && wantsJson) { + return null; + } + return (
{ > Preflight Script - + {/* { const tab = @@ -416,7 +416,7 @@ const LaboratoryContent = () => { }} > Settings - + */} Settings @@ -433,7 +433,7 @@ const LaboratoryContent = () => {
-
{contentNode}
+
{contentNode}
@@ -623,7 +623,7 @@ export const Laboratory = ( [], ); - const containerRef = useRef(null); + const [container, setContainer] = useState(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} > @@ -809,7 +809,7 @@ export const Laboratory = ( {...collectionsApi} {...operationsApi} {...historyApi} - container={containerRef.current} + container={container} openAddCollectionDialog={openAddCollectionDialog} openUpdateEndpointDialog={openUpdateEndpointDialog} openAddTestDialog={openAddTestDialog} diff --git a/packages/libraries/laboratory/src/components/laboratory/operation.tsx b/packages/libraries/laboratory/src/components/laboratory/operation.tsx index 7d1d2da5c..d82ce7efd 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'; @@ -86,6 +94,7 @@ const Headers = (props: { operation?: LaboratoryOperation | null; isReadOnly?: b { updateActiveOperation({ headers: value ?? '', @@ -109,6 +118,7 @@ const Extensions = (props: { operation?: LaboratoryOperation | null; isReadOnly? { updateActiveOperation({ extensions: value ?? '', @@ -178,6 +188,56 @@ export const ResponsePreflight = ({ historyItem }: { historyItem?: LaboratoryHis ); }; +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 ( +
+ setMode(value as 'text' | 'visual')} + > + + + Text + + + + Visual + + + {mode === 'visual' ? ( + + ) : ( + + )} +
+ ); +}; 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 ( - - + + + {isFullScreen ? ( + + + + + Minimize panel + + ) : ( + + + + + Maximize panel + + )} Response + {hasValidQueryPlan && ( + + Query Plan + + )} Headers @@ -315,6 +430,9 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque + + + @@ -735,7 +853,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 852f170dc..020297ac5 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 ( -
+
@@ -66,6 +79,154 @@ export const Settings = () => { ); }} + + {field => { + return ( + + Timeout + field.handleChange(Number(e.target.value))} + /> + + ); + }} + + + {field => { + return ( + + Retry + field.handleChange(Number(e.target.value))} + /> + + ); + }} + + + {field => { + return ( + + + Use GET for queries + + ); + }} + + + + + + + Subscriptions + + Configure the subscriptions options for the laboratory. + + + + + + {field => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + Protocol + + + ); + }} + + + + + + + Introspection + + Configure the introspection options for the laboratory. + + + + + + {field => { + return ( + + Query name + field.handleChange(e.target.value)} + /> + + ); + }} + + + {field => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + Method + + + ); + }} + + + {field => { + return ( + + + Schema description + + ); + }} + diff --git a/packages/libraries/laboratory/src/components/ui/combobox.tsx b/packages/libraries/laboratory/src/components/ui/combobox.tsx new file mode 100644 index 000000000..d915eb34a --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/combobox.tsx @@ -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 ; +} + +function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + ); +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ); +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean; + showClear?: boolean; +}) { + return ( + + } {...props} /> + + {showTrigger && ( + + + + )} + {showClear && } + + {children} + + ); +} + +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 ( + + + + + + ); +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + +function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ); +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ); +} + +function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ); +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ; +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( +