mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: Vega-Lite expression intelligence; single-home render errors
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* LivePreview — busy overlay guard (spec §04; arch §10.2).
|
||||
* LivePreview — busy overlay + render serialization (spec §04; arch §10.2).
|
||||
*
|
||||
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco); the
|
||||
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests
|
||||
* set that flag directly and assert the DOM result — no timing, no mocking of the
|
||||
* async render path.
|
||||
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so
|
||||
* `renderSpec` is mocked to park each embed in `H.pending` — a test decides when
|
||||
* embeds settle. Render status (`error`/`busy`) is the pane's own local state, so
|
||||
* the busy overlay is driven through its real path — a render left in flight past
|
||||
* the ~1s timer — not by poking a flag.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
@@ -12,7 +13,6 @@ import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { chartConfigForSelection } from '@core/vega-themes';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { LivePreview } from './LivePreview';
|
||||
@@ -71,7 +71,6 @@ beforeEach(() => {
|
||||
H.pending.length = 0;
|
||||
H.destroyed.length = 0;
|
||||
H.configs.length = 0;
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
useSnippetStore.getState().reset();
|
||||
useDatasetStore.getState().reset();
|
||||
|
||||
@@ -84,11 +83,12 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('LivePreview busy overlay', () => {
|
||||
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
|
||||
|
||||
// The overlay is the aria-hidden element carrying the "Rendering…" label — a
|
||||
// bare [aria-hidden] query would also match decorative bits of the header
|
||||
// controls (e.g. the chart-theme select's caret).
|
||||
@@ -97,33 +97,44 @@ describe('LivePreview busy overlay', () => {
|
||||
/rendering/i.test(el.textContent ?? ''),
|
||||
) ?? null;
|
||||
|
||||
test('does not render the busy overlay when busy=false', () => {
|
||||
// The overlay element should not be in the DOM at all during normal operation.
|
||||
// Start a render and leave it parked in `H.pending`; advancing past the debounce
|
||||
// and the ~1s busy timer flips `busy` on — the real (and only) path now that it
|
||||
// is local state.
|
||||
const startSlowRender = async () => {
|
||||
act(() => {
|
||||
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
|
||||
});
|
||||
await tick();
|
||||
};
|
||||
|
||||
test('no overlay and no aria-busy before a render is in flight', () => {
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
test('renders the busy overlay when PreviewStore.busy=true', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
expect(overlay()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('the preview body carries aria-busy=true when busy', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
// The body element has aria-busy when the store says busy.
|
||||
const busyEl = container.querySelector('[aria-busy="true"]');
|
||||
expect(busyEl).not.toBeNull();
|
||||
});
|
||||
|
||||
test('aria-busy is absent when busy=false (no aria-busy="false" noise)', () => {
|
||||
// aria-busy="false" is technically valid but needlessly verbose; we omit it.
|
||||
expect(container.querySelector('[aria-busy]')).toBeNull();
|
||||
});
|
||||
|
||||
test('overlay disappears when busy returns to false', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
expect(overlay()).not.toBeNull();
|
||||
act(() => usePreviewStore.setState({ busy: false }));
|
||||
expect(overlay()).toBeNull();
|
||||
test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await startSlowRender();
|
||||
expect(overlay()).not.toBeNull();
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('the overlay clears once the render settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await startSlowRender();
|
||||
expect(overlay()).not.toBeNull();
|
||||
act(() => H.pending[0]()); // the parked embed resolves → busy cleared on settle
|
||||
await tick(0);
|
||||
expect(overlay()).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
* mode applied) → renderSpec (vega-embed). A render-generation token guards
|
||||
* against a slow render resolving after a newer one.
|
||||
*
|
||||
* The pane header carries the Fit control (4 sizing modes, §04). Render errors
|
||||
* are published to the shared PreviewStore so the editor pane mirrors them
|
||||
* (§03E); the preview shows the same message in place of the chart.
|
||||
* The pane header carries the Fit control (4 sizing modes, §04). A render or
|
||||
* parse error shows here in place of the chart (error xor chart) — its single
|
||||
* message home (arch 10 §1); the editor pinpoints the cause with a squiggle
|
||||
* (§03E) rather than repeating the text.
|
||||
*
|
||||
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
|
||||
* (M3) plugs into prepareSpecForRender without changing this component.
|
||||
@@ -22,6 +23,7 @@ import type { Config } from 'vega-lite';
|
||||
import { referencedUploadedFonts } from '@core/chart-export';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
import { firstExpressionError } from '@core/spec-expressions';
|
||||
import {
|
||||
chartConfigForSelection,
|
||||
chartThemeOptions,
|
||||
@@ -33,7 +35,6 @@ import { useAppStore } from '../stores/AppStore';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { ChartExport } from './ChartExport';
|
||||
@@ -198,10 +199,11 @@ export function LivePreview() {
|
||||
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
|
||||
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
|
||||
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
const busy = usePreviewStore((s) => s.busy);
|
||||
const setBusy = usePreviewStore((s) => s.setBusy);
|
||||
// Render status is local to this pane — it is both the only producer and the
|
||||
// only consumer, so it needs no store (arch 01). `error` is the render/parse
|
||||
// message (null = clean or blank); `busy` gates the >1s render overlay.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Mirrors whether `handleRef` currently holds a live view, so the per-chart
|
||||
// export's image actions (which need the view) can enable/disable reactively —
|
||||
// a ref change alone wouldn't re-render. Set true on a successful render, false
|
||||
@@ -253,7 +255,7 @@ export function LivePreview() {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) setError(`Invalid JSON: ${(e as Error).message}`);
|
||||
if (mine === generationRef.current) setError(`Invalid JSON · ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -333,19 +335,25 @@ export function LivePreview() {
|
||||
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:
|
||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||
// All render-failure messages share a line-led / noun-led terse shape
|
||||
// (`<location|noun> · <detail>`, arch 10 §1). A missing dataset and a
|
||||
// malformed expression are attributed precisely — naming the fixable cause
|
||||
// beats the generic "check your JSON" hint, which is wrong when the JSON is
|
||||
// valid (council: GOV.UK error-message "be specific" + name the real fix).
|
||||
if (e instanceof DatasetNotFoundError) {
|
||||
setError(
|
||||
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
|
||||
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
|
||||
`Dataset "${e.datasetName}" not found · create it from Datasets (⌘/Ctrl+K)`,
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
// A malformed Vega expression is located by line (the editor also
|
||||
// squiggles it) and carries the same parser message the hover shows.
|
||||
// Scanned over the untrimmed buffer so the line matches the editor's.
|
||||
const exprError = firstExpressionError(shownText);
|
||||
if (exprError) {
|
||||
setError(`Line ${exprError.line} · ${exprError.message.replace(/\.$/, '')}`);
|
||||
} else {
|
||||
setError(`Render error · ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,16 +442,14 @@ export function LivePreview() {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Finalize the live view on unmount, and clear the shared error + busy state so
|
||||
// stale transient state never outlives this pane.
|
||||
// Finalize the live view on unmount so its timers/listeners don't outlive the
|
||||
// pane. Render status is local state and dies with the component.
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setChartReady(false);
|
||||
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||
usePreviewStore.getState().setError(null);
|
||||
usePreviewStore.getState().setBusy(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -474,10 +480,15 @@ export function LivePreview() {
|
||||
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
|
||||
<div className={styles.host} ref={hostRef} />
|
||||
</div>
|
||||
{/* Visual only — no live region. The same error is announced once by the
|
||||
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
|
||||
so adding one here would double-announce it. */}
|
||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||
{/* The single home for a render/parse error (arch 10 §1): it sits where the
|
||||
chart would be — error XOR chart — and is the lone live region, assertive
|
||||
since the user just caused it. The editor pinpoints the spot via its squiggle,
|
||||
so the message lives here, not duplicated under the editor. */}
|
||||
{error !== null && (
|
||||
<pre className={styles.error} role="alert">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
{/*
|
||||
* Busy overlay: non-blocking, overlays only the chart body, never the header
|
||||
* or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
|
||||
|
||||
@@ -79,20 +79,3 @@
|
||||
background: var(--bg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Inline render/parse error surface (spec §03E) — monospaced, distinct. */
|
||||
.error {
|
||||
flex: 0 0 auto;
|
||||
max-height: 30%;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: var(--border-width) solid var(--support-error);
|
||||
background: var(--layer-01);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--support-error);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
*
|
||||
* The pane header carries the Draft/Published toggle plus Publish and Revert
|
||||
* (spec §03D). The published view is read-only — it shows the last published
|
||||
* spec for reference; all editing happens on the draft. Render problems surface
|
||||
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
|
||||
* spec for reference; all editing happens on the draft. Render/parse problems
|
||||
* surface in the preview pane (arch 10 §1); the editor marks the spot with an
|
||||
* inline squiggle (spec §03E).
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, type RefObject } from 'react';
|
||||
@@ -45,6 +46,10 @@ import {
|
||||
runWrapViews,
|
||||
} from '../services/spec-transform-actions';
|
||||
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
|
||||
import {
|
||||
configureSpecExpressionHints,
|
||||
installExpressionMarkers,
|
||||
} from '../services/spec-expression-hints';
|
||||
import { runExtract } from '../services/extract-action';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
@@ -52,7 +57,6 @@ import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { hasExtractableData } from '../stores/ExtractStore';
|
||||
import { publishActiveSnippet } from '../services/snippet-actions';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { Icon } from './Icon';
|
||||
@@ -167,6 +171,8 @@ configureJsonFormatter();
|
||||
configureSpecTransformCodeActions();
|
||||
// Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08).
|
||||
configureSpecDatasetHints();
|
||||
// Register the expression completion/signature-help/hover providers once (docs/architecture/08).
|
||||
configureSpecExpressionHints();
|
||||
|
||||
/** The two spec↔config operations, surfaced as an overflow menu (council:
|
||||
* Carbon menu-buttons — overflow for additional options under space
|
||||
@@ -390,7 +396,6 @@ export function SpecEditor() {
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
const revealTarget = useAppStore((s) => s.revealTarget);
|
||||
const composeRequest = useAppStore((s) => s.composeRequest);
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
// Editor preferences (spec §07 → Editor); applied live below as they change.
|
||||
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
|
||||
|
||||
@@ -450,6 +455,11 @@ export function SpecEditor() {
|
||||
// editor, because its commands need this editor's handle to apply the edit.
|
||||
const codeLensSub = installSpecTransformCodeLens(editor);
|
||||
|
||||
// Validate Vega expressions in the draft and squiggle the invalid ones — per
|
||||
// editor, because it writes markers to this model (docs/architecture/08).
|
||||
// Debounced internally; recomputes on edit and on a draft↔published toggle.
|
||||
const exprMarkersSub = installExpressionMarkers(editor);
|
||||
|
||||
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
||||
// "bind listeners in exactly one place"), which publishes before the
|
||||
// interactive-context gate so it works while the editor has focus. Monaco
|
||||
@@ -461,6 +471,7 @@ export function SpecEditor() {
|
||||
configActionsSub.dispose();
|
||||
transformActionsSub.dispose();
|
||||
codeLensSub.dispose();
|
||||
exprMarkersSub.dispose();
|
||||
editor.dispose();
|
||||
editorRef.current = null;
|
||||
};
|
||||
@@ -551,14 +562,8 @@ export function SpecEditor() {
|
||||
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
||||
<div className={styles.editor} ref={hostRef} />
|
||||
</div>
|
||||
{/* The single live region for render/parse errors: assertive, since the
|
||||
user just caused it. The preview shows the same text visually but is
|
||||
not a live region, so the message is announced once (doc §10.1). */}
|
||||
{error !== null && (
|
||||
<pre className={styles.error} role="alert">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
{/* Render/parse errors surface in the preview pane (arch 10 §1), where the
|
||||
chart would be; the editor pinpoints the spot with its squiggle. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user