Menu

Change language

GitHub repo

Graphql Query Explorer

blockdevops

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.

Installation

$npx uipkge-ng@latest add graphql-query-explorer
Manifest:https://uipkge.dev/r/angular/graphql-query-explorer.jsonInstalls to:src/app/components/blocks/graphql-query-explorer/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SchemaField
interface SchemaField {
  name: string
  args?: string
  returnType: string
  description: string
  sampleQuery: string
  sampleVariables: string
}
RootType
interface RootType {
  name: 'Query' | 'Mutation' | 'Subscription'
  color: string
  badgeVariant: 'default' | 'secondary' | 'outline'
  fields: SchemaField[]
}
QueryPreset
interface QueryPreset {
  id: string
  name: string
  operationType: 'query' | 'mutation' | 'subscription'
  query: string
  variables: string
  response: Record<string, unknown>
  latency: string
  size: string
  timestamp: string
}
ResolverTrace
interface 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: '[email protected]',
              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: '[email protected]',
                role: 'ADMIN',
                isVerified: true,
                lastActiveAt: '2026-08-21T10:45:00Z',
              },
              {
                id: 'usr_9012',
                fullName: 'Elena Rostova',
                email: '[email protected]',
                role: 'DEVELOPER',
                isVerified: true,
                lastActiveAt: '2026-08-21T09:12:30Z',
              },
              {
                id: 'usr_9013',
                fullName: 'Marcus Chen',
                email: '[email protected]',
                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, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;')
    }
    
    export function highlightJsonLine(line: string): string {
      if (!line) return ''
      const escaped = escapeHtml(line)
      return escaped.replace(
        /(&quot;(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*&quot;(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
        (match) => {
          let cls = 'text-warning'
          if (/^&quot;/.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
  • src/app/components/blocks/graphql-query-explorer/graphql-tracing-panel.component.ts2.7 kB
  • src/app/components/blocks/graphql-query-explorer/graphql-query-explorer-types.ts0.7 kB
  • src/app/components/blocks/graphql-query-explorer/index.ts0.3 kB

Raw manifest:https://uipkge.dev/r/angular/graphql-query-explorer.json