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 uipkge-ng@latest add graphql-query-explorer$npx uipkge-ng@latest add graphql-query-explorer$yarn dlx uipkge-ng@latest add graphql-query-explorer$bunx uipkge-ng@latest add graphql-query-explorerManifest:
https://uipkge.dev/r/angular/graphql-query-explorer.jsonInstalls to:src/app/components/blocks/graphql-query-explorer/Variants
Loading interactive previews…
Loading interactive previews…
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 (5)
src/app/components/blocks/graphql-query-explorer/graphql-query-explorer.component.ts30.8 kB
// Apollo 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. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { AlignLeft, Check, ChevronDown, ChevronUp, Copy, FileCode, Globe, History, Loader2, LucideAngularModule, Play, Zap, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent } from '@/ui/card/card.component' import { UiGraphqlSchemaSidebarComponent } from './graphql-schema-sidebar.component' import { UiGraphqlTracingPanelComponent } from './graphql-tracing-panel.component' import type { QueryPreset, SchemaField } from './graphql-query-explorer-types' 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', }, ] function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export 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)) { cls = /:$/.test(match) ? 'text-info font-medium' : '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>` }, ) } export 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>` } const parked: string[] = [] const park = (html: string): 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] ?? '' }) } export 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') } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-graphql-query-explorer, [ui-graphql-query-explorer]', standalone: true, // React renders the root <Card> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiGraphqlSchemaSidebarComponent, UiGraphqlTracingPanelComponent, ], template: ` <div ui-card data-slot="graphql-query-explorer" [class]="rootClass"> <!-- TOP TOOLBAR --> <header class="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3"> <div class="flex flex-wrap items-center gap-2.5"> <span ui-badge variant="outline" class="border-border bg-background font-mono text-xs font-semibold tracking-wider" > POST </span> <div class="border-border bg-background flex h-8 items-center gap-2 rounded-md border px-3 text-xs shadow-xs"> <lucide-icon [img]="Globe" class="text-muted-foreground size-3.5" /> <input [value]="endpointUrl()" (input)="endpointUrl.set($any($event.target).value)" type="text" class="text-foreground w-64 bg-transparent font-mono text-xs focus:outline-none" placeholder="https://api.example.com/graphql" /> </div> <span ui-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"></span> Schema v2.4 · 48 Types </span> </div> <div class="flex flex-wrap items-center gap-2"> <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" title="Format query (Shift + Option + F)" (click)="handlePrettify()" > <lucide-icon [img]="AlignLeft" class="size-3.5" /> Prettify </button> <div class="relative"> <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" (click)="showHistoryDropdown.set(!showHistoryDropdown())" > <lucide-icon [img]="History" class="size-3.5" /> History <span ui-badge variant="secondary" class="ml-0.5 h-4 px-1 font-mono text-xs">{{ presetList.length }}</span> </button> @if (showHistoryDropdown()) { <div 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"> @for (preset of presetList; track preset.id) { <button 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> <span ui-badge variant="outline" class="font-mono text-xs font-normal">{{ preset.latency }}</span> </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> <button ui-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()" > @if (isLoading()) { <lucide-icon [img]="Loader2" class="size-3.5 animate-spin" /> } @else { <lucide-icon [img]="Play" 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" > <ui-graphql-schema-sidebar (loadField)="loadFieldQuery($event)" /> <!-- CENTER --> <main class="bg-card flex flex-col overflow-hidden lg:col-span-5"> <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"> <lucide-icon [img]="FileCode" class="text-primary size-4" /> <span class="text-foreground font-mono text-xs font-semibold">Query.graphql</span> <span ui-badge variant="outline" [class]="'font-mono text-xs uppercase ' + detectedOperation().color">{{ detectedOperation().type }}</span> </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]="editorTabClass('edit')" (click)="editorMode.set('edit')">Edit</button> <button [class]="editorTabClass('preview')" (click)="editorMode.set('preview')">Preview</button> </div> </div> </div> <div class="relative flex min-h-[260px] flex-1 overflow-hidden"> <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" > @for (line of queryLines(); track $index) { <div>{{ $index + 1 }}</div> } </div> <div class="bg-background/50 flex-1 overflow-auto"> @if (editorMode() === 'edit') { <textarea [value]="activeQuery()" (input)="activeQuery.set($any($event.target).value)" 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..." ></textarea> } @else { <div class="space-y-0.5 p-3 font-mono text-xs leading-relaxed"> @for (line of queryLines(); track $index) { <div class="whitespace-pre" [innerHTML]="highlightGraphqlLine(line)"></div> } </div> } </div> </div> <!-- VARIABLES / HEADERS --> <div class="border-border bg-muted/10 flex flex-col border-t"> <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]="centerTabClass('variables')" (click)="activeCenterTab.set('variables')"> {{ '{ }' }} Query Variables </button> <span class="text-border">|</span> <button [class]="centerTabClass('headers')" (click)="activeCenterTab.set('headers')"> HTTP Headers (2) </button> </div> <div class="flex flex-wrap items-center gap-2"> @if (activeCenterTab() === 'variables') { <span ui-badge variant="outline" [class]="'font-mono text-xs ' + variablesBadgeClass"> {{ isVariablesValidJson() ? 'Valid JSON' : 'Invalid JSON' }} </span> } <button class="text-muted-foreground hover:text-foreground min-h-6 p-0.5 transition-colors" [attr.title]="isVariablesCollapsed() ? 'Expand pane' : 'Collapse pane'" (click)="isVariablesCollapsed.set(!isVariablesCollapsed())" > @if (isVariablesCollapsed()) { <lucide-icon [img]="ChevronUp" class="size-3.5" /> } @else { <lucide-icon [img]="ChevronDown" class="size-3.5" /> } </button> </div> </div> @if (!isVariablesCollapsed()) { <div class="bg-background/50 relative flex h-36 min-h-[140px] overflow-hidden"> @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" > @for (line of variableLines(); track $index) { <div>{{ $index + 1 }}</div> } </div> <textarea [value]="activeVariables()" (input)="activeVariables.set($any($event.target).value)" 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="{}" ></textarea> } @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" > @for (line of headersLines(); track $index) { <div>{{ $index + 1 }}</div> } </div> <textarea [value]="activeHeaders()" (input)="activeHeaders.set($any($event.target).value)" spellcheck="false" class="text-foreground h-full w-full resize-none bg-transparent p-2 font-mono text-xs leading-relaxed focus:outline-none" ></textarea> } </div> } </div> </main> <!-- RESPONSE --> <section class="bg-muted/10 flex flex-col overflow-hidden lg:col-span-4"> <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"> <span ui-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"></span> {{ responseStatus() }} </span> <span class="text-muted-foreground flex items-center gap-1 font-mono text-xs"> <lucide-icon [img]="Zap" class="text-warning size-3" /> {{ responseLatency() }} </span> <span class="text-muted-foreground font-mono text-xs">{{ responseSize() }}</span> </div> <button ui-button variant="ghost" size="sm" class="h-7 gap-1 px-2 text-xs" (click)="copyResponse()"> @if (copiedResponse()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span>{{ copiedResponse() ? 'Copied' : 'Copy' }}</span> </button> </div> <div class="border-border bg-muted/20 flex items-center gap-1 border-b px-3 py-1"> <button [class]="responseTabClass('response')" (click)="activeResponseTab.set('response')"> Response JSON </button> <button [class]="responseTabClass('tracing')" (click)="activeResponseTab.set('tracing')">Tracing</button> <button [class]="responseTabClass('headers')" (click)="activeResponseTab.set('headers')">Headers</button> </div> @if (isLoading()) { <div class="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center"> <lucide-icon [img]="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> } @else if (activeResponseTab() === 'response') { <div class="relative flex flex-1 overflow-auto bg-neutral-950 text-neutral-100 dark:bg-neutral-950"> <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" > @for (line of responseLines(); track $index) { <div>{{ $index + 1 }}</div> } </div> <div class="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed"> @for (line of responseLines(); track $index) { <div class="font-mono whitespace-pre" [innerHTML]="highlightJsonLine(line)"></div> } </div> </div> } @else if (activeResponseTab() === 'tracing') { <ui-graphql-tracing-panel [latency]="responseLatency()" /> } @else { <div 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> </div> `, }) export class UiGraphqlQueryExplorerComponent { protected readonly AlignLeft = AlignLeft protected readonly Check = Check protected readonly ChevronDown = ChevronDown protected readonly ChevronUp = ChevronUp protected readonly Copy = Copy protected readonly FileCode = FileCode protected readonly Globe = Globe protected readonly History = History protected readonly Loader2 = Loader2 protected readonly Play = Play protected readonly Zap = Zap protected readonly presetList = presets @Input('class') className?: string readonly endpointUrl = signal('https://api.uipkge.dev/graphql') readonly editorMode = signal<'edit' | 'preview'>('edit') readonly activeCenterTab = signal<'variables' | 'headers'>('variables') readonly isVariablesCollapsed = signal(false) readonly activeResponseTab = signal<'response' | 'tracing' | 'headers'>('response') readonly isLoading = signal(false) readonly copiedResponse = signal(false) readonly showHistoryDropdown = signal(false) readonly activeQuery = signal(presets[0]!.query) readonly activeVariables = signal(presets[0]!.variables) readonly activeHeaders = signal( `{\n "Authorization": "Bearer uipkge_live_9f81a7",\n "X-Tenant-ID": "tenant_prod_eu"\n}`, ) readonly responseJson = signal<Record<string, unknown>>(presets[0]!.response) readonly responseStatus = signal('200 OK') readonly responseLatency = signal('64ms') readonly responseSize = signal('1.8 kB') private copyTimer: ReturnType<typeof setTimeout> | undefined readonly queryLines = computed(() => this.activeQuery().split('\n')) readonly variableLines = computed(() => this.activeVariables().split('\n')) readonly headersLines = computed(() => this.activeHeaders().split('\n')) readonly formattedResponseString = computed(() => JSON.stringify(this.responseJson(), null, 2)) readonly responseLines = computed(() => this.formattedResponseString().split('\n')) readonly detectedOperation = computed(() => { const q = this.activeQuery().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' } }) readonly isVariablesValidJson = computed(() => { try { JSON.parse(this.activeVariables()) return true } catch { return false } }) get rootClass(): string { return cn( 'border-border bg-card text-card-foreground flex min-h-[720px] flex-col overflow-hidden rounded-xl border shadow-xs', this.className, ) } get variablesBadgeClass(): string { return this.isVariablesValidJson() ? 'border-success/30 text-success' : 'border-destructive/30 text-destructive' } editorTabClass(mode: 'edit' | 'preview'): string { return cn( 'min-h-6 rounded px-2 py-0.5 text-xs font-medium transition-colors', this.editorMode() === mode ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground', ) } centerTabClass(tab: 'variables' | 'headers'): string { return cn( 'font-mono font-medium transition-colors', this.activeCenterTab() === tab ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', ) } responseTabClass(tab: 'response' | 'tracing' | 'headers'): string { return cn( 'min-h-6 rounded px-2 py-1 font-mono text-xs font-medium transition-colors', this.activeResponseTab() === tab ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground', ) } highlightJsonLine(line: string): string { return highlightJsonLine(line) } highlightGraphqlLine(line: string): string { return highlightGraphqlLine(line) } handlePrettify(): void { this.activeQuery.set(prettifyGraphql(this.activeQuery())) if (this.isVariablesValidJson()) { try { this.activeVariables.set(JSON.stringify(JSON.parse(this.activeVariables()), null, 2)) } catch { // Keep as-is } } } runQuery(): void { if (this.isLoading()) return this.isLoading.set(true) setTimeout(() => { const randMs = Math.floor(Math.random() * 40) + 35 this.responseLatency.set(`${randMs}ms`) const trimmed = this.activeQuery().trim() if (trimmed.includes('createOrder') || trimmed.includes('mutation')) { this.responseJson.set(presets[2]!.response) this.responseSize.set(presets[2]!.size) this.responseStatus.set('200 OK') } else if (trimmed.includes('orderStatusUpdated') || trimmed.includes('subscription')) { this.responseJson.set(presets[3]!.response) this.responseSize.set(presets[3]!.size) this.responseStatus.set('200 OK · SSE Stream') } else if (trimmed.includes('users') || trimmed.includes('ListUsers')) { this.responseJson.set(presets[1]!.response) this.responseSize.set(presets[1]!.size) this.responseStatus.set('200 OK') } else { this.responseJson.set(presets[0]!.response) this.responseSize.set(presets[0]!.size) this.responseStatus.set('200 OK') } this.isLoading.set(false) }, 260) } /** Synchronous variant used by specs (no timer): picks the stubbed response for the active query. */ resolveActiveQueryResponse(): void { const trimmed = this.activeQuery().trim() if (trimmed.includes('createOrder') || trimmed.includes('mutation')) { this.responseJson.set(presets[2]!.response) this.responseSize.set(presets[2]!.size) this.responseStatus.set('200 OK') } else if (trimmed.includes('orderStatusUpdated') || trimmed.includes('subscription')) { this.responseJson.set(presets[3]!.response) this.responseSize.set(presets[3]!.size) this.responseStatus.set('200 OK · SSE Stream') } else if (trimmed.includes('users') || trimmed.includes('ListUsers')) { this.responseJson.set(presets[1]!.response) this.responseSize.set(presets[1]!.size) this.responseStatus.set('200 OK') } else { this.responseJson.set(presets[0]!.response) this.responseSize.set(presets[0]!.size) this.responseStatus.set('200 OK') } } loadPreset(preset: QueryPreset): void { this.activeQuery.set(preset.query) this.activeVariables.set(preset.variables) this.responseJson.set(preset.response) this.responseLatency.set(preset.latency) this.responseSize.set(preset.size) this.showHistoryDropdown.set(false) } loadFieldQuery(field: SchemaField): void { this.activeQuery.set(field.sampleQuery) this.activeVariables.set(field.sampleVariables) } async copyResponse(): Promise<void> { try { await navigator.clipboard.writeText(this.formattedResponseString()) } catch { // Clipboard unavailable: still confirm. } this.copiedResponse.set(true) clearTimeout(this.copyTimer) this.copyTimer = setTimeout(() => this.copiedResponse.set(false), 2000) } }src/app/components/blocks/graphql-query-explorer/graphql-schema-sidebar.component.ts11.7 kB
// Apollo-style schema documentation sidebar: root-type tree with search, field detail // preview, and click-to-load sample queries. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, signal } from '@angular/core' import { ChevronDown, ChevronRight, Database, LucideAngularModule, Search } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiInputComponent } from '@/ui/input/input.component' import type { RootType, SchemaField } from './graphql-query-explorer-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}`, }, ], }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-graphql-schema-sidebar, [ui-graphql-schema-sidebar]', standalone: true, host: { '[attr.class]': '"contents"' }, imports: [LucideAngularModule, UiBadgeComponent, UiInputComponent], template: ` <aside class="bg-muted/15 flex flex-col overflow-hidden lg:col-span-3"> <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"> <lucide-icon [img]="Database" class="text-primary size-3.5" /> <span>Schema Explorer</span> </div> <span ui-badge variant="outline" class="font-mono text-xs">v2.4</span> </div> <div class="border-border border-b p-2.5"> <div class="relative"> <lucide-icon [img]="Search" class="text-muted-foreground pointer-events-none absolute top-2.5 left-2.5 size-3.5" /> <ui-input [value]="schemaSearch()" (valueChange)="schemaSearch.set($event)" type="text" placeholder="Search types & fields..." class="h-8 pl-8 font-mono text-xs" /> </div> </div> <div class="flex-1 space-y-3 overflow-y-auto p-2.5"> @for (root of filteredSchemaRoots(); track root.name) { <div class="space-y-1"> <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"> @if (expandedRoots()[root.name]) { <lucide-icon [img]="ChevronDown" class="text-muted-foreground size-3.5" /> } @else { <lucide-icon [img]="ChevronRight" class="text-muted-foreground size-3.5" /> } <span [class]="root.color">{{ root.name }}</span> </div> <span ui-badge variant="secondary" class="font-mono text-xs font-normal">{{ root.fields.length }}</span> </button> @if (expandedRoots()[root.name]) { <div class="border-border/70 ml-2 space-y-0.5 border-l pl-2"> @for (field of root.fields; track field.name) { <button [class]="fieldButtonClass(field.name)" (click)="handleSelectField(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> @if (field.args) { <div class="text-muted-foreground truncate font-mono text-xs">({{ field.args }})</div> } </button> } </div> } </div> } </div> @if (selectedField(); as sel) { <div 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">{{ sel.name }}</span> <span ui-badge variant="secondary" class="font-mono text-xs">{{ sel.returnType }}</span> </div> <p class="text-muted-foreground mt-1.5 text-xs leading-normal">{{ sel.description }}</p> @if (sel.args) { <div 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">{{ sel.args }}</span> </div> } </div> } </aside> `, }) export class UiGraphqlSchemaSidebarComponent { protected readonly ChevronDown = ChevronDown protected readonly ChevronRight = ChevronRight protected readonly Database = Database protected readonly Search = Search @Output() loadField = new EventEmitter<SchemaField>() readonly schemaSearch = signal('') readonly selectedField = signal<SchemaField | null>(null) readonly expandedRoots = signal<Record<string, boolean>>({ Query: true, Mutation: true, Subscription: true }) readonly filteredSchemaRoots = computed(() => { const query = this.schemaSearch().trim().toLowerCase() if (!query) return schemaRoots return schemaRoots .map((root) => ({ ...root, fields: 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), ), })) .filter((root) => root.fields.length > 0) }) toggleRoot(name: string): void { this.expandedRoots.update((prev) => ({ ...prev, [name]: !prev[name] })) } handleSelectField(field: SchemaField): void { this.selectedField.set(field) this.loadField.emit(field) } fieldButtonClass(name: string): string { return cn( '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', this.selectedField()?.name === name && 'bg-muted/90 ring-border ring-1', ) } }src/app/components/blocks/graphql-query-explorer/graphql-tracing-panel.component.ts2.7 kB
// Apollo tracing waterfall: total duration plus per-resolver execution bars. // Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { UiBadgeComponent } from '@/ui/badge/badge.component' import type { ResolverTrace } from './graphql-query-explorer-types' 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, }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-graphql-tracing-panel, [ui-graphql-tracing-panel]', standalone: true, host: { '[attr.class]': '"contents"' }, imports: [UiBadgeComponent], template: ` <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> <span ui-badge variant="secondary" class="font-mono text-xs">Apollo Tracing v1</span> </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"> @for (trace of traces; track trace.path) { <div 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> <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> `, }) export class UiGraphqlTracingPanelComponent { @Input() latency = '64ms' protected readonly traces = traces }src/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 }src/app/components/blocks/graphql-query-explorer/index.ts0.3 kB
export { UiGraphqlQueryExplorerComponent } from './graphql-query-explorer.component' export { UiGraphqlSchemaSidebarComponent } from './graphql-schema-sidebar.component' export { UiGraphqlTracingPanelComponent } from './graphql-tracing-panel.component' export type { SchemaField, RootType, QueryPreset, ResolverTrace } from './graphql-query-explorer-types'
Raw manifest:https://uipkge.dev/r/angular/graphql-query-explorer.json