Editor: view-scoped Extract-to-Dataset for inline and self-defined data

This commit is contained in:
2026-06-29 02:08:59 +03:00
parent 8cad80f738
commit 6656811b8e
16 changed files with 910 additions and 180 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* Extract-to-Dataset — the modal body (spec §03F).
*
* Shows a read-only preview of the active snippet draft's inline data and asks
* Shows a read-only preview of the focused view's embedded data and asks
* for a dataset name. On confirm it saves the data as a new dataset and rewrites
* the draft to reference it by name (logic in ExtractStore), then force-closes
* (the commit is the user's confirmation, so no discard prompt). Cancel leaves
@@ -38,13 +38,13 @@ export function ExtractModal() {
};
if (!source) {
return <p className={styles.muted}>This snippet has no inline data to extract.</p>;
return <p className={styles.muted}>This snippet has no embedded data to extract.</p>;
}
return (
<div className={styles.extract}>
<p className={styles.intro}>
Save this snippet&rsquo;s inline data as a reusable dataset. The spec will be rewritten to
Save this snippet&rsquo;s embedded data as a reusable dataset. The spec will be rewritten to
reference it by name.
</p>
+17 -7
View File
@@ -26,7 +26,7 @@ import '../infrastructure/monaco-env'; // side-effect: wire workers before creat
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
import { parseChartSpecText } from '@core/chart-builder';
import { openChartBuilderForEdit, openModal } from '../modals/ModalCoordinator';
import { openChartBuilderForEdit } from '../modals/ModalCoordinator';
import {
installSpecConfigActions,
runExtractConfig,
@@ -41,10 +41,11 @@ import {
runWrap,
} from '../services/spec-transform-actions';
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
import { runExtract } from '../services/extract-action';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { hasInlineData } from '../stores/ExtractStore';
import { hasExtractableData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore';
@@ -222,10 +223,11 @@ function EditorToolbar({
const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec;
return draft !== active.spec;
});
// Offer Extract only when the live draft carries top-level inline data to lift
// out (spec §03F → hidden when the spec has no inline data).
// Offer Extract only when the live draft carries data to lift out — inline
// `values` in any view, or a reference to a self-defined `datasets` entry (spec
// §03F → hidden when there is nothing extractable).
const canExtract = useSnippetStore(
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText),
(s) => s.activeSnippetId !== null && hasExtractableData(s.draftText),
);
// Offer "Open in builder" only when the active snippet's published spec is
@@ -248,6 +250,14 @@ function EditorToolbar({
if (snippet) openChartBuilderForEdit(snippet);
};
// Extract is scoped to the view at the cursor (services/extract-action), so it
// goes through the editor handle like the wrap/config actions, not a bare
// openModal — the service captures the focused binding before opening the modal.
const handleExtract = () => {
const editor = editorRef.current;
if (editor) runExtract(editor);
};
// 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;
@@ -315,8 +325,8 @@ function EditorToolbar({
{canExtract && (
<Button
className={styles.collapsible}
onClick={() => openModal('extract')}
title="Extract inline data into a reusable dataset"
onClick={handleExtract}
title="Extract embedded data into a reusable dataset"
aria-label="Extract to Dataset"
>
<Icon name="dataset" className={styles.actionIcon} />
+3 -2
View File
@@ -63,11 +63,12 @@ const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
},
},
// Opened from the snippet editor with the active draft's inline data to lift out.
// Opened from the snippet editor by `services/extract-action`, which seeds the
// store with the focused view's inline data (`begin`) *before* opening — so no
// `init` here, which would re-read top-level and clobber the view-scoped capture.
extract: {
name: 'extract',
title: 'Extract to Dataset',
init: () => useExtractStore.getState().init(),
getState: () => ({ name: useExtractStore.getState().name }),
},
+82
View File
@@ -0,0 +1,82 @@
/**
* Extract-to-Dataset as an editor action (spec §03F; docs/architecture/08 →
* editor augmentation) — the data-facing counterpart to the wrap/config actions.
*
* Resolves the embedded data of the **view the cursor sits in** (the nearest
* enclosing `data` binding, honoring Vega-Lite's parent→child inheritance), seeds
* the Extract modal with it, and opens the modal. The cursor picks which view to
* extract; a single-view spec resolves to the root binding from any cursor, so the
* common case needs no thought. Two binding shapes are liftable — a view's inline
* `data.values`, and a `{ name }` reference to the spec's own top-level `datasets`
* (the data lives in the `datasets` map; we pre-fill the modal with its name).
*
* The toolbar offers the action whenever *some* view has extractable data
* (`hasExtractableData`); when the focused view itself has none — it references a
* library dataset, a url, a generator — there is nothing to lift here, so guide the
* user to a view that does rather than silently extract the wrong one.
*/
import type * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { pathAtOffset } from '@core/spec-cursor';
import { dataBindingAtPath } from '@core/spec-data';
import { inlineValuesOf, selfDefinedPayloadOf } from '@core/spec-inline-data';
import { openModal } from '../modals/ModalCoordinator';
import { type ExtractTarget, useExtractStore } from '../stores/ExtractStore';
import { notify } from '../stores/NotificationStore';
// TODO(ux-second-pass): toolbar-only — no F1-palette accelerator like the sibling
// wrap/config actions, since this opens a modal rather than an in-place edit.
/** Open Extract-to-Dataset scoped to the view at the cursor. */
export function runExtract(editor: monaco.editor.IStandaloneCodeEditor): void {
const model = editor.getModel();
if (!model) return;
const text = model.getValue();
let spec: unknown;
try {
spec = JSON.parse(text);
} catch {
notify({
kind: 'info',
title: 'Cant extract yet',
message: 'Fix the JSON so the spec parses, then extract.',
});
return;
}
const position = editor.getPosition();
const offset = position ? model.getOffsetAt(position) : 0;
const binding = dataBindingAtPath(spec, pathAtOffset(text, offset));
// Inline `values` → rewrite this view's data block; a self-defined `datasets`
// reference → lift the named entry and pre-fill the modal with that name.
const inline = binding && inlineValuesOf(binding.data);
const selfDefined = binding && !inline && selfDefinedPayloadOf(spec, binding.data);
let seed: {
source: ReturnType<typeof inlineValuesOf>;
target: ExtractTarget;
name?: string;
} | null = null;
if (binding && inline) {
seed = { source: inline, target: { kind: 'inline', anchorPath: binding.anchorPath } };
} else if (binding && selfDefined) {
const datasetName = (binding.data as { name: string }).name;
seed = {
source: selfDefined,
target: { kind: 'self-defined', datasetName },
name: datasetName,
};
}
if (!seed || !seed.source) {
notify({
kind: 'info',
title: 'No data to extract here',
message: 'Place the cursor in a view with inline or embedded data to extract it.',
});
return;
}
useExtractStore.getState().begin({ source: seed.source, target: seed.target, name: seed.name });
openModal('extract');
}
+199
View File
@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createSnippet } from '@core/snippet';
import { useDatasetStore } from './DatasetStore';
import { hasExtractableData, useExtractStore } from './ExtractStore';
import { useSnippetStore } from './SnippetStore';
const extract = () => useExtractStore.getState();
const snippets = () => useSnippetStore.getState();
const datasets = () => useDatasetStore.getState();
/** Load a single active snippet whose live draft buffer is `draft`. */
function activeDraft(draft: string): void {
const s = createSnippet({ id: 's', spec: draft, now: new Date('2026-01-01T00:00:00Z') });
useSnippetStore.getState().hydrate([s], 's');
useSnippetStore.getState().updateDraft(draft);
}
beforeEach(() => {
useExtractStore.getState().reset();
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
});
describe('hasExtractableData', () => {
test('true for inline data, false for a library reference or bad JSON', () => {
expect(hasExtractableData('{"data":{"values":[{"a":1}]},"mark":"bar"}')).toBe(true);
expect(hasExtractableData('{"data":{"name":"sales"}}')).toBe(false);
expect(hasExtractableData('not json')).toBe(false);
});
test('true when inline data lives only in a nested view', () => {
expect(
hasExtractableData('{"layer":[{"data":{"name":"sales"}},{"data":{"values":[{"a":1}]}}]}'),
).toBe(true);
});
test('true for a reference to a self-defined datasets entry', () => {
expect(hasExtractableData('{"datasets":{"sales":[{"a":1}]},"data":{"name":"sales"}}')).toBe(
true,
);
});
});
describe('confirm — inline rewrite', () => {
test('extracts the root binding and rewrites it to a by-name reference', () => {
activeDraft('{"data":{"values":[{"a":1},{"a":2}]},"mark":"bar"}');
extract().begin({
source: { values: [{ a: 1 }, { a: 2 }], format: 'json' },
target: { kind: 'inline', anchorPath: [] },
});
extract().setName('Sales');
expect(extract().confirm(new Date('2026-02-01T00:00:00Z'))).toBe(true);
const ds = datasets().datasets.find((d) => d.name === 'Sales');
expect(ds?.data).toEqual([{ a: 1 }, { a: 2 }]);
const draft = JSON.parse(snippets().draftText) as { data: unknown; mark: unknown };
expect(draft.data).toEqual({ name: 'Sales' });
expect(draft.mark).toBe('bar');
});
test('rewrites only the focused view in a composition, leaving siblings intact', () => {
activeDraft(
JSON.stringify({
layer: [{ data: { name: 'sales' }, mark: 'line' }, { data: { values: [{ a: 1 }] } }],
}),
);
extract().begin({
source: { values: [{ a: 1 }], format: 'json' },
target: { kind: 'inline', anchorPath: ['layer', 1] },
});
extract().setName('Overlay');
expect(extract().confirm()).toBe(true);
const draft = JSON.parse(snippets().draftText) as { layer: Array<{ data: unknown }> };
expect(draft.layer[0].data).toEqual({ name: 'sales' }); // sibling untouched
expect(draft.layer[1].data).toEqual({ name: 'Overlay' }); // focused view rewritten
});
test("rewrites a lookup transform's inline from.data (the cursor sits inside it)", () => {
activeDraft(
JSON.stringify({
data: { name: 'Library' },
transform: [
{ lookup: 'k', from: { data: { values: [{ k: 1, v: 9 }] }, key: 'k', fields: ['v'] } },
],
mark: 'bar',
}),
);
extract().begin({
source: { values: [{ k: 1, v: 9 }], format: 'json' },
target: { kind: 'inline', anchorPath: ['transform', 0, 'from'] },
});
extract().setName('Lookup');
expect(extract().confirm()).toBe(true);
const draft = JSON.parse(snippets().draftText) as {
transform: Array<{ from: { data: unknown } }>;
};
expect(draft.transform[0].from.data).toEqual({ name: 'Lookup' });
});
test('preserves a CSV string payload as a raw-text dataset', () => {
activeDraft('{"data":{"values":"a,b\\n1,2","format":{"type":"csv"}},"mark":"bar"}');
extract().begin({
source: { values: 'a,b\n1,2', format: 'csv' },
target: { kind: 'inline', anchorPath: [] },
});
extract().setName('Raw');
expect(extract().confirm()).toBe(true);
const ds = datasets().datasets.find((d) => d.name === 'Raw');
expect(ds?.format).toBe('csv');
expect(ds?.data).toBe('a,b\n1,2');
});
});
describe('confirm — self-defined datasets rewrite', () => {
test('keeping the name drops the datasets entry; the reference resolves to the library', () => {
activeDraft('{"datasets":{"sales":[{"a":1},{"a":2}]},"data":{"name":"sales"},"mark":"bar"}');
extract().begin({
source: { values: [{ a: 1 }, { a: 2 }], format: 'json' },
target: { kind: 'self-defined', datasetName: 'sales' },
name: 'sales',
});
expect(extract().confirm()).toBe(true);
const ds = datasets().datasets.find((d) => d.name === 'sales');
expect(ds?.data).toEqual([{ a: 1 }, { a: 2 }]);
const draft = JSON.parse(snippets().draftText) as { datasets?: unknown; data: unknown };
expect(draft.datasets).toBeUndefined(); // map emptied → removed
expect(draft.data).toEqual({ name: 'sales' }); // reference unchanged, now a library ref
});
test('renaming rewrites every reference and keeps other datasets entries', () => {
activeDraft(
JSON.stringify({
datasets: { sales: [{ a: 1 }], other: [{ b: 2 }] },
layer: [
{ data: { name: 'sales' } },
{ data: { name: 'sales' } },
{ data: { name: 'other' } },
],
}),
);
extract().begin({
source: { values: [{ a: 1 }], format: 'json' },
target: { kind: 'self-defined', datasetName: 'sales' },
name: 'Sales 2024',
});
expect(extract().confirm()).toBe(true);
const draft = JSON.parse(snippets().draftText) as {
datasets: Record<string, unknown>;
layer: Array<{ data: unknown }>;
};
expect(draft.layer[0].data).toEqual({ name: 'Sales 2024' }); // both sales refs rewritten
expect(draft.layer[1].data).toEqual({ name: 'Sales 2024' });
expect(draft.layer[2].data).toEqual({ name: 'other' }); // untouched
expect(draft.datasets).toEqual({ other: [{ b: 2 }] }); // sales entry dropped, other kept
});
});
describe('confirm — guards', () => {
test('rejects a duplicate name and creates nothing', () => {
activeDraft('{"data":{"values":[{"a":1}]}}');
extract().begin({
source: { values: [{ a: 1 }], format: 'json' },
target: { kind: 'inline', anchorPath: [] },
});
extract().setName('Sales');
extract().confirm();
const countAfterFirst = datasets().datasets.length;
activeDraft('{"data":{"values":[{"b":2}]}}');
extract().begin({
source: { values: [{ b: 2 }], format: 'json' },
target: { kind: 'inline', anchorPath: [] },
});
extract().setName('Sales');
expect(extract().confirm()).toBe(false);
expect(extract().error).toMatch(/already exists/);
expect(datasets().datasets.length).toBe(countAfterFirst);
});
test('refuses when the anchor path no longer resolves, creating nothing', () => {
activeDraft('{"data":{"values":[{"a":1}]}}'); // no layer array
extract().begin({
source: { values: [{ a: 1 }], format: 'json' },
target: { kind: 'inline', anchorPath: ['layer', 3] },
});
extract().setName('Ghost');
expect(extract().confirm()).toBe(false);
expect(extract().error).toMatch(/Could not locate/);
expect(datasets().datasets.length).toBe(0);
});
});
+87 -67
View File
@@ -1,92 +1,86 @@
/**
* Extract-inline-data → Dataset state (spec §03F).
* Extract-embedded-data → Dataset state (spec §03F).
*
* Backs the Extract modal: the reverse of a named reference. It captures the
* active snippet draft's inline data, takes a dataset name, and on confirm saves
* the data as a new dataset and rewrites the draft so the inline data is replaced
* by a by-name reference (`{ "data": { "name": … } }`).
* embedded data of the **view the cursor sits in**, takes a dataset name, and on
* confirm saves the data as a new library dataset and rewrites the spec so the
* embedded data is replaced by a by-name reference.
*
* Scope (M3): the **top-level** `data` block of the draft spec — the common case
* for a single-view chart. Inline data nested inside layers/concats is left for a
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the
* editor only offers the action when this store can act on it.
* Two shapes of embedded data, captured as a `target` (spec §03F; multi-view scope
* doc M3):
* - **inline** — a view's `data.values`. Confirm rewrites that view's `data`
* block at its anchor path to `{ name }`.
* - **self-defined** — a `{ name: X }` reference to the spec's own top-level
* `datasets.X`. Confirm removes the `datasets` entry (and the map when it
* empties); the reference resolves to the new library dataset, renamed when the
* name changes (`core/spec-refs` → `promoteSelfDefinedDataset`).
*
* The store is the editor-free side of the flow: it never reads the cursor or the
* Monaco model — `services/extract-action` resolves the focused binding and seeds
* it via `begin`, so this stays a plain, testable store.
*/
import { create } from 'zustand';
import type { DataFormat } from '@core/format-detection';
import { createDataset } from '@core/dataset';
import { formatSpec } from '@core/json-format';
import { isNameTaken } from '@core/naming';
import { setDataBindingAtPath } from '@core/spec-data';
import { type InlinePayload, specHasExtractableData } from '@core/spec-inline-data';
import { promoteSelfDefinedDataset } from '@core/spec-refs';
import { useDatasetStore } from './DatasetStore';
import { notify } from './NotificationStore';
import { useSnippetStore } from './SnippetStore';
/** The inline `data` block of a parsed spec, if it carries `values`. */
interface InlineData {
values: unknown;
format: DataFormat;
}
/** The path of the view whose `data` block Extract rewrites (`[]` = root). */
type AnchorPath = ReadonlyArray<string | number>;
/**
* Read the top-level inline data from a draft spec's text, or null when there is
* none (no snippet, unparseable, or no `data.values`). The format comes from an
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON.
*/
function readInlineData(draftText: string): InlineData | null {
let parsed: unknown;
/** What confirm rewrites — an inline `data` block, or a self-defined `datasets` entry. */
export type ExtractTarget =
| { kind: 'inline'; anchorPath: AnchorPath }
| { kind: 'self-defined'; datasetName: string };
/** True when the active snippet's draft has data Extract can lift, in any view. */
export function hasExtractableData(draftText: string): boolean {
try {
parsed = JSON.parse(draftText);
return specHasExtractableData(JSON.parse(draftText));
} catch {
return null;
return false;
}
if (!parsed || typeof parsed !== 'object') return null;
const data = (parsed as Record<string, unknown>).data;
if (!data || typeof data !== 'object') return null;
const values = (data as Record<string, unknown>).values;
if (values === undefined) return null;
const declared = (data as Record<string, unknown>).format;
const type =
declared && typeof declared === 'object'
? (declared as Record<string, unknown>).type
: undefined;
const format: DataFormat =
type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
return { values, format };
}
/** True when the active snippet's draft has top-level inline data to extract. */
export function hasInlineData(draftText: string): boolean {
return readInlineData(draftText) !== null;
}
export interface ExtractState {
/** Proposed dataset name (required, unique). */
name: string;
/** The inline data captured at open, for the read-only preview. */
source: InlineData | null;
/** The focused view's embedded data captured at open, for the read-only preview. */
source: InlinePayload | null;
/** Where `confirm` writes the by-name reference. */
target: ExtractTarget | null;
/** Inline validation message, or null. */
error: string | null;
/** Capture the active snippet's inline data and reset the form. */
init: () => void;
/** Seed the form with the focused view's captured data (extract-action). */
begin: (captured: { source: InlinePayload; target: ExtractTarget; name?: string }) => void;
setName: (name: string) => void;
/**
* Validate, create the dataset, and rewrite the active draft to reference it by
* name. Returns whether it committed; on failure `error` is set. `now`
* injectable for tests.
* Validate, create the dataset, and rewrite the spec to reference it by name.
* Returns whether it committed; on failure `error` is set. `now` injectable for
* tests.
*/
confirm: (now?: Date) => boolean;
reset: () => void;
}
const INITIAL = { name: '', source: null as InlineData | null, error: null as string | null };
const INITIAL = {
name: '',
source: null as InlinePayload | null,
target: null as ExtractTarget | null,
error: null as string | null,
};
export const useExtractStore = create<ExtractState>((set, get) => ({
...INITIAL,
init: () => {
const draft = useSnippetStore.getState().draftText;
set({ name: '', source: readInlineData(draft), error: null });
},
begin: ({ source, target, name = '' }) => set({ name, source, target, error: null }),
setName: (name) => set({ name, error: null }),
@@ -100,13 +94,42 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
set({ error: `A dataset named "${name}" already exists. Choose a different name.` });
return false;
}
const source = get().source;
if (!source) {
set({ error: 'No inline data to extract.' });
const { source, target } = get();
if (!source || !target) {
set({ error: 'No data to extract.' });
return false;
}
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but inline
// Resolve the rewrite before any side effect: parse the live draft and apply the
// target's rewrite to a fresh copy. Both can fail (the draft is no longer valid
// JSON, or its shape changed under the modal); bail with a message and create
// nothing rather than leave a half-done extraction.
const draftText = useSnippetStore.getState().draftText;
let spec: unknown;
try {
spec = JSON.parse(draftText);
} catch {
set({ error: 'The spec is no longer valid JSON. Close and reopen Extract.' });
return false;
}
let rewritten: unknown;
if (target.kind === 'inline') {
if (!setDataBindingAtPath(spec, target.anchorPath, { name })) {
set({ error: 'Could not locate the data to replace. Close and reopen Extract.' });
return false;
}
rewritten = spec;
} else {
const next = promoteSelfDefinedDataset(spec, target.datasetName, name);
if (!next) {
set({ error: 'Could not locate the data to replace. Close and reopen Extract.' });
return false;
}
rewritten = next;
}
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but the captured
// `values` is already the right runtime shape for each, so store it directly.
const dataset = createDataset({
name,
@@ -117,20 +140,17 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
});
useDatasetStore.getState().add(dataset);
// Rewrite the top-level data block to a by-name reference, preserving the rest
// of the spec and its pretty-printed text shape.
const draftText = useSnippetStore.getState().draftText;
const spec = JSON.parse(draftText) as Record<string, unknown>;
spec.data = { name };
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
// Re-serialize in the app's house style (json-format), preserving the spec and
// only swapping the captured data for the reference.
useSnippetStore.getState().replaceActiveDraft(formatSpec(rewritten), now);
// Success confirmation (spec §03F). Title states the action; the message
// adds the consequence — the spec was rewritten to reference the new dataset
// by name (council toast-copy rule, docs/architecture/10 → Toast copy).
// Success confirmation (spec §03F). Title states the action; the message adds
// the consequence — the spec was rewritten to reference the new dataset by name
// (council toast-copy rule, docs/architecture/10 → Toast copy).
notify({
kind: 'success',
title: 'Dataset created',
message: `The spec now references "${name}" instead of its inline data.`,
message: `The spec now references "${name}" instead of its embedded data.`,
});
set(INITIAL);
return true;
+1 -1
View File
@@ -114,7 +114,7 @@ export interface SnippetState {
/**
* Replace the active snippet's draft spec with new text and reload the editor
* on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such
* as Extract-to-Dataset (spec §03F), which substitutes inline data for a
* as Extract-to-Dataset (spec §03F), which substitutes embedded data for a
* by-name reference. No-op when no snippet is active. `now` injectable.
*/
replaceActiveDraft: (text: string, now?: Date) => void;