mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: discoverable entry points — Build Chart door, dataset picker, no-datasets state
This commit is contained in:
@@ -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) ──── */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user