Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/response-table-view.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/graphiql-react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,7 @@ 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';
export { ResponseTreeView } from './response-tree-view';
export type { ResponseTreeViewProps } from './response-tree-view';
5 changes: 2 additions & 3 deletions packages/graphiql-react/src/components/response-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { ResponseTreeView } from './response-tree-view';
import {
getOrCreateModel,
Expand Down Expand Up @@ -215,9 +216,7 @@ export const ResponseEditor: FC<ResponseEditorProps> = ({
</div>
))}
{responseView === 'table' && (
<div className="graphiql-response-empty-state" role="status">
<span>Table view is not yet available.</span>
</div>
<ResponseTableView data={lastResponse?.body} />
)}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use no memo';

import type { FC } from 'react';
import './index.css';

export type ResponseTableViewProps = { data: unknown };

type ListMatch = {
key: string;
rows: Record<string, unknown>[];
};

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<string, unknown>[] };
}
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<ResponseTableViewProps> = ({ data }) => {
const match = findFirstList(data);

if (!match) {
return (
<div className="graphiql-response-table-empty" role="status">
Table view requires a list response.
</div>
);
}

const { key, rows } = match;
const cols = Array.from(new Set(rows.flatMap(row => Object.keys(row ?? {}))));

return (
<div className="graphiql-response-table-wrapper">
<table className="graphiql-response-table">
<caption className="graphiql-response-table-caption">{key}</caption>
<thead>
<tr>
{cols.map(col => (
<th key={col} scope="col">
{col}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
// eslint-disable-next-line react/no-array-index-key
<tr key={i}>
{cols.map(col => (
<td key={col}>
{formatCell((row as Record<string, unknown>)[col])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { ResponseTableView } from './';

const meta: Meta<typeof ResponseTableView> = {
title: 'Components/ResponseTableView',
component: ResponseTableView,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
Story => (
<div
style={{
height: 400,
display: 'flex',
flexDirection: 'column',
border: '1px solid oklch(var(--border-default))',
}}
>
<Story />
</div>
),
],
};
export default meta;

type Story = StoryObj<typeof ResponseTableView>;

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,
},
};
Loading
Loading