mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Data inspector: input/resolved rows below the chart, with a resizable divider
A collapsible Data panel under the Live Preview and Chart Builder charts shows the rows the chart actually uses, switching between Input (parsed source) and Resolved (post-transform) views read from the live Vega view. Collapsed by default; the open-state and a draggable height divider persist. Rows come through a new RenderHandle.inspectData() accessor, so no component touches the view: core/result-data picks the most-upstream source and most-downstream result from the compiled dataflow, read lazily. The divider reuses the window-splitter pattern (horizontal variant). Consolidations: a shared DataTable primitive replaces the inspector's and the builder's duplicate read-only tables; useResizeDrag merges the col/row drag-gesture twins. Docs: spec 04/06 and arch 05/10 updated; the now-shipped exploration memo removed.
This commit is contained in:
@@ -355,44 +355,8 @@
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.previewTableWrap {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.previewTableWrap:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.previewTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.previewTable th,
|
||||
.previewTable td {
|
||||
text-align: left;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.previewTable thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
/* The per-column type chip in the source-preview header (the table structure and
|
||||
cell styling now live in the shared DataTable). */
|
||||
.previewColName {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
@@ -403,10 +367,6 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.previewTable tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.previewEmptyNote {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -24,7 +24,9 @@ vi.mock('../services/chart-renderer', () => {
|
||||
}
|
||||
}
|
||||
return {
|
||||
renderSpec: vi.fn(() => Promise.resolve({ destroy() {}, resize() {} })),
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
|
||||
),
|
||||
ChartTooLargeError,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* `LivePreview`, which is bound to the snippet editor's stores.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import {
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
type TimeUnit,
|
||||
} from '@core/chart-builder';
|
||||
import { referencedFields, validateExpression } from '@core/expr-validate';
|
||||
import { cellText, tabularRows } from '@core/dataset';
|
||||
import { tabularRows } from '@core/dataset';
|
||||
import type { ColumnType } from '@core/type-inference';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
@@ -77,6 +77,8 @@ import {
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { Button } from './Button';
|
||||
import { ColorField } from './ColorField';
|
||||
import { DataInspectorPanel } from './DataInspector';
|
||||
import { DataTable } from './DataTable';
|
||||
import { IconButton } from './IconButton';
|
||||
import { SelectControl } from './SelectControl';
|
||||
import { Icon } from './Icon';
|
||||
@@ -959,43 +961,21 @@ function DataPreview() {
|
||||
|
||||
{open &&
|
||||
(rows ? (
|
||||
<div
|
||||
className={styles.previewTableWrap}
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label="Data preview"
|
||||
>
|
||||
<table className={styles.previewTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
{baseColumns.columns.map((col) => (
|
||||
<th key={col} scope="col">
|
||||
<span className={styles.previewColName}>{col}</span>{' '}
|
||||
<span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{baseColumns.columns.map((col) => (
|
||||
<td key={col}>{cellText(row[col])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={baseColumns.columns}
|
||||
rows={rows}
|
||||
total={dataset.rowCount ?? undefined}
|
||||
ariaLabel="Data preview"
|
||||
renderHeader={(col) => (
|
||||
<>
|
||||
<span className={styles.previewColName}>{col}</span>{' '}
|
||||
<span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.previewEmptyNote}>This dataset has no tabular rows to preview.</p>
|
||||
))}
|
||||
|
||||
{open && rows && dataset.rowCount != null && dataset.rowCount > rows.length && (
|
||||
<p className={styles.previewEmptyNote}>
|
||||
Showing the first {rows.length} of {dataset.rowCount.toLocaleString()} rows.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1083,6 +1063,12 @@ function BuilderPreview() {
|
||||
// Set when the chart resolves larger than the canvas backend can draw — a
|
||||
// physical render-size limit, distinct from the readability cardinality warnings.
|
||||
const [tooLarge, setTooLarge] = useState<{ heightPx: number; limitPx: number } | null>(null);
|
||||
// Resolved-data disclosure: open state (modal-local, not persisted) + an epoch
|
||||
// bumped on each settled render so the open table re-reads the post-transform rows
|
||||
// the builder's filters/calculated fields produce (the output, beside the source
|
||||
// rows in the config pane's preview — see DataInspector).
|
||||
const [dataOpen, setDataOpen] = useState(false);
|
||||
const [renderEpoch, setRenderEpoch] = useState(0);
|
||||
|
||||
const specText = useChartBuilderStore(selectBuilderSpecText);
|
||||
const valid = useChartBuilderStore(selectBuilderValid);
|
||||
@@ -1104,6 +1090,7 @@ function BuilderPreview() {
|
||||
handleRef.current = null;
|
||||
setError(null);
|
||||
setTooLarge(null);
|
||||
setRenderEpoch((e) => e + 1);
|
||||
return;
|
||||
}
|
||||
if (!node) return;
|
||||
@@ -1134,6 +1121,7 @@ function BuilderPreview() {
|
||||
handleRef.current = handle;
|
||||
setError(null);
|
||||
setTooLarge(null);
|
||||
setRenderEpoch((e) => e + 1);
|
||||
const t4 = performance.now();
|
||||
// The browser lays out/paints the (possibly huge) SVG after embed resolves;
|
||||
// a double rAF lands just after that paint, capturing the freeze the user
|
||||
@@ -1154,6 +1142,7 @@ function BuilderPreview() {
|
||||
);
|
||||
} catch (e) {
|
||||
if (mine !== generationRef.current) return;
|
||||
setRenderEpoch((epoch) => epoch + 1);
|
||||
if (e instanceof ChartTooLargeError) {
|
||||
// A physical render-size limit (canvas max dimension), not a data error.
|
||||
setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx });
|
||||
@@ -1188,6 +1177,11 @@ function BuilderPreview() {
|
||||
[],
|
||||
);
|
||||
|
||||
// The input + resolved rows the chart drew (the latter after the builder's
|
||||
// filters/calculated fields). Reads the live view through the handle; null when
|
||||
// no chart is up.
|
||||
const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);
|
||||
|
||||
return (
|
||||
<div className={styles.previewPane}>
|
||||
{!valid && (
|
||||
@@ -1215,6 +1209,17 @@ function BuilderPreview() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Data inspector — input vs. resolved rows the chart drew (the latter after
|
||||
the builder's transforms). Only with a live chart, so it never duplicates
|
||||
the "map a channel" / error hints above. */}
|
||||
{valid && tooLarge === null && error === null && (
|
||||
<DataInspectorPanel
|
||||
open={dataOpen}
|
||||
onToggle={setDataOpen}
|
||||
getData={getInspectData}
|
||||
renderEpoch={renderEpoch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@ import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
|
||||
Promise.resolve({
|
||||
destroy() {},
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/* Data inspector — the resolved-data disclosure below the chart (spec §04).
|
||||
Mirrors the builder's source-preview styling (tokens only) so the two read as
|
||||
one family. In the live preview it is the preview pane's last child, set off
|
||||
from the chart body by a top border. */
|
||||
|
||||
.inspector {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-top: var(--border-width) solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Resizable-height mode (live preview): the inspector is given an explicit height
|
||||
by the pane's divider; the DataTable's own fill mode makes its rows fill the
|
||||
space left under the bar. */
|
||||
.inspector.fill {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The header row: disclosure toggle, the Input|Resolved switch, the row count. */
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.caret {
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.stateNote {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Data inspector — input vs. resolved views (spec §04).
|
||||
*
|
||||
* `DataInspectorPanel` is prop-driven (rows come via `getData`, not the live
|
||||
* view), so these cover the behaviour without the render pipeline: the lazy read,
|
||||
* the Input | Resolved switch, the per-view empty/guidance states, truncation, the
|
||||
* re-read on `renderEpoch`, and the toggle. `DataInspector` is the thin
|
||||
* AppStore-bound wrapper.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { InspectedData } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { DataInspector, DataInspectorPanel } from './DataInspector';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const toggle = () => container.querySelector<HTMLButtonElement>('button[aria-expanded]')!;
|
||||
const text = () => container.textContent ?? '';
|
||||
const viewButton = (label: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
|
||||
(b) => b.textContent === label,
|
||||
)!;
|
||||
|
||||
const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) =>
|
||||
act(() => {
|
||||
root.render(
|
||||
<DataInspectorPanel
|
||||
open
|
||||
onToggle={() => {}}
|
||||
getData={() => null}
|
||||
renderEpoch={0}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
describe('DataInspectorPanel', () => {
|
||||
test('collapsed by default: no table, no view switch, and getData is not read (lazy)', () => {
|
||||
const getData = vi.fn(() => null);
|
||||
render({ open: false, getData });
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false');
|
||||
expect(container.querySelector('table')).toBeNull();
|
||||
expect(container.querySelector('[role="radiogroup"]')).toBeNull();
|
||||
expect(getData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('open with no live chart: guidance to render one', () => {
|
||||
render({ getData: () => null });
|
||||
expect(text()).toContain('Render a chart');
|
||||
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 });
|
||||
// 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 });
|
||||
act(() => viewButton('Input').click());
|
||||
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
|
||||
expect(headers).toEqual(['region', 'sales']);
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
|
||||
expect(text()).toContain('2 rows');
|
||||
});
|
||||
|
||||
test('resolved empty: names the empty-transform signal', () => {
|
||||
render({ getData: () => ({ input: [{ a: 1 }], resolved: [] }) });
|
||||
expect(text()).toContain('left nothing to draw');
|
||||
});
|
||||
|
||||
test('input empty: names the empty source', () => {
|
||||
render({ getData: () => ({ input: [], resolved: [] }) });
|
||||
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 }) });
|
||||
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 }] }));
|
||||
render({ getData, renderEpoch: 0 });
|
||||
const before = getData.mock.calls.length;
|
||||
render({ getData, renderEpoch: 1 });
|
||||
expect(getData.mock.calls.length).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
test('applies an explicit height when given (resizable mode)', () => {
|
||||
render({ getData: () => ({ input: [], resolved: [{ a: 1 }] }), heightPx: 240 });
|
||||
const panel = container.firstElementChild as HTMLElement;
|
||||
expect(panel.style.height).toBe('240px');
|
||||
});
|
||||
|
||||
test('toggle reports the next open state', () => {
|
||||
const onToggle = vi.fn();
|
||||
render({ open: false, onToggle });
|
||||
act(() => toggle().click());
|
||||
expect(onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataInspector', () => {
|
||||
test('binds the panel to the persisted AppStore open state', () => {
|
||||
act(() => useAppStore.getState().setDataInspectorOpen(false));
|
||||
act(() => {
|
||||
root.render(<DataInspector getData={() => null} renderEpoch={0} />);
|
||||
});
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
act(() => useAppStore.getState().setDataInspectorOpen(true));
|
||||
expect(toggle().getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
act(() => toggle().click());
|
||||
expect(useAppStore.getState().dataInspectorOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Data inspector — input vs. resolved rows (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).
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { InspectedData } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { DataTable } from './DataTable';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import styles from './DataInspector.module.css';
|
||||
|
||||
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
|
||||
const ROW_LIMIT = 50;
|
||||
|
||||
type DataView = 'input' | 'resolved';
|
||||
|
||||
/** The two views, 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 spec’s transforms' },
|
||||
{
|
||||
value: 'resolved',
|
||||
label: 'Resolved',
|
||||
title: 'Resolved — the rows the chart draws, after its transforms',
|
||||
},
|
||||
];
|
||||
|
||||
interface DataInspectorPanelProps {
|
||||
/** Whether the panel is expanded. */
|
||||
open: boolean;
|
||||
/** Toggle the expanded state. */
|
||||
onToggle: (open: boolean) => void;
|
||||
/**
|
||||
* Reads the input + resolved rows from the live view, or null when there is no
|
||||
* chart to inspect. Must be stable across renders (the read is memoized on
|
||||
* `renderEpoch`).
|
||||
*/
|
||||
getData: () => InspectedData | null;
|
||||
/** Bumps whenever a render settles, so the open table re-reads the new rows. */
|
||||
renderEpoch: number;
|
||||
/**
|
||||
* Explicit panel height (px) — the live-preview pane sets this from its
|
||||
* resizable divider so the table fills the allotted space. Omitted (the builder)
|
||||
* leaves the table at its default capped height.
|
||||
*/
|
||||
heightPx?: number;
|
||||
/** id of the panel root, for a splitter's `aria-controls` to point at. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reusable disclosure: a toggle bar (APG disclosure — `aria-expanded`,
|
||||
* conditional content, matching the builder's source-rows preview) over an
|
||||
* Input | Resolved view switch and the chosen table. Expanded states, each named
|
||||
* (council: GOV.UK / NN/g — say what happened and the next step):
|
||||
* - no live chart → guidance to render one;
|
||||
* - the chosen view's table is empty → say which side and why (the resolved side's
|
||||
* transforms produced nothing; the input side's source is empty);
|
||||
* - rows → the grid.
|
||||
*/
|
||||
export function DataInspectorPanel({
|
||||
open,
|
||||
onToggle,
|
||||
getData,
|
||||
renderEpoch,
|
||||
heightPx,
|
||||
id,
|
||||
}: DataInspectorPanelProps) {
|
||||
const [view, setView] = useState<DataView>('resolved');
|
||||
|
||||
// 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.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]);
|
||||
const rows = data === null ? null : data[view];
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={`${styles.inspector} ${heightPx !== undefined ? styles.fill : ''}`}
|
||||
style={heightPx !== undefined ? { height: heightPx } : undefined}
|
||||
>
|
||||
<div className={styles.bar}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
aria-expanded={open}
|
||||
onClick={() => onToggle(!open)}
|
||||
>
|
||||
<span className={styles.caret} aria-hidden="true">
|
||||
{open ? '▾' : '▸'}
|
||||
</span>
|
||||
Data
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<SegmentedControl
|
||||
label="Data view"
|
||||
options={VIEW_OPTIONS}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
/>
|
||||
{rows !== null && rows.length > 0 && (
|
||||
<span className={styles.meta}>
|
||||
{rows.length.toLocaleString()} {rows.length === 1 ? 'row' : 'rows'}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open &&
|
||||
(rows === null ? (
|
||||
<p className={styles.stateNote}>Render a chart to inspect its data.</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className={styles.stateNote}>
|
||||
{view === 'resolved'
|
||||
? 'No rows — the spec’s filters or transforms left nothing to draw.'
|
||||
: 'The source data has no rows.'}
|
||||
</p>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={Object.keys(rows[0])}
|
||||
rows={rows.slice(0, ROW_LIMIT)}
|
||||
total={rows.length}
|
||||
ariaLabel="Data rows"
|
||||
fill={heightPx !== undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataInspectorProps {
|
||||
/** Reads the input + resolved rows from the live preview's view (stable). */
|
||||
getData: () => InspectedData | null;
|
||||
/** Bumps on each settled render so the open table refreshes. */
|
||||
renderEpoch: number;
|
||||
/** Panel height (px) from the preview pane's resizable divider (only when open). */
|
||||
heightPx?: number;
|
||||
/** id of the panel root, for the divider's `aria-controls`. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** The live-preview data inspector: the panel bound to the persisted open state. */
|
||||
export function DataInspector({ getData, renderEpoch, heightPx, id }: DataInspectorProps) {
|
||||
const open = useAppStore((s) => s.dataInspectorOpen);
|
||||
const setOpen = useAppStore((s) => s.setDataInspectorOpen);
|
||||
return (
|
||||
<DataInspectorPanel
|
||||
open={open}
|
||||
onToggle={setOpen}
|
||||
getData={getData}
|
||||
renderEpoch={renderEpoch}
|
||||
heightPx={heightPx}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Shared read-only data table — the data inspector and the Chart Builder's
|
||||
source-row preview both render through this, so the look stays in one place.
|
||||
Tokens only. */
|
||||
|
||||
.wrap {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.wrap:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Resizable-height mode (the live-preview inspector): fill the allotted space
|
||||
instead of the default cap, so the divider grows the visible table. */
|
||||
.wrapFill {
|
||||
flex: 1 1 auto;
|
||||
max-height: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
text-align: left;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--layer-01);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* DataTable — a read-only, scrollable table of rows, the shared shape behind the
|
||||
* data inspector (input/resolved rows) and the Chart Builder's source-row preview.
|
||||
*
|
||||
* Columns and any per-column header adornment are the caller's: the builder passes
|
||||
* its declared column list with a type chip in each header; the inspector passes the
|
||||
* row keys with plain names. This owns the table structure, the "first N of M" note,
|
||||
* and the styling, so the two surfaces never drift. Capping is the caller's policy —
|
||||
* it passes the rows to show plus the true `total`.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { cellText } from '@core/dataset';
|
||||
import styles from './DataTable.module.css';
|
||||
|
||||
interface DataTableProps {
|
||||
/** Column names, in display order. Cells read each row by these keys. */
|
||||
columns: readonly string[];
|
||||
/** The rows to render — already limited to what should be shown. */
|
||||
rows: readonly Record<string, unknown>[];
|
||||
/** The true row count; when it exceeds `rows`, a "first N of M" note is shown. */
|
||||
total?: number;
|
||||
/** Custom header content per column (e.g. a type chip); defaults to the name. */
|
||||
renderHeader?: (column: string) => ReactNode;
|
||||
/** Accessible name for the scroll region. */
|
||||
ariaLabel: string;
|
||||
/** Fill the available height instead of the default capped height (resizable panes). */
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable({ columns, rows, total, renderHeader, ariaLabel, fill }: DataTableProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.wrap} ${fill ? styles.wrapFill : ''}`}
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col} scope="col">
|
||||
{renderHeader ? renderHeader(col) : col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{columns.map((col) => (
|
||||
<td key={col}>{cellText(row[col])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{total != null && total > rows.length && (
|
||||
<p className={styles.note}>
|
||||
Showing the first {rows.length} of {total.toLocaleString()} rows.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Horizontal splitter between the chart and the data inspector. The row-axis
|
||||
* mirror of ResizeHandle: a 6px-tall full-width hit target with a thin centered
|
||||
* grip, tracking the design tokens so it reads identically to the pane handles.
|
||||
*/
|
||||
.handle {
|
||||
flex: 0 0 6px;
|
||||
position: relative;
|
||||
cursor: row-resize;
|
||||
background: var(--border);
|
||||
touch-action: none; /* let pointer drags own the gesture, not scroll */
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.handle:hover,
|
||||
.handle:focus-visible {
|
||||
background: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* A short centered grip line (horizontal), so the handle reads as a draggable divider. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.handle:hover .grip,
|
||||
.handle:focus-visible .grip {
|
||||
background: var(--accent-contrast);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Horizontal drag handle between the live-preview chart and the data inspector
|
||||
* below it (spec §04). The vertical-stacking twin of the pane ResizeHandle:
|
||||
* dragging resizes the inspector's height while the chart above absorbs the
|
||||
* change, each kept above its minimum by the pure `clampInspectorHeight`.
|
||||
*
|
||||
* The span is read from the live DOM — the chart (previous sibling) and inspector
|
||||
* (next sibling) — so the divider tracks the pointer 1:1 at any pane height, the
|
||||
* same technique as PaneSplitHandle.
|
||||
*
|
||||
* Accessibility follows WAI-ARIA APG → Window Splitter (docs/architecture/10 §5),
|
||||
* the same contract as the pane handles with the axis flipped: a focusable
|
||||
* `separator` with `aria-orientation="horizontal"` reporting the inspector's 0–100
|
||||
* position via `aria-valuenow`, driven by ↑/↓ to nudge and Home/End to jump to
|
||||
* min/max. Rendered only while the inspector is open (there's nothing to resize
|
||||
* when it's collapsed).
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useResizeDrag } from '../hooks/useResizeDrag';
|
||||
import { clampInspectorHeight, inspectorHeightValue, useAppStore } from '../stores/AppStore';
|
||||
import styles from './InspectorSplitHandle.module.css';
|
||||
|
||||
/** Keyboard nudge step (px) per arrow press — matches ResizeHandle. */
|
||||
const KEY_STEP = 16;
|
||||
|
||||
interface InspectorSplitHandleProps {
|
||||
/** Accessible label, e.g. "Resize data inspector". */
|
||||
label: string;
|
||||
/** id of the inspector region this divider sizes (APG aria-controls). */
|
||||
controls: string;
|
||||
}
|
||||
|
||||
export function InspectorSplitHandle({ label, controls }: InspectorSplitHandleProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const height = useAppStore((s) => s.dataInspectorHeight);
|
||||
|
||||
/** The two regions this handle sits between: chart above, inspector below. */
|
||||
const flanks = () => ({
|
||||
chart: ref.current?.previousElementSibling as HTMLElement | null,
|
||||
inspector: ref.current?.nextElementSibling as HTMLElement | null,
|
||||
});
|
||||
|
||||
// Observe the flanking regions so aria-valuenow stays correct across window and
|
||||
// pane resizes, not only divider drags.
|
||||
const [availForBoth, setAvailForBoth] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
const { chart, inspector } = flanks();
|
||||
if (!chart || !inspector) return;
|
||||
const measure = () => setAvailForBoth(chart.clientHeight + inspector.clientHeight);
|
||||
measure();
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(chart);
|
||||
ro.observe(inspector);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
/** Apply a desired inspector height, clamped against the live span. */
|
||||
const applyHeight = (desired: number) => {
|
||||
const { chart, inspector } = flanks();
|
||||
if (!chart || !inspector) return;
|
||||
const span = chart.clientHeight + inspector.clientHeight;
|
||||
useAppStore.getState().setDataInspectorHeight(clampInspectorHeight(desired, span));
|
||||
};
|
||||
|
||||
// Drag up (negative delta) grows the inspector into the chart's space.
|
||||
const onPointerDown = useResizeDrag(
|
||||
'y',
|
||||
() => flanks().inspector?.clientHeight ?? height,
|
||||
(startHeight, delta) => applyHeight(startHeight - delta),
|
||||
);
|
||||
|
||||
// Keyboard per WAI-ARIA APG → Window Splitter: ↑ grows the inspector, ↓ shrinks;
|
||||
// Home/End jump to its smallest/largest allowed height.
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const current = flanks().inspector?.clientHeight ?? height;
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
applyHeight(current + KEY_STEP);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
applyHeight(current - KEY_STEP);
|
||||
break;
|
||||
case 'Home': // inspector at its minimum
|
||||
applyHeight(0);
|
||||
break;
|
||||
case 'End': // inspector at its maximum (chart at its minimum)
|
||||
applyHeight(Number.MAX_SAFE_INTEGER);
|
||||
break;
|
||||
default:
|
||||
return; // not ours — let it bubble
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const valueNow = inspectorHeightValue(height, availForBoth);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.handle}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={label}
|
||||
aria-controls={controls}
|
||||
aria-valuenow={valueNow ?? undefined}
|
||||
aria-valuemin={valueNow === null ? undefined : 0}
|
||||
aria-valuemax={valueNow === null ? undefined : 100}
|
||||
aria-valuetext={valueNow === null ? undefined : `${valueNow}%`}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<span className={styles.grip} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,10 @@ 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) => {
|
||||
@@ -44,6 +48,7 @@ vi.mock('../services/chart-renderer', () => ({
|
||||
H.destroyed.push(id);
|
||||
},
|
||||
resize() {},
|
||||
inspectData: () => H.inspected,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,8 @@ import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { ChartExport } from './ChartExport';
|
||||
import { DataInspector } from './DataInspector';
|
||||
import { InspectorSplitHandle } from './InspectorSplitHandle';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
||||
@@ -176,6 +178,9 @@ export function LivePreview() {
|
||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
const chartTheme = useAppStore((s) => s.chartTheme);
|
||||
// Data inspector: open-state gates the divider + table; its height is the divider's.
|
||||
const inspectorOpen = useAppStore((s) => s.dataInspectorOpen);
|
||||
const inspectorHeight = useAppStore((s) => s.dataInspectorHeight);
|
||||
// Custom themes feed `custom:<id>` selection resolution; re-rendering on a
|
||||
// change keeps the chart live while a selected theme is edited in the builder.
|
||||
const customThemes = useCustomThemeStore((s) => s.themes);
|
||||
@@ -201,6 +206,11 @@ export function LivePreview() {
|
||||
// a ref change alone wouldn't re-render. Set true on a successful render, false
|
||||
// on clear/error/unmount.
|
||||
const [chartReady, setChartReady] = useState(false);
|
||||
// Bumped whenever a render settles and changes the live view (success, clear,
|
||||
// error) so the data inspector re-reads the resolved rows. A monotonic counter,
|
||||
// not `chartReady` — consecutive successful renders keep `chartReady` true, but
|
||||
// each one is new data the inspector must pick up.
|
||||
const [renderEpoch, setRenderEpoch] = useState(0);
|
||||
|
||||
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
||||
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
||||
@@ -284,6 +294,7 @@ export function LivePreview() {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setChartReady(false);
|
||||
setRenderEpoch((e) => e + 1);
|
||||
setError(null);
|
||||
clearBusy();
|
||||
return;
|
||||
@@ -306,11 +317,13 @@ export function LivePreview() {
|
||||
handleRef.current = handle;
|
||||
configRef.current = config;
|
||||
setChartReady(true);
|
||||
setRenderEpoch((e) => e + 1);
|
||||
setError(null);
|
||||
clearBusy();
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) {
|
||||
setChartReady(false);
|
||||
setRenderEpoch((e) => e + 1);
|
||||
clearBusy();
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
@@ -379,6 +392,11 @@ export function LivePreview() {
|
||||
[],
|
||||
);
|
||||
|
||||
// The input + resolved rows for the data inspector — reads the live view through
|
||||
// the handle (null when no chart is up). Stable identity; the inspector re-reads
|
||||
// on `renderEpoch`, so this need not depend on it (it always reads the latest handle).
|
||||
const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);
|
||||
|
||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||
// observe the element, so we do: one observer on the stable host node for the
|
||||
// component's life. Only responsive fit modes depend on container size;
|
||||
@@ -450,6 +468,19 @@ export function LivePreview() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Data inspector — input vs. resolved rows, stacked below the chart (a
|
||||
collapsed disclosure by default); reads the live view through getInspectData.
|
||||
When open, a draggable divider sizes it (chart absorbs the change) and the
|
||||
stored height fills the table; collapsed, it's just the disclosure bar. */}
|
||||
{inspectorOpen && (
|
||||
<InspectorSplitHandle label="Resize data inspector" controls="preview-data-inspector" />
|
||||
)}
|
||||
<DataInspector
|
||||
id="preview-data-inspector"
|
||||
getData={getInspectData}
|
||||
renderEpoch={renderEpoch}
|
||||
heightPx={inspectorOpen ? inspectorHeight : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Onboarding } from './Onboarding';
|
||||
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
|
||||
// finalizes it on unmount.
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
|
||||
renderSpec: () => Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { useColResizeDrag } from '../hooks/useColResizeDrag';
|
||||
import { useResizeDrag } from '../hooks/useResizeDrag';
|
||||
import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore';
|
||||
import styles from './ResizeHandle.module.css';
|
||||
|
||||
@@ -57,7 +57,8 @@ export function PaneSplitHandle({ label }: PaneSplitHandleProps) {
|
||||
usePanesStore.getState().setSplit(library, avail - library);
|
||||
};
|
||||
|
||||
const onPointerDown = useColResizeDrag(
|
||||
const onPointerDown = useResizeDrag(
|
||||
'x',
|
||||
() => flanks().lib?.clientWidth ?? 0,
|
||||
(startLib, delta) => applySplit(startLib + delta),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useColResizeDrag } from '../hooks/useColResizeDrag';
|
||||
import { useResizeDrag } from '../hooks/useResizeDrag';
|
||||
import { clampSideWidth, sideWidthValue, usePanesStore, type PaneSide } from '../stores/PanesStore';
|
||||
import styles from './ResizeHandle.module.css';
|
||||
|
||||
@@ -66,7 +66,8 @@ export function ResizeHandle({ side, label }: ResizeHandleProps) {
|
||||
|
||||
// The left handle grows its pane as it moves right; the right handle (left
|
||||
// of the preview) shrinks the preview as it moves right.
|
||||
const onPointerDown = useColResizeDrag(
|
||||
const onPointerDown = useResizeDrag(
|
||||
'x',
|
||||
() =>
|
||||
side === 'library'
|
||||
? usePanesStore.getState().libraryWidth
|
||||
|
||||
@@ -16,7 +16,12 @@ import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||
|
||||
vi.mock('../services/chart-renderer', () => ({ renderSpec: vi.fn() }));
|
||||
|
||||
const okHandle = () => ({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') });
|
||||
const okHandle = () => ({
|
||||
destroy() {},
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
});
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
||||
@@ -17,7 +17,12 @@ import { ThemeBuilderModal } from './ThemeBuilderModal';
|
||||
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
|
||||
Promise.resolve({
|
||||
destroy() {},
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user