Graphql Query Explorer
blockdevopsApollo and GraphiQL style GraphQL query builder and response inspector with schema documentation sidebar, syntax-highlighted query & variables editor, query history presets, execution tracing, and formatted JSON response viewer.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/graphql-query-explorer.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/graphql-query-explorer.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/graphql-query-explorer.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/graphql-query-explorer.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/graphql-query-explorerInstalls to:app/components/blocks/graphql-query-explorer/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
SchemaFieldinterface SchemaField {
name: string
args?: string
returnType: string
description: string
sampleQuery: string
sampleVariables: string
}RootTypeinterface RootType {
name: 'Query' | 'Mutation' | 'Subscription'
color: string
badgeVariant: 'default' | 'secondary' | 'outline'
fields: SchemaField[]
}QueryPresetinterface QueryPreset {
id: string
name: string
operationType: 'query' | 'mutation' | 'subscription'
query: string
variables: string
response: Record<string, unknown>
latency: string
size: string
timestamp: string
}ResolverTraceinterface ResolverTrace {
path: string
parentType: string
fieldName: string
returnType: string
durationMs: number
percentage: number
}npm dependencies
Files installed (4)
app/components/blocks/graphql-query-explorer/GraphqlQueryExplorer.vue28.8 kB
<script setup lang="ts"> import { computed, ref } from 'vue' import type { HTMLAttributes } from 'vue' import { AlignLeft, Check, ChevronDown, ChevronUp, Copy, FileCode, Globe, History, Loader2, Play, Zap, } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import GraphqlSchemaSidebar from './GraphqlSchemaSidebar.vue' import GraphqlTracingPanel from './GraphqlTracingPanel.vue' import type { QueryPreset, SchemaField } from './graphql-query-explorer-types' const props = defineProps<{ class?: HTMLAttributes['class'] }>() // --- State --- const endpointUrl = ref('https://api.uipkge.dev/graphql') const editorMode = ref<'edit' | 'preview'>('edit') const activeCenterTab = ref<'variables' | 'headers'>('variables') const isVariablesCollapsed = ref(false) const activeResponseTab = ref<'response' | 'tracing' | 'headers'>('response') const isLoading = ref(false) const copiedResponse = ref(false) const showHistoryDropdown = ref(false) // Sample Query Presets & History const presets: QueryPreset[] = [ { id: 'preset-customer', name: 'GetCustomerProfile', operationType: 'query', query: `query GetCustomerProfile($id: ID!) { customer(id: $id) { id name email avatarUrl status createdAt subscriptions { id status plan renewalDate seats } billingAddress { city country postalCode } } }`, variables: `{\n "id": "cust_8492"\n}`, response: { data: { customer: { id: 'cust_8492', name: 'Sarah Jenkins', email: 'sarah.jenkins@acme-corp.io', avatarUrl: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330', status: 'ACTIVE', createdAt: '2024-03-15T08:22:19Z', subscriptions: [ { id: 'sub_9812', status: 'ACTIVE', plan: 'ENTERPRISE_ANNUAL', renewalDate: '2027-03-15', seats: 48, }, { id: 'sub_4410', status: 'CANCELED', plan: 'STARTER_TRIAL', renewalDate: '2024-04-01', seats: 5, }, ], billingAddress: { city: 'San Francisco', country: 'US', postalCode: '94107', }, }, }, extensions: { tracing: { version: 1, duration: 64120000, }, }, }, latency: '64ms', size: '1.8 kB', timestamp: 'Just now', }, { id: 'preset-users', name: 'ListUsers', operationType: 'query', query: `query ListUsers($limit: Int, $offset: Int) { users(limit: $limit, offset: $offset) { id fullName email role isVerified lastActiveAt } }`, variables: `{\n "limit": 3,\n "offset": 0\n}`, response: { data: { users: [ { id: 'usr_9011', fullName: 'Alex Rivera', email: 'alex.rivera@uipkge.dev', role: 'ADMIN', isVerified: true, lastActiveAt: '2026-08-21T10:45:00Z', }, { id: 'usr_9012', fullName: 'Elena Rostova', email: 'elena.r@uipkge.dev', role: 'DEVELOPER', isVerified: true, lastActiveAt: '2026-08-21T09:12:30Z', }, { id: 'usr_9013', fullName: 'Marcus Chen', email: 'marcus.chen@uipkge.dev', role: 'MEMBER', isVerified: false, lastActiveAt: '2026-08-20T18:04:12Z', }, ], }, }, latency: '38ms', size: '0.9 kB', timestamp: '2 mins ago', }, { id: 'preset-create-order', name: 'CreateOrder', operationType: 'mutation', query: `mutation CreateOrder($input: CreateOrderInput!) { createOrder(input: $input) { order { id status totalAmount currency itemsCount createdAt } clientSecret error { code message } } }`, variables: `{\n "input": {\n "customerId": "cust_8492",\n "items": [\n { "sku": "SKU_PRO_REGISTRY", "quantity": 1, "price": 149.00 }\n ],\n "currency": "USD"\n }\n}`, response: { data: { createOrder: { order: { id: 'ord_88201', status: 'PENDING_PAYMENT', totalAmount: 149.0, currency: 'USD', itemsCount: 1, createdAt: '2026-08-21T12:00:00Z', }, clientSecret: 'pi_3Mtwx2_secret_9941a8', error: null, }, }, }, latency: '112ms', size: '1.1 kB', timestamp: '5 mins ago', }, { id: 'preset-subscription-stream', name: 'OnOrderStatusChanged', operationType: 'subscription', query: `subscription OnOrderStatusChanged($orderId: ID!) { orderStatusUpdated(orderId: $orderId) { orderId previousStatus newStatus updatedAt carrierTracking { carrier trackingNumber estimatedDelivery } } }`, variables: `{\n "orderId": "ord_88201"\n}`, response: { data: { orderStatusUpdated: { orderId: 'ord_88201', previousStatus: 'PROCESSING', newStatus: 'SHIPPED', updatedAt: '2026-08-21T12:05:44Z', carrierTracking: { carrier: 'FedEx Priority', trackingNumber: 'FDX-9982-1049-US', estimatedDelivery: '2026-08-23T16:00:00Z', }, }, }, }, latency: '18ms', size: '0.7 kB', timestamp: '12 mins ago', }, ] // Current active values const activeQuery = ref(presets[0].query) const activeVariables = ref(presets[0].variables) const activeHeaders = ref(`{\n "Authorization": "Bearer uipkge_live_9f81a7",\n "X-Tenant-ID": "tenant_prod_eu"\n}`) const responseJson = ref<Record<string, unknown>>(presets[0].response) const responseStatus = ref('200 OK') const responseLatency = ref('64ms') const responseSize = ref('1.8 kB') // Computed const queryLines = computed(() => activeQuery.value.split('\n')) const variableLines = computed(() => activeVariables.value.split('\n')) const headersLines = computed(() => activeHeaders.value.split('\n')) const formattedResponseString = computed(() => JSON.stringify(responseJson.value, null, 2)) const responseLines = computed(() => formattedResponseString.value.split('\n')) const detectedOperation = computed(() => { const q = activeQuery.value.trim() if (q.startsWith('mutation')) return { type: 'mutation', color: 'bg-warning/10 text-warning border-warning/30' } if (q.startsWith('subscription')) return { type: 'subscription', color: 'bg-chart-1/10 text-chart-1 border-chart-1/30' } return { type: 'query', color: 'bg-info/10 text-info border-info/30' } }) const isVariablesValidJson = computed(() => { try { JSON.parse(activeVariables.value) return true } catch { return false } }) // --- Helper Functions --- function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function highlightJsonLine(line: string): string { if (!line) return '' const escaped = escapeHtml(line) return escaped.replace( /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (match) => { let cls = 'text-warning' if (/^"/.test(match)) { if (/:$/.test(match)) { cls = 'text-info font-medium' } else { cls = 'text-success' } } else if (/true|false/.test(match)) { cls = 'text-chart-2 font-semibold' } else if (/null/.test(match)) { cls = 'text-destructive italic' } return `<span class="${cls}">${match}</span>` }, ) } function highlightGraphqlLine(line: string): string { if (!line) return '' const escaped = escapeHtml(line) if (escaped.trim().startsWith('#')) { return `<span class="text-muted-foreground/60 italic">${escaped}</span>` } // Park each emitted <span> behind a letter-only placeholder: the type pass // (`:\s*Type`) otherwise matched the `dark:text-...` inside markup an earlier // pass had already inserted, splitting the class attribute. const parked: string[] = [] const park = (html: string) => { const key = String(parked.length) .split('') .map((d) => String.fromCharCode(97 + Number(d))) .join('') parked.push(html) return `\u0000${key}\u0000` } return escaped .replace(/\b(query|mutation|subscription|fragment|on)\b/g, (m) => park(`<span class="text-info font-semibold">${m}</span>`), ) .replace(/(\$[a-zA-Z0-9_]+)/g, (m) => park(`<span class="text-success font-medium">${m}</span>`)) .replace(/:\s*([A-Za-z0-9_\[\]!]+)/g, (_, t) => ': ' + park(`<span class="text-chart-1 font-mono">${t}</span>`)) .replace(/(@[a-zA-Z0-9_]+)/g, (m) => park(`<span class="text-warning">${m}</span>`)) .replace(/\u0000([a-j]+)\u0000/g, (_, key: string) => { const idx = Number( key .split('') .map((c: string) => String(c.charCodeAt(0) - 97)) .join(''), ) return parked[idx] ?? '' }) } function prettifyGraphql(queryStr: string): string { const lines = queryStr.split('\n') let indentLevel = 0 const formatted: string[] = [] for (const rawLine of lines) { const line = rawLine.trim() if (!line) continue if (line.startsWith('}') || line.startsWith(')')) { indentLevel = Math.max(0, indentLevel - 1) } formatted.push(' '.repeat(indentLevel) + line) if (line.endsWith('{') || line.endsWith('(')) { indentLevel++ } } return formatted.join('\n') } function handlePrettify() { activeQuery.value = prettifyGraphql(activeQuery.value) if (isVariablesValidJson.value) { try { activeVariables.value = JSON.stringify(JSON.parse(activeVariables.value), null, 2) } catch { // Keep as-is } } } function runQuery() { if (isLoading.value) return isLoading.value = true setTimeout(() => { // Generate realistic dynamic latency and check for matching preset const randMs = Math.floor(Math.random() * 40) + 35 responseLatency.value = `${randMs}ms` const trimmed = activeQuery.value.trim() if (trimmed.includes('createOrder') || trimmed.includes('mutation')) { responseJson.value = presets[2].response responseSize.value = presets[2].size responseStatus.value = '200 OK' } else if (trimmed.includes('orderStatusUpdated') || trimmed.includes('subscription')) { responseJson.value = presets[3].response responseSize.value = presets[3].size responseStatus.value = '200 OK · SSE Stream' } else if (trimmed.includes('users') || trimmed.includes('ListUsers')) { responseJson.value = presets[1].response responseSize.value = presets[1].size responseStatus.value = '200 OK' } else { responseJson.value = presets[0].response responseSize.value = presets[0].size responseStatus.value = '200 OK' } isLoading.value = false }, 260) } function loadPreset(preset: QueryPreset) { activeQuery.value = preset.query activeVariables.value = preset.variables responseJson.value = preset.response responseLatency.value = preset.latency responseSize.value = preset.size showHistoryDropdown.value = false } function loadFieldQuery(field: SchemaField) { activeQuery.value = field.sampleQuery activeVariables.value = field.sampleVariables } function copyResponse() { navigator.clipboard.writeText(formattedResponseString.value) copiedResponse.value = true setTimeout(() => { copiedResponse.value = false }, 2000) } </script> <template> <Card data-slot="graphql-query-explorer" :class=" cn( 'border-border bg-card text-card-foreground flex min-h-[720px] flex-col overflow-hidden rounded-xl border shadow-xs', props.class, ) " > <!-- TOP TOOLBAR --> <header class="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3"> <!-- Left: Endpoint & Method & Schema Badge --> <div class="flex flex-wrap items-center gap-2.5"> <Badge variant="outline" class="border-border bg-background font-mono text-xs font-semibold tracking-wider"> POST </Badge> <div class="border-border bg-background flex h-8 items-center gap-2 rounded-md border px-3 text-xs shadow-xs"> <Globe class="text-muted-foreground size-3.5" /> <input v-model="endpointUrl" type="text" class="text-foreground w-64 bg-transparent font-mono text-xs focus:outline-none" placeholder="https://api.example.com/graphql" /> </div> <Badge variant="secondary" class="gap-1.5 py-1 font-mono text-xs font-normal"> <span class="bg-success size-1.5 animate-pulse rounded-full" /> Schema v2.4 · 48 Types </Badge> </div> <!-- Right: Action Buttons --> <div class="flex flex-wrap items-center gap-2"> <Button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" title="Format query (Shift + Option + F)" @click="handlePrettify" > <AlignLeft class="size-3.5" /> Prettify </Button> <!-- History Menu Toggle --> <div class="relative"> <Button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" @click="showHistoryDropdown = !showHistoryDropdown" > <History class="size-3.5" /> History <Badge variant="secondary" class="ml-0.5 h-4 px-1 font-mono text-xs"> {{ presets.length }} </Badge> </Button> <!-- History Dropdown Card --> <div v-if="showHistoryDropdown" class="border-border bg-popover text-popover-foreground absolute right-0 z-50 mt-2 w-80 rounded-lg border p-2 text-xs shadow-md" > <div class="border-border text-muted-foreground flex items-center justify-between gap-x-2 border-b px-2 py-1.5 font-medium" > <span>Recent Executions</span> <span class="font-mono text-xs">Saved Stubs</span> </div> <div class="mt-1 max-h-60 space-y-1 overflow-y-auto"> <button v-for="preset in presets" :key="preset.id" class="hover:bg-muted focus:bg-muted flex w-full flex-col gap-0.5 rounded-md p-2 text-left transition-colors" @click="loadPreset(preset)" > <div class="flex items-center justify-between gap-x-2"> <span class="text-foreground font-mono font-medium">{{ preset.name }}</span> <Badge variant="outline" class="font-mono text-xs font-normal"> {{ preset.latency }} </Badge> </div> <div class="text-muted-foreground flex flex-wrap items-center gap-2 font-mono text-xs"> <span class="text-primary font-semibold uppercase">{{ preset.operationType }}</span> <span>·</span> <span>{{ preset.timestamp }}</span> </div> </button> </div> </div> </div> <!-- Run Query Primary Action --> <Button variant="default" size="sm" class="bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-2 text-xs font-semibold shadow-xs" :disabled="isLoading" @click="runQuery" > <Loader2 v-if="isLoading" class="size-3.5 animate-spin" /> <Play v-else class="size-3.5 fill-current" /> Run Query <kbd class="border-primary-foreground/30 bg-primary-foreground/15 hidden rounded border px-1 py-0.5 font-mono text-xs font-normal sm:inline-block" > ⌘↵ </kbd> </Button> </div> </header> <!-- 3-COLUMN STUDIO LAYOUT --> <div class="divide-border grid flex-1 grid-cols-1 divide-y overflow-hidden lg:grid-cols-12 lg:divide-x lg:divide-y-0" > <!-- COLUMN 1: SCHEMA DOCUMENTATION SIDEBAR (w-56 / lg:col-span-3) --> <GraphqlSchemaSidebar @load-field="loadFieldQuery" /> <!-- COLUMN 2: CENTER QUERY & VARIABLES EDITOR (lg:col-span-5) --> <main class="bg-card flex flex-col overflow-hidden lg:col-span-5"> <!-- Query Toolbar --> <div class="border-border bg-muted/20 flex items-center justify-between gap-x-2 border-b px-3 py-2"> <div class="flex flex-wrap items-center gap-2"> <FileCode class="text-primary size-4" /> <span class="text-foreground font-mono text-xs font-semibold">Query.graphql</span> <Badge variant="outline" :class="cn('font-mono text-xs uppercase', detectedOperation.color)"> {{ detectedOperation.type }} </Badge> </div> <div class="flex flex-wrap items-center gap-2"> <span class="text-muted-foreground font-mono text-xs"> {{ queryLines.length }} lines · {{ activeQuery.length }} chars </span> <div class="border-border bg-muted/60 flex items-center rounded-md border p-0.5"> <button class="min-h-6 rounded px-2 py-0.5 text-xs font-medium transition-colors" :class=" editorMode === 'edit' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground' " @click="editorMode = 'edit'" > Edit </button> <button class="min-h-6 rounded px-2 py-0.5 text-xs font-medium transition-colors" :class=" editorMode === 'preview' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground' " @click="editorMode = 'preview'" > Preview </button> </div> </div> </div> <!-- Query Editor Body with Line Numbers --> <div class="relative flex min-h-[260px] flex-1 overflow-hidden"> <!-- Line Numbers Gutter --> <div class="border-border/60 bg-muted/20 text-muted-foreground/50 w-10 shrink-0 overflow-hidden border-r py-3 pr-2 text-right font-mono text-xs leading-relaxed select-none" > <div v-for="n in queryLines.length" :key="n">{{ n }}</div> </div> <!-- Code Area: Editable or Syntax Preview --> <div class="bg-background/50 flex-1 overflow-auto"> <!-- Edit Mode: Monospace Textarea --> <textarea v-if="editorMode === 'edit'" v-model="activeQuery" spellcheck="false" class="text-foreground h-full w-full resize-none bg-transparent p-3 font-mono text-xs leading-relaxed focus:outline-none" placeholder="# Write GraphQL query or mutation here..." /> <!-- Preview Mode: Syntax Highlighted Lines --> <div v-else class="space-y-0.5 p-3 font-mono text-xs leading-relaxed"> <div v-for="(line, idx) in queryLines" :key="idx" class="whitespace-pre" v-html="highlightGraphqlLine(line)" /> </div> </div> </div> <!-- COLLAPSIBLE BOTTOM PANE: VARIABLES / HEADERS --> <div class="border-border bg-muted/10 flex flex-col border-t"> <!-- Pane Header --> <div class="border-border bg-muted/30 flex items-center justify-between gap-x-2 border-b px-3 py-1.5 text-xs"> <div class="flex items-center gap-3"> <button class="font-mono font-medium transition-colors" :class=" activeCenterTab === 'variables' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground' " @click="activeCenterTab = 'variables'" > { } Query Variables </button> <span class="text-border">|</span> <button class="font-mono font-medium transition-colors" :class=" activeCenterTab === 'headers' ? 'text-foreground' : 'text-muted-foreground hover:text-foreground' " @click="activeCenterTab = 'headers'" > HTTP Headers (2) </button> </div> <div class="flex flex-wrap items-center gap-2"> <Badge v-if="activeCenterTab === 'variables'" variant="outline" :class=" isVariablesValidJson ? 'border-success/30 text-success' : 'border-destructive/30 text-destructive' " class="font-mono text-xs" > {{ isVariablesValidJson ? 'Valid JSON' : 'Invalid JSON' }} </Badge> <button class="text-muted-foreground hover:text-foreground min-h-6 p-0.5 transition-colors" :title="isVariablesCollapsed ? 'Expand pane' : 'Collapse pane'" @click="isVariablesCollapsed = !isVariablesCollapsed" > <ChevronUp v-if="isVariablesCollapsed" class="size-3.5" /> <ChevronDown v-else class="size-3.5" /> </button> </div> </div> <!-- Pane Content --> <div v-if="!isVariablesCollapsed" class="bg-background/50 relative flex h-36 min-h-[140px] overflow-hidden"> <!-- Variables Mode --> <template v-if="activeCenterTab === 'variables'"> <div class="border-border/60 bg-muted/20 text-muted-foreground/50 w-8 shrink-0 overflow-hidden border-r py-2 pr-2 text-right font-mono text-xs leading-relaxed select-none" > <div v-for="n in variableLines.length" :key="n">{{ n }}</div> </div> <textarea v-model="activeVariables" spellcheck="false" class="text-foreground h-full w-full resize-none bg-transparent p-2 font-mono text-xs leading-relaxed focus:outline-none" placeholder="{}" /> </template> <!-- Headers Mode --> <template v-else> <div class="border-border/60 bg-muted/20 text-muted-foreground/50 w-8 shrink-0 overflow-hidden border-r py-2 pr-2 text-right font-mono text-xs leading-relaxed select-none" > <div v-for="n in headersLines.length" :key="n">{{ n }}</div> </div> <textarea v-model="activeHeaders" spellcheck="false" class="text-foreground h-full w-full resize-none bg-transparent p-2 font-mono text-xs leading-relaxed focus:outline-none" /> </template> </div> </div> </main> <!-- COLUMN 3: RIGHT RESPONSE PANEL (lg:col-span-4) --> <section class="bg-muted/10 flex flex-col overflow-hidden lg:col-span-4"> <!-- Response Header --> <div class="border-border bg-muted/30 flex items-center justify-between gap-x-2 border-b px-3 py-2"> <div class="flex flex-wrap items-center gap-2"> <Badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 font-mono text-xs font-semibold" > <span class="bg-success size-1.5 rounded-full" /> {{ responseStatus }} </Badge> <span class="text-muted-foreground flex items-center gap-1 font-mono text-xs"> <Zap class="text-warning size-3" /> {{ responseLatency }} </span> <span class="text-muted-foreground font-mono text-xs"> {{ responseSize }} </span> </div> <Button variant="ghost" size="sm" class="h-7 gap-1 px-2 text-xs" @click="copyResponse"> <Check v-if="copiedResponse" class="text-success size-3" /> <Copy v-else class="size-3" /> <span>{{ copiedResponse ? 'Copied' : 'Copy' }}</span> </Button> </div> <!-- Response View Tabs --> <div class="border-border bg-muted/20 flex items-center gap-1 border-b px-3 py-1"> <button class="min-h-6 rounded px-2 py-1 font-mono text-xs font-medium transition-colors" :class=" activeResponseTab === 'response' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground' " @click="activeResponseTab = 'response'" > Response JSON </button> <button class="min-h-6 rounded px-2 py-1 font-mono text-xs font-medium transition-colors" :class=" activeResponseTab === 'tracing' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground' " @click="activeResponseTab = 'tracing'" > Tracing </button> <button class="min-h-6 rounded px-2 py-1 font-mono text-xs font-medium transition-colors" :class=" activeResponseTab === 'headers' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground' " @click="activeResponseTab = 'headers'" > Headers </button> </div> <!-- Loading Indicator --> <div v-if="isLoading" class="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center"> <Loader2 class="text-primary size-6 animate-spin" /> <div class="space-y-1"> <p class="text-foreground font-mono text-xs font-medium">Executing GraphQL Operation...</p> <p class="text-muted-foreground font-mono text-xs">{{ endpointUrl }}</p> </div> </div> <!-- Response Body: Tab 1 (JSON Response) --> <div v-else-if="activeResponseTab === 'response'" class="relative flex flex-1 overflow-auto bg-neutral-950 text-neutral-100 dark:bg-neutral-950" > <!-- Gutter --> <div class="w-10 shrink-0 overflow-hidden border-r border-neutral-800 bg-neutral-900/50 py-3 pr-2 text-right font-mono text-xs leading-relaxed text-neutral-500 select-none" > <div v-for="n in responseLines.length" :key="n">{{ n }}</div> </div> <!-- Colorized JSON Code --> <div class="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed"> <div v-for="(line, idx) in responseLines" :key="idx" class="font-mono whitespace-pre" v-html="highlightJsonLine(line)" /> </div> </div> <!-- Response Body: Tab 2 (Tracing Waterfall) --> <GraphqlTracingPanel v-else-if="activeResponseTab === 'tracing'" :latency="responseLatency" /> <!-- Response Body: Tab 3 (HTTP Response Headers) --> <div v-else-if="activeResponseTab === 'headers'" class="flex-1 space-y-2 overflow-y-auto p-3 font-mono text-xs"> <div class="border-border bg-card space-y-2 rounded-lg border p-3"> <div class="border-border/50 flex justify-between border-b pb-1.5"> <span class="text-muted-foreground">content-type:</span> <span class="text-foreground font-medium">application/graphql-response+json; charset=utf-8</span> </div> <div class="border-border/50 flex justify-between border-b pb-1.5"> <span class="text-muted-foreground">cache-control:</span> <span class="text-foreground font-medium">max-age=0, private, must-revalidate</span> </div> <div class="border-border/50 flex justify-between border-b pb-1.5"> <span class="text-muted-foreground">x-request-id:</span> <span class="text-foreground font-medium">req_01hx8921a9vnm8</span> </div> <div class="flex justify-between"> <span class="text-muted-foreground">server-timing:</span> <span class="text-foreground font-medium">graphql;dur=64.1</span> </div> </div> </div> </section> </div> </Card> </template>app/components/blocks/graphql-query-explorer/GraphqlSchemaSidebar.vue10.8 kB
<script setup lang="ts"> import { computed, ref } from 'vue' import { ChevronDown, ChevronRight, Database, Search } from 'lucide-vue-next' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' import type { RootType, SchemaField } from './graphql-query-explorer-types' const emit = defineEmits<{ 'load-field': [field: SchemaField] }>() const selectedRootType = ref<'Query' | 'Mutation' | 'Subscription' | 'all'>('all') const schemaSearch = ref('') const selectedField = ref<SchemaField | null>(null) const expandedRoots = ref<Record<string, boolean>>({ Query: true, Mutation: true, Subscription: true, }) // Schema Root Types const schemaRoots: RootType[] = [ { name: 'Query', color: 'text-info', badgeVariant: 'default', fields: [ { name: 'customer', args: 'id: ID!', returnType: 'Customer', description: 'Fetches verified customer account, active SaaS subscriptions, and verified billing profile.', sampleQuery: `query GetCustomerProfile($id: ID!) {\n customer(id: $id) {\n id\n name\n email\n subscriptions { id status plan }\n }\n}`, sampleVariables: `{\n "id": "cust_8492"\n}`, }, { name: 'users', args: 'limit: Int, offset: Int', returnType: '[User!]!', description: 'Returns paginated workspace members with security roles and activity status flags.', sampleQuery: `query ListUsers($limit: Int, $offset: Int) {\n users(limit: $limit, offset: $offset) {\n id\n fullName\n email\n role\n }\n}`, sampleVariables: `{\n "limit": 10,\n "offset": 0\n}`, }, { name: 'userById', args: 'id: ID!', returnType: 'User', description: 'Look up specific team member by their unique UUID identifier.', sampleQuery: `query GetUser($id: ID!) {\n userById(id: $id) {\n id\n fullName\n email\n role\n isVerified\n }\n}`, sampleVariables: `{\n "id": "usr_9011"\n}`, }, { name: 'products', args: 'category: String, inStock: Boolean', returnType: '[Product!]!', description: 'Catalog items inventory with real-time stock levels and tiered price book.', sampleQuery: `query GetProducts($category: String) {\n products(category: $category) {\n id\n title\n price\n inventoryCount\n }\n}`, sampleVariables: `{\n "category": "developer-tools"\n}`, }, { name: 'orders', args: 'status: OrderStatus, limit: Int', returnType: '[Order!]!', description: 'Historical and in-flight purchase orders with settlement metadata.', sampleQuery: `query GetOrders($status: OrderStatus) {\n orders(status: $status) {\n id\n status\n totalAmount\n createdAt\n }\n}`, sampleVariables: `{\n "status": "COMPLETED"\n}`, }, { name: 'organization', args: 'slug: String!', returnType: 'Organization', description: 'Retrieve tenant workspace configurations, member limits, and billing tier.', sampleQuery: `query GetOrg($slug: String!) {\n organization(slug: $slug) {\n id\n name\n slug\n memberCount\n }\n}`, sampleVariables: `{\n "slug": "acme-corp"\n}`, }, ], }, { name: 'Mutation', color: 'text-warning', badgeVariant: 'secondary', fields: [ { name: 'createOrder', args: 'input: CreateOrderInput!', returnType: 'OrderPayload!', description: 'Creates new purchase order, holds inventory reservations, and emits payment intent.', sampleQuery: `mutation CreateOrder($input: CreateOrderInput!) {\n createOrder(input: $input) {\n order { id status totalAmount }\n clientSecret\n }\n}`, sampleVariables: `{\n "input": {\n "customerId": "cust_8492",\n "items": [{ "sku": "SKU_01", "quantity": 1, "price": 149.00 }]\n }\n}`, }, { name: 'updateCustomer', args: 'id: ID!, input: CustomerInput!', returnType: 'Customer!', description: 'Updates customer profile attributes and notification preferences.', sampleQuery: `mutation UpdateCustomer($id: ID!, $input: CustomerInput!) {\n updateCustomer(id: $id, input: $input) {\n id\n name\n email\n }\n}`, sampleVariables: `{\n "id": "cust_8492",\n "input": { "name": "Sarah Jenkins" }\n}`, }, { name: 'cancelSubscription', args: 'id: ID!, reason: String', returnType: 'SubscriptionResult!', description: 'Immediately pauses or schedules cancellation for recurring billing plan.', sampleQuery: `mutation CancelSub($id: ID!, $reason: String) {\n cancelSubscription(id: $id, reason: $reason) {\n id\n status\n effectiveDate\n }\n}`, sampleVariables: `{\n "id": "sub_4410",\n "reason": "Upgraded to Enterprise annual"\n}`, }, { name: 'rotateApiKey', args: 'keyId: ID!', returnType: 'ApiKeyRotationPayload!', description: 'Invalidates existing API secret and provisions new machine token.', sampleQuery: `mutation RotateKey($keyId: ID!) {\n rotateApiKey(keyId: $keyId) {\n keyId\n secretToken\n expiresAt\n }\n}`, sampleVariables: `{\n "keyId": "key_live_9941"\n}`, }, ], }, { name: 'Subscription', color: 'text-chart-1', badgeVariant: 'outline', fields: [ { name: 'orderStatusUpdated', args: 'orderId: ID!', returnType: 'OrderStatusEvent!', description: 'Live push stream delivering order state transitions and logistics milestones.', sampleQuery: `subscription WatchOrder($orderId: ID!) {\n orderStatusUpdated(orderId: $orderId) {\n orderId\n newStatus\n updatedAt\n }\n}`, sampleVariables: `{\n "orderId": "ord_88201"\n}`, }, { name: 'userActivityStream', args: 'channelId: ID!', returnType: 'ActivityEvent!', description: 'Workspace real-time collaboration telemetry and presence updates.', sampleQuery: `subscription ActivityStream($channelId: ID!) {\n userActivityStream(channelId: $channelId) {\n userId\n action\n timestamp\n }\n}`, sampleVariables: `{\n "channelId": "chan_engineering"\n}`, }, ], }, ] const filteredSchemaRoots = computed(() => { const query = schemaSearch.value.trim().toLowerCase() if (!query) { if (selectedRootType.value === 'all') return schemaRoots return schemaRoots.filter((r) => r.name === selectedRootType.value) } return schemaRoots .map((root) => { const matchingFields = root.fields.filter( (f) => f.name.toLowerCase().includes(query) || f.returnType.toLowerCase().includes(query) || (f.args && f.args.toLowerCase().includes(query)) || f.description.toLowerCase().includes(query), ) return { ...root, fields: matchingFields, } }) .filter((root) => root.fields.length > 0) }) function toggleRoot(name: string) { expandedRoots.value[name] = !expandedRoots.value[name] } function selectField(field: SchemaField) { selectedField.value = field emit('load-field', field) } </script> <template> <!-- COLUMN 1: SCHEMA DOCUMENTATION SIDEBAR (w-56 / lg:col-span-3) --> <aside class="bg-muted/15 flex flex-col overflow-hidden lg:col-span-3"> <!-- Sidebar Header --> <div class="border-border bg-muted/30 flex items-center justify-between gap-x-2 border-b px-3 py-2.5"> <div class="flex items-center gap-1.5 text-xs font-semibold"> <Database class="text-primary size-3.5" /> <span>Schema Explorer</span> </div> <Badge variant="outline" class="font-mono text-xs"> v2.4 </Badge> </div> <!-- Search Types Input --> <div class="border-border border-b p-2.5"> <div class="relative"> <Search class="text-muted-foreground absolute top-2.5 left-2.5 size-3.5" /> <Input v-model="schemaSearch" type="text" placeholder="Search types & fields..." class="h-8 pl-8 font-mono text-xs" /> </div> </div> <!-- Root Types & Field Tree --> <div class="flex-1 space-y-3 overflow-y-auto p-2.5"> <div v-for="root in filteredSchemaRoots" :key="root.name" class="space-y-1"> <!-- Root Type Toggle --> <button class="hover:bg-muted/60 flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-xs font-semibold transition-colors" @click="toggleRoot(root.name)" > <div class="flex items-center gap-1.5"> <ChevronDown v-if="expandedRoots[root.name]" class="text-muted-foreground size-3.5" /> <ChevronRight v-else class="text-muted-foreground size-3.5" /> <span :class="root.color">{{ root.name }}</span> </div> <Badge variant="secondary" class="font-mono text-xs font-normal"> {{ root.fields.length }} </Badge> </button> <!-- Fields List --> <div v-if="expandedRoots[root.name]" class="border-border/70 ml-2 space-y-0.5 border-l pl-2"> <button v-for="field in root.fields" :key="field.name" class="hover:bg-muted focus:bg-muted group flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left transition-colors" :class="selectedField?.name === field.name ? 'bg-muted/90 ring-border ring-1' : ''" @click="selectField(field)" > <div class="flex items-center justify-between gap-x-2"> <span class="text-foreground group-hover:text-primary font-mono text-xs font-medium transition-colors"> {{ field.name }} </span> <span class="text-muted-foreground font-mono text-xs"> {{ field.returnType }} </span> </div> <div v-if="field.args" class="text-muted-foreground truncate font-mono text-xs">({{ field.args }})</div> </button> </div> </div> </div> <!-- Selected Field Documentation Preview --> <div v-if="selectedField" class="border-border bg-card/60 border-t p-3 text-xs"> <div class="flex items-center justify-between gap-x-2"> <span class="text-foreground font-mono font-semibold">{{ selectedField.name }}</span> <Badge variant="secondary" class="font-mono text-xs">{{ selectedField.returnType }}</Badge> </div> <p class="text-muted-foreground mt-1.5 text-xs leading-normal"> {{ selectedField.description }} </p> <div v-if="selectedField.args" class="border-border bg-muted/40 mt-2 rounded border p-1.5 font-mono text-xs"> <span class="text-muted-foreground">Args: </span> <span class="text-foreground font-medium">{{ selectedField.args }}</span> </div> </div> </aside> </template>app/components/blocks/graphql-query-explorer/GraphqlTracingPanel.vue2.3 kB
<script setup lang="ts"> import { Badge } from '@/components/ui/badge' import type { ResolverTrace } from './graphql-query-explorer-types' defineProps<{ latency: string }>() // Resolver Waterfall Tracing Data const traces: ResolverTrace[] = [ { path: 'Query.customer', parentType: 'Query', fieldName: 'customer', returnType: 'Customer', durationMs: 48.2, percentage: 75, }, { path: 'Customer.subscriptions', parentType: 'Customer', fieldName: 'subscriptions', returnType: '[Subscription!]!', durationMs: 14.9, percentage: 23, }, { path: 'Customer.billingAddress', parentType: 'Customer', fieldName: 'billingAddress', returnType: 'BillingAddress', durationMs: 1.0, percentage: 2, }, ] </script> <template> <!-- Response Body: Tab 2 (Tracing Waterfall) --> <div class="flex-1 space-y-3 overflow-y-auto p-4"> <div class="border-border bg-card flex items-center justify-between gap-x-2 rounded-lg border p-3"> <div> <span class="text-muted-foreground text-xs">Total Duration</span> <p class="text-foreground font-mono text-base font-bold">{{ latency }}</p> </div> <Badge variant="secondary" class="font-mono text-xs">Apollo Tracing v1</Badge> </div> <div class="space-y-2"> <span class="text-muted-foreground font-mono text-xs font-medium">Resolver Execution Times</span> <div class="space-y-2"> <div v-for="trace in traces" :key="trace.path" class="border-border bg-card space-y-1.5 rounded-lg border p-2.5 text-xs" > <div class="flex items-center justify-between gap-x-2"> <span class="text-foreground font-mono font-semibold">{{ trace.path }}</span> <span class="text-muted-foreground font-mono">{{ trace.durationMs }}ms</span> </div> <div class="bg-muted h-1.5 w-full overflow-hidden rounded-full"> <div class="bg-primary h-full rounded-full" :style="{ width: `${trace.percentage}%` }" /> </div> <div class="text-muted-foreground flex justify-between font-mono text-xs"> <span>Type: {{ trace.returnType }}</span> <span>{{ trace.percentage }}% of query</span> </div> </div> </div> </div> </div> </template>app/components/blocks/graphql-query-explorer/graphql-query-explorer-types.ts0.7 kB
export interface SchemaField { name: string args?: string returnType: string description: string sampleQuery: string sampleVariables: string } export interface RootType { name: 'Query' | 'Mutation' | 'Subscription' color: string badgeVariant: 'default' | 'secondary' | 'outline' fields: SchemaField[] } export interface QueryPreset { id: string name: string operationType: 'query' | 'mutation' | 'subscription' query: string variables: string response: Record<string, unknown> latency: string size: string timestamp: string } export interface ResolverTrace { path: string parentType: string fieldName: string returnType: string durationMs: number percentage: number }
Raw manifest:https://uipkge.dev/r/vue/graphql-query-explorer.json