Chart Builder: open a snippet in the builder to edit it in place

This commit is contained in:
2026-06-20 12:36:37 +03:00
parent efb5a9bbe0
commit be8cfc402d
16 changed files with 1046 additions and 14 deletions
@@ -58,6 +58,19 @@
gap: var(--space-3);
}
/* Edit-in-place banner (spec §06 → Open in builder): names the snippet the builder
is editing, so the primary "Save changes" action reads unambiguously. */
.editingBanner {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.editingBanner strong {
color: var(--text-primary);
font-weight: 600;
}
/* ── Intent front door (spec §06 → Intent) ───────────────────────────────
A persistent "what do you want to show?" strip under the dataset picker. A
quiet accent-soft wash marks it as the guided on-ramp without competing with
+15 -4
View File
@@ -1466,6 +1466,12 @@ export function ChartBuilderModal() {
const setStack = useChartBuilderStore((s) => s.setStack);
const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
const runCreate = useChartBuilderStore((s) => s.createSnippet);
const runSave = useChartBuilderStore((s) => s.saveEdits);
// Edit-in-place (spec §06 → Open in builder): when a snippet was opened in the
// builder, the primary action *saves back* to it instead of creating a new snippet.
const editingSnippetId = useChartBuilderStore((s) => s.editingSnippetId);
const editingSnippetName = useChartBuilderStore((s) => s.editingSnippetName);
const editing = editingSnippetId !== null;
// Validity + guidance + which chart-level controls apply are derived from the
// stable `config` reference via useMemo, NOT a store selector that would build a
@@ -1526,6 +1532,11 @@ export function ChartBuilderModal() {
<div className="visually-hidden" role="status" aria-live="polite">
{fixAnnouncement}
</div>
{editing && (
<p className={styles.editingBanner}>
Editing <strong>{editingSnippetName}</strong>
</p>
)}
<DatasetPicker datasetId={datasetId} />
<IntentStrip />
@@ -1610,7 +1621,7 @@ export function ChartBuilderModal() {
{!valid && (
<p id="cb-create-hint" className={styles.createHint}>
Map at least one channel to a column to create a snippet.
Map at least one channel to a column to {editing ? 'save changes' : 'create a snippet'}.
</p>
)}
<div className={styles.actions}>
@@ -1622,12 +1633,12 @@ export function ChartBuilderModal() {
size="lg"
disabled={!valid}
aria-describedby={!valid ? 'cb-create-hint' : undefined}
// The create is the user's confirmation — close with no discard prompt.
// The commit is the user's confirmation — close with no discard prompt.
onClick={() => {
if (runCreate()) void closeModal(true);
if (editing ? runSave() : runCreate()) void closeModal(true);
}}
>
Create Snippet
{editing ? 'Save changes' : 'Create Snippet'}
</Button>
</div>
</div>
+36 -2
View File
@@ -13,7 +13,8 @@
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
*/
import { useEffect, useRef, type RefObject } from 'react';
import { useEffect, useMemo, useRef, type RefObject } from 'react';
import { useShallow } from 'zustand/react/shallow';
// `edcore.main` is the full standalone editor — every feature contribution
// (folding, suggest widget, word operations like Cmd+Backspace, find, bracket
// colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's
@@ -24,7 +25,8 @@ import 'monaco-editor/esm/vs/language/json/monaco.contribution';
import '../infrastructure/monaco-env'; // side-effect: wire workers before create
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
import { openModal } from '../modals/ModalCoordinator';
import { parseChartSpecText } from '@core/chart-builder';
import { openChartBuilderForEdit, openModal } from '../modals/ModalCoordinator';
import {
installSpecConfigActions,
runExtractConfig,
@@ -33,6 +35,7 @@ import {
} from '../services/spec-config-actions';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { hasInlineData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore';
@@ -193,6 +196,26 @@ function EditorToolbar({
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText),
);
// Offer "Open in builder" only when the active snippet's published spec is
// losslessly representable in the builder dialect AND its referenced dataset
// exists — the gate the builder's openForEdit enforces (spec §06 → Open in
// builder). Content-gated like Extract, so it is **hidden** when inapplicable
// (the toolbar's convention for content-gated actions — vs Revert/Config, which
// disable because they are merely state-gated; arch 10 §5). `datasetNames` is a
// shallow-stable string[] so the memo doesn't churn (MEMORY → stable selectors).
const activeSpec = useSnippetStore((s) => selectActiveSnippet(s)?.spec ?? null);
const datasetNames = useDatasetStore(useShallow((s) => s.datasets.map((d) => d.name)));
const canOpenInBuilder = useMemo(() => {
if (activeSpec === null) return false;
const config = parseChartSpecText(activeSpec);
return config !== null && datasetNames.includes(config.datasetName);
}, [activeSpec, datasetNames]);
const handleOpenInBuilder = () => {
const snippet = selectActiveSnippet(useSnippetStore.getState());
if (snippet) openChartBuilderForEdit(snippet);
};
// Publish + its success toast live in one place (services/snippet-actions) so
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
const handlePublish = publishActiveSnippet;
@@ -239,6 +262,17 @@ function EditorToolbar({
to two lines and Revert/Publish stay on one row. The label rides in
`title` (and the accessible name) when only the icon shows. Publish — the
one primary action — keeps its label at every width. */}
{canOpenInBuilder && (
<Button
className={styles.collapsible}
onClick={handleOpenInBuilder}
title="Open this chart in the visual builder to edit it"
aria-label="Open in builder"
>
<Icon name="chart" className={styles.actionIcon} />
<span className={styles.actionLabel}>Open in builder</span>
</Button>
)}
{canExtract && (
<Button
className={styles.collapsible}
+25
View File
@@ -9,6 +9,8 @@
*/
import { useAppStore } from '../stores/AppStore';
import { useChartBuilderStore } from '../stores/ChartBuilderStore';
import type { Snippet } from '@core/snippet';
import type { ModalName } from './types';
import { getModalConfig } from './modal-registry';
import { clearModalFromUrl, syncModalToUrl } from './UrlStateSync';
@@ -43,6 +45,29 @@ export function openModal(name: ModalName, arg?: string): void {
syncModalToUrl(name, arg);
}
/**
* Open the Chart Builder to edit an existing snippet in place (spec §06 → Open in
* builder). Hydrates the builder from the snippet (strict spec→config parse + its
* referenced dataset) *before* showing the modal, deliberately skipping the
* registry's default `init` — which would re-derive a fresh config and clobber the
* hydration. A no-op when the snippet isn't losslessly builder-representable or its
* dataset is gone; the caller gates the entry point on the same two conditions, so
* this guard only ever fires on a race. Mirrors `openModal`'s bookkeeping minus init
* (the builder has no `getState`, so there is no editable snapshot to capture).
*/
export function openChartBuilderForEdit(snippet: Snippet): void {
// An edit session is builder-local and intentionally not URL-serializable: the
// hydrated builder serializes to `#datasets/dataset-N/build` (the same hash as a
// *create* flow on that dataset), so a reload / Back / shared link re-runs `init`
// and reopens a fresh create flow. The published spec is untouched until Save
// changes, so nothing is lost — only the transient edit context, matching the
// builder's already-non-persistent configuration (spec §06 → Open in builder).
if (!useChartBuilderStore.getState().openForEdit(snippet)) return;
useAppStore.getState().setActiveModal('chartBuilder');
stateSnapshot = snapshotOf('chartBuilder');
syncModalToUrl('chartBuilder');
}
/**
* Re-baseline the change snapshot to the modal's current state. Call after a
* commit (the form was saved) or when a form view opens, so subsequent
+67
View File
@@ -464,3 +464,70 @@ describe('switchDataset (the builder dataset picker)', () => {
expect(cb().config).toBe(before);
});
});
describe('openForEdit / saveEdits (edit-in-place, spec §06 → Open in builder)', () => {
/** Seed a dataset, build a snippet from it, and return that linked snippet. */
function buildSnippetFrom(name: string, data: unknown) {
const id = seedDataset(name, data);
cb().init(id);
cb().createSnippet(T); // creates a linked, builder-dialect snippet; resets the builder
return { id, snippet: useSnippetStore.getState().snippets[0] };
}
test('hydrates the builder from a builder-made snippet and records the edit target', () => {
const { id, snippet } = buildSnippetFrom('Sales', [
{ region: 'E', sales: 5 },
{ region: 'W', sales: 9 },
]);
expect(cb().openForEdit(snippet)).toBe(true);
expect(cb().editingSnippetId).toBe(snippet.id);
expect(cb().editingSnippetName).toBe(snippet.name);
expect(cb().datasetId).toBe(id);
expect(cb().config.datasetName).toBe('Sales');
// A loaded chart is built-on work: a dataset switch must rebase, not re-derive,
// so config must not be reference-equal to initialConfig (see switchDataset).
expect(cb().config).not.toBe(cb().initialConfig);
});
test('saveEdits republishes into the same snippet and clears edit state', () => {
const { snippet } = buildSnippetFrom('Sales', [
{ region: 'E', sales: 5 },
{ region: 'W', sales: 9 },
]);
const originalSpec = snippet.spec;
cb().openForEdit(snippet);
cb().setMark('point');
expect(cb().saveEdits(new Date('2026-06-02T00:00:00Z'))).toBe(true);
const after = useSnippetStore.getState().snippets.find((s) => s.id === snippet.id)!;
expect(after.spec).not.toBe(originalSpec);
expect(after.spec).toContain('"point"');
expect(after.spec).toBe(after.draftSpec); // republished — no pending draft
expect(useSnippetStore.getState().activeSnippetId).toBe(snippet.id);
// The builder reset out of edit mode.
expect(cb().editingSnippetId).toBeNull();
expect(cb().datasetId).toBeNull();
});
test('openForEdit refuses a spec the builder cannot represent (inline data)', () => {
seedDataset('Sales', [{ region: 'E', sales: 5 }]);
// The default sample template is an inline-data bar chart — no `{ data: { name } }`,
// so it is not builder-representable.
useSnippetStore.getState().createSnippet();
const inline = useSnippetStore.getState().snippets[0];
expect(cb().openForEdit(inline)).toBe(false);
expect(cb().editingSnippetId).toBeNull();
});
test('openForEdit refuses when the referenced dataset is gone', () => {
const { snippet } = buildSnippetFrom('Sales', [{ region: 'E', sales: 5 }]);
useDatasetStore.getState().reset(); // the dataset no longer exists
expect(cb().openForEdit(snippet)).toBe(false);
});
test('saveEdits is a no-op outside an edit session', () => {
expect(cb().saveEdits(T)).toBe(false);
});
});
+81
View File
@@ -29,6 +29,7 @@ import {
isBuilderConfigValid,
isChannelTypeAllowed,
isColumnAllowedOnChannel,
parseChartSpecText,
pruneEncodings,
rebaseBuilderConfig,
supportsBin,
@@ -53,6 +54,7 @@ import {
type TimeUnit,
} from '@core/chart-builder';
import type { ColumnType } from '@core/type-inference';
import type { Snippet } from '@core/snippet';
import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';
@@ -99,12 +101,38 @@ export interface ChartBuilderState {
* first empty channel that accepts it).
*/
activeChannel: ChannelName | null;
/**
* The snippet being **edited in place** (spec §06 → Open in builder), or null in
* the ordinary create flow. When set, the modal's primary action saves back to
* this snippet (`saveEdits`) instead of creating a new one, and its header names
* the snippet (`editingSnippetName`).
*/
editingSnippetId: string | null;
/** The edited snippet's name, for the modal header (snapshot at open). */
editingSnippetName: string | null;
/**
* Load a dataset and pre-populate a smart default config (spec §06 → Opening).
* `null` picks the most recently modified dataset (the un-targeted entry points).
* Clears any edit-in-place state — a fresh open is a create flow.
*/
init: (datasetId: number | null) => void;
/**
* Hydrate the builder from an existing snippet for **edit-in-place** (spec §06 →
* Open in builder). Parses the snippet's published spec back to a config (strict —
* `parseChartSpecText`), loads the referenced dataset's columns, and records the
* snippet so `saveEdits` writes back to it. Returns false (a no-op) when the spec
* isn't losslessly representable or its dataset is gone — the caller gates the
* entry point on the same two conditions, so this is a defensive guard.
*/
openForEdit: (snippet: Snippet) => boolean;
/**
* Save the working config back to the edited snippet (the **Save changes** action,
* spec §06 → Open in builder): republishes the built spec into both the snippet's
* versions via `SnippetStore.replaceSnippetSpec`, then resets. Returns false when
* not in edit mode or the config is invalid. `now` injectable.
*/
saveEdits: (now?: Date) => boolean;
/**
* Re-point the open builder at another dataset (the header picker, spec §06).
* An untouched default config re-derives fresh smart defaults for the new
@@ -252,6 +280,8 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG,
activeChannel: null,
editingSnippetId: null,
editingSnippetName: null,
init: (datasetId) => {
const all = useDatasetStore.getState().datasets;
@@ -272,6 +302,8 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG,
activeChannel: null,
editingSnippetId: null,
editingSnippetName: null,
});
return;
}
@@ -288,9 +320,56 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
config,
initialConfig: config,
activeChannel: null,
editingSnippetId: null,
editingSnippetName: null,
});
},
openForEdit: (snippet) => {
const config = parseChartSpecText(snippet.spec);
if (!config) return false; // not losslessly representable (caller gates on this)
const dataset = useDatasetStore.getState().datasets.find((d) => d.name === config.datasetName);
if (!dataset) return false; // the referenced dataset is gone (caller gates too)
const columns: BuilderColumns = {
columns: dataset.columns,
columnTypes: dataset.columnTypes,
columnStats: dataset.columnStats,
};
// Re-stamp the parsed transform ids through this store's own sequence so a later
// addFilter/addCalculate can't collide with one (parse numbers them by position).
const hydrated: BuilderConfig = { ...config };
if (config.calculates) {
hydrated.calculates = config.calculates.map((c) => ({ ...c, id: nextTransformId('c') }));
}
if (config.filters) {
hydrated.filters = config.filters.map((f) => ({ ...f, id: nextTransformId('f') }));
}
set({
datasetId: dataset.id,
columns,
rowCount: dataset.rowCount,
config: hydrated,
// A loaded chart has no re-derivable "opening default"; keep initialConfig
// distinct from config so a dataset switch rebases (preserving the loaded
// chart), never re-derives fresh defaults (see switchDataset).
initialConfig: EMPTY_CONFIG,
activeChannel: null,
editingSnippetId: snippet.id,
editingSnippetName: snippet.name,
});
return true;
},
saveEdits: (now) => {
const { config, editingSnippetId } = get();
if (editingSnippetId === null) return false; // not an edit session
if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too
const specText = buildSnippetSpecText(config);
const ok = useSnippetStore.getState().replaceSnippetSpec(editingSnippetId, specText, now);
if (ok) get().reset();
return ok;
},
switchDataset: (datasetId) => {
const s = get();
if (datasetId === s.datasetId) return;
@@ -576,6 +655,8 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG,
activeChannel: null,
editingSnippetId: null,
editingSnippetName: null,
}),
}));
+46
View File
@@ -639,3 +639,49 @@ describe('library view state (spec §02 → Search / Sort)', () => {
expect(store().activeSnippetId).toBe('a');
});
});
describe('replaceSnippetSpec (Chart Builder edit-in-place save)', () => {
const newSpec = JSON.stringify(
{
$schema: 'https://vega.github.io/schema/vega-lite/v6.json',
data: { name: 'D' },
mark: { type: 'point', tooltip: true },
encoding: { x: { field: 'a', type: 'quantitative' } },
},
null,
2,
);
test('republishes both versions, activates the snippet, and bumps modified', () => {
const a = createSnippet({
id: 'a',
spec: '{"mark":"bar"}',
now: new Date('2026-01-01T00:00:00Z'),
});
const b = createSnippet({ id: 'b', now: new Date('2026-02-01T00:00:00Z') });
store().hydrate([a, b]); // active = b (newest)
const ok = store().replaceSnippetSpec('a', newSpec, new Date('2026-03-01T00:00:00Z'));
expect(ok).toBe(true);
const after = store().snippets.find((s) => s.id === 'a')!;
expect(after.spec).toBe(newSpec);
expect(after.draftSpec).toBe(newSpec); // republished — no pending draft
expect(after.modified).toBe('2026-03-01T00:00:00.000Z');
expect(after.datasetRefs).toEqual(['D']); // recomputed from the new spec
expect(store().activeSnippetId).toBe('a'); // the edited snippet becomes active
expect(store().draftText).toBe(newSpec);
expect(store().editorView).toBe('draft');
});
test('keeps a user-chosen name; returns false for an unknown id', () => {
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a]);
store().renameSnippet('a', 'My chart'); // nameSource -> 'user'
store().replaceSnippetSpec('a', newSpec);
expect(store().snippets.find((s) => s.id === 'a')!.name).toBe('My chart');
expect(store().replaceSnippetSpec('missing', newSpec)).toBe(false);
});
});
+45
View File
@@ -118,6 +118,17 @@ export interface SnippetState {
* by-name reference. No-op when no snippet is active. `now` injectable.
*/
replaceActiveDraft: (text: string, now?: Date) => void;
/**
* Replace a snippet's spec wholesale and make it active — the Chart Builder's
* **Save changes** (edit-in-place, spec §06 → Open in builder). Unlike the draft
* rewrite above, this *republishes*: both `spec` and `draftSpec` become the built
* text, so the edited snippet has no pending draft (the builder is the source).
* An auto-named snippet re-derives its name from the new content (like `publish`);
* a user-renamed one keeps its name. Flushes the outgoing active buffer first,
* recomputes `datasetRefs`, and reloads the editor on the now-active snippet.
* No-op (returns false) for an unknown id. `now` injectable.
*/
replaceSnippetSpec: (id: string, specText: string, now?: Date) => boolean;
/**
* Persist the editor buffer into the active snippet's **draft**, if it parses
* as JSON. Returns whether it committed (a half-typed, unparseable buffer is
@@ -338,6 +349,40 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
}));
},
replaceSnippetSpec: (id, specText, now) => {
// Flush the outgoing active snippet's valid in-progress edits before we switch
// the active snippet (mirrors createSnippet / selectSnippet).
get().commitDraft(now);
const target = get().snippets.find((s) => s.id === id);
if (!target) return false;
const modified = (now ?? new Date()).toISOString();
const datasetRefs = recomputeDatasetRefs(specText);
set((s) => ({
snippets: s.snippets.map((x) =>
x.id === id
? {
...x,
// Auto names track content (as publish does); a user-chosen name stays.
...(isAutoNamed(x)
? { name: deriveSnippetName(specText) ?? x.name, nameSource: 'auto' as const }
: {}),
// Republish: the built spec becomes both versions, so there is no
// pending draft to reconcile after a builder save.
spec: specText,
draftSpec: specText,
datasetRefs,
modified,
}
: x,
),
activeSnippetId: id,
draftText: specText,
editorView: 'draft',
bufferEpoch: s.bufferEpoch + 1,
}));
return true;
},
commitDraft: (now) => {
const { activeSnippetId, draftText, snippets } = get();
if (!activeSnippetId) return false;
+285
View File
@@ -27,6 +27,8 @@ import {
activeIntent,
buildChartSpec,
buildSnippetSpecText,
parseChartSpec,
parseChartSpecText,
generateChartName,
validFilterOps,
filterOpArity,
@@ -1692,3 +1694,286 @@ describe('rebaseBuilderConfig (dataset switch)', () => {
expect(out).toEqual({ ...config, datasetName: 'New' });
});
});
describe('parseChartSpec (strict spec → config hydration, spec §06 → Open in builder)', () => {
// The master invariant: parse is the exact inverse of build *at the spec level*.
// Transform ids and an ignored value-type may legitimately differ on the config but
// never reach the spec, so the round-trip is asserted on the rebuilt spec, not the
// config: build ∘ parse ∘ build === build.
const roundTrips = (config: BuilderConfig) => {
const spec = buildChartSpec(config);
const parsed = parseChartSpec(spec);
expect(parsed).not.toBeNull();
expect(buildChartSpec(parsed!)).toEqual(spec);
};
it('round-trips the full range of builder configs', () => {
roundTrips({
datasetName: 'Sales',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
});
// count + distinct + bin + timeUnit + aggregate
roundTrips({
datasetName: 'Sales',
mark: 'bar',
encodings: {
x: { field: 'value', type: 'quantitative', bin: true },
y: { type: 'quantitative', aggregate: 'count' },
},
});
roundTrips({
datasetName: 'Sales',
mark: 'line',
encodings: {
x: { field: 'when', type: 'temporal', timeUnit: 'yearmonth' },
y: { field: 'value', type: 'quantitative', aggregate: 'sum' },
color: { field: 'category', type: 'nominal' },
},
});
roundTrips({
datasetName: 'Sales',
mark: 'rect',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'segment', type: 'ordinal' },
color: { field: 'value', type: 'quantitative', aggregate: 'distinct' },
},
});
// constant colour + size, explicit dimensions, title + subtitle
roundTrips({
datasetName: 'Sales',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
color: { value: '#ff0000', type: 'nominal' },
size: { value: 100, type: 'quantitative' },
},
width: 480,
height: 320,
title: 'Profit by region',
subtitle: 'FY26',
});
// sort (ranking) + a field whose name needs escaping
roundTrips({
datasetName: 'Sales',
mark: 'bar',
sort: 'descending',
encodings: {
x: { field: 'user.region', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
});
// stack (part-to-whole)
roundTrips({
datasetName: 'Sales',
mark: 'bar',
stack: 'normalize',
encodings: {
x: { field: 'when', type: 'temporal' },
y: { field: 'value', type: 'quantitative' },
color: { field: 'series', type: 'nominal' },
},
});
// every guarded filter operator + a calculated field
roundTrips({
datasetName: 'Sales',
mark: 'point',
encodings: {
x: { field: 'value', type: 'quantitative' },
y: { field: 'ratio', type: 'quantitative' },
},
calculates: [calc({ as: 'ratio', expr: 'datum.profit / datum.value' })],
filters: [
filter({ field: 'value', fieldType: 'quantitative', op: 'gte', value: '10' }),
filter({
id: 'f2',
field: 'value',
fieldType: 'quantitative',
op: 'range',
value: '0',
value2: '100',
}),
filter({
id: 'f3',
field: 'region',
fieldType: 'nominal',
op: 'oneOf',
value: 'North,South',
}),
filter({ id: 'f4', field: 'tier', fieldType: 'nominal', op: 'notEqual', value: 'Z' }),
filter({ id: 'f5', mode: 'expression', expr: 'datum.value > 0' }),
],
});
});
it('parses a clean bar chart to the exact config', () => {
const spec = {
$schema: VEGA_LITE_SCHEMA_URL,
data: { name: 'Sales' },
mark: { type: 'bar', tooltip: true },
encoding: {
x: { field: 'category', type: 'nominal' },
y: { aggregate: 'count', type: 'quantitative' },
},
};
expect(parseChartSpec(spec)).toEqual({
datasetName: 'Sales',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
});
});
it('accepts a bare-string mark and a missing tooltip (injected tooltip is ignored)', () => {
expect(
parseChartSpec({
data: { name: 'D' },
mark: 'point',
encoding: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
},
}),
).not.toBeNull();
});
it('rejects specs whose data is not a bare name reference', () => {
const enc = { x: { field: 'a', type: 'quantitative' } };
expect(parseChartSpec({ data: { values: [{ a: 1 }] }, mark: 'bar', encoding: enc })).toBeNull();
expect(parseChartSpec({ data: { url: 'x.csv' }, mark: 'bar', encoding: enc })).toBeNull();
expect(parseChartSpec({ mark: 'bar', encoding: enc })).toBeNull();
});
it('rejects content the builder cannot represent', () => {
const data = { name: 'D' };
// a mark outside the builder's set
expect(
parseChartSpec({
data,
mark: 'arc',
encoding: { theta: { field: 'v', type: 'quantitative' } },
}),
).toBeNull();
// a mark carrying an extra property
expect(
parseChartSpec({
data,
mark: { type: 'bar', filled: true },
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
// a channel the builder doesn't own
expect(
parseChartSpec({
data,
mark: 'arc',
encoding: { theta: { field: 'v', type: 'quantitative' } },
}),
).toBeNull();
expect(
parseChartSpec({
data,
mark: 'point',
encoding: {
x: { field: 'a', type: 'quantitative' },
tooltip: { field: 'b', type: 'nominal' },
},
}),
).toBeNull();
// an encoding field carrying an extra key (scale/axis/legend/title)
expect(
parseChartSpec({
data,
mark: 'bar',
encoding: {
x: { field: 'a', type: 'nominal', axis: { title: 'X' } },
y: { field: 'b', type: 'quantitative' },
},
}),
).toBeNull();
// a non-bare bin
expect(
parseChartSpec({
data,
mark: 'bar',
encoding: {
x: { field: 'a', type: 'quantitative', bin: { maxbins: 10 } },
y: { aggregate: 'count', type: 'quantitative' },
},
}),
).toBeNull();
// a foreign transform
expect(
parseChartSpec({
data,
mark: 'bar',
transform: [{ fold: ['a', 'b'] }],
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
// a calculated field with an extra key
expect(
parseChartSpec({
data,
mark: 'bar',
transform: [{ calculate: 'datum.a', as: 'c', extra: 1 }],
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
// an unknown top-level key (params/selection/config/…)
expect(
parseChartSpec({
data,
mark: 'bar',
params: [{ name: 'sel' }],
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
// a title object with an extra key
expect(
parseChartSpec({
data,
mark: 'bar',
title: { text: 'T', anchor: 'start' },
encoding: { x: { field: 'a', type: 'nominal' } },
}),
).toBeNull();
});
it('rejects a transform order the builder would not emit (filter before calculate)', () => {
// The builder always emits calculated fields before filters; the reverse order
// re-assembles differently, so the round-trip gate rejects it.
expect(
parseChartSpec({
data: { name: 'D' },
mark: 'point',
transform: [{ filter: 'datum.x > 0' }, { calculate: 'datum.a * 2', as: 'b' }],
encoding: {
x: { field: 'b', type: 'quantitative' },
y: { field: 'x', type: 'quantitative' },
},
}),
).toBeNull();
});
it('parseChartSpecText parses valid JSON and rejects the rest', () => {
const text = buildSnippetSpecText({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'a', type: 'nominal' },
y: { aggregate: 'count', type: 'quantitative' },
},
});
expect(parseChartSpecText(text)).not.toBeNull();
expect(parseChartSpecText('{ not valid json')).toBeNull();
expect(parseChartSpecText('42')).toBeNull();
});
});
+352 -1
View File
@@ -27,7 +27,7 @@ import { DISTINCT_CAP } from './profile';
import type { ColumnType } from './type-inference';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate';
import { escapeVegaField } from './rendering';
import { escapeVegaField, unescapeVegaField } from './rendering';
/** The six mark types the builder offers, in selector order (spec §06). `rect` is the
* heatmap mark — an X×Y grid of cells shaded by a Colour measure. */
@@ -1458,6 +1458,357 @@ export function buildSnippetSpecText(config: BuilderConfig): string {
return JSON.stringify(buildChartSpec(config), null, 2);
}
// ── Spec → config hydration (spec §06 → Open in builder) ────────────────────────
//
// The strict inverse of `buildChartSpec`: re-enter the builder populated from an
// existing snippet. Strict means *losslessly representable in the builder's
// dialect* — checked not by enumerating supported features but by parsing a
// candidate config, **re-assembling it, and deep-comparing against the original**
// (ignoring key order, `$schema`, and the injected `mark.tooltip`). That keeps the
// gate exact and self-correcting: every channel/transform the assembler learns to
// emit widens what hydrates for free, with no second list to maintain. A spec the
// builder can't reproduce exactly returns `null` and stays Monaco-only — the GUI
// never overwrites a richer spec (the source of truth stays the JSON).
type JsonObject = Record<string, unknown>;
/** A plain (non-array) object guard. */
function isObject(v: unknown): v is JsonObject {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** A canonical string for a JSON value with object keys sorted — order-independent equality. */
function canonicalJson(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
if (isObject(v)) {
return `{${Object.keys(v)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`)
.join(',')}}`;
}
return JSON.stringify(v) ?? 'null';
}
/** Reduce a spec to the form the round-trip compares: drop `$schema`, and treat a
* bare-string mark and an `{ type, tooltip: true }` mark as one (the builder always
* injects `tooltip: true`, so it must not count as a difference). */
function normalizeForRoundTrip(spec: JsonObject): JsonObject {
const { $schema: _schema, ...rest } = spec;
void _schema;
if (rest.mark !== undefined) {
if (typeof rest.mark === 'string') {
rest.mark = { type: rest.mark };
} else if (isObject(rest.mark)) {
const mark = { ...rest.mark };
if (mark.tooltip === true) delete mark.tooltip;
rest.mark = mark;
}
}
return rest;
}
/** A `FieldType` if the value is one, else `null`. */
function asFieldType(v: unknown): FieldType | null {
return v === 'quantitative' || v === 'nominal' || v === 'ordinal' || v === 'temporal' ? v : null;
}
/** A constant value back-coerced to its `BuilderFilter` string shape (for the predicate forms). */
function filterValueToString(v: unknown): string | null {
if (typeof v === 'string') return v;
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
return null;
}
/** The `fieldType` whose `coerceFilterValue` reproduces this JSON value: a number
* came from a quantitative compare, anything else from a string compare. */
function filterTypeFor(v: unknown): FieldType {
return typeof v === 'number' ? 'quantitative' : 'nominal';
}
/** Parse one `{ filter }` transform entry into a `BuilderFilter`, or `null` if it is
* not a shape the guarded shelf / expression mode can represent. */
function parseFilter(entry: unknown, id: string): BuilderFilter | null {
if (!isObject(entry) || !('filter' in entry)) return null;
const f = entry.filter;
if (typeof f === 'string') return { id, mode: 'expression', expr: f };
if (!isObject(f)) return null;
// `notEqual` is the only wrapped predicate the assembler emits: `{ not: { field, equal } }`.
if (isObject(f.not) && Object.keys(f).length === 1) {
const inner = f.not;
if (typeof inner.field === 'string' && 'equal' in inner && Object.keys(inner).length === 2) {
const value = filterValueToString(inner.equal);
if (value === null) return null;
return {
id,
mode: 'predicate',
field: unescapeVegaField(inner.field),
fieldType: filterTypeFor(inner.equal),
op: 'notEqual',
value,
};
}
return null;
}
if (typeof f.field !== 'string') return null;
const field = unescapeVegaField(f.field);
const keys = Object.keys(f).filter((k) => k !== 'field');
if (keys.length !== 1) return null; // exactly one predicate operator besides `field`
const op = keys[0];
const base = { id, mode: 'predicate' as const, field };
switch (op) {
case 'equal':
case 'lt':
case 'lte':
case 'gt':
case 'gte': {
const value = filterValueToString(f[op]);
if (value === null) return null;
return { ...base, fieldType: filterTypeFor(f[op]), op, value };
}
case 'range': {
const r = f.range;
if (!Array.isArray(r) || r.length !== 2) return null;
const lo = filterValueToString(r[0]);
const hi = filterValueToString(r[1]);
if (lo === null || hi === null) return null;
return { ...base, fieldType: filterTypeFor(r[0]), op: 'range', value: lo, value2: hi };
}
case 'oneOf': {
const list = f.oneOf;
if (!Array.isArray(list) || list.length === 0) return null;
const items = list.map(filterValueToString);
if (items.some((s) => s === null)) return null;
// The shelf rebuilds the list by splitting on "," and trimming — so a member
// that itself contains a comma or edge whitespace can't round-trip; the gate
// (re-assemble + compare) rejects those, keeping this strict.
return {
...base,
fieldType: filterTypeFor(list[0]),
op: 'oneOf',
value: (items as string[]).join(','),
};
}
default:
return null;
}
}
/** Parse the top-level `transform` array into the config's calculates + filters
* (the two kinds the builder emits), or `null` if any entry is foreign. */
function parseTransforms(
transform: unknown,
): { calculates: BuilderCalculate[]; filters: BuilderFilter[] } | null {
const calculates: BuilderCalculate[] = [];
const filters: BuilderFilter[] = [];
if (transform === undefined) return { calculates, filters };
if (!Array.isArray(transform)) return null;
const entries: unknown[] = transform;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (isObject(entry) && 'calculate' in entry) {
// A calculated field is exactly `{ calculate, as }` — any other key is foreign.
if (
typeof entry.calculate !== 'string' ||
typeof entry.as !== 'string' ||
Object.keys(entry).length !== 2
) {
return null;
}
calculates.push({ id: `c${i}`, expr: entry.calculate, as: entry.as });
continue;
}
const filter = parseFilter(entry, `f${i}`);
if (!filter) return null; // a transform the builder can't represent ⇒ reject
filters.push(filter);
}
return { calculates, filters };
}
/** Parse one encoding channel object into a `ChannelMapping`, plus any channel-level
* `sort`/`stack` the builder lifts to config level. `null` ⇒ not builder dialect. */
function parseChannel(
channel: ChannelName,
enc: unknown,
): { mapping: ChannelMapping; sort?: SortOrder; stack?: StackMode } | null {
if (!isObject(enc)) return null;
const rest = { ...enc };
// Lift channel-level sort/stack out before reading the field mapping.
let sort: SortOrder | undefined;
let stack: StackMode | undefined;
if ('sort' in rest) {
const s = rest.sort;
if (s === 'x' || s === 'y') sort = 'ascending';
else if (s === '-x' || s === '-y') sort = 'descending';
else return null; // a richer sort spec (object/array) isn't builder dialect
delete rest.sort;
}
if ('stack' in rest) {
if (rest.stack === 'zero' || rest.stack === 'normalize') stack = rest.stack;
else return null;
delete rest.stack;
}
// A constant value: `{ value }` only.
if ('value' in rest) {
if (Object.keys(rest).length !== 1) return null;
const value = rest.value;
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
return null;
}
const type: FieldType = channel === 'size' ? 'quantitative' : 'nominal';
return { mapping: { value, type }, sort, stack };
}
// A field-less count: `{ aggregate: 'count', type: 'quantitative' }`.
if (rest.aggregate === 'count') {
if (rest.type !== 'quantitative' || 'field' in rest || Object.keys(rest).length !== 2) {
return null;
}
return { mapping: { type: 'quantitative', aggregate: 'count' }, sort, stack };
}
// A field mapping: field + type (+ optional aggregate/bin/timeUnit), nothing else.
if (typeof rest.field !== 'string') return null;
const type = asFieldType(rest.type);
if (type === null) return null;
const mapping: ChannelMapping = { field: unescapeVegaField(rest.field), type };
const consumed = new Set(['field', 'type']);
if ('aggregate' in rest) {
const agg = rest.aggregate;
const valid: AggregateOp[] = ['distinct', 'sum', 'mean', 'median', 'min', 'max'];
if (typeof agg !== 'string' || !valid.includes(agg as AggregateOp)) return null;
mapping.aggregate = agg as AggregateOp;
consumed.add('aggregate');
}
if ('bin' in rest) {
if (rest.bin !== true) return null; // only the bare `bin: true` form
mapping.bin = true;
consumed.add('bin');
}
if ('timeUnit' in rest) {
if (!TIME_UNITS.includes(rest.timeUnit as TimeUnit)) return null;
mapping.timeUnit = rest.timeUnit as TimeUnit;
consumed.add('timeUnit');
}
if (Object.keys(rest).some((k) => !consumed.has(k))) return null; // a foreign key (axis/scale/…)
return { mapping, sort, stack };
}
/**
* The strict inverse of {@link buildChartSpec} (spec §06 → Open in builder): a
* Vega-Lite spec object → the `BuilderConfig` that produces it, or `null` when the
* spec is not **losslessly** representable in the builder's dialect. Losslessness is
* the authority — a candidate config is parsed leniently, then re-assembled and
* deep-compared against the input (key order, `$schema`, and the injected
* `mark.tooltip` ignored). Only a spec the builder can reproduce *exactly* hydrates;
* everything else stays Monaco-only, so the builder never silently drops a richer
* spec's content.
*
* The dataset reference is read by name only (`{ data: { name } }`) — the builder's
* sole data model; an inline-`values`/`url` spec has no builder representation and
* returns `null`. The caller still confirms the named dataset exists before opening.
*/
export function parseChartSpec(spec: unknown): BuilderConfig | null {
if (!isObject(spec)) return null;
// Data must be a bare name reference — the only data shape the builder emits.
if (!isObject(spec.data) || typeof spec.data.name !== 'string') return null;
const datasetName = spec.data.name;
// Mark: a known type, as a bare string or `{ type, tooltip? }`.
const markValue = isObject(spec.mark) ? spec.mark.type : spec.mark;
if (typeof markValue !== 'string' || !MARK_TYPES.includes(markValue as MarkType)) return null;
const mark = markValue as MarkType;
const config: BuilderConfig = { datasetName, mark, encodings: {} };
// Title: a bare string, or `{ text, subtitle? }`.
if ('title' in spec) {
if (typeof spec.title === 'string') {
config.title = spec.title;
} else if (isObject(spec.title) && typeof spec.title.text === 'string') {
config.title = spec.title.text;
if ('subtitle' in spec.title) {
if (typeof spec.title.subtitle !== 'string') return null;
config.subtitle = spec.title.subtitle;
}
} else {
return null;
}
}
// Transforms → calculated fields + filters.
const transforms = parseTransforms(spec.transform);
if (transforms === null) return null;
if (transforms.calculates.length > 0) config.calculates = transforms.calculates;
if (transforms.filters.length > 0) config.filters = transforms.filters;
// Encodings → channel mappings, lifting channel-level sort/stack to config level.
if ('encoding' in spec) {
if (!isObject(spec.encoding)) return null;
for (const key of Object.keys(spec.encoding)) {
if (!CHANNELS.includes(key as ChannelName)) return null; // a channel the builder doesn't own
const parsed = parseChannel(key as ChannelName, spec.encoding[key]);
if (parsed === null) return null;
config.encodings[key as ChannelName] = parsed.mapping;
if (parsed.sort) config.sort = parsed.sort;
if (parsed.stack) config.stack = parsed.stack;
}
}
// Explicit dimensions.
if ('width' in spec) {
if (typeof spec.width !== 'number') return null;
config.width = spec.width;
}
if ('height' in spec) {
if (typeof spec.height !== 'number') return null;
config.height = spec.height;
}
// Any top-level key the assembler doesn't emit ⇒ not builder dialect.
const allowedTop = new Set([
'$schema',
'data',
'mark',
'encoding',
'transform',
'title',
'width',
'height',
]);
if (Object.keys(spec).some((k) => !allowedTop.has(k))) return null;
// The authority: the parsed config must re-assemble to the original spec exactly.
if (
canonicalJson(normalizeForRoundTrip(buildChartSpec(config))) !==
canonicalJson(normalizeForRoundTrip(spec))
) {
return null;
}
return config;
}
/**
* Parse a snippet's spec **text** into a `BuilderConfig`, or `null` when the text
* isn't valid JSON or isn't losslessly representable in the builder (see
* {@link parseChartSpec}). The convenience the library/store use — snippets store
* their spec as text.
*/
export function parseChartSpecText(specText: string): BuilderConfig | null {
let parsed: unknown;
try {
parsed = JSON.parse(specText);
} catch {
return null;
}
return parseChartSpec(parsed);
}
/** Title-case a single mark type for display/naming (e.g. `bar` → `Bar`). */
function markLabel(mark: MarkType): string {
return mark.charAt(0).toUpperCase() + mark.slice(1);
+9
View File
@@ -209,6 +209,15 @@ export function escapeVegaField(name: string): string {
return name.replace(/([.[\]])/g, '\\$1');
}
/**
* Inverse of {@link escapeVegaField}: drop the backslash escaping `.`/`[`/`]` to
* recover the literal column name. Used when *reading* a constructed `field:` back
* (the chart builder's spec→config hydration, spec §06 → Open in builder).
*/
export function unescapeVegaField(field: string): string {
return field.replace(/\\([.[\]])/g, '$1');
}
/**
* Transform the authored spec into the spec to embed. Operates on a deep copy
* and returns it; the input is never mutated.