Chart builder: discoverable entry points — Build Chart door, dataset picker, no-datasets state

This commit is contained in:
2026-06-12 21:46:25 +03:00
parent dbb4522d78
commit ca70b3e491
22 changed files with 625 additions and 75 deletions
+25 -10
View File
@@ -13,10 +13,28 @@
min-width: 0;
}
.muted {
/* ── No-dataset states: Carbon no-data empty state / dataset chooser ──── */
.emptyState {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
max-width: 48ch;
padding: var(--space-7) var(--space-6);
}
.emptyTitle {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.emptyBody {
margin: 0;
padding: var(--space-5);
font-size: 14px;
line-height: 1.5;
color: var(--text-secondary);
}
@@ -33,14 +51,11 @@
min-height: 0;
}
.datasetName {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
}
.datasetName strong {
color: var(--text);
/* The dataset picker pinned at the top of the config pane. */
.datasetRow {
display: flex;
align-items: center;
gap: var(--space-3);
}
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
+24 -3
View File
@@ -80,7 +80,7 @@ describe('ChartBuilderModal', () => {
await Promise.resolve();
});
expect(container.textContent).toContain('Building from');
expect(container.textContent).toContain('Nums'); // the dataset picker names the data
expect(container.textContent).toContain('scatter'); // the guidance hint rendered
});
@@ -155,12 +155,33 @@ describe('ChartBuilderModal', () => {
vi.useRealTimers();
});
test('shows the empty state when no dataset is loaded', async () => {
test('shows the no-datasets empty state with its one next step (3D)', async () => {
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
expect(container.textContent).toContain('No dataset loaded');
expect(container.textContent).toContain('No datasets yet');
const action = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'Add a dataset',
);
expect(action).toBeDefined();
});
test('offers a dataset chooser when datasets exist but none is loaded', async () => {
const ds = createDataset({
name: 'Waiting',
data: [{ a: 1 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
expect(container.textContent).toContain('Choose a dataset');
});
test('a complete filter row reaches the renderer as a top-level transform (1C)', async () => {
+66 -6
View File
@@ -61,7 +61,7 @@ import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
import { ChartTooLargeError, renderSpec, type RenderHandle } from '../services/chart-renderer';
import { closeModal } from '../modals/ModalCoordinator';
import { closeModal, openModal } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
import {
@@ -1264,9 +1264,62 @@ function ChartProps() {
);
}
/**
* The dataset picker at the top of the config pane (spec §06 → Dataset picker):
* which data the chart builds from is itself a builder choice, so the builder can
* open without a preselected dataset and the data can be switched without leaving.
* Switching re-derives smart defaults while the config is untouched, and rebases
* (keeps chart-level intent, sheds bindings to missing columns) once it isn't.
*/
function DatasetPicker({ datasetId }: { datasetId: number | null }) {
const datasets = useDatasetStore(useShallow((s) => s.datasets));
const switchDataset = useChartBuilderStore((s) => s.switchDataset);
return (
<div className={styles.datasetRow}>
<span className={styles.fieldLabel}>Dataset</span>
<SelectControl
id="cb-dataset"
label="Dataset to build from"
heading="Dataset"
options={datasets.map((d) => ({ value: String(d.id), label: d.name }))}
value={datasetId !== null ? String(datasetId) : undefined}
onSelect={(v) => switchDataset(Number(v))}
triggerContent={datasetId === null ? 'Choose a dataset…' : undefined}
/>
</div>
);
}
/**
* The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says
* what the builder does and offers the one next step — never a dead end. The
* action swaps this modal for the Datasets manager opened on its create form.
*/
function NoDatasets() {
return (
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>No datasets yet</h3>
<p className={styles.emptyBody}>
The Chart Builder turns a saved dataset into a chart pick columns, watch the chart take
shape, and save it as a snippet. Add a dataset to start building.
</p>
<button
type="button"
className={`${styles.action} ${styles.primary}`}
onClick={() => {
openModal('datasets');
useDatasetStore.getState().startCreate();
}}
>
Add a dataset
</button>
</div>
);
}
export function ChartBuilderModal() {
const datasetId = useChartBuilderStore((s) => s.datasetId);
const datasetName = useChartBuilderStore((s) => s.config.datasetName);
const datasetCount = useDatasetStore((s) => s.datasets.length);
const mark = useChartBuilderStore((s) => s.config.mark);
const sort = useChartBuilderStore((s) => s.config.sort);
const stack = useChartBuilderStore((s) => s.config.stack);
@@ -1317,7 +1370,16 @@ export function ChartBuilderModal() {
}, [warnings]);
if (datasetId === null) {
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
if (datasetCount === 0) return <NoDatasets />;
// Datasets exist but none is loaded (init auto-picks, so this is a fallback
// for a builder restored into an odd state): offer the choice directly.
return (
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>Choose a dataset</h3>
<p className={styles.emptyBody}>Pick the dataset to build a chart from.</p>
<DatasetPicker datasetId={null} />
</div>
);
}
return (
@@ -1326,9 +1388,7 @@ export function ChartBuilderModal() {
<div className="visually-hidden" role="status" aria-live="polite">
{fixAnnouncement}
</div>
<p className={styles.datasetName}>
Building from <strong>{datasetName}</strong>
</p>
<DatasetPicker datasetId={datasetId} />
<DataSection />
+11
View File
@@ -27,6 +27,7 @@ export type IconName =
| 'dataset' // "references a dataset" — Carbon DataTable
| 'delete' // delete — Carbon TrashCan
| 'add' // add / create-new — Carbon Add
| 'chart' // build / open a chart — rising columns on a baseline (pane-icon style)
| 'search' // live library search — Carbon Search
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
| 'import' // import a workspace file — Carbon Upload (a file goes in)
@@ -55,6 +56,16 @@ const GLYPHS: Record<IconName, ReactNode> = {
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
),
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
// Rising columns on a baseline — drawn in the pane-icon rect style (not a Carbon
// trace) so it reads at 16px next to the pane glyphs.
chart: (
<>
<rect x="4" y="24" width="24" height="2" />
<rect x="8" y="16" width="4" height="8" />
<rect x="14" y="10" width="4" height="14" />
<rect x="20" y="6" width="4" height="18" />
</>
),
// Carbon Search — magnifying glass, the active-search affordance (council SEARCH).
search: (
<path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z" />
+31
View File
@@ -28,6 +28,13 @@
line-height: 1.5;
}
/* The two ways in, side by side: sample-first (primary) and data-first (ghost). */
.ctaRow {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
/* Primary call to action — the accent button, matching the library's Create. */
.primary {
display: inline-flex;
@@ -52,6 +59,30 @@
outline-offset: 2px;
}
/* The data-first door — bordered ghost, same height as the primary beside it. */
.buildCta {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 40px;
padding: 0 var(--space-5);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.buildCta:hover {
background: var(--layer-01);
}
.buildCta:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* The "or start from an example" header row, with Add all pushed to the end. */
.galleryHead {
display: flex;
+18 -3
View File
@@ -19,6 +19,7 @@ import type { VisualizationSpec } from 'vega-embed';
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
import { createSnippet as createSnippetRecord } from '@core/snippet';
import { chartConfigFor } from '@core/vega-themes';
import { openModal } from '../modals/ModalCoordinator';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { usePanesStore } from '../stores/PanesStore';
@@ -105,6 +106,15 @@ export function Onboarding() {
layoutWorkspace();
};
// The data-first door (spec §02): straight into the Chart Builder, which picks
// up the freshest dataset — or, with none yet, explains itself and offers "Add a
// dataset". The workspace split is applied up front: if the builder creates the
// first snippet, onboarding unmounts without another chance to lay the panes out.
const handleBuild = () => {
layoutWorkspace();
openModal('chartBuilder');
};
const handleAddAll = () => {
// Stagger the timestamps so the first example (the bar chart) is the newest:
// it then sorts to the top of the library and `addSnippets` makes it active
@@ -130,9 +140,14 @@ export function Onboarding() {
your device.
</p>
<button type="button" className={styles.primary} onClick={handleCreate}>
<Icon name="add" /> Create your first snippet
</button>
<div className={styles.ctaRow}>
<button type="button" className={styles.primary} onClick={handleCreate}>
<Icon name="add" /> Create your first snippet
</button>
<button type="button" className={styles.buildCta} onClick={handleBuild}>
<Icon name="chart" /> Build a chart from your data
</button>
</div>
<div className={styles.galleryHead}>
<h3 className={styles.galleryTitle}>Or start from an example</h3>
+47 -7
View File
@@ -28,15 +28,23 @@
color: var(--text-secondary);
}
.createNew {
/* The creation surface: primary Build Chart beside the ghost New-JSON action
(spec §02). One row, the primary takes the slack. */
.createRow {
flex: 0 0 auto;
display: flex;
gap: var(--space-2);
margin: var(--space-4);
}
.createNew {
flex: 1 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
margin: var(--space-4);
height: 40px;
padding: 0 var(--space-5);
padding: 0 var(--space-4);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: var(--accent);
@@ -54,10 +62,42 @@
background: var(--accent-hover);
}
/* Narrow pane → the CTA sheds its label and centres the "+" icon (the button stays
full-width, so it keeps a large target). The threshold sits comfortably above the
216px pane minimum, so the full label is shown across the normal width range and
only collapses when the pane is dragged tight. */
/* The expert accelerator: a quiet bordered ghost beside the primary. */
.createGhost {
flex: 0 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 40px;
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font: inherit;
white-space: nowrap;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.createGhost:hover {
background: var(--layer-01);
color: var(--text);
}
/* Two-stage label collapse as the pane narrows: the long ghost label goes first
(its icon + title still name it), then below the tight threshold the primary's
label too — both buttons stay side by side with large targets. The lower
threshold sits comfortably above the 216px pane minimum. */
@container (max-width: 340px) {
.createGhost .createNewLabel {
display: none;
}
}
@container (max-width: 250px) {
.createNewLabel {
display: none;
+28 -15
View File
@@ -20,6 +20,7 @@ import {
type Snippet,
} from '@core/snippet';
import { filterAndSortSnippets } from '@core/snippet-sort';
import { openModal } from '../modals/ModalCoordinator';
import { confirm } from '../stores/ConfirmStore';
import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
@@ -251,21 +252,33 @@ export function SnippetLibrary() {
<LibrarySettings />
</div>
{/* Create raises no toast: the new snippet opens in the editor, so the
result is already on-screen (spec §02; docs/architecture/10 → Toast
copy). Delete/duplicate toast because the outcome isn't visible. */}
{/* Primary CTA. When the library pane is dragged narrow the label collapses
to just the "+" icon (a @container query) so it never wraps to two lines;
`aria-label` keeps the accessible name when the text is hidden. */}
<button
className={styles.createNew}
onClick={() => createSnippet()}
aria-label="Create New Snippet"
title="Create New Snippet"
>
<Icon name="add" />
<span className={styles.createNewLabel}>Create New Snippet</span>
</button>
{/* The creation surface (spec §02): the guided path is the primary — Build
Chart opens the Chart Builder — and raw JSON stays one visible click away
as a ghost action (NN/g #6 recognition / #7 expert accelerator; one
primary per surface — Carbon). Neither raises a toast: both outcomes
open on-screen (the builder, or the new snippet in the editor). When the
pane is dragged narrow the labels collapse to icons (a @container
query); `aria-label` keeps each accessible name. */}
<div className={styles.createRow}>
<button
className={styles.createNew}
onClick={() => openModal('chartBuilder')}
aria-label="Build Chart"
title="Build Chart"
>
<Icon name="chart" />
<span className={styles.createNewLabel}>Build Chart</span>
</button>
<button
className={styles.createGhost}
onClick={() => createSnippet()}
aria-label="New JSON snippet"
title="New JSON snippet"
>
<Icon name="add" />
<span className={styles.createNewLabel}>New JSON snippet</span>
</button>
</div>
{/* Search + Sort controls, pinned above the list (spec §02; council
SEARCH + SORT). Search is an unlabelled Carbon active-search: a search
+3
View File
@@ -16,6 +16,7 @@ const ALL_VIEWS: ViewState[] = [
{ kind: 'dataset', datasetId: 7 },
{ kind: 'dataset-new' },
{ kind: 'dataset-build', datasetId: 42 },
{ kind: 'build' },
];
describe('serializeHash', () => {
@@ -28,6 +29,7 @@ describe('serializeHash', () => {
expect(serializeHash({ kind: 'dataset-build', datasetId: 42 })).toBe(
'#datasets/dataset-42/build',
);
expect(serializeHash({ kind: 'build' })).toBe('#build');
});
});
@@ -42,6 +44,7 @@ describe('parseHash', () => {
kind: 'dataset-build',
datasetId: 42,
});
expect(parseHash('#build')).toEqual({ kind: 'build' });
});
it('tolerates a missing leading "#"', () => {
+7 -1
View File
@@ -18,6 +18,7 @@
* dataset → #datasets/dataset-<id> (id is a decimal number)
* dataset-new → #datasets/new
* dataset-build → #datasets/dataset-<id>/build
* build → #build (builder, no dataset yet)
*/
/** The serialized view. Snippet id is opaque; dataset id is the numeric id. */
@@ -27,7 +28,8 @@ export type ViewState =
| { kind: 'datasets' } // #datasets
| { kind: 'dataset'; datasetId: number } // #datasets/dataset-<id>
| { kind: 'dataset-new' } // #datasets/new
| { kind: 'dataset-build'; datasetId: number }; // #datasets/dataset-<id>/build
| { kind: 'dataset-build'; datasetId: number } // #datasets/dataset-<id>/build
| { kind: 'build' }; // #build — the Chart Builder with no dataset loaded yet
/**
* Parse a raw hash (with or without the leading `#`) into a typed `ViewState`.
@@ -41,6 +43,8 @@ export function parseHash(rawHash: string): ViewState {
const snippet = /^snippet-(.+)$/.exec(hash);
if (snippet) return { kind: 'snippet', snippetId: snippet[1] };
if (hash === 'build') return { kind: 'build' };
const parts = hash.split('/').filter(Boolean);
if (parts[0] === 'datasets') {
if (parts.length === 1) return { kind: 'datasets' };
@@ -75,6 +79,8 @@ export function serializeHash(view: ViewState): string {
return '#datasets/new';
case 'dataset-build':
return `#datasets/dataset-${view.datasetId}/build`;
case 'build':
return '#build';
default: {
const _exhaustive: never = view;
return _exhaustive;
+6
View File
@@ -31,6 +31,12 @@ function snapshotOf(name: ModalName | null): string | null {
/** Open `name`, optionally with a sub-target (dataset id, source key). */
export function openModal(name: ModalName, arg?: string): void {
// TODO: setActiveModal fires the UrlStateSync subscribers before init() has
// loaded the target, so a navigable modal pushes one hash derived from the
// *previous* modal state (e.g. a stale `#datasets/dataset-N/build`) before the
// correct one — a spurious Back step. Swapping the two lines or suppressing
// sync until init completes would fix it; check init implementations don't
// assume the modal is already active.
useAppStore.getState().setActiveModal(name);
getModalConfig(name)?.init?.(arg);
stateSnapshot = snapshotOf(name);
+8 -1
View File
@@ -58,7 +58,7 @@ export function deriveViewState(): ViewState {
if (modal && getModalConfig(modal)?.isUrlNavigable) {
if (modal === 'chartBuilder') {
const datasetId = useChartBuilderStore.getState().datasetId;
return datasetId !== null ? { kind: 'dataset-build', datasetId } : { kind: 'datasets' };
return datasetId !== null ? { kind: 'dataset-build', datasetId } : { kind: 'build' };
}
if (modal === 'datasets') {
const ds = useDatasetStore.getState();
@@ -136,6 +136,13 @@ export function applyView(view: ViewState): void {
app.setActiveModal('datasets');
useDatasetStore.getState().startCreate();
return;
case 'build':
// The no-target builder door. init(null) picks the freshest dataset, so
// with any datasets present the derived view immediately self-corrects to
// `dataset-build`; only an empty dataset library stays on `#build`.
useChartBuilderStore.getState().init(null);
app.setActiveModal('chartBuilder');
return;
}
} finally {
applying = false;
+4 -2
View File
@@ -71,8 +71,10 @@ const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
getState: () => ({ name: useExtractStore.getState().name }),
},
// Opened from a selected dataset's "Build Chart" action; `arg` is its id. Loads
// the dataset and pre-populates a smart default config (§06). Applies on Create
// Opened from a dataset's "Build Chart" action (`arg` is its id) or from the
// library / onboarding doors with no arg — init(null) then picks the most
// recently modified dataset (§06 → Opening). Loads the dataset and
// pre-populates a smart default config (§06). Applies on Create
// (a new snippet), so there is nothing transient to lose on close — no getState.
// Backdrop dismissal is off: the config is real in-progress work, and a stray
// click outside this large surface shouldn't throw it away (Escape/× still close).
+77
View File
@@ -370,3 +370,80 @@ describe('constant values (setChannelConstant — the Property model)', () => {
expect(cb().config.encodings.x).toBe(before); // unchanged — no-op
});
});
describe('init(null) — the un-targeted doors (library / onboarding)', () => {
test('picks the most recently modified dataset', () => {
const dsOld = createDataset({
name: 'Older',
data: [{ a: 'x', b: 1 }],
format: 'json',
source: 'inline',
now: new Date('2026-05-01T00:00:00Z'),
});
const dsNew = createDataset({
name: 'Fresher',
data: [{ c: 'y', d: 2 }],
format: 'json',
source: 'inline',
now: new Date('2026-06-01T00:00:00Z'),
});
useDatasetStore.getState().add(dsOld);
useDatasetStore.getState().add(dsNew);
cb().init(null);
expect(cb().config.datasetName).toBe('Fresher');
expect(cb().datasetId).not.toBeNull();
});
test('lands empty when the dataset library is empty', () => {
cb().init(null);
expect(cb().datasetId).toBeNull();
expect(cb().config.encodings).toEqual({});
});
});
describe('switchDataset (the builder dataset picker)', () => {
test('an untouched default config re-derives smart defaults for the new dataset', () => {
const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
const bId = seedDataset('B', [{ day: '2026-01-01', visits: 10 }]);
cb().init(aId);
cb().switchDataset(bId);
expect(cb().datasetId).toBe(bId);
expect(cb().config.datasetName).toBe('B');
// Fresh defaults for B's columns, not a pruned remnant of A's.
expect(cb().config.encodings.x).toEqual({ field: 'day', type: 'temporal' });
});
test('a built-on config is rebased: chart intent kept, missing-column bindings shed', () => {
const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
const bId = seedDataset('B', [{ region: 'S', profit: 2 }]);
cb().init(aId);
cb().setChannelColumn('x', 'region');
cb().setChannelColumn('y', 'revenue');
cb().setTitle('Revenue by region');
cb().addFilter();
const fid = useChartBuilderStore.getState().config.filters![0].id;
cb().setFilterField(fid, 'revenue');
cb().switchDataset(bId);
const config = cb().config;
expect(config.datasetName).toBe('B');
expect(config.title).toBe('Revenue by region');
expect(config.encodings.x).toEqual({ field: 'region', type: 'nominal' }); // shared column survives
expect(config.encodings.y).toBeNull(); // `revenue` doesn't exist on B
expect(config.filters).toEqual([]); // the revenue filter is shed too
});
test('no-op for an unknown id or the already-loaded dataset', () => {
const aId = seedDataset('A', [{ region: 'N', revenue: 5 }]);
cb().init(aId);
cb().setTitle('Kept');
const before = cb().config;
cb().switchDataset(999);
expect(cb().config).toBe(before);
cb().switchDataset(aId);
expect(cb().config).toBe(before);
});
});
+67 -7
View File
@@ -8,9 +8,11 @@
* that, plus the create-flow side effects (new snippet, toast, activate, close).
*
* `init(datasetId)` loads the dataset's columns and pre-populates a smart default
* config; with no dataset it lands empty so the modal can show "No dataset loaded".
* The mark is sticky after open (changing a column does not re-derive it) so the
* user's choice is never overridden mid-edit.
* config. `init(null)` — the library's Build-Chart door, which opens without a
* target (spec §06 → Opening) — picks the most recently modified dataset; only
* with an empty dataset library does the builder land empty (the modal then shows
* the no-datasets state). The mark is sticky after open (changing a column does
* not re-derive it) so the user's choice is never overridden mid-edit.
*/
import { create } from 'zustand';
@@ -27,6 +29,7 @@ import {
isChannelTypeAllowed,
isColumnAllowedOnChannel,
pruneEncodings,
rebaseBuilderConfig,
supportsBin,
supportsTimeUnit,
validAggregateOps,
@@ -81,6 +84,12 @@ export interface ChartBuilderState {
rowCount: number | null;
/** The working configuration the preview and the produced spec read from. */
config: BuilderConfig;
/**
* The exact config object `init` produced. Mutations replace `config` via
* spread, so `config === initialConfig` means "still the untouched opening
* default" — the dataset switch uses it to choose re-derive vs rebase.
*/
initialConfig: BuilderConfig;
/**
* The channel "armed" to receive the next clicked field (field-first assignment,
* spec §06 → Encoding). Clicking an empty channel slot arms it; clicking a field in
@@ -89,8 +98,19 @@ export interface ChartBuilderState {
*/
activeChannel: ChannelName | null;
/** Load a dataset and pre-populate a smart default config (spec §06 → Opening). */
/**
* 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).
*/
init: (datasetId: number | null) => void;
/**
* 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
* dataset; a config the user has built on is rebased instead — chart-level
* intent kept, bindings to columns the new dataset lacks shed (core
* `rebaseBuilderConfig`).
*/
switchDataset: (datasetId: number) => void;
setMark: (mark: MarkType) => void;
/**
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
@@ -220,19 +240,27 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
columns: EMPTY_COLUMNS,
rowCount: null,
config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG,
activeChannel: null,
init: (datasetId) => {
const all = useDatasetStore.getState().datasets;
// An un-targeted open (the library / onboarding doors) lands on the most
// recently modified dataset — the freshest data is the likeliest subject.
const dataset =
datasetId === null
? undefined
: useDatasetStore.getState().datasets.find((d) => d.id === datasetId);
? all.reduce<(typeof all)[number] | undefined>(
(best, d) => (best === undefined || d.modified > best.modified ? d : best),
undefined,
)
: all.find((d) => d.id === datasetId);
if (!dataset) {
set({
datasetId: null,
columns: EMPTY_COLUMNS,
rowCount: null,
config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG,
activeChannel: null,
});
return;
@@ -242,11 +270,42 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
columnTypes: dataset.columnTypes,
columnStats: dataset.columnStats,
};
const config = defaultBuilderConfig(dataset.name, columns);
set({
datasetId: dataset.id,
columns,
rowCount: dataset.rowCount,
config: defaultBuilderConfig(dataset.name, columns),
config,
initialConfig: config,
activeChannel: null,
});
},
switchDataset: (datasetId) => {
const s = get();
if (datasetId === s.datasetId) return;
const dataset = useDatasetStore.getState().datasets.find((d) => d.id === datasetId);
if (!dataset) return;
// An untouched opening config (every mutation replaces `config` via spread, so
// reference identity with the init-produced object means nothing changed) — or
// no dataset at all — re-derives fresh defaults for the new one; anything the
// user has built on is rebased so their work survives the switch.
if (s.datasetId === null || s.config === s.initialConfig) {
get().init(datasetId);
return;
}
const columns: BuilderColumns = {
columns: dataset.columns,
columnTypes: dataset.columnTypes,
columnStats: dataset.columnStats,
};
// `initialConfig` is deliberately left stale: a rebased config is still the
// user's built-on work, so a later switch must rebase again, never re-derive.
set({
datasetId: dataset.id,
columns,
rowCount: dataset.rowCount,
config: rebaseBuilderConfig(s.config, dataset.name, columns),
activeChannel: null,
});
},
@@ -499,6 +558,7 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
columns: EMPTY_COLUMNS,
rowCount: null,
config: EMPTY_CONFIG,
initialConfig: EMPTY_CONFIG,
activeChannel: null,
}),
}));
+89
View File
@@ -28,6 +28,7 @@ import {
calculatedFieldNames,
effectiveColumns,
pruneEncodings,
rebaseBuilderConfig,
type BuilderCalculate,
type BuilderColumns,
type BuilderConfig,
@@ -1290,3 +1291,91 @@ describe('pruneEncodings', () => {
expect(pruneEncodings(config, columns)).toBe(config);
});
});
describe('rebaseBuilderConfig (dataset switch)', () => {
// The new dataset shares `category` but lacks `value`/`when`/`flag`.
const newColumns: BuilderColumns = {
columns: ['category', 'profit'],
columnTypes: [
{ name: 'category', type: 'string' },
{ name: 'profit', type: 'number' },
],
};
it('keeps chart-level intent and same-name bindings, re-pointing the dataset', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
title: 'Revenue by region',
subtitle: 'FY26',
width: 400,
height: 200,
sort: 'descending',
stack: 'normalize',
encodings: { x: { field: 'category', type: 'nominal' } },
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.datasetName).toBe('New');
expect(out.mark).toBe('bar');
expect(out.title).toBe('Revenue by region');
expect(out.subtitle).toBe('FY26');
expect(out.width).toBe(400);
expect(out.height).toBe(200);
expect(out.sort).toBe('descending');
expect(out.stack).toBe('normalize');
expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
});
it('sheds encodings and predicate filters bound to columns the new dataset lacks', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
filters: [
filter({ id: 'f1', field: 'value', fieldType: 'quantitative', op: 'gt', value: '0' }),
filter({ id: 'f2', field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' }),
],
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.encodings.x).toEqual({ field: 'category', type: 'nominal' });
expect(out.encodings.y).toBeNull();
expect(out.filters?.map((f) => f.id)).toEqual(['f2']);
});
it('keeps expression filters, constants, count mappings, and calculated-field bindings', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'bar',
encodings: {
x: { field: 'ratio', type: 'quantitative' }, // a calculated field travels along
y: { type: 'quantitative', aggregate: 'count' }, // field-less count
color: { value: '#ff0000', type: 'nominal' }, // constant — no column binding
},
calculates: [calc({ as: 'ratio', expr: 'datum.profit * 2' })],
filters: [filter({ id: 'fx', mode: 'expression', expr: 'datum.value > 0' })],
};
const out = rebaseBuilderConfig(config, 'New', newColumns);
expect(out.encodings.x).toEqual({ field: 'ratio', type: 'quantitative' });
expect(out.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
expect(out.encodings.color).toEqual({ value: '#ff0000', type: 'nominal' });
expect(out.calculates).toEqual(config.calculates);
expect(out.filters).toEqual(config.filters);
});
it('a same-schema dataset keeps the whole config (only the name changes)', () => {
const config: BuilderConfig = {
datasetName: 'Old',
mark: 'line',
encodings: {
x: { field: 'when', type: 'temporal' },
y: { field: 'value', type: 'quantitative', aggregate: 'sum' },
},
filters: [filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'A' })],
};
const out = rebaseBuilderConfig(config, 'New', columns);
expect(out).toEqual({ ...config, datasetName: 'New' });
});
});
+24
View File
@@ -1034,6 +1034,30 @@ export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): Bui
return changed ? { ...config, encodings } : config;
}
/**
* Re-point an in-progress config at a different dataset (spec §06 → Dataset
* picker). Chart-level intent survives the switch — mark, title/subtitle,
* explicit size, sort/stack, calculated fields, and expression filters (their
* `datum` references are surfaced by the unknown-field feedback, not dropped) —
* while anything bound to a column the new dataset doesn't have is shed:
* encodings via `pruneEncodings`, predicate filters by field lookup. With a
* same-schema dataset (the common switch: a fresher version of the same data)
* everything survives verbatim.
*/
export function rebaseBuilderConfig(
config: BuilderConfig,
datasetName: string,
columns: BuilderColumns,
): BuilderConfig {
const available = new Set(effectiveColumns(columns, config.calculates).columns);
const filters = (config.filters ?? []).filter(
(f) => f.mode === 'expression' || (f.field !== undefined && available.has(f.field)),
);
const rebased: BuilderConfig = { ...config, datasetName };
if (config.filters !== undefined) rebased.filters = filters;
return pruneEncodings(rebased, columns);
}
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record<string, unknown>;