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}