# GraphQL adapter

Server-side sort / filter / page wired to a mock GraphQL resolver. Side panel shows the live query doc so you can compare what the grid sent to the network tab.

Uses `@svgrid/grid` (MIT). Category: Server-Side Data. Live demo: https://svgrid.com/demos/72-graphql-adapter/

Install: `npm i @svgrid/grid` (Svelte 5).

## About this example

The Svelte 5 data grid wired to a GraphQL endpoint. Sorting, filtering and paging run in externalSort and externalFilter mode, so each change hands the grid state to the component, which builds a typed GraphQL query from it, sends it and resets data from the response. A side panel shows the live query document so you can compare it with the network tab; the resolver is an in-process mock that swaps for fetch('/graphql') unchanged.

Wires the grid to a GraphQL endpoint. Whenever the user sorts, filters, or pages, the grid hands the state to the consumer via the `externalSort` / `externalFilter` callbacks; we build a typed GraphQL query from that state, send it, and re-set `data` from the response.

The endpoint here is a mock in-process resolver so the demo runs stand-alone; swap `runQuery` for `fetch('/graphql', ...)` against your own backend and the rest of the wiring is unchanged.

## Imports, features and API used

- Imports: `@svgrid/grid`, `../shared/seed`
- Table features registered: `rowSortingFeature`, `columnFilteringFeature`
- Columns: `orderId` (Order ID), `company` (Company), `product` (Product), `sellDate` (Sell date), `quantity` (Qty), `price` (Price), `country` (Country)

## Frequently asked questions

### How is the query built from grid state?

onSortingChange and onFiltersChange store the sort clause and column filters; a function maps them to the orders field's page, pageSize, sort { field, dir } and filters [{ field, op, value }] arguments and serializes the document that the side panel displays.

### Does this work with a GraphQL client library?

Yes. runQuery is the only place the document is sent; replace its body with your client's query call and pass the variables it produces.

### How is the response mapped to rows?

The resolver returns total and items; the items array becomes the grid's data and total drives the pager.

## Related documentation

- [Loading data from REST + GraphQL](https://svgrid.com/docs/recipes/loading-data.md)

## Related articles

- [A Svelte Data Grid with GraphQL](https://svgrid.com/blog/svelte-data-grid-graphql/) - Wire SvGrid to a GraphQL API with server-side sort, filter, and pagination - covering offset and cursor paging, variable translation, and the three callbacks that keep everything in sync.

## Source code (72-graphql-adapter.svelte)

```svelte
<script lang="ts">
  /**
   * 72. GraphQL adapter - server-side sort + filter + page
   * -----------------------------------------------------
   * Wires the grid to a GraphQL endpoint. Whenever the user sorts,
   * filters, or pages, the grid hands the state to the consumer via the
   * `externalSort` / `externalFilter` callbacks; we build a typed GraphQL
   * query from that state, send it, and re-set `data` from the response.
   *
   * The endpoint here is a mock in-process resolver so the demo runs
   * stand-alone; swap `runQuery` for `fetch('/graphql', ...)` against
   * your own backend and the rest of the wiring is unchanged.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
    type SvGridApi,
  } from '@svgrid/grid'
  import { makeOrders, type Order } from '../shared/seed'

  const ALL_ORDERS = makeOrders(2500)

  // ---- Mock GraphQL resolver --------------------------------------------
  type Sort   = { field: string; dir: 'asc' | 'desc' }
  type Filter = { field: string; op: 'contains' | 'eq' | 'gt' | 'lt'; value: string }
  type OrdersQuery = {
    page: number; pageSize: number
    sort?: Sort
    filters?: Filter[]
  }
  type OrdersResult = {
    items: Order[]
    total: number
    elapsedMs: number
  }

  /** "Backend". Pretends to be GraphQL: receives a typed query and returns rows. */
  async function runQuery(q: OrdersQuery): Promise<OrdersResult> {
    const started = performance.now()
    // Realistic 150-300ms server hop.
    await new Promise((r) => setTimeout(r, 150 + Math.random() * 150))

    let working = ALL_ORDERS.slice()
    for (const f of q.filters ?? []) {
      working = working.filter((row) => {
        const v = (row as Record<string, unknown>)[f.field]
        switch (f.op) {
          case 'contains': return String(v ?? '').toLowerCase().includes(f.value.toLowerCase())
          case 'eq':       return String(v ?? '') === f.value
          case 'gt':       return Number(v) > Number(f.value)
          case 'lt':       return Number(v) < Number(f.value)
        }
      })
    }
    if (q.sort) {
      const { field, dir } = q.sort
      const sign = dir === 'desc' ? -1 : 1
      working = working.slice().sort((a, b) => {
        const av = (a as Record<string, unknown>)[field]
        const bv = (b as Record<string, unknown>)[field]
        if (typeof av === 'number' && typeof bv === 'number') return sign * (av - bv)
        return sign * String(av ?? '').localeCompare(String(bv ?? ''))
      })
    }
    const total = working.length
    const start = (q.page - 1) * q.pageSize
    const items = working.slice(start, start + q.pageSize)
    return { items, total, elapsedMs: performance.now() - started }
  }

  /** Pretty-print the equivalent GraphQL doc for the side panel. */
  function toGraphQLDoc(q: OrdersQuery): string {
    const args: string[] = [`page: ${q.page}`, `pageSize: ${q.pageSize}`]
    if (q.sort) args.push(`sort: { field: "${q.sort.field}", dir: ${q.sort.dir.toUpperCase()} }`)
    if (q.filters?.length) {
      const lines = q.filters.map((f) => `    { field: "${f.field}", op: ${f.op.toUpperCase()}, value: "${f.value}" }`).join(',\n')
      args.push(`filters: [\n${lines}\n  ]`)
    }
    return `query OrdersPage {\n  orders(\n    ${args.join(',\n    ')}\n  ) {\n    total\n    items {\n      orderId company product sellDate quantity price country\n    }\n  }\n}`
  }

  // ---- Component state --------------------------------------------------
  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
  let api = $state<SvGridApi<typeof features, Order> | null>(null)

  let rows = $state<Order[]>([])
  let total = $state(0)
  let page = $state(1)
  const pageSize = 25
  let sort = $state<Sort | undefined>(undefined)
  let filters = $state<Filter[]>([])
  let loading = $state(false)
  let lastDoc = $state<string>('')
  let lastElapsed = $state<number>(0)

  async function refresh() {
    loading = true
    const q: OrdersQuery = { page, pageSize, sort, filters: filters.length ? filters : undefined }
    lastDoc = toGraphQLDoc(q)
    const res = await runQuery(q)
    rows = res.items
    total = res.total
    lastElapsed = Math.round(res.elapsedMs)
    loading = false
  }

  // Initial load.
  $effect(() => { void refresh() })

  // ---- Grid wiring ------------------------------------------------------
  function onSortChange(clauses: Array<{ id: string; desc: boolean }>) {
    sort = clauses[0]
      ? { field: clauses[0].id, dir: clauses[0].desc ? 'desc' : 'asc' }
      : undefined
    page = 1
    void refresh()
  }
  function onFiltersChange(payload: {
    global: string
    columns: Array<{ id: string; operator: string; value: string }>
  }) {
    const out: Filter[] = []
    for (const c of payload.columns) {
      if (!c.value) continue
      const op: Filter['op'] = c.operator === 'equals' ? 'eq'
        : c.operator === 'greaterThan' ? 'gt'
        : c.operator === 'lessThan' ? 'lt'
        : 'contains'
      out.push({ field: c.id, op, value: c.value })
    }
    filters = out
    page = 1
    void refresh()
  }

  const totalPages = $derived(Math.max(1, Math.ceil(total / pageSize)))

  const columns: GridColumns<Order> = [
    { field: 'orderId',  header: 'Order ID', editorType: 'text',   width: 140 },
    { field: 'company',  header: 'Company',  editorType: 'text',   width: 180 },
    { field: 'product',  header: 'Product',  editorType: 'text',   width: 180 },
    { field: 'sellDate', header: 'Sell date',editorType: 'date',   width: 130,
      format: { type: 'date', pattern: 'y-m-d' } },
    { field: 'quantity', header: 'Qty',      editorType: 'number', width: 90 },
    { field: 'price',    header: 'Price',    editorType: 'number', width: 130,
      format: { type: 'currency', currency: 'USD' } },
    { field: 'country',  header: 'Country',  editorType: 'text',   width: 110 },
  ]
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="flex flex-wrap items-center gap-2 text-sm shrink-0">
    <span class="font-medium">
      {total.toLocaleString()} rows · page {page} of {totalPages}
    </span>
    {#if loading}<span class="text-amber-600 dark:text-amber-400">Fetching…</span>
    {:else}<span class="text-emerald-600 dark:text-emerald-400">✓ {lastElapsed} ms</span>{/if}

    <div class="ml-auto inline-flex items-center gap-1">
      <button type="button" disabled={page <= 1}
        onclick={() => { page = Math.max(1, page - 1); void refresh() }}
        class="gql-btn rounded-md border px-2 py-1 disabled:opacity-40"
        >‹ Prev</button>
      <button type="button" disabled={page >= totalPages}
        onclick={() => { page = Math.min(totalPages, page + 1); void refresh() }}
        class="gql-btn rounded-md border px-2 py-1 disabled:opacity-40"
        >Next ›</button>
    </div>
  </div>

  <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] gap-4 flex-1 min-h-0">
    <div class="flex-1 min-h-0 min-w-0">
      <SvGrid responsive={true}
      columnResize
        data={rows}
        columns={columns}
        features={features}
        filterMode="menu"
        externalSort={true}
        externalFilter={true}
        selectionMode="cell"
        showRowNumbers={true}
        enableInlineEditing={false}
        enableCellSelection={true}
        rowHeight={32}
        containerHeight="100%"
        fitColumns={true}
        onApiReady={(next) => (api = next)}
        onSortingChange={onSortChange}
        onFiltersChange={onFiltersChange}
      />
    </div>

    <aside class="flex flex-col gap-2 min-h-0">
      <header class="flex items-baseline justify-between shrink-0">
        <div class="gql-label text-xs uppercase tracking-wide">Last GraphQL query</div>
        <div class="gql-meta text-[10px]">round-trip {lastElapsed} ms</div>
      </header>
      <pre class="gql-doc flex-1 min-h-0 overflow-auto text-[11px] leading-snug border rounded p-3">{lastDoc}</pre>
    </aside>
  </div>
</section>

<style>
  /* Pager + query panel chrome track the active grid theme. */
  .gql-btn {
    border-color: var(--sg-border, #cbd5e1);
    background: var(--sg-bg, #ffffff);
    color: var(--sg-fg, #0f172a);
  }
  .gql-label { color: var(--sg-muted, #64748b); }
  .gql-meta { color: var(--sg-muted, #94a3b8); }
  .gql-doc {
    border-color: var(--sg-border, #e2e8f0);
    background: var(--sg-bg-subtle, var(--sg-header-bg, #f8fafc));
    color: var(--sg-fg, #0f172a);
  }
</style>
```

[View this example on GitHub](https://github.com/sv-grid/sv-grid/blob/main/examples/src/demos/72-graphql-adapter.svelte)
