Inspector: multi-view data inspection with a per-table view picker

This commit is contained in:
2026-06-28 22:13:34 +03:00
parent a75ea5b59e
commit 8cad80f738
10 changed files with 537 additions and 258 deletions
+90 -19
View File
@@ -32,12 +32,38 @@ afterEach(() => {
vi.clearAllMocks();
});
const toggle = () => container.querySelector<HTMLButtonElement>('button[aria-expanded]')!;
const toggle = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')).find((b) =>
b.textContent?.includes('Data'),
)!;
const text = () => container.textContent ?? '';
const viewButton = (label: string) =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
(b) => b.textContent === label,
)!;
/** The view picker's trigger, or null when it is not shown (single-table case). */
const picker = () => container.querySelector<HTMLButtonElement>('[aria-label^="Inspected view"]');
/** Build the inspector payload — one table per (label, input, resolved) entry. */
const tablesOf = (
...entries: Array<{
label: string;
input?: Record<string, unknown>[];
resolved: Record<string, unknown>[];
}>
): InspectedData => ({
tables: entries.map((e, i) => ({
id: `t${i}`,
label: e.label,
input: e.input ?? [],
resolved: e.resolved,
})),
});
/** The common single-table payload. */
const oneTable = (
input: Record<string, unknown>[],
resolved: Record<string, unknown>[],
): InspectedData => tablesOf({ label: 'View 1', input, resolved });
const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) =>
act(() => {
@@ -68,26 +94,33 @@ describe('DataInspectorPanel', () => {
expect(container.querySelector('table')).toBeNull();
});
test('open with a chart that draws nothing inspectable: says so', () => {
render({ getData: () => ({ tables: [] }) });
expect(text()).toContain('no inspectable data');
expect(container.querySelector('table')).toBeNull();
});
test('defaults to the Resolved view and renders its rows', () => {
const data: InspectedData = {
input: [{ region: 'West', sales: '1204' }],
resolved: [{ region: 'West', total: 1204 }],
};
render({ getData: () => data });
render({
getData: () =>
oneTable([{ region: 'West', sales: '1204' }], [{ region: 'West', total: 1204 }]),
});
// Resolved is the default — its column ("total"), not the input's ("sales").
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'total']);
});
test('switching to Input shows the source rows', () => {
const data: InspectedData = {
input: [
{ region: 'West', sales: '1204' },
{ region: 'East', sales: '980' },
],
resolved: [{ region: 'West', total: 1204 }],
};
render({ getData: () => data });
render({
getData: () =>
oneTable(
[
{ region: 'West', sales: '1204' },
{ region: 'East', sales: '980' },
],
[{ region: 'West', total: 1204 }],
),
});
act(() => viewButton('Input').click());
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'sales']);
@@ -96,25 +129,25 @@ describe('DataInspectorPanel', () => {
});
test('resolved empty: names the empty-transform signal', () => {
render({ getData: () => ({ input: [{ a: 1 }], resolved: [] }) });
render({ getData: () => oneTable([{ a: 1 }], []) });
expect(text()).toContain('left nothing to draw');
});
test('input empty: names the empty source', () => {
render({ getData: () => ({ input: [], resolved: [] }) });
render({ getData: () => oneTable([], []) });
act(() => viewButton('Input').click());
expect(text()).toContain('source data has no rows');
});
test('caps the table and reports the total', () => {
const resolved = Array.from({ length: 120 }, (_, i) => ({ i }));
render({ getData: () => ({ input: [], resolved }) });
render({ getData: () => oneTable([], resolved) });
expect(container.querySelectorAll('tbody tr')).toHaveLength(50);
expect(text()).toContain('first 50 of 120');
});
test('re-reads getData when renderEpoch changes', () => {
const getData = vi.fn((): InspectedData => ({ input: [], resolved: [{ a: 1 }] }));
const getData = vi.fn(() => oneTable([], [{ a: 1 }]));
render({ getData, renderEpoch: 0 });
const before = getData.mock.calls.length;
render({ getData, renderEpoch: 1 });
@@ -122,7 +155,7 @@ describe('DataInspectorPanel', () => {
});
test('applies an explicit height when given (resizable mode)', () => {
render({ getData: () => ({ input: [], resolved: [{ a: 1 }] }), heightPx: 240 });
render({ getData: () => oneTable([], [{ a: 1 }]), heightPx: 240 });
const panel = container.firstElementChild as HTMLElement;
expect(panel.style.height).toBe('240px');
});
@@ -133,6 +166,44 @@ describe('DataInspectorPanel', () => {
act(() => toggle().click());
expect(onToggle).toHaveBeenCalledWith(true);
});
// ── Multi-view selector ──────────────────────────────────────────────────────
test('a single drawn table shows no view picker', () => {
render({ getData: () => oneTable([], [{ a: 1 }]) });
expect(picker()).toBeNull();
});
test('multiple drawn tables show a picker, defaulting to the first table', () => {
render({
getData: () =>
tablesOf(
{ label: 'sales', resolved: [{ region: 'W', revenue: 1 }] },
{ label: 'regions', resolved: [{ region: 'W', population: 2 }] },
),
});
// Picker present and on the first table; the grid shows that table's columns.
expect(picker()?.getAttribute('aria-label')).toBe('Inspected view: sales');
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'revenue']);
});
test('choosing another table switches the inspected data', () => {
render({
getData: () =>
tablesOf(
{ label: 'sales', resolved: [{ region: 'W', revenue: 1 }] },
{ label: 'regions', resolved: [{ region: 'W', population: 2 }] },
),
});
act(() => picker()!.click()); // open the portaled popover
const option = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((b) =>
b.textContent?.includes('regions'),
)!;
act(() => option.click());
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'population']);
});
});
describe('DataInspector', () => {
+66 -23
View File
@@ -1,46 +1,67 @@
/**
* Data inspector — input vs. resolved rows (spec §04).
* Data inspector — input vs. resolved rows, per drawn table (spec §04).
*
* Shows the chart's data with a toggle between two views of the same rendered
* view: **Input** (the parsed source rows, before the spec's transforms) and
* **Resolved** (the rows the chart draws, after filters / calculated fields /
* aggregation). Seeing input → output side by side is how you answer "why is my
* chart empty/wrong" — look at what the transforms did to the data. Both tables
* come from the live Vega view via the renderer's `RenderHandle.inspectData()`
* accessor, passed in as `getData` so this component never touches the view (the
* embedding boundary, arch 05).
* Shows the chart's data with a toggle between two ends of a table's pipeline:
* **Input** (the parsed source rows, before the view's transforms) and
* **Resolved** (the rows the marks draw, after filters / calculated fields /
* aggregation). Seeing input → output is how you answer "why is my chart
* empty/wrong" — look at what the transforms did to the data.
*
* A composed spec (layer/concat/facet/repeat) draws several tables, so a **view
* picker** (`SelectControl`) lets the user choose which one to inspect — "what data
* am I actually visualizing?". The picker is hidden for the common single-table
* case (council: NN/g #8 — no one-option control; docs/architecture/10). Labels
* never show Vega's compiler names (`source_0`/`data_2`), only a user-authored
* dataset name or an ordinal "View N" plus a columns·rows recognition cue
* (`@core/inspect-views`; NN/g #2/#6).
*
* Tables come from the live Vega view via `RenderHandle.inspectData()`, passed in as
* `getData` so this component never touches the view (the embedding boundary, arch
* 05). Read lazily — only while expanded — so a collapsed inspector costs nothing.
*
* `DataInspectorPanel` is the reusable shape (an APG disclosure, mirroring the
* builder's source-rows preview); `DataInspector` binds it to the persisted
* preview-pane open state for the live-preview pane. The data is read lazily —
* only while expanded — because listing the view's datasets serializes them (see
* `RenderHandle.inspectData`), so a collapsed inspector costs nothing.
* preview-pane open state for the live-preview pane.
*/
import { useMemo, useState } from 'react';
import type { InspectedData } from '../services/chart-renderer';
import type { InspectableTable, InspectedData } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { DataTable } from './DataTable';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './DataInspector.module.css';
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
const ROW_LIMIT = 50;
/** Columns named in a table's picker `detail` before eliding the rest. */
const DETAIL_COLUMNS = 4;
type DataView = 'input' | 'resolved';
/** The two views, in input → output order (the natural reading direction). */
/** The two stages, in input → output order (the natural reading direction). */
const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<DataView>> = [
// `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with
// the visible label).
{ value: 'input', label: 'Input', title: 'Input — the source rows before the specs transforms' },
{ value: 'input', label: 'Input', title: 'Input — the source rows before the views transforms' },
{
value: 'resolved',
label: 'Resolved',
title: 'Resolved — the rows the chart draws, after its transforms',
title: 'Resolved — the rows the marks draw, after the views transforms',
},
];
/** A recognition cue for the view picker: row count + the first few columns. */
function tableDetail(table: InspectableTable): string {
const rows = table.resolved;
const count = `${rows.length.toLocaleString()} ${rows.length === 1 ? 'row' : 'rows'}`;
const columns = rows[0] ? Object.keys(rows[0]) : [];
if (columns.length === 0) return count;
const shown = columns.slice(0, DETAIL_COLUMNS).join(', ');
const more = columns.length > DETAIL_COLUMNS ? `, +${columns.length - DETAIL_COLUMNS}` : '';
return `${count} · ${shown}${more}`;
}
interface DataInspectorPanelProps {
/** Whether the panel is expanded. */
open: boolean;
@@ -83,13 +104,20 @@ export function DataInspectorPanel({
id,
}: DataInspectorPanelProps) {
const [view, setView] = useState<DataView>('resolved');
const [selectedId, setSelectedId] = useState<string | null>(null);
// Read both tables only while open; re-read when a render settles. `renderEpoch`
// is an intentional refresh trigger — not read in the body (getData is stable and
// always reads the latest view), so exhaustive-deps sees it as unnecessary.
// Read tables only while open; re-read when a render settles. `renderEpoch` is an
// intentional refresh trigger — not read in the body (getData is stable and always
// reads the latest view), so exhaustive-deps sees it as unnecessary.
// eslint-disable-next-line react-hooks/exhaustive-deps
const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]);
const rows = data === null ? null : data[view];
const tables = data?.tables ?? null;
// The chosen table, falling back to the first when the selection is stale (the
// spec changed under it) or unset — so a re-render never lands on a missing table.
const table = tables?.find((t) => t.id === selectedId) ?? tables?.[0] ?? null;
const rows = table ? table[view] : null;
const pickerId = `${id ?? 'preview'}-view-picker`;
return (
<div
@@ -111,8 +139,21 @@ export function DataInspectorPanel({
</button>
{open && (
<>
{tables && tables.length > 1 && table && (
<SelectControl
id={pickerId}
label="Inspected view"
value={table.id}
onSelect={setSelectedId}
options={tables.map<SelectControlOption<string>>((t) => ({
value: t.id,
label: t.label,
detail: tableDetail(t),
}))}
/>
)}
<SegmentedControl
label="Data view"
label="Pipeline stage"
options={VIEW_OPTIONS}
value={view}
onChange={setView}
@@ -127,12 +168,14 @@ export function DataInspectorPanel({
</div>
{open &&
(rows === null ? (
(data === null ? (
<p className={styles.stateNote}>Render a chart to inspect its data.</p>
) : table === null || rows === null ? (
<p className={styles.stateNote}>This chart has no inspectable data.</p>
) : rows.length === 0 ? (
<p className={styles.stateNote}>
{view === 'resolved'
? 'No rows — the specs filters or transforms left nothing to draw.'
? 'No rows — the views filters or transforms left nothing to draw.'
: 'The source data has no rows.'}
</p>
) : (
+1 -5
View File
@@ -27,10 +27,6 @@ const H = vi.hoisted(() => ({
pending: [] as Array<() => void>,
destroyed: [] as number[],
configs: [] as unknown[],
inspected: null as {
input: ReadonlyArray<Record<string, unknown>>;
resolved: ReadonlyArray<Record<string, unknown>>;
} | null,
}));
vi.mock('../services/chart-renderer', () => ({
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
@@ -48,7 +44,7 @@ vi.mock('../services/chart-renderer', () => ({
H.destroyed.push(id);
},
resize() {},
inspectData: () => H.inspected,
inspectData: () => null,
});
});
});