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 && ( +
+ )} +
+ ); + })} + + {edges + .sort((a, b) => { + const isHoveredA = hoveredNodeId === a.from; + const isHoveredB = hoveredNodeId === b.from; + const isFollowingHoveredNodeA = hoveredNodeFollowers.some( + follower => follower.id === a.from, + ); + const isFollowingHoveredNodeB = hoveredNodeFollowers.some( + follower => follower.id === b.from, + ); + + if ( + (isHoveredA || isFollowingHoveredNodeA) && + (!isHoveredB || !isFollowingHoveredNodeB) + ) { + return 1; + } + + if ( + (!isHoveredA || !isFollowingHoveredNodeA) && + (isHoveredB || isFollowingHoveredNodeB) + ) { + return -1; + } + + return 0; + }) + .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; + } + + const isHovered = hoveredNodeId === edge.from; + const isFollowingHoveredNode = hoveredNodeFollowers.some( + follower => follower.id === edge.from, + ); + + return ( + + ); + })} + +
+
+ ); +}; 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 ( -
+
@@ -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 00000000000..d915eb34ac6 --- /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 ( +