From c33be4e15e7998d7d90588bfdf411a7c994a55d2 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Fri, 5 Jun 2026 16:37:28 -0700 Subject: [PATCH 1/2] Add `ResponseTableView` Renders the first list field in a GraphQL response as a table. Object/array cells render with shorthand summaries (`Object {n}`, `Array [n]`); non-list responses show an empty state. --- .changeset/response-table-view.md | 5 + .../graphiql-react/src/components/index.ts | 2 + .../src/components/response-editor.tsx | 8 +- .../components/response-table-view/index.css | 64 +++++++ .../components/response-table-view/index.tsx | 91 ++++++++++ .../response-table-view.stories.tsx | 140 +++++++++++++++ .../response-table-view.test.tsx | 170 ++++++++++++++++++ 7 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 .changeset/response-table-view.md create mode 100644 packages/graphiql-react/src/components/response-table-view/index.css create mode 100644 packages/graphiql-react/src/components/response-table-view/index.tsx create mode 100644 packages/graphiql-react/src/components/response-table-view/response-table-view.stories.tsx create mode 100644 packages/graphiql-react/src/components/response-table-view/response-table-view.test.tsx diff --git a/.changeset/response-table-view.md b/.changeset/response-table-view.md new file mode 100644 index 0000000000..ef77afdffc --- /dev/null +++ b/.changeset/response-table-view.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add `ResponseTableView` for the response pane's Table view. Renders the first list field in a GraphQL response as a table; nested objects and arrays show as shorthand summaries. Non-list responses show an empty state. diff --git a/packages/graphiql-react/src/components/index.ts b/packages/graphiql-react/src/components/index.ts index a229d3cb62..eb78f6fc28 100644 --- a/packages/graphiql-react/src/components/index.ts +++ b/packages/graphiql-react/src/components/index.ts @@ -39,3 +39,5 @@ export { ActivityRail } from './activity-rail'; export type { ActivityRailProps } from './activity-rail'; export { ResponseHeader } from './response-header'; export type { ResponseHeaderProps } from './response-header'; +export { ResponseTableView } from './response-table-view'; +export type { ResponseTableViewProps } from './response-table-view'; diff --git a/packages/graphiql-react/src/components/response-editor.tsx b/packages/graphiql-react/src/components/response-editor.tsx index 2a90ee45f5..2fb4870886 100644 --- a/packages/graphiql-react/src/components/response-editor.tsx +++ b/packages/graphiql-react/src/components/response-editor.tsx @@ -4,6 +4,7 @@ import { createRoot, Root } from 'react-dom/client'; import { useGraphiQL, useGraphiQLActions } from './provider'; import { ImagePreview } from './image-preview'; import { ResponseHeader } from './response-header'; +import { ResponseTableView } from './response-table-view'; import { getOrCreateModel, createEditor, @@ -205,12 +206,11 @@ export const ResponseEditor: FC = ({ onKeyDown={onEditorContainerKeyDown} className="result-window" /> + ) : responseView === 'table' ? ( + ) : (
- - {responseView === 'tree' ? 'Tree' : 'Table'} view is not yet - available. - + Tree view is not yet available.
)} diff --git a/packages/graphiql-react/src/components/response-table-view/index.css b/packages/graphiql-react/src/components/response-table-view/index.css new file mode 100644 index 0000000000..7572b16c7e --- /dev/null +++ b/packages/graphiql-react/src/components/response-table-view/index.css @@ -0,0 +1,64 @@ +.graphiql-response-table-wrapper { + flex: 1; + overflow: auto; + padding: var(--px-14); +} + +.graphiql-response-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + color: oklch(var(--fg-default)); +} + +.graphiql-response-table-caption { + text-align: left; + font-family: var(--font-family); + font-size: var(--font-size-small); + font-weight: 600; + color: oklch(var(--fg-muted)); + padding-bottom: var(--px-8); + caption-side: top; +} + +.graphiql-response-table th { + text-align: left; + padding: var(--px-6) var(--px-12); + border-bottom: 2px solid oklch(var(--border-default)); + color: oklch(var(--fg-strong)); + font-weight: 600; + white-space: nowrap; + background: oklch(var(--bg-elevated)); + position: sticky; + top: 0; + z-index: 1; +} + +.graphiql-response-table td { + padding: var(--px-6) var(--px-12); + border-bottom: 1px solid oklch(var(--border-subtle)); + vertical-align: top; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.graphiql-response-table tbody tr:nth-child(even) { + background: oklch(var(--bg-canvas)); +} + +.graphiql-response-table tbody tr:hover { + background: oklch(var(--bg-elevated)); +} + +.graphiql-response-table-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: oklch(var(--fg-subtle)); + font-family: var(--font-family); + font-size: var(--font-size-small); +} diff --git a/packages/graphiql-react/src/components/response-table-view/index.tsx b/packages/graphiql-react/src/components/response-table-view/index.tsx new file mode 100644 index 0000000000..2dd2507788 --- /dev/null +++ b/packages/graphiql-react/src/components/response-table-view/index.tsx @@ -0,0 +1,91 @@ +'use no memo'; + +import type { FC } from 'react'; +import './index.css'; + +export type ResponseTableViewProps = { data: unknown }; + +type ListMatch = { + key: string; + rows: Record[]; +}; + +function findFirstList(data: unknown): ListMatch | null { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null; + } + for (const [key, value] of Object.entries(data as object)) { + if ( + Array.isArray(value) && + value.length > 0 && + typeof value[0] === 'object' && + value[0] !== null + ) { + return { key, rows: value as Record[] }; + } + const nested = findFirstList(value); + if (nested) { + return nested; + } + } + return null; +} + +function formatCell(value: unknown): string { + if (value === null || value === undefined) { + return '—'; + } + if (Array.isArray(value)) { + return `Array [${value.length}]`; + } + if (typeof value === 'object') { + return `Object {${Object.keys(value).length}}`; + } + return String(value); +} + +export const ResponseTableView: FC = ({ data }) => { + const match = findFirstList(data); + + if (!match) { + return ( +
+ Table view requires a list response. +
+ ); + } + + const { key, rows } = match; + const cols = Array.from( + new Set(rows.flatMap(row => Object.keys(row ?? {}))), + ); + + return ( +
+ + + + + {cols.map(col => ( + + ))} + + + + {rows.map((row, i) => ( + // eslint-disable-next-line react/no-array-index-key + + {cols.map(col => ( + + ))} + + ))} + +
{key}
+ {col} +
+ {formatCell((row as Record)[col])} +
+
+ ); +}; diff --git a/packages/graphiql-react/src/components/response-table-view/response-table-view.stories.tsx b/packages/graphiql-react/src/components/response-table-view/response-table-view.stories.tsx new file mode 100644 index 0000000000..f697f11ec5 --- /dev/null +++ b/packages/graphiql-react/src/components/response-table-view/response-table-view.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ResponseTableView } from './'; + +const meta: Meta = { + title: 'Components/ResponseTableView', + component: ResponseTableView, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; +export default meta; + +type Story = StoryObj; + +export const ListOfObjects: Story = { + name: 'List of objects', + args: { + data: { + data: { + users: [ + { id: 1, name: 'Alice', role: 'admin', email: 'alice@example.com' }, + { id: 2, name: 'Bob', role: 'user', email: 'bob@example.com' }, + { id: 3, name: 'Carol', role: 'user', email: 'carol@example.com' }, + ], + }, + }, + }, +}; + +export const RaggedRows: Story = { + name: 'Ragged rows (inconsistent keys)', + args: { + data: { + data: { + products: [ + { id: 'A1', name: 'Widget', price: 9.99, discount: 0.1 }, + { id: 'B2', name: 'Gadget', price: 14.99 }, + { id: 'C3', name: 'Doohickey', price: 4.5, tags: ['sale', 'new'] }, + ], + }, + }, + }, +}; + +export const NestedObjects: Story = { + name: 'Cells with nested objects and arrays', + args: { + data: { + data: { + orders: [ + { + id: 101, + customer: { name: 'Alice', id: 1 }, + items: ['A1', 'C3'], + total: 14.49, + }, + { + id: 102, + customer: { name: 'Bob', id: 2 }, + items: ['B2'], + total: 14.99, + }, + ], + }, + }, + }, +}; + +export const NestedList: Story = { + name: 'List nested inside a wrapper field', + args: { + data: { + data: { + usersConnection: { + totalCount: 3, + nodes: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }, + }, + }, + }, +}; + +export const PrimitiveArray: Story = { + name: 'Primitive array (no table possible)', + args: { + data: { + data: { + tags: ['graphql', 'api', 'rest'], + }, + }, + }, +}; + +export const EmptyArray: Story = { + name: 'Empty array (no rows)', + args: { + data: { + data: { + users: [], + }, + }, + }, +}; + +export const NoListField: Story = { + name: 'Non-list response (scalar fields only)', + args: { + data: { + data: { + user: { id: 1, name: 'Alice', role: 'admin' }, + }, + }, + }, +}; + +export const NoResponse: Story = { + name: 'No response yet', + args: { + data: null, + }, +}; diff --git a/packages/graphiql-react/src/components/response-table-view/response-table-view.test.tsx b/packages/graphiql-react/src/components/response-table-view/response-table-view.test.tsx new file mode 100644 index 0000000000..41f3b0766d --- /dev/null +++ b/packages/graphiql-react/src/components/response-table-view/response-table-view.test.tsx @@ -0,0 +1,170 @@ +'use no memo'; + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ResponseTableView } from './'; + +const USERS = [ + { id: 1, name: 'Alice', role: 'admin' }, + { id: 2, name: 'Bob', role: 'user' }, +]; + +const PRODUCTS = [ + { sku: 'A1', price: 9.99 }, + { sku: 'B2', price: 14.99 }, +]; + +describe('ResponseTableView', () => { + describe('non-list responses', () => { + it('shows the empty state for null data', () => { + render(); + expect( + screen.getByText('Table view requires a list response.'), + ).toBeInTheDocument(); + }); + + it('shows the empty state for a primitive', () => { + render(); + expect( + screen.getByText('Table view requires a list response.'), + ).toBeInTheDocument(); + }); + + it('shows the empty state for an object with no list fields', () => { + render(); + expect( + screen.getByText('Table view requires a list response.'), + ).toBeInTheDocument(); + }); + + it('shows the empty state for a primitive array', () => { + render(); + expect( + screen.getByText('Table view requires a list response.'), + ).toBeInTheDocument(); + }); + + it('shows the empty state for an empty array', () => { + render(); + expect( + screen.getByText('Table view requires a list response.'), + ).toBeInTheDocument(); + }); + }); + + describe('list-shaped responses', () => { + it('renders a table for a top-level array of objects', () => { + render(); + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + }); + + it('renders column headers from the first object keys', () => { + render(); + expect( + screen.getByRole('columnheader', { name: 'id' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'name' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'role' }), + ).toBeInTheDocument(); + }); + + it('renders one row per element', () => { + render(); + const rows = screen.getAllByRole('row'); + // 1 header row + 2 data rows + expect(rows).toHaveLength(3); + }); + + it('renders cell values as strings', () => { + render(); + expect(screen.getByText('A1')).toBeInTheDocument(); + expect(screen.getByText('9.99')).toBeInTheDocument(); + }); + + it('renders null/undefined cells as an em dash', () => { + const rows = [{ id: 1, name: null }]; + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('renders nested objects as shorthand', () => { + const rows = [{ id: 1, address: { city: 'NYC', zip: '10001' } }]; + render(); + expect(screen.getByText('Object {2}')).toBeInTheDocument(); + }); + + it('renders nested arrays as shorthand', () => { + const rows = [{ id: 1, tags: ['a', 'b'] }]; + render(); + expect(screen.getByText('Array [2]')).toBeInTheDocument(); + }); + + it('handles ragged rows (missing keys in some rows)', () => { + const ragged = [ + { id: 1, name: 'Alice', role: 'admin' }, + { id: 2, name: 'Bob' }, + ]; + render(); + const cells = screen.getAllByRole('cell'); + // 3 cols * 2 rows = 6 cells; last row missing 'role' shows '—' + expect(cells).toHaveLength(6); + // The missing cell should be '—' + const emDashes = cells.filter(c => c.textContent === '—'); + expect(emDashes).toHaveLength(1); + }); + + it('unions column keys across all rows for ragged data', () => { + const ragged = [ + { id: 1, a: 'x' }, + { id: 2, b: 'y' }, + ]; + render(); + expect( + screen.getByRole('columnheader', { name: 'a' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'b' }), + ).toBeInTheDocument(); + }); + + it('picks the first list-of-objects field when multiple list fields exist', () => { + const data = { + data: { users: USERS, products: PRODUCTS }, + }; + render(); + // 'id', 'name', 'role' are users columns; products has 'sku', 'price' + expect( + screen.getByRole('columnheader', { name: 'id' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('columnheader', { name: 'sku' }), + ).not.toBeInTheDocument(); + }); + + it('finds a nested list field', () => { + const data = { data: { page: { items: USERS } } }; + render(); + expect( + screen.getByRole('columnheader', { name: 'name' }), + ).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('uses a caption to describe the source field', () => { + render(); + expect(screen.getByText('users')).toBeInTheDocument(); + }); + + it('wraps the empty state in a role=status region', () => { + const { container } = render(); + expect( + container.querySelector('.graphiql-response-table-empty'), + ).toHaveAttribute('role', 'status'); + }); + }); +}); From c1376d6e3093fa1fa337fbaedc3a03c6582270df Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Sat, 6 Jun 2026 10:34:36 -0700 Subject: [PATCH 2/2] format ResponseTableView --- .../src/components/response-table-view/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/graphiql-react/src/components/response-table-view/index.tsx b/packages/graphiql-react/src/components/response-table-view/index.tsx index 2dd2507788..00980f05a8 100644 --- a/packages/graphiql-react/src/components/response-table-view/index.tsx +++ b/packages/graphiql-react/src/components/response-table-view/index.tsx @@ -56,9 +56,7 @@ export const ResponseTableView: FC = ({ data }) => { } const { key, rows } = match; - const cols = Array.from( - new Set(rows.flatMap(row => Object.keys(row ?? {}))), - ); + const cols = Array.from(new Set(rows.flatMap(row => Object.keys(row ?? {})))); return (