mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart Builder: open a snippet in the builder to edit it in place
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user