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